rastrillo / fiesta Public

Queue two sign-in notes that promised a server, and hold every rule in the prose test

Human

Prompt

Task 2 of Fiesta part 3, fixes after review: queue the two sign-in notes that claim behaviour the static example lacks and apply their approved replacements, make the prose test apply the exporter's whole rule, list every carried string, and keep only reviewed prose in the seed README.

From this commit's own message. The prompt ledger has no entries for it — which is what a squashed, rebased or amended commit looks like, and what every commit looks like in a repo without a ledger.

Summary

The exporter's mechanical rule, the word list, the template arguments and the script names, is one function, proseReason, and internal/gallery/prose_rule_test.go holds the same text; TestTheProseRuleIsTheExporters parses both files and fails when the five declarations drift, and TestNoProseTeachesRastrillo applies that rule to every string in prose.json with a control per part of the rule. The exporter queues the sign-in notes for coming back with keymail and on the way to keymail, since each described what the app behind the screen does, and approvedProse carries the replacements copy review approved; examples/prose.json and hack/seed/prose-review.json are its output, run twice and byte-identical, with fresh hashes under the approved sentence in hack/seed/README.md. The exporter also prints every carried string with its slot, so a run's log is the inventory.
Agent notes
The two new controls, Add Lead to the row and Load missing-widget.js first, were red by name against the word-list-only check and are green under proseReason. TestTheProseRuleIsTheExporters was proved live by adding mutant.js to the gallery copy's script list, which it reported, then reverted. 92 strings carry and 56 are queued, all 56 with approved text. Both gallery shards and make ci are green.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 5a5c77a3701730f5358b4314fee054a157c0e5cc parent 2e238dc
6 files changed, +242 −50
  • examples/prose.json +2 −2
  • hack/seed/README.md +3 −3
  • hack/seed/_rastrillo/prose_fiestaseed_test.go +37 −12
  • hack/seed/prose-review.json +12 −0
  • internal/gallery/prose_rule_test.go +160 −0
  • internal/gallery/prose_test.go +28 −33

CI passed — run details

CI log
=== gofmt ===
=== no-rastrillo ===
go test -run '^(TestFiestaNeverImportsRastrillo|TestThePublicPackagesLinkNoBrowser)$' -count=1 .
ok  	amadan.net/rastrillo/fiesta	0.105s
=== size ===
go test ./internal/build -run '^TestTheOneLinkIsUnder25KiBGzipped$' -count=1 -v
=== RUN   TestTheOneLinkIsUnder25KiBGzipped
    build_test.go:138: fiesta-plain.css: 152856 bytes, 22303 bytes gzipped (21.8 KiB of the 25.0 KiB limit)
    build_test.go:138: fiesta-signal.css: 152951 bytes, 22414 bytes gzipped (21.9 KiB of the 25.0 KiB limit)
    build_test.go:138: fiesta.css: 155770 bytes, 23018 bytes gzipped (22.5 KiB of the 25.0 KiB limit)
