rastrillo / fiesta Public

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

Plain git — no account needed to clone.

Download

Download this file

1//go:build browser
2
3package www_test
4
5import (
6 "context"
7 "encoding/json"
8 "fmt"
9 "strings"
10 "testing"
11 "time"
12
13 "github.com/chromedp/cdproto/accessibility"
14 "github.com/chromedp/cdproto/cdp"
15 "github.com/chromedp/cdproto/emulation"
16 "github.com/chromedp/chromedp"
17 xhtml "golang.org/x/net/html"
18
19 "amadan.net/rastrillo/fiesta/internal/markup"
20 "amadan.net/rastrillo/fiesta/internal/rowrule"
21 "amadan.net/rastrillo/fiesta/internal/www"
22)
23
24// landingRows are the landing page's own bars, which hold its two
25// menus. The page lays them out itself rather than with a Fiesta row,
26// so it names them to the Row Rule's check by class.
27var landingRows = map[string]string{
28 "site-nav": "the top bar: the page's links, the language menu and Get started",
29 "site-themebar": "the theme bar: the theme buttons and the colour-scheme menu",
30}
31
32// rowWidths are a 320px phone, the 390px phone the bar first wrapped
33// on, and a desktop.
34var rowWidths = []int64{320, 390, 1280}
35
36// menuLinesJS measures every menu outside another menu's panel: the
37// row it sits in, and how many of that row's other children share a
38// line with its summary. A child with no box, or a 1px one like a
39// visually hidden label, is not on the line. Then the theme bar, which
40// must be one line: each piece it shows, with its top and bottom.
41const menuLinesJS = `((rows) => {
42 const seen = r => r.width > 1 && r.height > 1;
43 const name = e => e.tagName.toLowerCase() + [...e.attributes].filter(a => a.name.startsWith("rst-") || a.name === "class").map(a => a.name === "class" ? "." + a.value.trim().split(/\s+/).join(".") : "[" + a.name + "]").join("");
44 const menus = [];
45 for (const d of document.querySelectorAll("details[rst-dropdown]")) {
46 if (d.parentElement.closest("[rst-dropdown-menu]")) continue;
47 const s = d.querySelector(":scope > summary").getBoundingClientRect();
48 const row = d.parentElement.closest(rows.map(c => "." + c).join(","));
49 const others = row ? [...row.children].filter(c => !c.contains(d)).map(c => c.getBoundingClientRect()).filter(seen) : [];
50 menus.push({Menu: name(d), Row: row ? name(row) : "", Shown: seen(s), Beside: others.filter(r => r.top < s.bottom - 1 && s.top < r.bottom - 1).length, Others: others.length, Top: s.top, Bottom: s.bottom});
51 }
52 const bar = document.querySelector(".site-themebar");
53 const parts = bar ? [bar.querySelector(".site-themelabel"), ...bar.querySelectorAll("[data-site-theme]"), bar.querySelector("[rst-scheme] > summary")].filter(e => e) : [];
54 const shown = parts.map(e => ({Text: e.textContent.trim(), R: e.getBoundingClientRect()})).filter(p => seen(p.R));
55 return JSON.stringify({Menus: menus, VW: document.documentElement.clientWidth, Bar: bar ? bar.getBoundingClientRect().width : 0,
56 Parts: shown.map(p => ({Text: p.Text, Top: p.R.top, Bottom: p.R.bottom, Width: p.R.width}))});
57})`
58
59type menuLine struct {
60 Menu, Row string
61 Shown bool
62 Beside, Others int
63 Top, Bottom float64
64}
65
66type barPart struct {
67 Text string
68 Top, Bottom, Width float64
69}
70
71type menuLines struct {
72 Menus []menuLine
73 VW, Bar float64
74 Parts []barPart
75}
76
77// oneLine reports whether the parts share a line: some height that
78// every one of them spans.
79func oneLine(parts []barPart) bool {
80 if len(parts) == 0 {
81 return false
82 }
83 top, bottom := parts[0].Top, parts[0].Bottom
84 for _, p := range parts[1:] {
85 top, bottom = max(top, p.Top), min(bottom, p.Bottom)
86 }
87 return top < bottom-1
88}
89
90// axName is the accessible name Chromium gives the element sel finds.
91func axName(t *testing.T, ctx context.Context, sel string) string {
92 t.Helper()
93 var nodes []*cdp.Node
94 mustRun(t, ctx, chromedp.Nodes(sel, &nodes, chromedp.ByQuery))
95 var name string
96 mustRun(t, ctx, chromedp.ActionFunc(func(c context.Context) error {
97 ax, err := accessibility.GetPartialAXTree().WithBackendNodeID(nodes[0].BackendNodeID).WithFetchRelatives(false).Do(c)
98 if err != nil {
99 return err
100 }
101 if len(ax) > 0 && ax[0].Name != nil {
102 return json.Unmarshal(ax[0].Name.Value, &name)
103 }
104 return nil
105 }))
106 return name
107}
108
109// TestTheLandingKeepsTheRowRule: the landing page keeps the Row Rule
110// that DESIGN.md sets and the gallery's examples are held to, in every
111// language the site builds, Arabic right to left, at each width in
112// rowWidths. Each menu sits in a row, by the same check the examples
113// pass; each shares a line with something else in that row, which only
114// a browser at that width can say; and the theme bar keeps all it
115// shows, its label, its three buttons and the scheme menu, on one line.
116// The bar wrapping the scheme menu onto a line of its own on a phone is
117// the shape the rule forbids, on the page that sells Fiesta. A phone has
118// room for the bar only with its words out of sight, so the buttons'
119// group and the scheme menu are held to their names at every width:
120// hidden from sight, the words must still be read out.
121func TestTheLandingKeepsTheRowRule(t *testing.T) {
122 rig, _ := siteRig(t)
123 ctx, cancel := context.WithTimeout(rig.Context(), 5*time.Minute)
124 defer cancel()
125 rowsJS, err := json.Marshal(func() []string {
126 var classes []string
127 for c := range landingRows {
128 classes = append(classes, c)
129 }
130 return classes
131 }())
132 if err != nil {
133 t.Fatal(err)
134 }
135 for _, lang := range www.Languages {
136 mustRun(t, ctx, chromedp.ActionFunc(func(c context.Context) error {
137 return emulation.SetDeviceMetricsOverride(rowWidths[0], 800, 1, false).Do(c)
138 }), chromedp.Navigate(rig.Origin+pageURL(lang)), chromedp.WaitReady("body"),
139 chromedp.Evaluate(`new Promise(r => document.readyState === "complete" ? r() : addEventListener("load", r, {once: true}))`, nil, awaitPromise),
140 soon(".site-themebar"))
141 var got, dir, page string
142 mustRun(t, ctx, chromedp.Evaluate(`document.documentElement.lang`, &got), chromedp.Evaluate(`document.documentElement.dir`, &dir),
143 chromedp.Evaluate(`document.documentElement.outerHTML`, &page))
144 if got != lang || dir != www.Dir(lang) {
145 t.Fatalf("%s: the page is in %q, written %q; want %s, %s", pageURL(lang), got, dir, lang, www.Dir(lang))
146 }
147 respelled, _ := markup.Respell([]byte(page))
148 doc, err := xhtml.Parse(strings.NewReader(string(respelled)))
149 if err != nil {
150 t.Fatalf("%s: the page does not parse: %v", lang, err)
151 }
152 for _, msg := range rowrule.Breaches(doc, landingRows) {
153 t.Errorf("%s: %s", lang, msg)
154 }
155 words, err := www.Strings(siteSource(t).FS(), lang)
156 if err != nil {
157 t.Fatal(err)
158 }
159 for _, w := range rowWidths {
160 mustRun(t, ctx, chromedp.ActionFunc(func(c context.Context) error {
161 return emulation.SetDeviceMetricsOverride(w, 800, 1, false).Do(c)
162 }))
163 var m menuLines
164 at(t, ctx, menuLinesJS+"("+string(rowsJS)+")", &m)
165 where := fmt.Sprintf("%s at %d", lang, w)
166 // A page whose menus the walk cannot find passes whatever
167 // the rule says.
168 if len(m.Menus) != 2 || len(m.Parts) < 4 {
169 t.Fatalf("%s: found %d menus and %d theme bar pieces; want the language and scheme menus, and at least the three buttons and the menu", where, len(m.Menus), len(m.Parts))
170 }
171 for _, menu := range m.Menus {
172 switch {
173 case !menu.Shown:
174 t.Errorf("%s: %s is not shown", where, menu.Menu)
175 case menu.Row == "":
176 t.Errorf("%s: %s is in none of the page's rows", where, menu.Menu)
177 case menu.Beside == 0:
178 t.Errorf("%s: %s sits on a line of its own (%.0f to %.0f), beside none of the other %d things in %s", where, menu.Menu, menu.Top, menu.Bottom, menu.Others, menu.Row)
179 }
180 }
181 if !oneLine(m.Parts) {
182 var parts []string
183 for _, p := range m.Parts {
184 parts = append(parts, fmt.Sprintf("%q %.0f to %.0f, %.0fpx wide", p.Text, p.Top, p.Bottom, p.Width))
185 }
186 t.Errorf("%s: the theme bar (%.0fpx in a %.0fpx page) is more than one line:\n %s", where, m.Bar, m.VW, strings.Join(parts, "\n "))
187 }
188 for _, c := range []struct{ sel, key string }{
189 {".site-themebar [rst-seg-tabs]", "theme.label"},
190 {"[rst-scheme] > summary", "scheme.label"},
191 } {
192 if got := axName(t, ctx, c.sel); got != words[c.key] {
193 t.Errorf("%s: %s is named %q; want %q, its %s", where, c.sel, got, words[c.key], c.key)
194 }
195 }
196 }
197 }
198 if t.Failed() {
199 var rows []string
200 for row, why := range landingRows {
201 rows = append(rows, "."+row+" ("+why+")")
202 }
203 t.Logf("each menu belongs on a line in one of these rows: %s", strings.Join(rows, ", "))
204 }
205}
206