oficina / sheets Public

Clone
git clone https://amadan.net/oficina/sheets

Plain git — no account needed to clone.

Download

Download this file

1package guards
2
3import (
4 "io/fs"
5 "os"
6 "path/filepath"
7 "regexp"
8 "strings"
9 "testing"
10
11 "amadan.net/oficina/estilo.git/chrome"
12)
13
14// TestNoNestedExclusiveDetails is the guard on the one trap the menu
15// bar is built over.
16//
17// `name` on a <details> makes an exclusive accordion: opening one
18// member closes every other. That is exactly what a row of top-level
19// menus wants. It is exactly wrong for a submenu, which lives INSIDE a
20// grouped <details> — grouped, opening the submenu closes the parent it
21// is nested in, and the submenu goes down with it.
22//
23// The failure is silent and looks like a rendering glitch: the menu
24// blinks shut when you reach for the thing inside it. layout.html
25// already carries a long comment about the same trap on the shell menu,
26// which is how we know it is the kind of mistake that gets made twice.
27//
28// The check is structural rather than a name-by-name allowlist: any
29// <details name=…> that CONTAINS another <details name=…> fails,
30// whatever they are called.
31func TestNoNestedExclusiveDetails(t *testing.T) {
32 root := repoRoot(t)
33 files := templateFiles(t, root)
34 if len(files) == 0 {
35 t.Fatal("guards: found no templates; this test is not checking anything")
36 }
37
38 openTag := regexp.MustCompile(`(?i)<details\b[^>]*>`)
39 closeTag := regexp.MustCompile(`(?i)</details\s*>`)
40 named := regexp.MustCompile(`(?i)\bname\s*=`)
41
42 for _, rel := range files {
43 body, err := os.ReadFile(filepath.Join(root, rel))
44 if err != nil {
45 t.Fatalf("guards: read %s: %v", rel, err)
46 }
47 checkNesting(t, rel, string(body), openTag, closeTag, named)
48 }
49}
50
51// checkNesting walks a template's <details> tags keeping a stack of
52// whether each open element carried a name.
53//
54// Tag-level and not a real parser, deliberately: these templates are
55// hand-written HTML with no self-closing <details> and no <details>
56// inside a comment or an attribute value, and a regex that can be read
57// in one sitting beats a dependency for a rule this narrow. If a
58// template ever grows a case this cannot see, the fix is to stop
59// writing that case.
60func checkNesting(t *testing.T, rel, body string, openTag, closeTag, named *regexp.Regexp) {
61 t.Helper()
62
63 type tag struct {
64 named bool
65 line int
66 }
67 var stack []tag
68
69 for i, line := range strings.Split(body, "\n") {
70 // A line may hold both an open and a close; the opens are
71 // processed first, which is the order they appear in practice
72 // (a one-line <details>…</details> is not written here).
73 for _, m := range openTag.FindAllString(line, -1) {
74 isNamed := named.MatchString(m)
75 if isNamed {
76 for _, parent := range stack {
77 if parent.named {
78 t.Errorf("%s:%d: a <details name=…> is nested inside the one opened "+
79 "at line %d, which also carries name=. Grouped, opening the inner "+
80 "one CLOSES its parent and takes itself down with it — the menu "+
81 "blinks shut as you reach into it. A submenu carries no name.",
82 rel, i+1, parent.line)
83 }
84 }
85 }
86 stack = append(stack, tag{named: isNamed, line: i + 1})
87 }
88 for range closeTag.FindAllString(line, -1) {
89 if len(stack) > 0 {
90 stack = stack[:len(stack)-1]
91 }
92 }
93 }
94}
95
96// TestTheMenusShareOneGroup pins the design TestNoNestedExclusiveDetails
97// protects the edges of: every top-level menu on the grid — File, Edit,
98// Insert, Format, Data — and every trigger in the bar above it — Menu,
99// the switcher, the account — is one exclusive group, so opening File
100// closes the account menu and the other way round. Sheets chose that on
101// purpose when both halves were rastrillo dropdowns in "rst-menus"; they
102// are estilo's now, and the group is estilo's one name, "estilo-menus"
103// (shared chrome spec §7.1).
104//
105// Two halves because either can drift alone: the menubar is this app's
106// markup, the bar is estilo's.
107func TestTheMenusShareOneGroup(t *testing.T) {
108 const group = `name="estilo-menus"`
109 sheet := readRepoFile(t, repoRoot(t), "internal/sheets/templates/sheet.html")
110 titles := regexp.MustCompile(`<details estilo-menubar-menu[^>]*>`).FindAllString(sheet, -1)
111 if len(titles) < 5 {
112 t.Fatalf("found %d menubar menus in sheet.html, want File, Edit, Insert, Format and Data", len(titles))
113 }
114 for _, d := range titles {
115 if !strings.Contains(d, group) {
116 t.Errorf("sheet.html: %s is not in the one menu group", d)
117 }
118 }
119 for _, other := range regexp.MustCompile(`<details\b[^>]*\bname="([^"]+)"`).FindAllStringSubmatch(sheet, -1) {
120 if other[1] != "estilo-menus" && other[1] != "rst-tools" {
121 t.Errorf("sheet.html: a menu joins group %q; the menubar and the bar share estilo-menus, and only the toolbar's pickers have their own", other[1])
122 }
123 }
124
125 bar, err := fs.ReadFile(chrome.Templates(), "templates/bar.html")
126 if err != nil {
127 t.Fatal(err)
128 }
129 account, err := fs.ReadFile(chrome.Templates(), "templates/account.html")
130 if err != nil {
131 t.Fatal(err)
132 }
133 switcher, err := fs.ReadFile(chrome.Templates(), "templates/switcher.html")
134 if err != nil {
135 t.Fatal(err)
136 }
137 for name, body := range map[string][]byte{"the Menu sheet": bar, "the account": account, "the switcher": switcher} {
138 d := regexp.MustCompile(`<details estilo-[a-z-]+[^>]*>`).FindString(string(body))
139 if d == "" || !strings.Contains(d, group) {
140 t.Errorf("estilo's %s trigger is %q, not in the one menu group", name, d)
141 }
142 }
143}
144