--- PASS: TestTheOneLinkIsUnder25KiBGzipped (0.02s)
PASS
ok  	amadan.net/rastrillo/fiesta/internal/build	0.022s
=== suite ===
--- shard 0 of 12 ---
==> shard 0 of 12: make root
go build ./...
go vet ./...
FIESTA_TEST_REQUIRE_NODE=1 FIESTA_TEST_REQUIRE_GIT=1 go test -count=1 ./...
ok  	amadan.net/rastrillo/fiesta	1.793s
ok  	amadan.net/rastrillo/fiesta/check	5.172s
ok  	amadan.net/rastrillo/fiesta/cmd/fiesta	6.176s
ok  	amadan.net/rastrillo/fiesta/example	0.250s
ok  	amadan.net/rastrillo/fiesta/internal/axe	0.163s
ok  	amadan.net/rastrillo/fiesta/internal/build	1.911s
ok  	amadan.net/rastrillo/fiesta/internal/codeview	0.009s
ok  	amadan.net/rastrillo/fiesta/internal/gallery	5.376s
?   	amadan.net/rastrillo/fiesta/internal/harness	[no test files]
ok  	amadan.net/rastrillo/fiesta/internal/markup	0.056s
ok  	amadan.net/rastrillo/fiesta/internal/nodetest	0.591s
ok  	amadan.net/rastrillo/fiesta/internal/site	3.367s
ok  	amadan.net/rastrillo/fiesta/internal/tokens	0.029s
ok  	amadan.net/rastrillo/fiesta/internal/www	2.873s
--- shard 1 of 12 ---
==> shard 1 of 12: make staticcheck
go run honnef.co/go/tools/cmd/staticcheck@v0.8.2 -tags browser ./...
--- shard 2 of 12 ---
==> shard 2 of 12: make govulncheck
go run golang.org/x/vuln/cmd/govulncheck@v1.8.0 ./...
=== Symbol Results ===

No vulnerabilities found.

