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 "html"
5 "regexp"
6 "sort"
7 "strings"
8 "sync"
9 "testing"
10 "testing/fstest"
11
12 xhtml "golang.org/x/net/html"
13
14 "amadan.net/rastrillo/fiesta"
15 "amadan.net/rastrillo/fiesta/example"
16)
17
18// render is one tree at one mount, failed loudly. Render is
19// deterministic and a tree is thirty-six directories, so one render
20// per mount serves every test in the package; each gets its own copy
21// of the map, so a test that adds files beside the tree changes
22// nothing another reads.
23func render(t *testing.T, mount string) map[string][]byte {
24 t.Helper()
25 renderMu.Lock()
26 defer renderMu.Unlock()
27 files, ok := rendered[mount]
28 if !ok {
29 var err error
30 if files, err = Render(fiesta.FS(), Options{Mount: mount}); err != nil {
31 t.Fatalf("Render: %v", err)
32 }
33 rendered[mount] = files
34 }
35 out := make(map[string][]byte, len(files))
36 for name, body := range files {
37 out[name] = body
38 }
39 return out
40}
41
42var (
43 renderMu sync.Mutex
44 rendered = map[string]map[string][]byte{}
45)
46
47func manifest(t *testing.T) *example.Manifest {
48 t.Helper()
49 m, err := example.Load(fiesta.FS())
50 if err != nil {
51 t.Fatal(err)
52 }
53 return m
54}
55
56// attrsOf walks doc and calls fn for every element with its lower-case
57// tag name and attributes, so a test reads URLs the way a browser
58// would rather than with a regexp over the bytes.
59func attrsOf(t *testing.T, doc []byte, fn func(tag string, attrs map[string]string)) {
60 t.Helper()
61 z := xhtml.NewTokenizer(strings.NewReader(string(doc)))
62 for {
63 switch z.Next() {
64 case xhtml.ErrorToken:
65 return
66 case xhtml.StartTagToken, xhtml.SelfClosingTagToken:
67 name, more := z.TagName()
68 attrs := map[string]string{}
69 for more {
70 var k, v []byte
71 k, v, more = z.TagAttr()
72 attrs[string(k)] = string(v)
73 }
74 fn(string(name), attrs)
75 }
76 }
77}
78
79// galleryURLs are the URLs the gallery owns on one page: every
80// stylesheet, script, image and frame, and every link marked
81// data-gallery-nav. A sample's own links are not among them.
82func galleryURLs(t *testing.T, doc []byte) []string {
83 t.Helper()
84 var out []string
85 attrsOf(t, doc, func(tag string, attrs map[string]string) {
86 switch tag {
87 case "link":
88 out = append(out, attrs["href"])
89 case "script", "img", "iframe":
90 if v, ok := attrs["src"]; ok {
91 out = append(out, v)
92 }
93 case "a":
94 if _, ok := attrs["data-gallery-nav"]; ok {
95 out = append(out, attrs["href"])
96 }
97 }
98 })
99 return out
100}
101
102// TestRenderWritesEveryStateInEveryTheme: the tree holds every frame
103// document frameFiles names for every example, per theme and locale,
104// and no other frame; and the gallery page, the index, the tokens and
105// the icons pages beside them. Preservation: part 3's test, walking
106// Locales in place of the two directions and counting frames through
107// the frameFiles seam.
108func TestRenderWritesEveryStateInEveryTheme(t *testing.T) {
109 files := render(t, "")
110 s := newTestSite(t, fiesta.FS(), "")
111 m := s.m
112 perDir := 0
113 for _, theme := range fiesta.ThemeNames() {
114 for _, locale := range Locales {
115 dir := theme + "/" + locale + "/"
116 for _, page := range []string{"index.html", "tokens.html", "icons.html"} {
117 if _, ok := files[dir+page]; !ok {
118 t.Errorf("no %s%s", dir, page)
119 }
120 }
121 for _, e := range m.Examples {
122 if _, ok := files[dir+e.Name+".html"]; !ok {
123 t.Errorf("no gallery page %s%s.html", dir, e.Name)
124 }
125 for _, f := range s.frameFiles(e) {
126 if theme == fiesta.ThemeNames()[0] && locale == "en" {
127 perDir++
128 }
129 if _, ok := files[dir+f]; !ok {
130 t.Errorf("no frame %s%s", dir, f)
131 }
132 }
133 }
134 }
135 }
136 if perDir == 0 {
137 t.Fatal("frameFiles names no document; the loop above checked nothing")
138 }
139 frames := 0
140 for name := range files {
141 if strings.Contains(name, "/frames/") {
142 frames++
143 }
144 }
145 if want := perDir * len(fiesta.ThemeNames()) * len(Locales); frames != want {
146 t.Errorf("the tree holds %d frame documents, want %d (%d per directory x %d themes x %d locales)", frames, want, perDir, len(fiesta.ThemeNames()), len(Locales))
147 }
148 if _, ok := files["index.html"]; !ok {
149 t.Error("no index.html at the tree root")
150 }
151}
152
153// TestEveryGalleryURLResolves: at a mount other than the root, every
154// URL the gallery owns is an absolute path under that mount naming a
155// file the render wrote. A relative URL, or one written for the root,
156// would be a page that works on a developer's machine and nowhere else.
157func TestEveryGalleryURLResolves(t *testing.T) {
158 const mount = "/x"
159 files := render(t, mount)
160 checked, frames := 0, 0
161 for name, body := range files {
162 if !strings.HasSuffix(name, ".html") {
163 continue
164 }
165 for _, u := range galleryURLs(t, body) {
166 checked++
167 if !strings.HasPrefix(u, mount+"/") {
168 t.Errorf("%s links %q, which is not under the mount %s/", name, u, mount)
169 continue
170 }
171 path, _, _ := strings.Cut(strings.TrimPrefix(u, mount+"/"), "#")
172 if _, ok := files[path]; !ok {
173 t.Errorf("%s links %q, and the render wrote no %s", name, u, path)
174 }
175 if strings.Contains(path, "/frames/") {
176 frames++
177 }
178 }
179 }
180 if checked == 0 || frames == 0 {
181 t.Fatalf("checked %d URLs and %d of them frames; the walk above read no gallery page", checked, frames)
182 }
183}
184
185// highlighted is a highlight element, which a reader does not see and
186// the copied text does not hold.
187var highlighted = regexp.MustCompile(`</?ds-[tavxs]>`)
188
189// htmlTab returns the text a reader sees in one state's HTML tab on its
190// gallery page: the highlighted source with its elements removed and
191// its entities decoded. The tab is the first source block after the
192// state's marker, read from its <pre class="ds-src">, so the <code> in
193// the wrapper line above the block is never taken for it.
194func htmlTab(t *testing.T, page []byte, name, state string) string {
195 t.Helper()
196 marker := `data-gallery-state="` + name + "/" + state + `"`
197 i := strings.Index(string(page), marker)
198 if i < 0 {
199 t.Fatalf("%s/%s: the gallery page has no %s", name, state, marker)
200 }
201 rest := string(page)[i:]
202 pre := strings.Index(rest, `<pre class="ds-src`)
203 if pre < 0 {
204 t.Fatalf("%s/%s: no source block after its marker", name, state)
205 }
206 rest = rest[pre:]
207 open := strings.Index(rest, "<code>")
208 shut := strings.Index(rest, "</code>")
209 if open < 0 || shut < open {
210 t.Fatalf("%s/%s: no <code> block after its marker", name, state)
211 }
212 return html.UnescapeString(highlighted.ReplaceAllString(rest[open+len("<code>"):shut], ""))
213}
214
215// TestSampleURLsStayInTheMarkup: the routes a sample stands for are
216// shown as written in its HTML tab, where a reader copies them from,
217// and none of them is the href or action of anything in a frame
218// document, where a click or a submit would otherwise request it. In a
219// frame's body an href or action is either a fragment or a link the
220// gallery owns, marked data-gallery-nav and under the mount, which
221// TestEveryGalleryURLResolves then resolves; never a sample route.
222// Rendered at a mount, so an absolute route could not pass as the
223// gallery's. Preservation: part 3's test on day/en, with the one rule
224// the live shell demos need learnt; its control stays the sample-route
225// case.
226func TestSampleURLsStayInTheMarkup(t *testing.T) {
227 const mount = "/x"
228 files := render(t, mount)
229 m := manifest(t)
230 theme := fiesta.ThemeNames()[0]
231 routes := map[string]bool{}
232 tabs := 0
233 for _, e := range m.Examples {
234 page, ok := files[theme+"/en/"+e.Name+".html"]
235 if !ok {
236 t.Fatalf("no gallery page for %s", e.Name)
237 }
238 for _, s := range e.States {
239 if len(s.SampleURLs) == 0 {
240 continue
241 }
242 tabs++
243 tab := htmlTab(t, page, e.Name, s.ID)
244 for _, u := range s.SampleURLs {
245 routes[u] = true
246 if !strings.Contains(tab, u) {
247 t.Errorf("%s/%s: the HTML tab does not show %q", e.Name, s.ID, u)
248 }
249 }
250 }
251 }
252 if tabs == 0 {
253 t.Fatal("no state declares a sample URL; the check above read nothing")
254 }
255
256 frames, seen := 0, 0
257 for name, body := range files {
258 if !strings.Contains(name, "/frames/") {
259 continue
260 }
261 frames++
262 inBody := false
263 attrsOf(t, body, func(tag string, attrs map[string]string) {
264 if tag == "body" {
265 inBody = true
266 }
267 if !inBody {
268 return
269 }
270 for _, attr := range []string{"href", "action"} {
271 v, ok := attrs[attr]
272 if !ok {
273 continue
274 }
275 seen++
276 if routes[v] {
277 t.Errorf("%s: <%s %s=%q> would request a sample route", name, tag, attr, v)
278 }
279 if _, nav := attrs["data-gallery-nav"]; nav {
280 if !strings.HasPrefix(v, mount+"/") {
281 t.Errorf("%s: <%s %s=%q> is the gallery's and not under the mount", name, tag, attr, v)
282 }
283 } else if !strings.HasPrefix(v, "#") {
284 t.Errorf("%s: <%s %s=%q> is not a fragment", name, tag, attr, v)
285 }
286 }
287 })
288 }
289 if frames == 0 || seen == 0 {
290 t.Fatalf("%d frames with %d hrefs and actions between them; the check above read nothing", frames, seen)
291 }
292}
293
294// placeholder is how a label that has not been through copy review is
295// written while the code is built around it. None may reach a page.
296const placeholder = "TODO-COPY:"
297
298// TestTheGalleryCarriesNoPlaceholderCopy fails while any label is still
299// a placeholder: the table itself, label by label, since a label no
300// page renders today is still a label; and the render, so the
301// stylesheet and scripts it embeds are read too.
302func TestTheGalleryCarriesNoPlaceholderCopy(t *testing.T) {
303 for id, v := range text {
304 if strings.HasPrefix(v, placeholder) {
305 t.Errorf("%s is still a placeholder, waiting for copy review", id)
306 }
307 }
308 files := render(t, "")
309 if len(files) == 0 {
310 t.Fatal("the render is empty; the scan below would read nothing")
311 }
312 var names []string
313 for name := range files {
314 names = append(names, name)
315 }
316 sort.Strings(names)
317 left := map[string]bool{}
318 for _, name := range names {
319 for _, m := range regexp.MustCompile(placeholder+`[a-z0-9.-]+`).FindAll(files[name], -1) {
320 left[string(m)] = true
321 }
322 }
323 if len(left) > 0 {
324 var ids []string
325 for id := range left {
326 ids = append(ids, id)
327 }
328 sort.Strings(ids)
329 t.Errorf("%d placeholders are still in the render, waiting for copy review:\n %s", len(ids), strings.Join(ids, "\n "))
330 }
331}
332
333// TestRTLDocumentsReadRightToLeft: every document under a theme's ar/
334// says lang="ar" dir="rtl" on its <html>, gallery pages and frames
335// alike, and every document under en/ says lang="en" dir="ltr". The
336// axe leg over the Arabic tree would otherwise scan left-to-right
337// copies. Preservation: part 3's test reading day/ar/ in place of the
338// rtl/ twin.
339func TestRTLDocumentsReadRightToLeft(t *testing.T) {
340 files := render(t, "")
341 rtl, ltr := 0, 0
342 for name, body := range files {
343 if !strings.HasSuffix(name, ".html") {
344 continue
345 }
346 tag := htmlTag.Find(body)
347 if tag == nil {
348 t.Errorf("%s has no <html> tag", name)
349 continue
350 }
351 var want string
352 switch {
353 case strings.Contains(name, "/ar/"):
354 want = `lang="ar" dir="rtl"`
355 rtl++
356 case strings.Contains(name, "/en/"):
357 want = `lang="en" dir="ltr"`
358 ltr++
359 default:
360 continue
361 }
362 if !strings.Contains(string(tag), want) {
363 t.Errorf("%s: %s does not say %s", name, tag, want)
364 }
365 }
366 if rtl == 0 || ltr == 0 {
367 t.Fatalf("%d right-to-left and %d left-to-right documents; the loop above read one side only", rtl, ltr)
368 }
369}
370
371// TestTheHeaderLiftsOnlyTheSchemeMenu: when the scheme-menu example
372// shows its menu inside a row, as the footer row's states will, the
373// header lifts the menu and not the row around it. A lifted
374// <footer rst-page-foot> would put the page's foot row inside the
375// gallery's top bar, styled as a foot. The tree is fiesta.FS() with the
376// standalone state's body rebuilt as its menu alone inside one footer
377// row, whatever row the live example already shows it in, rendered for
378// Day in English, which is all the header needs.
379func TestTheHeaderLiftsOnlyTheSchemeMenu(t *testing.T) {
380 fsys := copyFS(t, fiesta.FS())
381 const file = "examples/scheme-menu/standalone.html"
382 doc := string(fsys[file].Data)
383 body, err := example.Markup(fsys, "scheme-menu", "standalone")
384 if err != nil {
385 t.Fatal(err)
386 }
387 scheme, err := example.Element(body, "rst-scheme")
388 if err != nil {
389 t.Fatal(err)
390 }
391 open, end := strings.Index(doc, "<body>"), strings.Index(doc, "</body>")
392 if open < 0 || end < open {
393 t.Fatalf("%s has no <body> and </body> to rebuild between", file)
394 }
395 wrapped := doc[:open] + "<body>\n<footer rst-page-foot>\n" + scheme + "\n</footer>\n" + doc[end:]
396 if n := strings.Count(wrapped, "rst-page-foot"); n != 1 {
397 t.Fatalf("the rebuilt %s carries rst-page-foot %d times, want once: the menu itself holds a footer row", file, n)
398 }
399 fsys[file] = &fstest.MapFile{Data: []byte(wrapped)}
400 s := newTestSite(t, fsys, "")
401 if err := s.theme("day", "en"); err != nil {
402 t.Fatalf("rendering day/en: %v", err)
403 }
404 page, ok := s.out["day/en/index.html"]
405 if !ok {
406 t.Fatal("the render wrote no day/en/index.html")
407 }
408 bar, ok := parse(t, page).first(func(n node) bool { return n.hasClass("ds-top__controls") })
409 if !ok {
410 t.Fatal("day/en/index.html has no .ds-top__controls bar")
411 }
412 menu, ok := bar.first(func(n node) bool { return hasAttr(n, "rst-scheme") })
413 switch {
414 case !ok:
415 t.Fatal("the bar holds no [rst-scheme] element")
416 case menu.n.Data != "details" || !hasAttr(menu, "rst-dropdown"):
417 t.Errorf("the bar's [rst-scheme] element is a <%s>, want <details rst-dropdown rst-scheme>", menu.n.Data)
418 case hasAttr(node{menu.n.Parent}, "rst-page-foot"):
419 t.Errorf("the bar's scheme menu sits in a <%s rst-page-foot>: the header lifted the example's row with it", menu.n.Parent.Data)
420 }
421}
422