oficina / estilo Public

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

Plain git — no account needed to clone.

Download

Download this file

1package menu
2
3import (
4 "bytes"
5 "html/template"
6 "net/http/httptest"
7 "strings"
8 "testing"
9
10 "amadan.net/oficina/estilo.git/internal/tokencheck"
11)
12
13func TestMenuUsesDefinedTokens(t *testing.T) {
14 missing, n := tokencheck.Undefined(CSS())
15 if n == 0 {
16 t.Fatal("menu.css reads no rastrillo token; the check would pass vacuously")
17 }
18 if len(missing) > 0 {
19 t.Fatal("menu.css reads undefined tokens:", missing)
20 }
21}
22
23// The square rule, stated in the stylesheet: a float and the rows inside
24// it are rounded, a menubar title is in the page flow and is square.
25func TestFloatsAreRoundedAndMenubarTitlesSquare(t *testing.T) {
26 src := string(CSS())
27 for _, want := range []string{
28 "--estilo-float-radius: 9px;",
29 "border-radius: var(--estilo-float-radius);",
30 "border-radius: 0; /* square: in-flow */",
31 // Sized to its rows, not to the <details> it hangs from.
32 "inline-size: max-content;",
33 } {
34 if !strings.Contains(src, want) {
35 t.Errorf("menu.css lost %q", want)
36 }
37 }
38}
39
40func render(t *testing.T, name string, data any) string {
41 t.Helper()
42 view, err := template.New("").ParseFS(Templates(), "templates/*.html")
43 if err != nil {
44 t.Fatal(err)
45 }
46 var out bytes.Buffer
47 if err := view.ExecuteTemplate(&out, name, data); err != nil {
48 t.Fatal(err)
49 }
50 return out.String()
51}
52
53var locales = map[string]string{"en": "Open source", "de": "Open Source", "es": "Código abierto", "fr": "Logiciels libres", "it": "Open source", "pt": "Código aberto", "nl": "Open source", "pl": "Otwarte oprogramowanie", "ar": "مفتوح المصدر", "ja": "オープンソース", "zh": "开源", "ko": "오픈 소스"}
54
55func TestItemRendersCallerLabelsInEveryLocale(t *testing.T) {
56 for locale, label := range locales {
57 t.Run(locale, func(t *testing.T) {
58 html := render(t, "estilo-menu-item", Item{Href: "/credits", Label: label, Key: "⌘K"})
59 for _, want := range []string{`<a estilo-menu-item href="/credits">`, "<span estilo-menu-label>" + template.HTMLEscapeString(label) + "</span>", "<kbd estilo-menu-key>⌘K</kbd>"} {
60 if !strings.Contains(html, want) {
61 t.Fatalf("missing %q in %s", want, html)
62 }
63 }
64 if strings.Contains(html, "<script") {
65 t.Fatal("a menu row needs no script")
66 }
67 })
68 }
69 if html := render(t, "estilo-menu-item", Item{Href: "/x", Label: "x"}); strings.Contains(html, "<kbd") {
70 t.Fatal("an item with no shortcut renders an empty key column", html)
71 }
72}
73
74// Sheets, the second adopter, needs rows that are not links: New sheet
75// is a POST, and Keyboard shortcuts… opens a dialog. Both must work with
76// scripts off, so a POST is a one-button form and a dialog opens with the
77// platform's invoker command (command="show-modal"), not a click handler.
78func TestItemCanPostOrOpenADialog(t *testing.T) {
79 for locale, label := range locales {
80 t.Run(locale, func(t *testing.T) {
81 esc := template.HTMLEscapeString(label)
82 html := render(t, "estilo-menu-item", Item{Action: "/sheets", Label: label, Key: "⌥N"})
83 if !strings.Contains(html, `<form method="post" action="/sheets"><button estilo-menu-item type="submit"><span estilo-menu-label>`+esc+`</span><kbd estilo-menu-key>⌥N</kbd></button></form>`) || strings.Contains(html, "<a ") {
84 t.Fatalf("a POST row is not a one-button form: %s", html)
85 }
86 html = render(t, "estilo-menu-item", Item{Dialog: "sheets-shortcuts", Label: label})
87 if !strings.Contains(html, `<button estilo-menu-item type="button" commandfor="sheets-shortcuts" command="show-modal"><span estilo-menu-label>`+esc+`</span></button>`) || strings.Contains(html, "<a ") || strings.Contains(html, "<form") {
88 t.Fatalf("a dialog row is not an invoker button: %s", html)
89 }
90 })
91 }
92 html := render(t, "estilo-menu-item", Item{Action: "javascript:bad()", Label: "x"})
93 if !strings.Contains(html, "#ZgotmplZ") {
94 t.Fatal("an unsafe POST target is not neutralised", html)
95 }
96 html = render(t, "estilo-menu-item", Item{Dialog: `x" onclick="bad()`, Label: "x"})
97 if strings.Contains(html, `onclick="bad()"`) {
98 t.Fatal("a dialog id escapes its attribute", html)
99 }
100}
101
102func TestItemEscapesUntrustedHrefAndLabel(t *testing.T) {
103 html := render(t, "estilo-menu-item", Item{Href: "javascript:alert(1)", Label: "<img src=x onerror=bad>"})
104 if !strings.Contains(html, "#ZgotmplZ") || strings.Contains(html, "<img") {
105 t.Fatal("unsafe menu item markup", html)
106 }
107}
108
109func TestShortcutPerPlatform(t *testing.T) {
110 cases := []struct{ keys, apple, other string }{
111 {"Mod+Z", "⌘Z", "Ctrl+Z"},
112 {"Mod+Shift+Z", "⇧⌘Z", "Ctrl+Shift+Z"},
113 {"Mod+Shift+K", "⇧⌘K", "Ctrl+Shift+K"},
114 {"Shift+Mod+K", "⇧⌘K", "Ctrl+Shift+K"},
115 {"Mod+Alt+1", "⌥⌘1", "Ctrl+Alt+1"},
116 {"Ctrl+Alt+Shift+Mod+P", "⌃⌥⇧⌘P", "Ctrl+Alt+Shift+P"},
117 {"Ctrl+Tab", "⌃⇥", "Ctrl+Tab"},
118 {"Mod+Ctrl+F", "⌃⌘F", "Ctrl+F"},
119 {"Alt+Enter", "⌥↩", "Alt+Enter"},
120 {"Shift+Enter", "⇧↩", "Shift+Enter"},
121 {"Shift+Tab", "⇧⇥", "Shift+Tab"},
122 {"Tab", "⇥", "Tab"},
123 {"Esc", "⎋", "Esc"},
124 {"Delete", "⌫", "Delete"},
125 {"Backspace", "⌫", "Backspace"},
126 {"Mod+K", "⌘K", "Ctrl+K"},
127 }
128 mac := httptest.NewRequest("GET", "/", nil)
129 mac.Header.Set("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36")
130 win := httptest.NewRequest("GET", "/", nil)
131 win.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36")
132 for _, c := range cases {
133 if got := Shortcut(mac, c.keys); got != c.apple {
134 t.Errorf("Apple %q = %q, want %q", c.keys, got, c.apple)
135 }
136 if got := Shortcut(win, c.keys); got != c.other {
137 t.Errorf("other %q = %q, want %q", c.keys, got, c.other)
138 }
139 }
140}
141
142func TestApplePlatformDetection(t *testing.T) {
143 cases := []struct {
144 header, value string
145 want bool
146 }{
147 {"Sec-CH-UA-Platform", `"macOS"`, true},
148 {"Sec-CH-UA-Platform", `"iOS"`, true},
149 {"Sec-CH-UA-Platform", `"Windows"`, false},
150 {"Sec-CH-UA-Platform", `"Linux"`, false},
151 {"User-Agent", "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X)", true},
152 {"User-Agent", "Mozilla/5.0 (iPad; CPU OS 18_0 like Mac OS X)", true},
153 {"User-Agent", "Mozilla/5.0 (X11; Linux x86_64)", false},
154 {"User-Agent", "Mozilla/5.0 (Linux; Android 14)", false},
155 }
156 for _, c := range cases {
157 r := httptest.NewRequest("GET", "/", nil)
158 r.Header.Set(c.header, c.value)
159 if got := Apple(r); got != c.want {
160 t.Errorf("%s %q: Apple = %v, want %v", c.header, c.value, got, c.want)
161 }
162 }
163}
164
165// A destructive row ("End meeting for everyone", Calls' first need) is
166// drawn in the negative tone, as a link, a POST or a dialog alike, and
167// its hover is the negative tint, not the accent: colour is not its only
168// cue — the label says what it does — but a red row that lights up blue
169// says two things at once.
170func TestADangerItemIsDrawnInTheNegativeTone(t *testing.T) {
171 for _, it := range []Item{{Href: "/m/x/end", Label: "End"}, {Action: "/m/x/end", Label: "End"}, {Dialog: "end", Label: "End"}} {
172 it.Danger = true
173 if html := render(t, "estilo-menu-item", it); !strings.Contains(html, `estilo-menu-item="danger"`) {
174 t.Fatalf("a danger row is not marked: %s", html)
175 }
176 it.Danger = false
177 if html := render(t, "estilo-menu-item", it); strings.Contains(html, "danger") {
178 t.Fatalf("a plain row is marked danger: %s", html)
179 }
180 }
181 for _, want := range []string{
182 `[estilo-menu-item~="danger"] { color: var(--rst-tone-negative-fg); }`,
183 `[estilo-menu-item~="danger"]:hover, [estilo-menu-item~="danger"]:focus-visible { background: var(--rst-tone-negative-bg); }`,
184 } {
185 if !strings.Contains(string(CSS()), want) {
186 t.Errorf("menu.css lacks %s", want)
187 }
188 }
189}
190