| 1 | package gallery |
| 2 | |
| 3 | import ( |
| 4 | "slices" |
| 5 | "strconv" |
| 6 | "strings" |
| 7 | "testing" |
| 8 | "testing/fstest" |
| 9 | |
| 10 | xhtml "golang.org/x/net/html" |
| 11 | |
| 12 | "amadan.net/rastrillo/fiesta" |
| 13 | ) |
| 14 | |
| 15 | // The design system's explanations: that the file is whole, that it |
| 16 | // names only what the manifest has, that it teaches nothing of |
| 17 | // rastrillo's, and that the render shows every piece where the page |
| 18 | // says it goes. |
| 19 | |
| 20 | func loadedProse(t *testing.T) *prose { |
| 21 | t.Helper() |
| 22 | p, err := loadProse(fiesta.FS()) |
| 23 | if err != nil { |
| 24 | t.Fatalf("loadProse: %v", err) |
| 25 | } |
| 26 | return p |
| 27 | } |
| 28 | |
| 29 | // node is one element of a parsed page. |
| 30 | type node struct{ n *xhtml.Node } |
| 31 | |
| 32 | // parse reads a rendered page into a tree, so a test reads it the way |
| 33 | // a browser does rather than with a regexp over the bytes. |
| 34 | func parse(t *testing.T, doc []byte) node { |
| 35 | t.Helper() |
| 36 | n, err := xhtml.Parse(strings.NewReader(string(doc))) |
| 37 | if err != nil { |
| 38 | t.Fatal(err) |
| 39 | } |
| 40 | return node{n} |
| 41 | } |
| 42 | |
| 43 | func (n node) attr(name string) (string, bool) { |
| 44 | for _, a := range n.n.Attr { |
| 45 | if a.Key == name { |
| 46 | return a.Val, true |
| 47 | } |
| 48 | } |
| 49 | return "", false |
| 50 | } |
| 51 | |
| 52 | func (n node) hasClass(class string) bool { |
| 53 | v, _ := n.attr("class") |
| 54 | for _, c := range strings.Fields(v) { |
| 55 | if c == class { |
| 56 | return true |
| 57 | } |
| 58 | } |
| 59 | return false |
| 60 | } |
| 61 | |
| 62 | // find returns every element under n, in document order, that pred |
| 63 | // accepts. |
| 64 | func (n node) find(pred func(node) bool) []node { |
| 65 | var out []node |
| 66 | var walk func(*xhtml.Node) |
| 67 | walk = func(x *xhtml.Node) { |
| 68 | if x.Type == xhtml.ElementNode && pred(node{x}) { |
| 69 | out = append(out, node{x}) |
| 70 | } |
| 71 | for c := x.FirstChild; c != nil; c = c.NextSibling { |
| 72 | walk(c) |
| 73 | } |
| 74 | } |
| 75 | for c := n.n.FirstChild; c != nil; c = c.NextSibling { |
| 76 | walk(c) |
| 77 | } |
| 78 | return out |
| 79 | } |
| 80 | |
| 81 | // first is the first element pred accepts, or a zero node. |
| 82 | func (n node) first(pred func(node) bool) (node, bool) { |
| 83 | all := n.find(pred) |
| 84 | if len(all) == 0 { |
| 85 | return node{}, false |
| 86 | } |
| 87 | return all[0], true |
| 88 | } |
| 89 | |
| 90 | // text is the element's text content, whitespace collapsed. |
| 91 | func (n node) text() string { |
| 92 | var b strings.Builder |
| 93 | var walk func(*xhtml.Node) |
| 94 | walk = func(x *xhtml.Node) { |
| 95 | if x.Type == xhtml.TextNode { |
| 96 | b.WriteString(x.Data) |
| 97 | } |
| 98 | for c := x.FirstChild; c != nil; c = c.NextSibling { |
| 99 | walk(c) |
| 100 | } |
| 101 | } |
| 102 | walk(n.n) |
| 103 | return strings.Join(strings.Fields(b.String()), " ") |
| 104 | } |
| 105 | |
| 106 | func byClass(class string) func(node) bool { |
| 107 | return func(n node) bool { return n.hasClass(class) } |
| 108 | } |
| 109 | |
| 110 | func byAttr(name, value string) func(node) bool { |
| 111 | return func(n node) bool { |
| 112 | v, ok := n.attr(name) |
| 113 | return ok && (value == "" || v == value) |
| 114 | } |
| 115 | } |
| 116 | |
| 117 | // squash is a prose string the way text() reads it off the page. |
| 118 | func squash(s string) string { return strings.Join(strings.Fields(s), " ") } |
| 119 | |
| 120 | // TestEveryFamilyHasAnExplanation: all ten families have a title and a |
| 121 | // blurb. |
| 122 | func TestEveryFamilyHasAnExplanation(t *testing.T) { |
| 123 | p := loadedProse(t) |
| 124 | fams := groupFamilies(manifest(t).Examples) |
| 125 | if len(fams) != 10 { |
| 126 | t.Fatalf("the manifest has %d families, not 10", len(fams)) |
| 127 | } |
| 128 | for _, f := range fams { |
| 129 | fp, ok := p.Families[f.Key] |
| 130 | if !ok { |
| 131 | t.Errorf("families.%s: no entry", f.Key) |
| 132 | continue |
| 133 | } |
| 134 | if fp.Title == "" { |
| 135 | t.Errorf("families.%s.title is empty", f.Key) |
| 136 | } |
| 137 | if fp.Blurb == "" { |
| 138 | t.Errorf("families.%s.blurb is empty", f.Key) |
| 139 | } |
| 140 | } |
| 141 | } |
| 142 | |
| 143 | // TestEveryExampleHasAnExplanation: every manifest entry has a blurb. |
| 144 | func TestEveryExampleHasAnExplanation(t *testing.T) { |
| 145 | p := loadedProse(t) |
| 146 | for _, e := range manifest(t).Examples { |
| 147 | if p.Examples[e.Name].Blurb == "" { |
| 148 | t.Errorf("examples.%s.blurb is empty", e.Name) |
| 149 | } |
| 150 | } |
| 151 | } |
| 152 | |
| 153 | // TestProseNamesOnlyRealExamplesAndStates: no slot in the prose names |
| 154 | // a family, example, state or idiom the manifest does not have. |
| 155 | func TestProseNamesOnlyRealExamplesAndStates(t *testing.T) { |
| 156 | p := loadedProse(t) |
| 157 | for _, s := range p.strays(manifest(t)) { |
| 158 | t.Error(s) |
| 159 | } |
| 160 | // The control: the check sees a slot that names nothing. |
| 161 | bad := &prose{Examples: map[string]exampleProse{ |
| 162 | "no-such-example": {Blurb: "x"}, |
| 163 | "button": {States: map[string]string{"no-such-state": "x"}}, |
| 164 | "signin": {Warnings: map[string]warning{"no-such-state": {Title: "x", Body: "y"}}}, |
| 165 | }} |
| 166 | if got := bad.strays(manifest(t)); len(got) != 3 { |
| 167 | t.Fatalf("the control has three strays; strays found %d: %v", len(got), got) |
| 168 | } |
| 169 | } |
| 170 | |
| 171 | // teaches lists every string in p the exporter's rule would queue: |
| 172 | // what a string says that is rastrillo's and not Fiesta's, with the |
| 173 | // reason. A match is a string that should have gone through copy |
| 174 | // review, or came back from it still saying it. |
| 175 | func teaches(p *prose) []string { |
| 176 | var out []string |
| 177 | p.each(func(id, text string) { |
| 178 | if why := proseReason(text); why != "" { |
| 179 | out = append(out, id+": "+why) |
| 180 | } |
| 181 | }) |
| 182 | return out |
| 183 | } |
| 184 | |
| 185 | // TestNoProseTeachesRastrillo: the exporter's rule, the word list, |
| 186 | // the template arguments and the script names, applied to prose.json, |
| 187 | // finds nothing. Its controls are a scratch prose.json per part of |
| 188 | // the rule. |
| 189 | func TestNoProseTeachesRastrillo(t *testing.T) { |
| 190 | p := loadedProse(t) |
| 191 | n := 0 |
| 192 | p.each(func(string, string) { n++ }) |
| 193 | if n < 100 { |
| 194 | t.Fatalf("the prose has %d strings; the walk read too little to mean anything", n) |
| 195 | } |
| 196 | for _, hit := range teaches(p) { |
| 197 | t.Error(hit) |
| 198 | } |
| 199 | // The controls: one string per part of the rule, each a scratch |
| 200 | // prose.json with that string in the scheme menu's blurb. |
| 201 | controls := []struct{ name, text, why string }{ |
| 202 | {"the word list", "Posts to /_theme.", "mentions /_theme"}, |
| 203 | {"a template argument", "Add Lead to the row.", "uses the template argument Lead"}, |
| 204 | {"a script Fiesta does not ship", "Load missing-widget.js first.", "names missing-widget.js, which Fiesta does not ship"}, |
| 205 | } |
| 206 | for _, c := range controls { |
| 207 | scratch := fstest.MapFS{proseFile: &fstest.MapFile{Data: []byte(`{"families":{},"examples":{"scheme-menu":{"blurb":` + strconv.Quote(c.text) + `}},"pages":{"idioms":[]}}`)}} |
| 208 | control, err := loadProse(scratch) |
| 209 | if err != nil { |
| 210 | t.Fatal(err) |
| 211 | } |
| 212 | if got, want := teaches(control), []string{"examples.scheme-menu.blurb: " + c.why}; !slices.Equal(got, want) { |
| 213 | t.Errorf("the control for %s, %q, should be caught as %v; teaches found %v", c.name, c.text, want, got) |
| 214 | } |
| 215 | } |
| 216 | } |
| 217 | |
| 218 | // stateSection is the section of one state on its example's page. |
| 219 | func stateSection(t *testing.T, page []byte, name, state string) node { |
| 220 | t.Helper() |
| 221 | sec, ok := parse(t, page).first(byAttr("data-gallery-state", name+"/"+state)) |
| 222 | if !ok { |
| 223 | t.Fatalf("%s.html has no section for state %s", name, state) |
| 224 | } |
| 225 | return sec |
| 226 | } |
| 227 | |
| 228 | // noteOf is the note under a state's label, or "" when there is none. |
| 229 | func noteOf(sec node) string { |
| 230 | note, ok := sec.first(byClass("ds-note")) |
| 231 | if !ok { |
| 232 | return "" |
| 233 | } |
| 234 | return note.text() |
| 235 | } |
| 236 | |
| 237 | // TestTheDropdownIdiomNoteSitsOnItsState: the dropdown example is a |
| 238 | // component with the dropdown idiom as one of its states, and the |
| 239 | // idiom's sentence is the note on that state, not the example's blurb. |
| 240 | func TestTheDropdownIdiomNoteSitsOnItsState(t *testing.T) { |
| 241 | m := manifest(t) |
| 242 | name, state, ok := m.Idiom("dropdown") |
| 243 | if !ok { |
| 244 | t.Fatal("the manifest has no dropdown idiom") |
| 245 | } |
| 246 | e, _ := m.Entry(name) |
| 247 | if e.Kind == "idiom" || len(e.States) < 2 { |
| 248 | t.Fatalf("%s is a %s with %d states; the idiom should ride on a component", name, e.Kind, len(e.States)) |
| 249 | } |
| 250 | p := loadedProse(t) |
| 251 | note := p.Examples[name].States[state] |
| 252 | if note == "" { |
| 253 | t.Fatalf("examples.%s.states.%s is empty", name, state) |
| 254 | } |
| 255 | if note == p.Examples[name].Blurb { |
| 256 | t.Errorf("examples.%s.blurb is the idiom's sentence; the component's should be", name) |
| 257 | } |
| 258 | page := render(t, "")[fiesta.ThemeNames()[0]+"/en/"+name+".html"] |
| 259 | if got := noteOf(stateSection(t, page, name, state)); got != squash(note) { |
| 260 | t.Errorf("%s/%s shows the note %q, want %q", name, state, got, note) |
| 261 | } |
| 262 | } |
| 263 | |
| 264 | // TestTheStandaloneSchemeStateSaysNoServer: the standalone state's |
| 265 | // note is Fiesta's own, and not the rastrillo note that describes a |
| 266 | // server. |
| 267 | func TestTheStandaloneSchemeStateSaysNoServer(t *testing.T) { |
| 268 | const name, state = "scheme-menu", "standalone" |
| 269 | const seed = "This one posts to /_theme, so it needs a server." |
| 270 | p := loadedProse(t) |
| 271 | note := p.Examples[name].States[state] |
| 272 | if note == "" { |
| 273 | t.Fatalf("examples.%s.states.%s is empty", name, state) |
| 274 | } |
| 275 | if strings.Contains(note, seed) || strings.Contains(note, "/_theme") { |
| 276 | t.Errorf("examples.%s.states.%s is the rastrillo note: %q", name, state, note) |
| 277 | } |
| 278 | page := render(t, "")[fiesta.ThemeNames()[0]+"/en/"+name+".html"] |
| 279 | if got := noteOf(stateSection(t, page, name, state)); got != squash(note) { |
| 280 | t.Errorf("%s/%s shows the note %q, want %q", name, state, got, note) |
| 281 | } |
| 282 | } |
| 283 | |
| 284 | // TestTheFamilyPagesShowTheFamilyBlurbs: every family's page has the |
| 285 | // family's title as its heading and its blurb under it, in English and |
| 286 | // in Arabic, each in the page's language. Preservation: part 3's |
| 287 | // TestTheIndexShowsTheFamilyBlurbs, reading the family pages the |
| 288 | // index's sections became, and reading the words through the catalog |
| 289 | // since the pages speak it. |
| 290 | func TestTheFamilyPagesShowTheFamilyBlurbs(t *testing.T) { |
| 291 | p := loadedProse(t) |
| 292 | files := render(t, "") |
| 293 | s := newTestSite(t, fiesta.FS(), "") |
| 294 | for _, locale := range []string{"en", "ar"} { |
| 295 | for _, f := range groupFamilies(manifest(t).Examples) { |
| 296 | name := fiesta.ThemeNames()[0] + "/" + locale + "/" + f.Key + ".html" |
| 297 | doc := parse(t, files[name]) |
| 298 | h, ok := doc.first(func(n node) bool { return n.n.Data == "h1" }) |
| 299 | if !ok { |
| 300 | t.Errorf("%s has no heading", name) |
| 301 | continue |
| 302 | } |
| 303 | if want := s.proseIn(locale, "families."+f.Key+".title", p.Families[f.Key].Title); h.text() != want { |
| 304 | t.Errorf("%s is headed %q, want %q", name, h.text(), want) |
| 305 | } |
| 306 | lead, ok := doc.first(byClass("ds-lead")) |
| 307 | if !ok { |
| 308 | t.Errorf("%s has no blurb under its heading", name) |
| 309 | continue |
| 310 | } |
| 311 | if want := squash(s.proseIn(locale, "families."+f.Key+".blurb", p.Families[f.Key].Blurb)); want == "" || lead.text() != want { |
| 312 | t.Errorf("%s shows %q, want %q", name, lead.text(), want) |
| 313 | } |
| 314 | } |
| 315 | } |
| 316 | } |
| 317 | |
| 318 | // TestAnExamplePageShowsItsBlurbAndNotes: every example page carries |
| 319 | // its family's heading and blurb, its own blurb under its title, and |
| 320 | // each state's note under that state's label, and no note where the |
| 321 | // prose has none. |
| 322 | func TestAnExamplePageShowsItsBlurbAndNotes(t *testing.T) { |
| 323 | p := loadedProse(t) |
| 324 | files := render(t, "") |
| 325 | theme := fiesta.ThemeNames()[0] |
| 326 | blurbs, notes := 0, 0 |
| 327 | for _, e := range manifest(t).Examples { |
| 328 | page := files[theme+"/en/"+e.Name+".html"] |
| 329 | doc := parse(t, page) |
| 330 | head, ok := doc.first(byClass("ds-kicker")) |
| 331 | if !ok { |
| 332 | t.Errorf("%s.html has no family heading", e.Name) |
| 333 | } else if want := squash(p.Families[e.Family].Title + " " + p.Families[e.Family].Blurb); head.text() != want { |
| 334 | t.Errorf("%s.html: the family heading reads %q, want %q", e.Name, head.text(), want) |
| 335 | } |
| 336 | sub, ok := doc.first(byAttr("rst-page-header-sub", "")) |
| 337 | if !ok { |
| 338 | t.Errorf("%s.html has no rst-page-header-sub under its title", e.Name) |
| 339 | } else if want := squash(p.Examples[e.Name].Blurb); want == "" || sub.text() != want { |
| 340 | t.Errorf("%s.html: the blurb reads %q, want %q", e.Name, sub.text(), want) |
| 341 | } else { |
| 342 | blurbs++ |
| 343 | } |
| 344 | for _, st := range e.States { |
| 345 | want := squash(p.Examples[e.Name].States[st.ID]) |
| 346 | got := noteOf(stateSection(t, page, e.Name, st.ID)) |
| 347 | if got != want { |
| 348 | t.Errorf("%s/%s shows the note %q, want %q", e.Name, st.ID, got, want) |
| 349 | } |
| 350 | if want != "" { |
| 351 | notes++ |
| 352 | } |
| 353 | } |
| 354 | } |
| 355 | if blurbs == 0 || notes == 0 { |
| 356 | t.Fatalf("%d blurbs and %d notes shown; the walk above read nothing", blurbs, notes) |
| 357 | } |
| 358 | } |
| 359 | |
| 360 | // TestTheIdiomRulesHeadThePrimitivesFamily: the two idiom rules are |
| 361 | // quoted on the primitives family's page, before its examples, and on |
| 362 | // no other family's page. Preservation: part 3's test, reading the |
| 363 | // family pages in place of the index's sections. |
| 364 | func TestTheIdiomRulesHeadThePrimitivesFamily(t *testing.T) { |
| 365 | p := loadedProse(t) |
| 366 | if len(p.Pages.Idioms) != 2 { |
| 367 | t.Fatalf("the prose has %d idiom rules, want 2", len(p.Pages.Idioms)) |
| 368 | } |
| 369 | files := render(t, "") |
| 370 | for _, f := range groupFamilies(manifest(t).Examples) { |
| 371 | if f.Key == "primitives" { |
| 372 | continue |
| 373 | } |
| 374 | if n := len(parse(t, files[fiesta.ThemeNames()[0]+"/en/"+f.Key+".html"]).find(byAttr("rst-callout", ""))); n != 0 { |
| 375 | t.Errorf("%s.html has %d callouts, want none", f.Key, n) |
| 376 | } |
| 377 | } |
| 378 | doc := parse(t, files[fiesta.ThemeNames()[0]+"/en/primitives.html"]) |
| 379 | callouts := doc.find(byAttr("rst-callout", "")) |
| 380 | if len(callouts) != len(p.Pages.Idioms) { |
| 381 | t.Fatalf("primitives.html has %d callouts, want %d", len(callouts), len(p.Pages.Idioms)) |
| 382 | } |
| 383 | for i, rule := range p.Pages.Idioms { |
| 384 | if got, want := callouts[i].text(), squash(rule.Title+" "+rule.Body); got != want { |
| 385 | t.Errorf("rule %d reads %q, want %q", i, got, want) |
| 386 | } |
| 387 | } |
| 388 | // Before the examples: the list of links comes after the last |
| 389 | // callout in document order. |
| 390 | order := doc.find(func(n node) bool { |
| 391 | _, callout := n.attr("rst-callout") |
| 392 | return callout || n.hasClass("ds-routes") |
| 393 | }) |
| 394 | if len(order) != len(p.Pages.Idioms)+1 || !order[len(order)-1].hasClass("ds-routes") { |
| 395 | t.Errorf("primitives.html's order is not rules then examples") |
| 396 | } |
| 397 | } |
| 398 | |
| 399 | // TestTheSigninPageHasItsSummary: the sign-in example, which has no |
| 400 | // sentence of its own at the seed, shows the one copy review gave it. |
| 401 | func TestTheSigninPageHasItsSummary(t *testing.T) { |
| 402 | p := loadedProse(t) |
| 403 | want := squash(p.Examples["signin"].Blurb) |
| 404 | if want == "" { |
| 405 | t.Fatal("examples.signin.blurb is empty") |
| 406 | } |
| 407 | page := render(t, "")[fiesta.ThemeNames()[0]+"/en/signin.html"] |
| 408 | sub, ok := parse(t, page).first(byAttr("rst-page-header-sub", "")) |
| 409 | if !ok { |
| 410 | t.Fatal("signin.html has no rst-page-header-sub under its title") |
| 411 | } |
| 412 | if sub.text() != want { |
| 413 | t.Errorf("signin.html's summary reads %q, want %q", sub.text(), want) |
| 414 | } |
| 415 | } |
| 416 | |