package gallery import ( "slices" "strconv" "strings" "testing" "testing/fstest" xhtml "golang.org/x/net/html" "amadan.net/rastrillo/fiesta" ) // The design system's explanations: that the file is whole, that it // names only what the manifest has, that it teaches nothing of // rastrillo's, and that the render shows every piece where the page // says it goes. func loadedProse(t *testing.T) *prose { t.Helper() p, err := loadProse(fiesta.FS()) if err != nil { t.Fatalf("loadProse: %v", err) } return p } // node is one element of a parsed page. type node struct{ n *xhtml.Node } // parse reads a rendered page into a tree, so a test reads it the way // a browser does rather than with a regexp over the bytes. func parse(t *testing.T, doc []byte) node { t.Helper() n, err := xhtml.Parse(strings.NewReader(string(doc))) if err != nil { t.Fatal(err) } return node{n} } func (n node) attr(name string) (string, bool) { for _, a := range n.n.Attr { if a.Key == name { return a.Val, true } } return "", false } func (n node) hasClass(class string) bool { v, _ := n.attr("class") for _, c := range strings.Fields(v) { if c == class { return true } } return false } // find returns every element under n, in document order, that pred // accepts. func (n node) find(pred func(node) bool) []node { var out []node var walk func(*xhtml.Node) walk = func(x *xhtml.Node) { if x.Type == xhtml.ElementNode && pred(node{x}) { out = append(out, node{x}) } for c := x.FirstChild; c != nil; c = c.NextSibling { walk(c) } } for c := n.n.FirstChild; c != nil; c = c.NextSibling { walk(c) } return out } // first is the first element pred accepts, or a zero node. func (n node) first(pred func(node) bool) (node, bool) { all := n.find(pred) if len(all) == 0 { return node{}, false } return all[0], true } // text is the element's text content, whitespace collapsed. func (n node) text() string { var b strings.Builder var walk func(*xhtml.Node) walk = func(x *xhtml.Node) { if x.Type == xhtml.TextNode { b.WriteString(x.Data) } for c := x.FirstChild; c != nil; c = c.NextSibling { walk(c) } } walk(n.n) return strings.Join(strings.Fields(b.String()), " ") } func byClass(class string) func(node) bool { return func(n node) bool { return n.hasClass(class) } } func byAttr(name, value string) func(node) bool { return func(n node) bool { v, ok := n.attr(name) return ok && (value == "" || v == value) } } // squash is a prose string the way text() reads it off the page. func squash(s string) string { return strings.Join(strings.Fields(s), " ") } // TestEveryFamilyHasAnExplanation: all ten families have a title and a // blurb. func TestEveryFamilyHasAnExplanation(t *testing.T) { p := loadedProse(t) fams := groupFamilies(manifest(t).Examples) if len(fams) != 10 { t.Fatalf("the manifest has %d families, not 10", len(fams)) } for _, f := range fams { fp, ok := p.Families[f.Key] if !ok { t.Errorf("families.%s: no entry", f.Key) continue } if fp.Title == "" { t.Errorf("families.%s.title is empty", f.Key) } if fp.Blurb == "" { t.Errorf("families.%s.blurb is empty", f.Key) } } } // TestEveryExampleHasAnExplanation: every manifest entry has a blurb. func TestEveryExampleHasAnExplanation(t *testing.T) { p := loadedProse(t) for _, e := range manifest(t).Examples { if p.Examples[e.Name].Blurb == "" { t.Errorf("examples.%s.blurb is empty", e.Name) } } } // TestProseNamesOnlyRealExamplesAndStates: no slot in the prose names // a family, example, state or idiom the manifest does not have. func TestProseNamesOnlyRealExamplesAndStates(t *testing.T) { p := loadedProse(t) for _, s := range p.strays(manifest(t)) { t.Error(s) } // The control: the check sees a slot that names nothing. bad := &prose{Examples: map[string]exampleProse{ "no-such-example": {Blurb: "x"}, "button": {States: map[string]string{"no-such-state": "x"}}, "signin": {Warnings: map[string]warning{"no-such-state": {Title: "x", Body: "y"}}}, }} if got := bad.strays(manifest(t)); len(got) != 3 { t.Fatalf("the control has three strays; strays found %d: %v", len(got), got) } } // 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) { if why := proseReason(text); why != "" { out = append(out, id+": "+why) } }) return out } // 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 p.each(func(string, string) { n++ }) if n < 100 { t.Fatalf("the prose has %d strings; the walk read too little to mean anything", n) } for _, hit := range teaches(p) { t.Error(hit) } // 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) } } } // stateSection is the section of one state on its example's page. func stateSection(t *testing.T, page []byte, name, state string) node { t.Helper() sec, ok := parse(t, page).first(byAttr("data-gallery-state", name+"/"+state)) if !ok { t.Fatalf("%s.html has no section for state %s", name, state) } return sec } // noteOf is the note under a state's label, or "" when there is none. func noteOf(sec node) string { note, ok := sec.first(byClass("ds-note")) if !ok { return "" } return note.text() } // TestTheDropdownIdiomNoteSitsOnItsState: the dropdown example is a // component with the dropdown idiom as one of its states, and the // idiom's sentence is the note on that state, not the example's blurb. func TestTheDropdownIdiomNoteSitsOnItsState(t *testing.T) { m := manifest(t) name, state, ok := m.Idiom("dropdown") if !ok { t.Fatal("the manifest has no dropdown idiom") } e, _ := m.Entry(name) if e.Kind == "idiom" || len(e.States) < 2 { t.Fatalf("%s is a %s with %d states; the idiom should ride on a component", name, e.Kind, len(e.States)) } p := loadedProse(t) note := p.Examples[name].States[state] if note == "" { t.Fatalf("examples.%s.states.%s is empty", name, state) } if note == p.Examples[name].Blurb { t.Errorf("examples.%s.blurb is the idiom's sentence; the component's should be", name) } page := render(t, "")[fiesta.ThemeNames()[0]+"/en/"+name+".html"] if got := noteOf(stateSection(t, page, name, state)); got != squash(note) { t.Errorf("%s/%s shows the note %q, want %q", name, state, got, note) } } // TestTheStandaloneSchemeStateSaysNoServer: the standalone state's // note is Fiesta's own, and not the rastrillo note that describes a // server. func TestTheStandaloneSchemeStateSaysNoServer(t *testing.T) { const name, state = "scheme-menu", "standalone" const seed = "This one posts to /_theme, so it needs a server." p := loadedProse(t) note := p.Examples[name].States[state] if note == "" { t.Fatalf("examples.%s.states.%s is empty", name, state) } if strings.Contains(note, seed) || strings.Contains(note, "/_theme") { t.Errorf("examples.%s.states.%s is the rastrillo note: %q", name, state, note) } page := render(t, "")[fiesta.ThemeNames()[0]+"/en/"+name+".html"] if got := noteOf(stateSection(t, page, name, state)); got != squash(note) { t.Errorf("%s/%s shows the note %q, want %q", name, state, got, note) } } // TestTheFamilyPagesShowTheFamilyBlurbs: every family's page has the // family's title as its heading and its blurb under it, in English and // in Arabic, each in the page's language. Preservation: part 3's // TestTheIndexShowsTheFamilyBlurbs, reading the family pages the // index's sections became, and reading the words through the catalog // since the pages speak it. func TestTheFamilyPagesShowTheFamilyBlurbs(t *testing.T) { p := loadedProse(t) files := render(t, "") s := newTestSite(t, fiesta.FS(), "") for _, locale := range []string{"en", "ar"} { for _, f := range groupFamilies(manifest(t).Examples) { name := fiesta.ThemeNames()[0] + "/" + locale + "/" + f.Key + ".html" doc := parse(t, files[name]) h, ok := doc.first(func(n node) bool { return n.n.Data == "h1" }) if !ok { t.Errorf("%s has no heading", name) continue } if want := s.proseIn(locale, "families."+f.Key+".title", p.Families[f.Key].Title); h.text() != want { t.Errorf("%s is headed %q, want %q", name, h.text(), want) } lead, ok := doc.first(byClass("ds-lead")) if !ok { t.Errorf("%s has no blurb under its heading", name) continue } if want := squash(s.proseIn(locale, "families."+f.Key+".blurb", p.Families[f.Key].Blurb)); want == "" || lead.text() != want { t.Errorf("%s shows %q, want %q", name, lead.text(), want) } } } } // TestAnExamplePageShowsItsBlurbAndNotes: every example page carries // its family's heading and blurb, its own blurb under its title, and // each state's note under that state's label, and no note where the // prose has none. func TestAnExamplePageShowsItsBlurbAndNotes(t *testing.T) { p := loadedProse(t) files := render(t, "") theme := fiesta.ThemeNames()[0] blurbs, notes := 0, 0 for _, e := range manifest(t).Examples { page := files[theme+"/en/"+e.Name+".html"] doc := parse(t, page) head, ok := doc.first(byClass("ds-kicker")) if !ok { t.Errorf("%s.html has no family heading", e.Name) } else if want := squash(p.Families[e.Family].Title + " " + p.Families[e.Family].Blurb); head.text() != want { t.Errorf("%s.html: the family heading reads %q, want %q", e.Name, head.text(), want) } sub, ok := doc.first(byAttr("rst-page-header-sub", "")) if !ok { t.Errorf("%s.html has no rst-page-header-sub under its title", e.Name) } else if want := squash(p.Examples[e.Name].Blurb); want == "" || sub.text() != want { t.Errorf("%s.html: the blurb reads %q, want %q", e.Name, sub.text(), want) } else { blurbs++ } for _, st := range e.States { want := squash(p.Examples[e.Name].States[st.ID]) got := noteOf(stateSection(t, page, e.Name, st.ID)) if got != want { t.Errorf("%s/%s shows the note %q, want %q", e.Name, st.ID, got, want) } if want != "" { notes++ } } } if blurbs == 0 || notes == 0 { t.Fatalf("%d blurbs and %d notes shown; the walk above read nothing", blurbs, notes) } } // TestTheIdiomRulesHeadThePrimitivesFamily: the two idiom rules are // quoted on the primitives family's page, before its examples, and on // no other family's page. Preservation: part 3's test, reading the // family pages in place of the index's sections. func TestTheIdiomRulesHeadThePrimitivesFamily(t *testing.T) { p := loadedProse(t) if len(p.Pages.Idioms) != 2 { t.Fatalf("the prose has %d idiom rules, want 2", len(p.Pages.Idioms)) } files := render(t, "") for _, f := range groupFamilies(manifest(t).Examples) { if f.Key == "primitives" { continue } if n := len(parse(t, files[fiesta.ThemeNames()[0]+"/en/"+f.Key+".html"]).find(byAttr("rst-callout", ""))); n != 0 { t.Errorf("%s.html has %d callouts, want none", f.Key, n) } } doc := parse(t, files[fiesta.ThemeNames()[0]+"/en/primitives.html"]) callouts := doc.find(byAttr("rst-callout", "")) if len(callouts) != len(p.Pages.Idioms) { t.Fatalf("primitives.html has %d callouts, want %d", len(callouts), len(p.Pages.Idioms)) } for i, rule := range p.Pages.Idioms { if got, want := callouts[i].text(), squash(rule.Title+" "+rule.Body); got != want { t.Errorf("rule %d reads %q, want %q", i, got, want) } } // Before the examples: the list of links comes after the last // callout in document order. order := doc.find(func(n node) bool { _, callout := n.attr("rst-callout") return callout || n.hasClass("ds-routes") }) if len(order) != len(p.Pages.Idioms)+1 || !order[len(order)-1].hasClass("ds-routes") { t.Errorf("primitives.html's order is not rules then examples") } } // TestTheSigninPageHasItsSummary: the sign-in example, which has no // sentence of its own at the seed, shows the one copy review gave it. func TestTheSigninPageHasItsSummary(t *testing.T) { p := loadedProse(t) want := squash(p.Examples["signin"].Blurb) if want == "" { t.Fatal("examples.signin.blurb is empty") } page := render(t, "")[fiesta.ThemeNames()[0]+"/en/signin.html"] sub, ok := parse(t, page).first(byAttr("rst-page-header-sub", "")) if !ok { t.Fatal("signin.html has no rst-page-header-sub under its title") } if sub.text() != want { t.Errorf("signin.html's summary reads %q, want %q", sub.text(), want) } }