| index 5d8f0f0..02acff9 100644 |
| --- a/templates_test.go |
| +++ b/templates_test.go |
| @@ -1,10 +1,13 @@ |
| package abono_test |
| |
| import ( |
| + "errors" |
| "html/template" |
| "io/fs" |
| "os" |
| + "path" |
| "regexp" |
| + "slices" |
| "strings" |
| "testing" |
| "time" |
| @@ -340,6 +343,79 @@ func TestPlanCard(t *testing.T) { |
| } |
| } |
| |
| +// A key documented as optional may be left out, in either shape of data |
| +// a partial takes: a map without it, or a struct without the field. |
| +func TestOptionalKeysMayBeOmitted(t *testing.T) { |
| + l := testLocalizer(t, testCatalog) |
| + type card struct { |
| + L *ui.Localizer |
| + Offer abono.Offer |
| + Action string |
| + } |
| + type paywallIn struct { |
| + L *ui.Localizer |
| + Offers []abono.Offer |
| + SignedIn bool |
| + Action string |
| + } |
| + type paywallOut struct { |
| + L *ui.Localizer |
| + Offers []abono.Offer |
| + SignedIn bool |
| + } |
| + type button struct { |
| + L *ui.Localizer |
| + Action string |
| + Plan string |
| + } |
| + oneSold := yearlyOnly() |
| + for name, c := range map[string]struct { |
| + partial string |
| + data any |
| + want []string |
| + not []string |
| + }{ |
| + "card, map": {"abono-plan-card", map[string]any{"L": l, "Offer": oneSold, "Action": "/s"}, |
| + []string{"T-PERYEAR", "T-SUBSCRIBE", `value="patron"`}, []string{"T-NOINTERVAL", `name="interval"`, `name="code"`}}, |
| + "card, struct": {"abono-plan-card", card{l, oneSold, "/s"}, |
| + []string{"T-PERYEAR", "T-SUBSCRIBE", `value="patron"`}, []string{"T-NOINTERVAL", `name="interval"`, `name="code"`}}, |
| + "paywall in, map": {"abono-paywall", map[string]any{"L": l, "Offers": []abono.Offer{oneSold}, "SignedIn": true, "Action": "/s"}, |
| + []string{"T-PAYWALL-TITLE", `action="/s"`}, []string{"T-SEEPLANS", "T-SIGNIN"}}, |
| + "paywall in, struct": {"abono-paywall", paywallIn{l, []abono.Offer{oneSold}, true, "/s"}, |
| + []string{"T-PAYWALL-TITLE", `action="/s"`}, []string{"T-SEEPLANS", "T-SIGNIN"}}, |
| + "paywall out, map": {"abono-paywall", map[string]any{"L": l, "Offers": []abono.Offer{oneSold}, "SignedIn": false}, |
| + []string{"T-SIGNIN-PROMPT", "T-SIGNIN"}, []string{"<form"}}, |
| + "paywall out, struct": {"abono-paywall", paywallOut{l, []abono.Offer{oneSold}, false}, |
| + []string{"T-SIGNIN-PROMPT", "T-SIGNIN"}, []string{"<form"}}, |
| + "button, map": {"abono-subscribe-button", map[string]any{"L": l, "Action": "/s", "Plan": "p"}, |
| + []string{"T-SUBSCRIBE", `value="p"`}, []string{`name="interval"`, `name="code"`}}, |
| + "button, struct": {"abono-subscribe-button", button{l, "/s", "p"}, |
| + []string{"T-SUBSCRIBE", `value="p"`}, []string{`name="interval"`, `name="code"`}}, |
| + "status, map": {"abono-status", map[string]any{"L": l, "Holding": holdingCases()[1].h, "Now": nowAt}, |
| + []string{"T-RENEWS", "2026-11-03"}, nil}, |
| + "status, struct": {"abono-status", struct { |
| + L *ui.Localizer |
| + Holding abono.Holding |
| + Now time.Time |
| + }{l, holdingCases()[1].h, nowAt}, |
| + []string{"T-RENEWS", "2026-11-03"}, nil}, |
| + } { |
| + t.Run(name, func(t *testing.T) { |
| + got := render(t, c.partial, c.data) |
| + for _, w := range c.want { |
| + if !strings.Contains(got, w) { |
| + t.Errorf("lacks %q:\n%s", w, got) |
| + } |
| + } |
| + for _, w := range c.not { |
| + if strings.Contains(got, w) { |
| + t.Errorf("has %q:\n%s", w, got) |
| + } |
| + } |
| + }) |
| + } |
| +} |
| + |
| func TestPlanPicker(t *testing.T) { |
| l := testLocalizer(t, testCatalog) |
| got := render(t, "abono-plan-picker", map[string]any{"L": l, "Offers": []abono.Offer{supporter(), yearlyOnly()}, "Action": "/subscribe"}) |
| @@ -501,28 +577,96 @@ func TestPartialsUseOnlyAgreedKeys(t *testing.T) { |
| } |
| } |
| |
| -// Every key the partials use, or Describe returns, is in en.toml, and |
| -// every key in en.toml is used: here, or by cmd/abono's templates. |
| +// keyLiteral finds a whole message key written as a string literal. A |
| +// prefix built up at run time ("abono.price.per_" + interval) ends in _ or |
| +// . and is not a key; the keys it builds are named by a partial. |
| +var keyLiteral = regexp.MustCompile(`"(abono\.[a-z][a-z_.]*[a-z])"`) |
| + |
| +// consumers are where message keys are used, other than the partials in |
| +// templates/: the Go that names them (describe.go's constants, the |
| +// sandbox pages in internal/fakestripe) and the starter pages. dir is |
| +// relative to the package; recursive walks below it; absent says the |
| +// directory may not exist yet. |
| +var consumers = []struct { |
| + dir string |
| + recursive bool |
| + absent bool |
| + exts []string |
| +}{ |
| + {"templates", false, false, []string{".html"}}, |
| + {".", false, false, []string{".go"}}, |
| + {"internal/fakestripe", true, false, []string{".go"}}, |
| + // The starter pages land with Task 9; until they do the directory is |
| + // absent, and only that is allowed to be. |
| + {"cmd/abono", true, true, []string{".html", ".go"}}, |
| +} |
| + |
| +// usedKeys is every abono.* key a consumer names. A directory that cannot |
| +// be read, or a file that cannot be read, is an error; only a consumer |
| +// marked absent may be missing. |
| +func usedKeys(t *testing.T) map[string]bool { |
| + t.Helper() |
| + used := map[string]bool{} |
| + for _, c := range consumers { |
| + root := os.DirFS(c.dir) |
| + if _, err := fs.Stat(root, "."); err != nil { |
| + if c.absent && errors.Is(err, fs.ErrNotExist) { |
| + continue |
| + } |
| + t.Fatalf("consumer %s: %v", c.dir, err) |
| + } |
| + err := fs.WalkDir(root, ".", func(p string, d fs.DirEntry, err error) error { |
| + if err != nil { |
| + return err |
| + } |
| + if d.IsDir() { |
| + if p != "." && !c.recursive { |
| + return fs.SkipDir |
| + } |
| + return nil |
| + } |
| + if strings.HasSuffix(p, "_test.go") || !slices.Contains(c.exts, path.Ext(p)) { |
| + return nil |
| + } |
| + src, err := fs.ReadFile(root, p) |
| + if err != nil { |
| + return err |
| + } |
| + for _, m := range keyLiteral.FindAllStringSubmatch(string(src), -1) { |
| + used[m[1]] = true |
| + } |
| + return nil |
| + }) |
| + if err != nil { |
| + t.Fatalf("consumer %s: %v", c.dir, err) |
| + } |
| + } |
| + return used |
| +} |
| + |
| +func TestUsedKeysSeesEveryConsumer(t *testing.T) { |
| + used := usedKeys(t) |
| + for _, k := range []string{ |
| + abono.StatusFailingNoDate, "abono.paywall.title", // Go constant, partial |
| + "abono.sandbox.pay", "abono.sandbox.portal_return", // the sandbox pages |
| + } { |
| + if !used[k] { |
| + t.Errorf("usedKeys misses %s", k) |
| + } |
| + } |
| + if used["abono.price.per_"] { |
| + t.Error("usedKeys takes a run-time prefix for a key") |
| + } |
| +} |
| + |
| +// Every key a consumer uses is in en.toml, and every key in en.toml is |
| +// used by one of them. |
| func TestCatalogKeysAreUsedAndDefined(t *testing.T) { |
| cat := abono.Catalog() |
| if len(cat) == 0 { |
| t.Skip("Task 8b: approved copy pending") |
| } |
| - used := partialKeys(t) |
| - for _, k := range []string{abono.StatusActive, abono.StatusRenews, abono.StatusCancelling, abono.StatusFailing, abono.StatusFailingNoDate, abono.StatusEnded, abono.StatusComp, abono.StatusCompUntil} { |
| - used[k] = true |
| - } |
| - re := regexp.MustCompile(`"(abono\.[a-z_.]+)"`) |
| - _ = fs.WalkDir(osDir("cmd/abono"), ".", func(p string, d fs.DirEntry, err error) error { |
| - if err != nil || d.IsDir() || !strings.HasSuffix(p, ".html") { |
| - return nil |
| - } |
| - src, _ := fs.ReadFile(osDir("cmd/abono"), p) |
| - for _, m := range re.FindAllStringSubmatch(string(src), -1) { |
| - used[m[1]] = true |
| - } |
| - return nil |
| - }) |
| + used := usedKeys(t) |
| for k := range used { |
| if _, ok := cat[k]; !ok { |
| t.Errorf("%s is used but not in locales/en.toml", k) |