oficina / sheets Public

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

Plain git — no account needed to clone.

Download

Download this file

1package sheets
2
3import (
4 "html"
5 "net/http"
6 "net/http/httptest"
7 "os"
8 "path/filepath"
9 "regexp"
10 "strconv"
11 "strings"
12 "testing"
13)
14
15// §5.4: the rename is the bar's editable title — a real form posting to
16// the rename route, only for someone who may write. A reader sees the
17// title read-only.
18func TestTheBarRenamesTheSheet(t *testing.T) {
19 app, cookies := signIn(t)
20 s, _ := newSheet(t, app, cookies, "Budget")
21 body := page(t, app, cookies, "/sheets/"+strconv.FormatUint(s.ID, 10)).Body.String()
22 for _, want := range []string{
23 `<form estilo-bar-title method="post" action="/sheets/` + strconv.FormatUint(s.ID, 10) + `/rename">`,
24 `<input estilo-bar-title-input name="title" value="Budget" aria-label="Sheet title"`,
25 `<button rst-btn="ghost" type="submit" estilo-bar-title-submit>Rename</button>`,
26 } {
27 if !strings.Contains(body, want) {
28 t.Errorf("the writer's bar lacks %s", want)
29 }
30 }
31 if strings.Contains(body, "data-doc-title") {
32 t.Error("the old in-bar rename form is still rendered")
33 }
34 // A reader: a viewer grant only, signed in through the real link.
35 if _, err := app.grant(s.ID, "reader@example.com", SheetViewer, signedInMember(t, app)); err != nil {
36 t.Fatal(err)
37 }
38 postSignin(t, app, "reader@example.com")
39 reader := followLink(t, app.Router(), app.testMailer(t).lastLink(t)).Result().Cookies()
40 body = page(t, app, reader, "/sheets/"+strconv.FormatUint(s.ID, 10)).Body.String()
41 if strings.Contains(body, "estilo-bar-title") || !strings.Contains(body, `<span estilo-bar-context dir="auto" title="Budget">Budget</span>`) {
42 t.Error("a reader is offered the rename, or does not see the title")
43 }
44}
45
46// The rename works through the bar with scripting off: Enter posts the
47// form, and the sheet comes back renamed.
48func TestTheBarsRenameFormRenames(t *testing.T) {
49 app, cookies := signIn(t)
50 s, _ := newSheet(t, app, cookies, "Budget")
51 rec := do(t, app, as(postForm("/sheets/"+strconv.FormatUint(s.ID, 10)+"/rename", map[string][]string{"title": {"Commission Q2"}}), cookies))
52 if rec.Code != http.StatusSeeOther {
53 t.Fatalf("rename = %d", rec.Code)
54 }
55 if body := page(t, app, cookies, "/sheets/"+strconv.FormatUint(s.ID, 10)).Body.String(); !strings.Contains(body, `value="Commission Q2"`) || titleOf(t, body) != "Commission Q2 · Sheets" {
56 t.Error("the bar does not show the new title")
57 }
58}
59
60// §0.5 and §12.1 item 2: <meta charset> first, then the stylesheets in
61// the suite's order — tokens, theme, brand, estilo base, menu, chrome,
62// then Sheets' own.
63func TestEstiloChromeAssetsAreLinkedInOrder(t *testing.T) {
64 body := page(t, New(Config{}), nil, "/signin").Body.String()
65 head := body[strings.Index(body, "<head>"):strings.Index(body, "</head>")]
66 if !strings.HasPrefix(strings.TrimSpace(strings.TrimPrefix(head, "<head>")), `<meta charset="utf-8">`) {
67 t.Error("<meta charset> is not the first thing in <head>")
68 }
69 last := -1
70 for _, css := range []string{"/static/tokens.", "/static/theme.", "/static/brand/brand.", "/static/estilo/base.", "/static/estilo/menu.", "/static/estilo/chrome.", "/static/sheets."} {
71 i := strings.Index(head, css)
72 if i < 0 || i < last {
73 t.Fatalf("%s is missing or out of order", css)
74 }
75 last = i
76 }
77 for _, asset := range []string{"/static/estilo/base.", "/static/estilo/chrome."} {
78 href := regexp.MustCompile(regexp.QuoteMeta(asset) + `[0-9a-f]+\.(css|js)`).FindString(head)
79 if href == "" {
80 t.Fatalf("%s is not fingerprinted", asset)
81 }
82 if rec := do(t, New(Config{}), httptest.NewRequest(http.MethodGet, href, nil)); rec.Code != http.StatusOK || rec.Body.Len() == 0 {
83 t.Errorf("%s does not serve (%d)", href, rec.Code)
84 }
85 }
86 if rec := do(t, New(Config{}), httptest.NewRequest(http.MethodGet, "/static/estilo/brand/sheets.svg", nil)); rec.Code != http.StatusOK || rec.Header().Get("Content-Type") != "image/svg+xml" {
87 t.Errorf("the bar's brand icon does not serve (%d)", rec.Code)
88 }
89}
90
91// §7.3: shortcuts are labelled for the request's platform, server-side,
92// in the menus, the toolbar's tips and the shortcuts dialog alike — and
93// the page says it varies by User-Agent.
94func TestShortcutLabelsFollowThePlatform(t *testing.T) {
95 app, cookies := signIn(t)
96 s, _ := newSheet(t, app, cookies, "Budget")
97 path := "/sheets/" + strconv.FormatUint(s.ID, 10)
98 for ua, want := range map[string][]string{
99 "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_0) AppleWebKit/537.36": {`<kbd estilo-menu-key>⌘Z</kbd>`, `data-tip="Undo (⌘Z)"`, `data-tip="Bold (⌘B)"`, `<dt><kbd>⌘C</kbd></dt>`, `data-key-copy="⌘C"`},
100 "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36": {`<kbd estilo-menu-key>Ctrl+Z</kbd>`, `data-tip="Undo (Ctrl+Z)"`, `data-tip="Bold (Ctrl+B)"`, `<dt><kbd>Ctrl+C</kbd></dt>`, `data-key-copy="Ctrl+C"`},
101 } {
102 req := httptest.NewRequest(http.MethodGet, path, nil)
103 req.Header.Set("User-Agent", ua)
104 rec := do(t, app, as(req, cookies))
105 // html/template writes "+" as &#43;; the browser reads it back.
106 body := html.UnescapeString(rec.Body.String())
107 for _, w := range want {
108 if !strings.Contains(body, w) {
109 t.Errorf("%s: missing %s", ua[13:30], w)
110 }
111 }
112 for _, stale := range []string{"Ctrl Z", "(Ctrl+Z)</", "Ctrl + C"} {
113 if strings.Contains(body, stale) {
114 t.Errorf("%s: an old label %q survives", ua[13:30], stale)
115 }
116 }
117 if !strings.Contains(strings.Join(rec.Header().Values("Vary"), ","), "User-Agent") {
118 t.Error("a page with platform labels does not Vary on User-Agent")
119 }
120 }
121}
122
123// §7.3: an item appears only if it works in this build. The Help menu
124// (a list of keys, not a menu) left the menu bar for the account menu's
125// "Keyboard shortcuts…" dialog; Format keeps no inline submenus; items
126// that need script are hidden until it runs.
127func TestUnbuiltMenuItemsAreHidden(t *testing.T) {
128 app, cookies := signIn(t)
129 s, _ := newSheet(t, app, cookies, "Budget")
130 body := page(t, app, cookies, "/sheets/"+strconv.FormatUint(s.ID, 10)).Body.String()
131 menubar := body[strings.Index(body, "<nav estilo-menubar"):]
132 menubar = menubar[:strings.Index(menubar, "</nav>")]
133 titles := regexp.MustCompile(`<summary estilo-menubar-title>([^<]+)</summary>`).FindAllStringSubmatch(menubar, -1)
134 var got []string
135 for _, m := range titles {
136 got = append(got, m[1])
137 }
138 if strings.Join(got, ",") != "File,Edit,Insert,Format,Data" {
139 t.Errorf("menu bar titles %v, want File, Edit, Insert, Format, Data", got)
140 }
141 if strings.Count(menubar, "<details") != len(titles) {
142 t.Error("a menu holds a submenu; menus here are one level")
143 }
144 for _, gone := range []string{"rst-dropdown", "rst-menu-group", "Fill colour", "Number format"} {
145 if strings.Contains(menubar, gone) {
146 t.Errorf("the menu bar still carries %q", gone)
147 }
148 }
149 for _, cmd := range regexp.MustCompile(`<button estilo-menu-item type="button" data-cmd="(cut|copy|paste|clear|fill-down|fill-right|select-all|fn)"[^>]*>`).FindAllString(menubar, -1) {
150 if !strings.Contains(cmd, " hidden") {
151 t.Errorf("%s is shown before the script that runs it", cmd)
152 }
153 }
154 // With scripting off Edit holds Undo alone, so its separators are
155 // script-only too: no rule above an empty group.
156 edit := menubar[strings.Index(menubar, "<summary estilo-menubar-title>Edit</summary>"):]
157 edit = edit[:strings.Index(edit, "</details>")]
158 if n, hidden := strings.Count(edit, "<hr"), strings.Count(edit, "<hr data-needs-js hidden>"); n == 0 || n != hidden {
159 t.Errorf("Edit has %d separators, %d of them hidden until script: with scripting off they rule off empty groups", n, hidden)
160 }
161 // The shortcuts moved to the account menu, as a dialog that exists.
162 if !strings.Contains(body, `<button estilo-menu-item type="button" commandfor="sheets-shortcuts" command="show-modal"><span estilo-menu-label>Keyboard shortcuts…</span></button>`) {
163 t.Error("the account menu's Help does not open the shortcuts")
164 }
165 if !strings.Contains(body, `<dialog id="sheets-shortcuts"`) {
166 t.Error("the shortcuts dialog the account menu opens is not on the page")
167 }
168}
169
170// §4: the only notice vocabulary is rastrillo's callout with a real tone.
171// The grep gate: no rst-notice, no valued rst-callout="…" (the old
172// "warn" matched nothing and painted grey), no rst-tone="warn".
173func TestTemplatesUseOnlyCalloutTones(t *testing.T) {
174 files, err := filepath.Glob("templates/*.html")
175 if err != nil || len(files) == 0 {
176 t.Fatal("no templates found", err)
177 }
178 strip := regexp.MustCompile(`(?s)\{\{/\*.*?\*/\}\}`)
179 bad := regexp.MustCompile(`rst-notice|rst-callout="|rst-tone="warn"`)
180 for _, f := range files {
181 body, err := os.ReadFile(f)
182 if err != nil {
183 t.Fatal(err)
184 }
185 if m := bad.FindString(strip.ReplaceAllString(string(body), "")); m != "" {
186 t.Errorf("%s uses %q", f, m)
187 }
188 }
189 // And the tones reach the page: a created link's warning, a grant's
190 // positive notice, a refused grant's negative alert.
191 app, cookies := signIn(t)
192 s, _ := newSheet(t, app, cookies, "Budget")
193 share := "/sheets/" + strconv.FormatUint(s.ID, 10) + "/share"
194 for query, want := range map[string]string{
195 "?notice=shared": `rst-tone="positive"`,
196 "?notice=bad-role": `rst-tone="negative" role="alert"`,
197 } {
198 if body := page(t, app, cookies, share+query).Body.String(); !strings.Contains(body, want) {
199 t.Errorf("%s: no %s callout", query, want)
200 }
201 }
202 rec := do(t, app, as(postForm(share+"/link", map[string][]string{"role": {"viewer"}}), cookies))
203 if body := rec.Body.String(); !strings.Contains(body, `<div rst-callout rst-tone="warning">`) || strings.Count(body, "Copy it now") != 1 {
204 t.Errorf("a new link is not one warning callout (status %d)", rec.Code)
205 }
206}
207
208// While a modal dialog is open no page listener may hear a key: static/
209// modal.js stops key and clipboard events at the window, in the capture
210// phase, and it has to be the first script so its listener is registered
211// before the grid library's (Astra, #39 finding 1; the browser proof is
212// tools/drive/modal.mjs, Chromium and WebKit).
213func TestTheModalGuardIsTheFirstScript(t *testing.T) {
214 app, cookies := signIn(t)
215 s, _ := newSheet(t, app, cookies, "Budget")
216 body := page(t, app, cookies, "/sheets/"+strconv.FormatUint(s.ID, 10)).Body.String()
217 first := regexp.MustCompile(`<script[^>]*src="([^"]+)"`).FindStringSubmatch(body)
218 if first == nil || !strings.HasPrefix(first[1], "/static/modal.") {
219 t.Fatalf("the first script is %v, want static/modal.js", first)
220 }
221 js, err := staticFiles().(interface{ ReadFile(string) ([]byte, error) }).ReadFile("static/modal.js")
222 if err != nil {
223 t.Fatal(err)
224 }
225 for _, want := range []string{"window.addEventListener(type", "stopImmediatePropagation()", "}, true);", "'keydown'", "'paste'", "dialog:modal"} {
226 if !strings.Contains(string(js), want) {
227 t.Errorf("modal.js lost %q", want)
228 }
229 }
230}
231