Hold the example file format and the layouts in tests, and cover the one-newline trim and the reversed slot
The exporter enforced the file format when it wrote the documents, and nothing held it afterwards; examples/README.md calls the documents hand-owned, and Tasks 9 to 13 read the head and the slot. Two tests now hold it over the real tree. Each has a control subtest: a copy of a real document with the doctype dropped, lang changed, the viewport removed, the theme renamed or the store attribute added must be caught, and a layout entry given a second state, a second slot or no slot must be caught, so a pass over the real files is not vacuous. Already passing on the stub: TestMarkupRefusesAMissingState and TestSlotRefusesTwoSlots (first Task 5 commit) went green against stubs that returned an error for everything. They are negative tests with no honest RED short of a wrong implementation.
Summary
example/example_test.go: TestEveryExampleHasTheStandardHead (doctype, html lang and dir, charset, viewport, title, tokens.css, day.css, and the scheme-store attribute only on scheme-menu/standalone) and TestEveryLayoutHasOneDefaultAndOneSlot, each with a control leg that plants the faults in a copy of a real document and expects them named; Markup's trim of exactly one newline; Slot's reversed markers; an empty srcset candidate is an error rather than a panic.
Agent notes
Task 5 fix round 1, Minors 5, 6 and 7. TestMarkupRefusesAMissingState and TestSlotRefusesTwoSlots, from the first Task 5 commit, were already passing on the stubs: a stub that always errors satisfies a test that wants an error. Recorded here as the test discipline asks; the new TestSlotRefusesReversedMarkers is of the same kind.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1 file changed,
+195
−1
example/example_test.go+195 −1
diff --git a/example/example_test.go b/example/example_test.go| index 70f19d9..8d6a8d4 100644 |
| --- a/example/example_test.go |
| +++ b/example/example_test.go |
| @@ -3,8 +3,10 @@ package example_test |
| import ( |
| "io/fs" |
| "path" |
| + "regexp" |
| "slices" |
| "sort" |
| + "strconv" |
| "strings" |
| "testing" |
| "testing/fstest" |
| @@ -47,6 +49,17 @@ func TestMarkupReturnsTheBodyAsWritten(t *testing.T) { |
| if got != synthBody { |
| t.Fatalf("Markup returned\n%s\nwant\n%s", got, synthBody) |
| } |
| + // Exactly one newline is trimmed at each end: a body that ends in a |
| + // blank line keeps it. |
| + fsys := synthFS() |
| + fsys["examples/field/blank.html"] = &fstest.MapFile{Data: []byte(synthDoc(synthBody + "\n"))} |
| + got, err = example.Markup(fsys, "field", "blank") |
| + if err != nil { |
| + t.Fatalf("Markup: %v", err) |
| + } |
| + if got != synthBody+"\n" { |
| + t.Fatalf("Markup trimmed more than one trailing newline:\n%q", got[len(got)-20:]) |
| + } |
| } |
| func TestMarkupRefusesAMissingState(t *testing.T) { |
| @@ -80,6 +93,13 @@ func TestSlotRefusesTwoSlots(t *testing.T) { |
| } |
| } |
| +func TestSlotRefusesReversedMarkers(t *testing.T) { |
| + doc := "<main><!-- /fiesta:slot -->a<!-- fiesta:slot --></main>" |
| + if _, err := example.Slot(doc, "c"); err == nil { |
| + t.Fatal("Slot on a document whose slot closes before it opens returned no error") |
| + } |
| +} |
| + |
| // ── The real examples ──────────────────────────────────────────────── |
| // tree is the embedded design system, which carries examples/ from |
| @@ -268,7 +288,12 @@ func TestExamplesLinkOnlyFiestasOwnFiles(t *testing.T) { |
| continue |
| } |
| for _, cand := range strings.Split(v, ",") { |
| - file := strings.Fields(cand)[0] |
| + fields := strings.Fields(cand) |
| + if len(fields) == 0 { |
| + t.Errorf("%s: the body's %s=%q has an empty candidate", where, name, v) |
| + continue |
| + } |
| + file := fields[0] |
| if !strings.HasPrefix(file, "../assets/") { |
| t.Errorf("%s: the body's %s=%q is not under ../assets/", where, name, v) |
| continue |
| @@ -381,6 +406,175 @@ func TestEveryStateIsValidOnItsOwn(t *testing.T) { |
| }) |
| } |
| +// headProblems lists what is wrong with one document's standard head: |
| +// the doctype, <html lang="en" dir="ltr"> (with the scheme store |
| +// attribute only where store says so), the charset, the viewport, a |
| +// non-empty <title>, and the two stylesheets. |
| +func headProblems(t *testing.T, doc string, store bool) []string { |
| + t.Helper() |
| + var problems []string |
| + if !strings.HasPrefix(doc, "<!doctype html>\n") { |
| + problems = append(problems, "no <!doctype html> first") |
| + } |
| + var sawHTML, charset, viewport, title, tokens, day bool |
| + walk(t, doc, func(tok html.Token, inHead bool) { |
| + switch { |
| + case tok.Data == "html": |
| + sawHTML = true |
| + if v, _ := attr(tok, "lang"); v != "en" { |
| + problems = append(problems, "<html> lang is "+strconv.Quote(v)+", want en") |
| + } |
| + if v, _ := attr(tok, "dir"); v != "ltr" { |
| + problems = append(problems, "<html> dir is "+strconv.Quote(v)+", want ltr") |
| + } |
| + if v, ok := attr(tok, "data-rst-scheme-store"); ok != store || (ok && v != "local") { |
| + problems = append(problems, "<html> data-rst-scheme-store is "+strconv.Quote(v)+", present "+strconv.FormatBool(ok)+", want present "+strconv.FormatBool(store)) |
| + } |
| + case !inHead: |
| + case tok.Data == "meta": |
| + if v, ok := attr(tok, "charset"); ok && strings.EqualFold(v, "utf-8") { |
| + charset = true |
| + } |
| + if v, _ := attr(tok, "name"); v == "viewport" { |
| + viewport = true |
| + } |
| + case tok.Data == "link": |
| + v, _ := attr(tok, "href") |
| + tokens = tokens || v == "../../css/tokens.css" |
| + day = day || v == "../../css/themes/day.css" |
| + } |
| + }) |
| + if m := titlePattern.FindStringSubmatch(doc); m != nil && strings.TrimSpace(m[1]) != "" { |
| + title = true |
| + } |
| + for _, c := range []struct { |
| + ok bool |
| + what string |
| + }{{sawHTML, "no <html> tag"}, {charset, "no <meta charset=\"utf-8\"> in the head"}, {viewport, "no viewport meta in the head"}, |
| + {title, "no non-empty <title>"}, {tokens, "the head does not link ../../css/tokens.css"}, {day, "the head does not link ../../css/themes/day.css"}} { |
| + if !c.ok { |
| + problems = append(problems, c.what) |
| + } |
| + } |
| + return problems |
| +} |
| + |
| +var titlePattern = regexp.MustCompile(`(?s)<title>(.*?)</title>`) |
| + |
| +// TestEveryExampleHasTheStandardHead holds the example file format |
| +// the exporter wrote and the README now calls hand-owned: a hand edit |
| +// that drops the viewport or the theme fails here. The control leg |
| +// plants each fault in a copy of a real document and expects the |
| +// checker to name it, so the pass below is not vacuous. |
| +func TestEveryExampleHasTheStandardHead(t *testing.T) { |
| + fsys, m := manifest(t) |
| + t.Run("control", func(t *testing.T) { |
| + doc, err := example.Document(fsys, m.Examples[0].Name, m.Examples[0].Default) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, c := range []struct { |
| + name, from, to, want string |
| + }{ |
| + {"doctype", "<!doctype html>\n", "", "no <!doctype html> first"}, |
| + {"lang", `lang="en"`, `lang="es"`, `<html> lang is "es", want en`}, |
| + {"viewport", `<meta name="viewport" content="width=device-width">`, "", "no viewport meta in the head"}, |
| + {"theme", `href="../../css/themes/day.css"`, `href="../../css/themes/night.css"`, "the head does not link ../../css/themes/day.css"}, |
| + {"store", `dir="ltr"`, `dir="ltr" data-rst-scheme-store="local"`, `<html> data-rst-scheme-store is "local", present true, want present false`}, |
| + } { |
| + mutated := strings.Replace(doc, c.from, c.to, 1) |
| + if mutated == doc { |
| + t.Fatalf("%s: the mutation changed nothing; the control proves nothing", c.name) |
| + } |
| + if got := headProblems(t, mutated, false); !slices.Contains(got, c.want) { |
| + t.Errorf("%s: the checker reported %v, want it to include %q", c.name, got, c.want) |
| + } |
| + } |
| + if got := headProblems(t, doc, false); len(got) != 0 { |
| + t.Errorf("the unmutated document reports %v", got) |
| + } |
| + }) |
| + everyDocument(t, func(e example.Entry, s example.State, doc string) { |
| + store := e.Name == "scheme-menu" && s.ID == "standalone" |
| + for _, p := range headProblems(t, doc, store) { |
| + t.Errorf("%s/%s: %s", e.Name, s.ID, p) |
| + } |
| + }) |
| +} |
| + |
| +// layoutProblems lists what is wrong with a layout entry and its one |
| +// document: it has exactly one state, default, which is its Default, |
| +// and the document has exactly one slot. A non-layout document has no |
| +// slot at all. |
| +func layoutProblems(e example.Entry, doc string) []string { |
| + var problems []string |
| + if !e.Document { |
| + if strings.Contains(doc, "<!-- fiesta:slot -->") || strings.Contains(doc, "<!-- /fiesta:slot -->") { |
| + problems = append(problems, "a non-layout document carries a slot marker") |
| + } |
| + return problems |
| + } |
| + if len(e.States) != 1 || e.States[0].ID != "default" { |
| + var ids []string |
| + for _, s := range e.States { |
| + ids = append(ids, s.ID) |
| + } |
| + problems = append(problems, "a layout must have the one state default, has "+strings.Join(ids, ", ")) |
| + } |
| + if e.Default != "default" { |
| + problems = append(problems, "a layout's default must be default, is "+e.Default) |
| + } |
| + if _, err := example.Slot(doc, "<p>x</p>"); err != nil { |
| + problems = append(problems, err.Error()) |
| + } |
| + return problems |
| +} |
| + |
| +func TestEveryLayoutHasOneDefaultAndOneSlot(t *testing.T) { |
| + fsys, m := manifest(t) |
| + t.Run("control", func(t *testing.T) { |
| + e, ok := m.Entry("layout-sidebar") |
| + if !ok || !e.Document { |
| + t.Fatal("no layout-sidebar layout to mutate") |
| + } |
| + doc, err := example.Document(fsys, e.Name, "default") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + two := e |
| + two.States = append(append([]example.State(nil), e.States...), example.State{ID: "wide"}) |
| + if got := layoutProblems(two, doc); len(got) == 0 { |
| + t.Error("an entry with two states passed") |
| + } |
| + if got := layoutProblems(e, strings.Replace(doc, "<!-- fiesta:slot -->", "<!-- fiesta:slot --><!-- fiesta:slot -->", 1)); len(got) == 0 { |
| + t.Error("a document with two slots passed") |
| + } |
| + if got := layoutProblems(e, strings.ReplaceAll(doc, "<!-- fiesta:slot -->", "")); len(got) == 0 { |
| + t.Error("a document with no slot passed") |
| + } |
| + plain := e |
| + plain.Document = false |
| + if got := layoutProblems(plain, doc); len(got) == 0 { |
| + t.Error("a non-layout document with a slot passed") |
| + } |
| + if got := layoutProblems(e, doc); len(got) != 0 { |
| + t.Errorf("the unmutated layout reports %v", got) |
| + } |
| + }) |
| + layouts := 0 |
| + everyDocument(t, func(e example.Entry, s example.State, doc string) { |
| + if e.Document { |
| + layouts++ |
| + } |
| + for _, p := range layoutProblems(e, doc) { |
| + t.Errorf("%s/%s: %s", e.Name, s.ID, p) |
| + } |
| + }) |
| + if layouts != 7 { |
| + t.Errorf("%d layout documents, want 7", layouts) |
| + } |
| +} |
| + |
| func TestTheStandaloneSchemeStateOwnsItsStorage(t *testing.T) { |
| fsys, m := manifest(t) |
| def, ok := m.Default("scheme-menu") |