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/template"
5 "regexp"
6 "strings"
7 "testing"
8
9 "amadan.net/rastrillo/fiesta"
10)
11
12// The demo application and the modal route as the render writes them:
13// four documents in the sidebar shell Fiesta ships, the modal at an
14// address of its own, the Overview framing the demo, and nothing else
15// in the tree laying a modal over the page it is on. The drives in
16// demo_browser_test.go take over where the engine has to answer.
17
18// demoFiles is the demo's four documents, the index first, keyed by
19// the view each shows.
20var demoFiles = map[string]string{
21 "index": "demo.html",
22 "dashboard": "demo-dashboard.html",
23 "requests": "demo-requests.html",
24 "request": "demo-request.html",
25}
26
27// demoScripts are the scripts a demo document loads, in order: frame.js
28// first, as every document the gallery frames, then the four the spec
29// names, which are the layout's own minus the ones the demo has no
30// control for.
31var demoScripts = []string{"frame.js", "js/fiesta.js", "js/light.js", "js/anchor.js", "js/shell.js"}
32
33// TestTheDemoIsFourDocumentsInTheShippedShell: in every theme and
34// locale the demo application is four documents, each the sidebar
35// shell the layout examples ship (the index on demo.html, a content
36// page on the other three), with the brand Harbour going to the
37// demo's own index, a rail of two links with the current one marked
38// for the view, the way up to the index at the row just left, Ada
39// Lovelace at the foot, the language menu going to the same document
40// in each of the twelve locales with this one marked, the view's own
41// section and no other, a way back to the design system, the scripts
42// the spec names and no others, and no link out of the gallery's own:
43// every href in the body is the gallery's or a fragment.
44func TestTheDemoIsFourDocumentsInTheShippedShell(t *testing.T) {
45 const mount = "/x"
46 files := render(t, mount)
47 s := newTestSite(t, fiesta.FS(), mount)
48 attr := func(n node, name string) string { v, _ := n.attr(name); return v }
49 index := parse(t, files["day/en/frames/layout-sidebar/default.html"])
50 page := parse(t, files["day/en/frames/layout-sidebar-page/default.html"])
51 read := 0
52 for _, dir := range dirs() {
53 theme, locale, _ := strings.Cut(strings.TrimSuffix(dir, "/"), "/")
54 for view, file := range demoFiles {
55 name := dir + file
56 body, ok := files[name]
57 if !ok {
58 t.Errorf("the render wrote no %s", name)
59 continue
60 }
61 read++
62 doc := parse(t, body)
63
64 // The shell, and its skeleton the layout's own.
65 wantShell, layout := "page", page
66 if view == "index" {
67 wantShell, layout = "index", index
68 }
69 if m := shellRoot.FindSubmatch(body); m == nil || string(m[1]) != wantShell {
70 t.Errorf("%s does not open the sidebar shell as %q", name, wantShell)
71 }
72 if got, want := shellSkeleton(doc), shellSkeleton(layout); !sameKeys(got, want) {
73 t.Errorf("%s: the shell's skeleton %v is not the layout's %v", name, keysOf(got), keysOf(want))
74 }
75
76 // The brand, both copies.
77 brands := doc.find(byAttr("rst-shell-brand", ""))
78 if len(brands) == 0 {
79 t.Errorf("%s has no brand", name)
80 }
81 for _, b := range brands {
82 if b.text() != "Harbour" || attr(b, "href") != s.href(theme, locale, "demo.html") || !hasAttr(b, "data-gallery-nav") {
83 t.Errorf("%s: the brand reads %q and goes to %q, want Harbour to the demo's index", name, b.text(), attr(b, "href"))
84 }
85 }
86
87 // The rail: two links, the current one the view's.
88 navs := doc.find(func(n node) bool {
89 return n.n.Data == "a" && within(n, func(p node) bool { return hasAttr(p, "rst-shell-nav") })
90 })
91 current := "nav-requests"
92 if view == "index" || view == "dashboard" {
93 current = "nav-dashboard"
94 }
95 if len(navs) != 2 {
96 t.Errorf("%s: the rail has %d links, want Dashboard and Requests", name, len(navs))
97 } else {
98 for i, want := range []struct{ id, file string }{{"nav-dashboard", "demo-dashboard.html"}, {"nav-requests", "demo-requests.html"}} {
99 a := navs[i]
100 if attr(a, "id") != want.id || attr(a, "href") != s.href(theme, locale, want.file) || !hasAttr(a, "data-gallery-nav") {
101 t.Errorf("%s: rail link %d is id=%q href=%q, want id=%q href=%q", name, i, attr(a, "id"), attr(a, "href"), want.id, s.href(theme, locale, want.file))
102 }
103 if mark := attr(a, "aria-current") == "page"; mark != (want.id == current) {
104 t.Errorf("%s: %s marked current %v, want %v", name, want.id, mark, want.id == current)
105 }
106 }
107 }
108
109 // The way up: the index itself on the index, and the row
110 // just left on a content page.
111 up := doc.find(func(n node) bool {
112 return n.n.Data == "a" && attr(n, "rel") == "up" && within(n, func(p node) bool { return hasAttr(p, "rst-shell-back") })
113 })
114 wantUp := s.href(theme, locale, "demo.html")
115 if view != "index" {
116 wantUp += "#" + current
117 }
118 if len(up) != 1 || attr(up[0], "href") != wantUp || !hasAttr(up[0], "data-gallery-nav") {
119 t.Errorf("%s: %d ways up, want one to %s", name, len(up), wantUp)
120 }
121
122 // The person at the foot.
123 for _, p := range doc.find(byAttr("rst-person-name", "")) {
124 if p.text() != "Ada Lovelace" {
125 t.Errorf("%s: the profile names %q, want Ada Lovelace", name, p.text())
126 }
127 }
128 if strings.Contains(string(body), "Grace Hopper") || strings.Contains(string(body), "Party planner") {
129 t.Errorf("%s still carries the layout's own person or brand", name)
130 }
131
132 // The language menu: the same document in every locale.
133 langs := doc.find(func(n node) bool {
134 return n.n.Data == "a" && within(n, func(p node) bool { return hasAttr(p, "rst-locale") })
135 })
136 if len(langs) != len(Locales) {
137 t.Errorf("%s: %d language links, want %d", name, len(langs), len(Locales))
138 }
139 for _, a := range langs {
140 lang := attr(a, "lang")
141 if attr(a, "href") != s.href(theme, lang, file) || !hasAttr(a, "data-gallery-nav") {
142 t.Errorf("%s: the %s link goes to %q, want %q", name, lang, attr(a, "href"), s.href(theme, lang, file))
143 }
144 if mark := attr(a, "aria-current") == "true"; mark != (lang == locale) {
145 t.Errorf("%s: the %s link marked current %v", name, lang, mark)
146 }
147 }
148
149 // The view, alone.
150 wantView := "view-" + view
151 if view == "index" {
152 wantView = "view-dashboard"
153 }
154 views := doc.find(byClass("app-view"))
155 if len(views) != 1 || attr(views[0], "id") != wantView {
156 var ids []string
157 for _, v := range views {
158 ids = append(ids, attr(v, "id"))
159 }
160 t.Errorf("%s shows the views %v, want exactly %s", name, ids, wantView)
161 }
162
163 // The way back to the design system.
164 back := s.say(locale, "gallery.back")
165 found := false
166 for _, a := range bodyLinks(t, doc) {
167 if a.text() == back && attr(a, "href") == s.href(theme, locale, "index.html") && hasAttr(a, "data-gallery-nav") {
168 found = true
169 }
170 }
171 if !found {
172 t.Errorf("%s has no %q link to the Overview", name, back)
173 }
174
175 // The scripts.
176 var scripts []string
177 for _, sc := range doc.find(func(n node) bool { return n.n.Data == "script" }) {
178 scripts = append(scripts, attr(sc, "src"))
179 }
180 var want []string
181 for _, sc := range demoScripts {
182 want = append(want, s.asset(sc))
183 }
184 if strings.Join(scripts, " ") != strings.Join(want, " ") {
185 t.Errorf("%s loads %v, want %v", name, scripts, want)
186 }
187
188 // Nothing leaves the gallery.
189 for _, a := range bodyLinks(t, doc) {
190 h := attr(a, "href")
191 if !strings.HasPrefix(h, "#") && !(hasAttr(a, "data-gallery-nav") && strings.HasPrefix(h, mount+"/")) {
192 t.Errorf("%s: %q goes to %q, neither the gallery's nor a fragment", name, a.text(), h)
193 }
194 }
195 }
196 }
197 if read != len(dirs())*len(demoFiles) {
198 t.Fatalf("read %d demo documents, want %d", read, len(dirs())*len(demoFiles))
199 }
200}
201
202// sameKeys says whether two sets hold the same keys.
203func sameKeys(a, b map[string]bool) bool {
204 if len(a) != len(b) {
205 return false
206 }
207 for k := range a {
208 if !b[k] {
209 return false
210 }
211 }
212 return true
213}
214
215// keysOf is a set's keys, for a message.
216func keysOf(m map[string]bool) []string {
217 var out []string
218 for k := range m {
219 out = append(out, k)
220 }
221 return out
222}
223
224// liveOverlay is a modal laid out live: the overlay or the backdrop as
225// an element, never as escaped source in a code block.
226var liveOverlay = regexp.MustCompile(`<div rst-(modal-overlay|backdrop)\b`)
227
228// TestNoGalleryPageOpensAModalOverTheGallery: no document under a theme
229// and locale lays a modal over itself but the modal route's own,
230// demo-modal.html (the frames are documents of their own, framed, and
231// excluded); the modal example's page shows the overlay as source and
232// links the route in a new tab; and on the route itself the panel's
233// tabs link the route, Close and the back button go to the Overview,
234// and the backdrop holds a page that is inert.
235func TestNoGalleryPageOpensAModalOverTheGallery(t *testing.T) {
236 const mount = "/x"
237 files := render(t, mount)
238 s := newTestSite(t, fiesta.FS(), mount)
239 attr := func(n node, name string) string { v, _ := n.attr(name); return v }
240 routes := 0
241 for name, body := range files {
242 if !strings.HasSuffix(name, ".html") || isFrame(name) || strings.HasSuffix(name, "/demo-modal.html") {
243 continue
244 }
245 if m := liveOverlay.Find(body); m != nil {
246 t.Errorf("%s lays out a live %s over the page", name, m)
247 }
248 }
249 for _, dir := range dirs() {
250 theme, locale, _ := strings.Cut(strings.TrimSuffix(dir, "/"), "/")
251 self := s.href(theme, locale, "demo-modal.html")
252 overview := s.href(theme, locale, "index.html")
253
254 page := examplePage(t, files, theme, locale, "modal")
255 if !strings.Contains(strings.Join(sourceTexts(page), "\n"), `<div rst-modal-overlay>`) {
256 t.Errorf("%smodal.html does not show the overlay as source", dir)
257 }
258 link := regexp.MustCompile(`<a data-gallery-nav href="` + regexp.QuoteMeta(self) + `" target="_blank" rel="noopener">`)
259 if !link.MatchString(page) {
260 t.Errorf("%smodal.html does not link %s in a new tab", dir, self)
261 }
262
263 name := dir + "demo-modal.html"
264 body, ok := files[name]
265 if !ok {
266 t.Errorf("the render wrote no %s", name)
267 continue
268 }
269 routes++
270 doc := parse(t, body)
271 if n := len(liveOverlay.FindAll(body, -1)); n != 2 {
272 t.Errorf("%s lays out %d of the backdrop and the overlay, want both", name, n)
273 }
274 backdrop, ok := doc.first(byAttr("rst-backdrop", ""))
275 if !ok || !hasAttr(backdrop, "inert") {
276 t.Errorf("%s: the backdrop is missing or not inert", name)
277 } else {
278 if h, ok := backdrop.first(func(n node) bool { return n.n.Data == "h1" }); !ok || h.text() != s.say(locale, "gallery.modal.settings") {
279 t.Errorf("%s: the page under the modal has no heading %q", name, s.say(locale, "gallery.modal.settings"))
280 }
281 if !strings.Contains(backdrop.text(), s.say(locale, "gallery.modal.own")) {
282 t.Errorf("%s: the page under the modal does not say %q", name, s.say(locale, "gallery.modal.own"))
283 }
284 }
285 panel, ok := doc.first(byAttr("rst-modal-panel", ""))
286 if !ok {
287 t.Errorf("%s has no panel", name)
288 continue
289 }
290 tabs := panel.find(func(n node) bool {
291 return n.n.Data == "a" && within(n, func(p node) bool { return p.n.Data == "nav" })
292 })
293 if len(tabs) != 3 {
294 t.Errorf("%s: the panel has %d tabs, want 3", name, len(tabs))
295 }
296 for i, a := range tabs {
297 if attr(a, "href") != self || !hasAttr(a, "data-gallery-nav") {
298 t.Errorf("%s: tab %q goes to %q, want the route itself", name, a.text(), attr(a, "href"))
299 }
300 if mark := attr(a, "aria-current") == "page"; mark != (i == 0) {
301 t.Errorf("%s: tab %q marked current %v", name, a.text(), mark)
302 }
303 }
304 closers := panel.find(byAttr("rst-modal-close", ""))
305 if len(closers) != 1 || attr(closers[0], "href") != overview || attr(closers[0], "aria-label") != s.say(locale, "gallery.modal.close") {
306 t.Errorf("%s: Close does not go to the Overview under its name", name)
307 }
308 back := false
309 for _, a := range panel.find(func(n node) bool { return n.n.Data == "a" }) {
310 if a.text() == s.say(locale, "gallery.back") && attr(a, "href") == overview && hasAttr(a, "data-gallery-nav") {
311 back = true
312 }
313 }
314 if !back {
315 t.Errorf("%s: the panel has no %q button to the Overview", name, s.say(locale, "gallery.back"))
316 }
317 for _, id := range []string{"gallery.modal.body", "gallery.modal.nojs", "gallery.modal.inapp"} {
318 if !strings.Contains(panel.text(), squash(s.say(locale, id))) {
319 t.Errorf("%s: the panel does not say %q", name, s.say(locale, id))
320 }
321 }
322 }
323 if routes != len(dirs()) {
324 t.Fatalf("read %d modal routes, want %d", routes, len(dirs()))
325 }
326}
327
328// TestTheOverviewFramesTheDemo: every Overview frames the demo
329// application between its intro and its routes: the heading and its
330// lead, one widget at the page width with Desktop and Mobile and no
331// HTML tab (an application is not a snippet to paste), at the demo's
332// heights, framing its own directory's demo.html, and the link that
333// opens it in a new tab and says so.
334func TestTheOverviewFramesTheDemo(t *testing.T) {
335 const mount = "/x"
336 files := render(t, mount)
337 s := newTestSite(t, fiesta.FS(), mount)
338 for _, dir := range dirs() {
339 theme, locale, _ := strings.Cut(strings.TrimSuffix(dir, "/"), "/")
340 name := dir + "index.html"
341 page := string(files[name])
342 section := regexp.MustCompile(`(?s)<section class="ds-demo" id="demo-app">(.*?)</section>`).FindStringSubmatch(page)
343 if section == nil {
344 t.Errorf("%s frames no demo", name)
345 continue
346 }
347 intro, demo, next := strings.Index(page, `class="ds-intro"`), strings.Index(page, `class="ds-demo"`), strings.Index(page, `class="ds-routes"`)
348 if !(intro >= 0 && intro < demo && demo < next) {
349 t.Errorf("%s: the demo is not between the intro and the routes", name)
350 }
351 body := section[1]
352 for _, id := range []string{"gallery.demo.heading", "gallery.demo.lead"} {
353 if !strings.Contains(body, template.HTMLEscapeString(s.say(locale, id))) {
354 t.Errorf("%s: the demo section does not say %q", name, s.say(locale, id))
355 }
356 }
357 widgets := widgetsOf(body)
358 if len(widgets) != 1 {
359 t.Errorf("%s: %d widgets in the demo section, want 1", name, len(widgets))
360 continue
361 }
362 w := widgets[0]
363 if _, ok := previewHeights["demo"]; !ok {
364 t.Fatal("heights.go has no measured height for the demo")
365 }
366 if !strings.Contains(body, `<div class="ds-view ds-view--page" style="`+string(previewStyle(locale, "demo"))) {
367 t.Errorf("%s: the demo's widget is not at the page width and the demo's heights (%s)", name, previewStyle(locale, "demo"))
368 }
369 var order string
370 for _, m := range tabClass.FindAllStringSubmatch(w, -1) {
371 order += m[1]
372 }
373 if order != "dm" || panelOf(w) != "" {
374 t.Errorf("%s: the demo's tabs are %q with panel %v, want Desktop and Mobile and no HTML", name, order, panelOf(w) != "")
375 }
376 src := `src="` + s.href(theme, locale, "demo.html") + `"`
377 if strings.Count(w, `<iframe class="ds-view__frame"`) != 1 || !strings.Contains(w, src) {
378 t.Errorf("%s: the widget does not frame %s", name, src)
379 }
380 open := `<a data-gallery-nav href="` + s.href(theme, locale, "demo.html") + `" target="_blank" rel="noopener">` +
381 template.HTMLEscapeString(s.say(locale, "gallery.demo.open")) +
382 `<span class="rst-sr-only"> (` + template.HTMLEscapeString(s.say(locale, "gallery.newtab")) + `)</span></a>`
383 if !strings.Contains(body, open) {
384 t.Errorf("%s: no link opens the demo in a new tab saying so", name)
385 }
386 }
387}
388