rastrillo / fiesta Public

Clone
git clone https://amadan.net/rastrillo/fiesta

Plain git — no account needed to clone.

Download

Download this file

1package gallery
2
3import (
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
20func 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.
30type 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.
34func 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
43func (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
52func (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.
64func (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.
82func (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.
91func (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
106func byClass(class string) func(node) bool {
107 return func(n node) bool { return n.hasClass(class) }
108}
109
110func 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.
118func squash(s string) string { return strings.Join(strings.Fields(s), " ") }
119
120// TestEveryFamilyHasAnExplanation: all ten families have a title and a
121// blurb.
122func 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.
144func 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.
155func 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.
175func 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.
189func 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.
219func 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.
229func 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.
240func 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.
267func 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.
290func 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.
322func 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.
364func 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.
401func 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