Your code is affected by 0 vulnerabilities.
This scan also found 2 vulnerabilities in packages you import and 5
vulnerabilities in modules you require, but your code doesn't appear to call
these vulnerabilities.
Use '-show verbose' for more details.
--- shard 3 of 12 ---
==> shard 3 of 12: make gitleaks
go run github.com/zricethezav/gitleaks/v8@v8.30.1 git --no-banner --redact --log-opts='--full-history --diff-filter=tuxdb HEAD' .
4:05PM INF Unknown SCM platform. Use --platform to include links in findings. host=amadan.net
4:05PM INF 1 commits scanned.
4:05PM INF scanned ~5594630 bytes (5.59 MB) in 3.57s
4:05PM INF no leaks found
--- shard 4 of 12 ---
==> shard 4 of 12: make browser.internal.harness
./hack/gotest.sh browser 'internal.harness'
gotest: on DOCS-T1, driving the harness's own choice (/usr/bin/chromium)
ok  	amadan.net/rastrillo/fiesta/internal/harness	11.320s
--- shard 5 of 12 ---
==> shard 5 of 12: make browser.check~1of3
./hack/gotest.sh browser 'check~1of3'
gotest: on DOCS-T1, driving the harness's own choice (/usr/bin/chromium)
gotest: slice 1 of 3 of ./check/ runs 95 of 286 tests
ok  	amadan.net/rastrillo/fiesta/check	196.663s
--- shard 6 of 12 ---
==> shard 6 of 12: make browser.check~2of3
./hack/gotest.sh browser 'check~2of3'
gotest: on DOCS-T1, driving the harness's own choice (/usr/bin/chromium)
gotest: slice 2 of 3 of ./check/ runs 95 of 286 tests
ok  	amadan.net/rastrillo/fiesta/check	210.914s
--- shard 7 of 12 ---
==> shard 7 of 12: make browser.check~3of3
./hack/gotest.sh browser 'check~3of3'
gotest: on DOCS-T1, driving the harness's own choice (/usr/bin/chromium)
gotest: slice 3 of 3 of ./check/ runs 96 of 286 tests
ok  	amadan.net/rastrillo/fiesta/check	211.669s
--- shard 8 of 12 ---
==> shard 8 of 12: make browser.internal.gallery~1of2
./hack/gotest.sh browser 'internal.gallery~1of2'
gotest: on DOCS-T1, driving the harness's own choice (/usr/bin/chromium)
gotest: slice 1 of 2 of ./internal/gallery/ runs 1 of 25 tests
ok  	amadan.net/rastrillo/fiesta/internal/gallery	75.334s
--- shard 9 of 12 ---
==> shard 9 of 12: make browser.internal.gallery~2of2
./hack/gotest.sh browser 'internal.gallery~2of2'
gotest: on DOCS-T1, driving the harness's own choice (/usr/bin/chromium)
gotest: slice 2 of 2 of ./internal/gallery/ runs 24 of 25 tests
ok  	amadan.net/rastrillo/fiesta/internal/gallery	32.619s
--- shard 10 of 12 ---
==> shard 10 of 12: make browser.internal.www~1of2
./hack/gotest.sh browser 'internal.www~1of2'
gotest: on DOCS-T1, driving the harness's own choice (/usr/bin/chromium)
gotest: slice 1 of 2 of ./internal/www/ runs 3 of 5 tests
ok  	amadan.net/rastrillo/fiesta/internal/www	1.503s
--- shard 11 of 12 ---
==> shard 11 of 12: make browser.internal.www~2of2
./hack/gotest.sh browser 'internal.www~2of2'
gotest: on DOCS-T1, driving the harness's own choice (/usr/bin/chromium)
gotest: slice 2 of 2 of ./internal/www/ runs 2 of 5 tests
ok  	amadan.net/rastrillo/fiesta/internal/www	1.231s
diff --git a/examples/prose.json b/examples/prose.json
index 7fa482a..8fea216 100644
--- a/examples/prose.json
+++ b/examples/prose.json
@@ -329,10 +329,10 @@
"an-address-that-does-not-look-right": "The message belongs to the field, and that is where focus starts.",
"asking-for-an-address": "One field and one button. Nothing to remember and nothing to steal.",
"coming-back-with-an-email-link": "The address is on the button, so there is nothing to type.",
- "coming-back-with-keymail": "The browser remembers how it got in last time. One tap sends the remembered address, which is checked again from scratch.",
+ "coming-back-with-keymail": "For someone coming back: the address they used last time is on the button, so one tap sends it. Remembering that address, and checking it again, is your app's job.",
"email-and-password": "If you do use passwords, use the same one-way-in layout, and put a way to reset directly under the button rather than hiding it. We do not recommend passwords: people reuse them, they leak, and you inherit the job of storing them safely. Use a link in your email plus a passkey, or passkeys on their own. The markup is here because some products still need it.",
"google-apple-and-the-rest": "We give you the buttons, drawn the way each company requires. We do not give you the sign-in itself: you wire that up to whichever provider you use.",
- "on-the-way-to-keymail": "The page moves on by itself, with a link in case it does not.",
+ "on-the-way-to-keymail": "A holding page while someone is sent on to sign in. Your app moves the page on by itself; the link is there in case it does not.",
"when-keymail-could-not-confirm-it": "The button changes to send a link instead, so nobody is stuck.",
"when-keymail-was-offered-and-a-link-went-out": "One line says a link was sent this time. It does not guess why."
}
diff --git a/hack/seed/README.md b/hack/seed/README.md
index 8a8b62f..a749961 100644
--- a/hack/seed/README.md
+++ b/hack/seed/README.md
@@ -66,7 +66,7 @@ f7b196f6eaaaedb39c051ef784a910665da6df2dc2f269372e778c7bf163a0e3 examples/asset
87f400ef28f9b9ec8a0cfa605077f0913b750dd88acab4310aeb14871a1bdf97 examples/assets/sample-logo.svg
6f0ba1eecc8aa675ae91b82fc274ecc4f8abe24833c974d25479856e1e261472 examples/assets/sample-pricing.svg
-run-prose.sh's output, as of the last run, so a later run can tell whether the explanations moved.
+The checksums of run-prose.sh's output at its last run, so the next run shows whether any explanation changed.
-55852407c4ce823b707329e5a6a11de587f199dd3a7a6ef2238788d3fda6d714 examples/prose.json
-d6efc4ac8168dfed9ccd9971176de705c1d45932e059c40adf3dd9396f05786f hack/seed/prose-review.json
+e90f894f4173eb7073f0995a6b91e655247e9584655a874a6863682c04b3f8c2 examples/prose.json
+f6019f286c4e0fa3d9ffc594cf7fa31e128c982cd34daef60e850f1078b7c610 hack/seed/prose-review.json
diff --git a/hack/seed/_rastrillo/prose_fiestaseed_test.go b/hack/seed/_rastrillo/prose_fiestaseed_test.go
index 1db8bb9..f30cb75 100644
--- a/hack/seed/_rastrillo/prose_fiestaseed_test.go
+++ b/hack/seed/_rastrillo/prose_fiestaseed_test.go
@@ -17,8 +17,9 @@
// A string joins the review queue when it mentions something Fiesta
// does not have (a Go template, a partial, a server, a cookie,
// rastrillo itself) or when, read against Fiesta's HTML, it is not
-// true. The mechanical part is proseWords and proseFields; the rest is
-// a judgement, written down in proseUntrue with its reason. A queued
+// true. The mechanical part is proseReason, over proseWords,
+// proseFields and proseScripts; the rest is a judgement, written down
+// in proseUntrue with its reason. A queued
// string's slot in prose.json is empty until approvedProse carries
// its replacement, so a reseed reproduces the approved text and never
// the rastrillo one.
@@ -156,10 +157,15 @@ var proseUntrue = map[string]string{
// Fiesta wrote itself: its page remembers the choice, and rastrillo's
// note describes a server. The sign-in example has no sentence about
// the set of screens, because screenDocs() explains each one and not
-// the whole.
+// the whole. Two of its screens' notes describe what the app behind
+// the screen does, which Fiesta's static example cannot: one has a
+// hard-coded address and forms that post, the other a link to # and
+// nothing that moves on.
var proseAlways = map[string]string{
- "examples.scheme-menu.states.standalone": "rastrillo's note describes a server; this state's page remembers the choice itself",
- "examples.signin.blurb": "no rastrillo source: screenDocs() explains each screen and not the set, so this one sentence is new",
+ "examples.scheme-menu.states.standalone": "rastrillo's note describes a server; this state's page remembers the choice itself",
+ "examples.signin.blurb": "no rastrillo source: screenDocs() explains each screen and not the set, so this one sentence is new",
+ "examples.signin.states.coming-back-with-keymail": "claims behaviour the static example lacks: remembering and checking the address",
+ "examples.signin.states.on-the-way-to-keymail": "claims behaviour the static example lacks: moving on by itself",
}
// approvedProse is the review's answer, by slot. Nothing joins this
@@ -212,7 +218,9 @@ var approvedProse = map[string]string{
"examples.shell-topbar.blurb": "The topbar shell's own chrome, as markup. The whole page it belongs to is layout-topbar, under Shells.",
"examples.signin.blurb": "The sign-in screen in every state it passes through: asking for an address, coming back, after the link is sent, when something went wrong, and the other ways in.",
"examples.signin.states.asking-for-an-address": "One field and one button. Nothing to remember and nothing to steal.",
+ "examples.signin.states.coming-back-with-keymail": "For someone coming back: the address they used last time is on the button, so one tap sends it. Remembering that address, and checking it again, is your app's job.",
"examples.signin.states.email-and-password": "If you do use passwords, use the same one-way-in layout, and put a way to reset directly under the button rather than hiding it. We do not recommend passwords: people reuse them, they leak, and you inherit the job of storing them safely. Use a link in your email plus a passkey, or passkeys on their own. The markup is here because some products still need it.",
+ "examples.signin.states.on-the-way-to-keymail": "A holding page while someone is sent on to sign in. Your app moves the page on by itself; the link is there in case it does not.",
"examples.stat.states.a-shortened-number": "Showing 4.1k instead of 4120? Put the full number in a <data> tag's value. Screen readers only read what is on screen, so keep anything a person needs in the number itself.",
"examples.stat.states.the-big-number": "Give one stat in the row rst-stat=\"lead\" to make it big. It is the same component, just larger.",
"examples.status-pill.states.neutral": "Neutral is the default tone, so rst-tone=\"neutral\" could be left off.",
@@ -229,7 +237,7 @@ type proser struct {
sources map[string]proseSource
file proseFile
queue []proseReview
- carried int
+ carried []proseReview
}
func TestFiestaProse(t *testing.T) {
@@ -248,7 +256,13 @@ func TestFiestaProse(t *testing.T) {
p.write("examples/prose.json", p.file)
sort.Slice(p.queue, func(i, j int) bool { return p.queue[i].ID < p.queue[j].ID })
p.write("hack/seed/prose-review.json", p.queue)
- fmt.Printf("fiesta prose: %d strings carried, %d queued for review\n", p.carried, len(p.queue))
+ // The inventory, for the run's log: every carried string with its
+ // slot, then every queued one with its reason.
+ sort.Slice(p.carried, func(i, j int) bool { return p.carried[i].ID < p.carried[j].ID })
+ fmt.Printf("fiesta prose: %d strings carried, %d queued for review\n", len(p.carried), len(p.queue))
+ for _, c := range p.carried {
+ fmt.Printf("carried: %s: %s\n", c.ID, c.Seed)
+ }
for _, q := range p.queue {
fmt.Printf("review: %s: %s\n", q.ID, q.Reason)
}
@@ -277,10 +291,12 @@ func (p *proser) write(rel string, v any) {
}
}
-// reason says why text cannot carry, or "" when it can: the word
-// list, then a field name used as an argument, then a script Fiesta
-// does not ship, then the judgement.
-func (p *proser) reason(id, text string) string {
+// proseReason is the mechanical part of the review: why text cannot
+// carry, or "" when the word list, the template arguments and the
+// script names all pass. internal/gallery/prose_rule_test.go holds
+// the same text and applies it to prose.json, and a test there fails
+// when the two drift.
+func proseReason(text string) string {
for _, w := range proseWords {
if w.re.MatchString(text) {
return "mentions " + w.what
@@ -296,6 +312,15 @@ func (p *proser) reason(id, text string) string {
return "names " + s + ", which Fiesta does not ship"
}
}
+ return ""
+}
+
+// reason says why text cannot carry, or "" when it can: the
+// mechanical rule, then the judgement.
+func (p *proser) reason(id, text string) string {
+ if why := proseReason(text); why != "" {
+ return why
+ }
return proseUntrue[id]
}
@@ -312,7 +337,7 @@ func (p *proser) slot(id, seed string) string {
why = p.reason(id, seed)
}
if why == "" {
- p.carried++
+ p.carried = append(p.carried, proseReview{ID: id, Seed: seed})
return seed
}
text := approvedProse[id]
diff --git a/hack/seed/prose-review.json b/hack/seed/prose-review.json
index d079885..c203423 100644
--- a/hack/seed/prose-review.json
+++ b/hack/seed/prose-review.json
@@ -275,12 +275,24 @@
"reason": "mentions rastrillo",
"text": "One field and one button. Nothing to remember and nothing to steal."
},
+ {
+ "id": "examples.signin.states.coming-back-with-keymail",
+ "seed": "The browser remembers how it got in last time. One tap sends the remembered address, which is checked again from scratch.",
+ "reason": "claims behaviour the static example lacks: remembering and checking the address",
+ "text": "For someone coming back: the address they used last time is on the button, so one tap sends it. Remembering that address, and checking it again, is your app's job."
+ },
{
"id": "examples.signin.states.email-and-password",
"seed": "If you do use passwords, use the same one-way-in layout, and put a way to reset directly under the button rather than hiding it. We do not recommend passwords: Rastrillo does not ship this screen. People reuse passwords, they leak, and you inherit the job of storing them safely. Use a link in your email plus a passkey, or passkeys on their own. The markup is here because some products still need it.",
"reason": "mentions rastrillo",
"text": "If you do use passwords, use the same one-way-in layout, and put a way to reset directly under the button rather than hiding it. We do not recommend passwords: people reuse them, they leak, and you inherit the job of storing them safely. Use a link in your email plus a passkey, or passkeys on their own. The markup is here because some products still need it."
},
+ {
+ "id": "examples.signin.states.on-the-way-to-keymail",
+ "seed": "The page moves on by itself, with a link in case it does not.",
+ "reason": "claims behaviour the static example lacks: moving on by itself",
+ "text": "A holding page while someone is sent on to sign in. Your app moves the page on by itself; the link is there in case it does not."
+ },
{
"id": "examples.stat.states.a-shortened-number",
"seed": "Showing 4.1k instead of 4120? Put the full number in Exact. Screen readers only read what is on screen, so keep anything a person needs in the number itself.",
diff --git a/internal/gallery/prose_rule_test.go b/internal/gallery/prose_rule_test.go
new file mode 100644
index 0000000..fb0183a
--- /dev/null
+++ b/internal/gallery/prose_rule_test.go
@@ -0,0 +1,160 @@
+package gallery
+
+import (
+ "bytes"
+ "go/ast"
+ "go/parser"
+ "go/printer"
+ "go/token"
+ "os"
+ "path/filepath"
+ "regexp"
+ "testing"
+)
+
+// The review rule the prose exporter applies to rastrillo's strings,
+// copied here so the same rule reads prose.json. The exporter,
+// hack/seed/_rastrillo/prose_fiestaseed_test.go, runs inside a
+// rastrillo worktree and cannot be imported, so the mechanical part
+// of its rule is the same text in both files, and
+// TestTheProseRuleIsTheExporters fails when the two drift. Change it
+// there and copy it here.
+
+// proseWords are the words that put a string in the review queue:
+// each names something Fiesta's HTML does not have.
+var proseWords = []struct {
+ what string
+ re *regexp.Regexp
+}{
+ {"Go", regexp.MustCompile(`\bGo\b`)},
+ {"a template", regexp.MustCompile(`(?i)\btemplates?\b`)},
+ {"{{", regexp.MustCompile(`\{\{`)},
+ {"ui.", regexp.MustCompile(`\bui\.`)},
+ {"rastrillo", regexp.MustCompile(`(?i)rastrillo`)},
+ {"a partial", regexp.MustCompile(`(?i)\bpartials?\b`)},
+ {"/_theme", regexp.MustCompile(`/_theme`)},
+ {"a cookie", regexp.MustCompile(`(?i)\bcookies?\b`)},
+ {"a server", regexp.MustCompile(`(?i)\bservers?\b`)},
+ {".go", regexp.MustCompile(`\.go\b`)},
+}
+
+// proseFields are the capitalised field names a note uses as a
+// template argument.
+var proseFields = []string{
+ "Lead", "Exact", "Primary", "Hidden", "ClearHref", "Placeholder", "Fit", "Desc",
+ "SelectName", "CancelLabel", "Alert", "Tone", "Options", "Plain", "RevokePath",
+ "RevokeAgentPath", "RevokeAllPath", "Created", "Currency", "Render", "PageData",
+}
+
+// proseScripts are the script files a string may name: the ones
+// Fiesta ships under that name.
+var proseScripts = map[string]bool{
+ "anchor.js": true, "busy.js": true, "calendar.js": true, "datetime.js": true,
+ "fiesta.js": true, "light.js": true, "select.js": true, "shell.js": true,
+}
+
+var proseScript = regexp.MustCompile(`\b[a-z][a-z0-9-]*\.js\b`)
+
+// proseReason is the mechanical part of the review: why text cannot
+// carry, or "" when the word list, the template arguments and the
+// script names all pass.
+func proseReason(text string) string {
+ for _, w := range proseWords {
+ if w.re.MatchString(text) {
+ return "mentions " + w.what
+ }
+ }
+ for _, f := range proseFields {
+ if regexp.MustCompile(`\b` + f + `\b`).MatchString(text) {
+ return "uses the template argument " + f
+ }
+ }
+ for _, s := range proseScript.FindAllString(text, -1) {
+ if !proseScripts[s] {
+ return "names " + s + ", which Fiesta does not ship"
+ }
+ }
+ return ""
+}
+
+// The exporter's file, from this package's directory.
+const proseExporter = "../../hack/seed/_rastrillo/prose_fiestaseed_test.go"
+
+// proseRuleNames are the declarations that make up the rule, in both
+// files.
+var proseRuleNames = []string{"proseWords", "proseFields", "proseScripts", "proseScript", "proseReason"}
+
+// declText is the source of one top-level declaration in a parsed
+// file, printed without its comments, so the two copies may be
+// commented differently and must otherwise read the same.
+func declText(t *testing.T, fset *token.FileSet, f *ast.File, name string) string {
+ t.Helper()
+ for _, d := range f.Decls {
+ var n ast.Node
+ switch d := d.(type) {
+ case *ast.FuncDecl:
+ if d.Name.Name == name {
+ n = &ast.FuncDecl{Recv: d.Recv, Name: d.Name, Type: d.Type, Body: d.Body}
+ }
+ case *ast.GenDecl:
+ for _, s := range d.Specs {
+ if v, ok := s.(*ast.ValueSpec); ok && len(v.Names) == 1 && v.Names[0].Name == name {
+ n = &ast.ValueSpec{Names: v.Names, Type: v.Type, Values: v.Values}
+ }
+ }
+ }
+ if n == nil {
+ continue
+ }
+ var b bytes.Buffer
+ if err := printer.Fprint(&b, fset, n); err != nil {
+ t.Fatal(err)
+ }
+ return b.String()
+ }
+ t.Fatalf("%s: no declaration named %s", fset.Position(f.Pos()).Filename, name)
+ return ""
+}
+
+func parseFile(t *testing.T, fset *token.FileSet, path string) *ast.File {
+ t.Helper()
+ src, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ f, err := parser.ParseFile(fset, path, src, parser.ParseComments|parser.SkipObjectResolution)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return f
+}
+
+// TestTheProseRuleIsTheExporters: the five declarations above are,
+// comments aside, the exporter's own, so the rule prose.json is read
+// by is the rule its strings were queued by. The control is a copy
+// of this file with one word added to the field list.
+func TestTheProseRuleIsTheExporters(t *testing.T) {
+ fset := token.NewFileSet()
+ here := parseFile(t, fset, "prose_rule_test.go")
+ there := parseFile(t, fset, filepath.FromSlash(proseExporter))
+ for _, name := range proseRuleNames {
+ if a, b := declText(t, fset, here, name), declText(t, fset, there, name); a != b {
+ t.Errorf("%s differs from the exporter's:\n%s\n\nthe exporter has:\n%s", name, a, b)
+ }
+ }
+ src, err := os.ReadFile("prose_rule_test.go")
+ if err != nil {
+ t.Fatal(err)
+ }
+ drifted := bytes.Replace(src, []byte(`"Lead", "Exact",`), []byte(`"Lead", "Exact", "Drift",`), 1)
+ if bytes.Equal(drifted, src) {
+ t.Fatal("the control did not change the field list")
+ }
+ control, err := parser.ParseFile(fset, "control.go", drifted, parser.SkipObjectResolution)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if declText(t, fset, control, "proseFields") == declText(t, fset, there, "proseFields") {
+ t.Fatal("the control adds a field and the comparison did not see it")
+ }
+}
diff --git a/internal/gallery/prose_test.go b/internal/gallery/prose_test.go
index 8214f2c..33de4e6 100644
--- a/internal/gallery/prose_test.go
+++ b/internal/gallery/prose_test.go
@@ -1,7 +1,8 @@
package gallery
import (
- "regexp"
+ "slices"
+ "strconv"
"strings"
"testing"
"testing/fstest"
@@ -165,39 +166,24 @@ func TestProseNamesOnlyRealExamplesAndStates(t *testing.T) {
}
}
-// proseTeaches is the review queue's word list: what a string says
-// that is rastrillo's and not Fiesta's. A match is a string that
-// should have gone through copy review.
-var proseTeaches = []*regexp.Regexp{
- regexp.MustCompile(`\bGo\b`),
- regexp.MustCompile(`(?i)\btemplates?\b`),
- regexp.MustCompile(`\{\{`),
- regexp.MustCompile(`\bui\.`),
- regexp.MustCompile(`(?i)rastrillo`),
- regexp.MustCompile(`(?i)\bpartials?\b`),
- regexp.MustCompile(`/_theme`),
- regexp.MustCompile(`(?i)\bcookies?\b`),
- regexp.MustCompile(`(?i)\bservers?\b`),
- regexp.MustCompile(`\.go\b`),
-}
-
-// teaches lists every string in p that hits the word list.
+// teaches lists every string in p the exporter's rule would queue:
+// what a string says that is rastrillo's and not Fiesta's, with the
+// reason. A match is a string that should have gone through copy
+// review, or came back from it still saying it.
func teaches(p *prose) []string {
var out []string
p.each(func(id, text string) {
- for _, re := range proseTeaches {
- if re.MatchString(text) {
- out = append(out, id+": "+re.String())
- break
- }
+ if why := proseReason(text); why != "" {
+ out = append(out, id+": "+why)
}
})
return out
}
-// TestNoProseTeachesRastrillo: the queue's word list, applied to
-// prose.json, finds nothing. Its control is a scratch prose.json that
-// mentions /_theme.
+// TestNoProseTeachesRastrillo: the exporter's rule, the word list,
+// the template arguments and the script names, applied to prose.json,
+// finds nothing. Its controls are a scratch prose.json per part of
+// the rule.
func TestNoProseTeachesRastrillo(t *testing.T) {
p := loadedProse(t)
n := 0
@@ -208,13 +194,22 @@ func TestNoProseTeachesRastrillo(t *testing.T) {
for _, hit := range teaches(p) {
t.Error(hit)
}
- scratch := fstest.MapFS{proseFile: &fstest.MapFile{Data: []byte(`{"families":{},"examples":{"scheme-menu":{"blurb":"Posts to /_theme."}},"pages":{"idioms":[]}}`)}}
- control, err := loadProse(scratch)
- if err != nil {
- t.Fatal(err)
- }
- if got := teaches(control); len(got) != 1 || !strings.HasPrefix(got[0], "examples.scheme-menu.blurb:") {
- t.Fatalf("the control mentions /_theme once; teaches found %v", got)
+ // The controls: one string per part of the rule, each a scratch
+ // prose.json with that string in the scheme menu's blurb.
+ controls := []struct{ name, text, why string }{
+ {"the word list", "Posts to /_theme.", "mentions /_theme"},
+ {"a template argument", "Add Lead to the row.", "uses the template argument Lead"},
+ {"a script Fiesta does not ship", "Load missing-widget.js first.", "names missing-widget.js, which Fiesta does not ship"},
+ }
+ for _, c := range controls {
+ scratch := fstest.MapFS{proseFile: &fstest.MapFile{Data: []byte(`{"families":{},"examples":{"scheme-menu":{"blurb":` + strconv.Quote(c.text) + `}},"pages":{"idioms":[]}}`)}}
+ control, err := loadProse(scratch)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got, want := teaches(control), []string{"examples.scheme-menu.blurb: " + c.why}; !slices.Equal(got, want) {
+ t.Errorf("the control for %s, %q, should be caught as %v; teaches found %v", c.name, c.text, want, got)
+ }
}
}