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 "fmt"
5 "net/http"
6 "net/http/httptest"
7 "strings"
8 "testing"
9
10 "amadan.net/oficina/sheets/internal/sheets/store"
11)
12
13// formatPath is the format endpoint for a sheet's first tab.
14func formatPath(s SheetSummary) string {
15 return fmt.Sprintf("/api/sheets/%d/tabs/%d/format", s.ID, s.TabID)
16}
17
18func postFormat(t *testing.T, app *App, cookies []*http.Cookie, s SheetSummary, body string) *httptest.ResponseRecorder {
19 t.Helper()
20 req := httptest.NewRequest(http.MethodPost, formatPath(s), strings.NewReader(body))
21 req.Header.Set("Content-Type", "application/json")
22 return do(t, app, as(req, cookies))
23}
24
25// styleFor resolves a cell's style through the response's own format
26// table — the indirection the wire uses, exercised the way a client
27// does, so a test cannot pass against a shape no client could read.
28func styleFor(t *testing.T, g gridResponse, r, c int) wireStyle {
29 t.Helper()
30 for _, cell := range g.Cells {
31 if cell.R != r || cell.C != c {
32 continue
33 }
34 if cell.F == "" {
35 t.Fatalf("cell (%d,%d) carries no format key", r, c)
36 }
37 st, ok := g.Formats[cell.F]
38 if !ok {
39 t.Fatalf("cell (%d,%d) names format %q, which the response does not carry — "+
40 "a client would paint nothing and never know why", r, c, cell.F)
41 }
42 return st
43 }
44 t.Fatalf("no cell at (%d,%d) in %+v", r, c, g.Cells)
45 return wireStyle{}
46}
47
48func TestFormatAppliesAcrossARange(t *testing.T) {
49 app, cookies := signIn(t)
50 s, _ := newSheet(t, app, cookies, "Budget")
51
52 rec := postFormat(t, app, cookies, s, `{"r1":0,"c1":0,"r2":1,"c2":2,"bold":true}`)
53 if rec.Code != http.StatusOK {
54 t.Fatalf("POST format = %d: %s", rec.Code, rec.Body.String())
55 }
56 g := decodeGrid(t, rec)
57 if len(g.Cells) != 6 {
58 t.Fatalf("formatted %d cells, want 6 (2 rows x 3 cols)", len(g.Cells))
59 }
60 // Every cell in the range must be bold, INCLUDING the blank ones: a
61 // coloured or bold header nobody has typed into yet is the ordinary
62 // case, and it needs a Cell row to carry the format.
63 for r := 0; r <= 1; r++ {
64 for c := 0; c <= 2; c++ {
65 if !styleFor(t, g, r, c).Bold {
66 t.Errorf("cell (%d,%d) is not bold", r, c)
67 }
68 }
69 }
70 // Interning survives the wire: six cells, one style.
71 if len(g.Formats) != 1 {
72 t.Errorf("response carries %d distinct formats, want 1 — six identically "+
73 "styled cells are one interned format, and inlining would un-intern them", len(g.Formats))
74 }
75}
76
77func TestFormatMergesRatherThanReplaces(t *testing.T) {
78 app, cookies := signIn(t)
79 s, _ := newSheet(t, app, cookies, "Budget")
80
81 if rec := postFormat(t, app, cookies, s, `{"r1":0,"c1":0,"r2":0,"c2":0,"bold":true}`); rec.Code != http.StatusOK {
82 t.Fatalf("first patch = %d: %s", rec.Code, rec.Body.String())
83 }
84 g := decodeGrid(t, postFormat(t, app, cookies, s, `{"r1":0,"c1":0,"r2":0,"c2":0,"fill":"yellow"}`))
85
86 st := styleFor(t, g, 0, 0)
87 // The second patch never mentioned bold, so bold must survive it.
88 // This is the whole reason every patch field is a pointer: pressing
89 // a fill button must not un-bold the selection.
90 if !st.Bold {
91 t.Error("applying a fill cleared bold — a patch changes only the fields it names")
92 }
93 if st.FillH == nil || st.FillC == nil {
94 t.Fatalf("fill intent missing: %+v", st)
95 }
96}
97
98func TestFormatFillIsIntentNotHex(t *testing.T) {
99 app, cookies := signIn(t)
100 s, _ := newSheet(t, app, cookies, "Budget")
101
102 g := decodeGrid(t, postFormat(t, app, cookies, s, `{"r1":0,"c1":0,"r2":0,"c2":0,"fill":"green"}`))
103 st := styleFor(t, g, 0, 0)
104 // A fill this app applies carries no lightness, so it can resolve
105 // against the reader's theme. A hex would bake one in.
106 if st.FillHex != "" {
107 t.Errorf("fill hex = %q, want empty — a fill we apply is stored as intent, not a literal", st.FillHex)
108 }
109 if st.FillH == nil {
110 t.Fatal("no fill hue")
111 }
112 if *st.FillH != 145 {
113 t.Errorf("fill hue = %v, want green's 145", *st.FillH)
114 }
115}
116
117// TestFormatInkIsIntentNotHex is fill's twin, and it exists because
118// nothing else exercised the write side of ink at all: migration 0008's
119// columns, applyInk and the InkH/InkC wire fields were reachable only by
120// CLEARING them, so a bug that stored no ink would have passed
121// everything.
122func TestFormatInkIsIntentNotHex(t *testing.T) {
123 app, cookies := signIn(t)
124 s, _ := newSheet(t, app, cookies, "Budget")
125
126 g := decodeGrid(t, postFormat(t, app, cookies, s, `{"r1":0,"c1":0,"r2":0,"c2":0,"ink":"red"}`))
127 st := styleFor(t, g, 0, 0)
128 if st.Ink != "" {
129 t.Errorf("ink hex = %q, want empty — an ink we apply is stored as intent so it "+
130 "follows the reader's theme, and a literal would be a light-mode colour "+
131 "sitting on a dark grid", st.Ink)
132 }
133 if st.InkH == nil || st.InkC == nil {
134 t.Fatalf("no ink intent: %+v", st)
135 }
136 if *st.InkH != 27 {
137 t.Errorf("ink hue = %v, want red's 27", *st.InkH)
138 }
139 // Ink is more saturated than a fill on purpose — a fill sits behind
140 // text and must not fight it, ink IS the text. The cap is what
141 // TestEverySwatchClearsAA measures against.
142 if *st.InkC != inkChroma {
143 t.Errorf("ink chroma = %v, want %v", *st.InkC, inkChroma)
144 }
145}
146
147func TestFormatClearingAFillClearsTheImportedHexToo(t *testing.T) {
148 app, cookies := signIn(t)
149 s, _ := newSheet(t, app, cookies, "Budget")
150
151 // Stand in for an import: a format carrying the author's literal.
152 h, err := store.Open(app.db, app.sealer, s.TabID)
153 if err != nil {
154 t.Fatalf("open tab: %v", err)
155 }
156 rowID, err := h.EnsureRow(0)
157 if err != nil {
158 t.Fatalf("ensure row: %v", err)
159 }
160 colID, err := h.EnsureCol(0)
161 if err != nil {
162 t.Fatalf("ensure col: %v", err)
163 }
164 if err := h.SetFormat(rowID, colID, store.Format{FillHex: "FFFFEB9C", InkHex: "FF9C6500"}); err != nil {
165 t.Fatalf("seed imported format: %v", err)
166 }
167 h.Release()
168
169 g := decodeGrid(t, postFormat(t, app, cookies, s, `{"r1":0,"c1":0,"r2":0,"c2":0,"fill":"none","ink":"none"}`))
170 st := styleFor(t, g, 0, 0)
171 // Clearing must clear the retained literal as well, or export
172 // writes the old colour back and the file comes out the colour it
173 // went in whatever the screen showed.
174 if st.FillHex != "" || st.Ink != "" {
175 t.Errorf("clearing left literals behind: fill %q ink %q", st.FillHex, st.Ink)
176 }
177 if st.FillH != nil || st.FillC != nil {
178 t.Errorf("clearing left an intent behind: %+v", st)
179 }
180}
181
182func TestFormatNumberFormatChangesTheServerRenderedDisplay(t *testing.T) {
183 app, cookies := signIn(t)
184 s, cells := newSheet(t, app, cookies, "Budget")
185
186 if rec := postCell(t, app, cookies, cells, `{"r":0,"c":0,"raw":"0.5"}`); rec.Code != http.StatusOK {
187 t.Fatalf("write cell = %d: %s", rec.Code, rec.Body.String())
188 }
189 g := decodeGrid(t, postFormat(t, app, cookies, s, `{"r1":0,"c1":0,"r2":0,"c2":0,"numfmt":"0%"}`))
190 for _, cell := range g.Cells {
191 if cell.R == 0 && cell.C == 0 {
192 // The response re-renders rather than echoing the patch:
193 // the server owns every painted string, so a number format
194 // must not need a JavaScript implementation to take effect.
195 if cell.Display != "50%" {
196 t.Errorf("display = %q, want %q", cell.Display, "50%")
197 }
198 return
199 }
200 }
201 t.Fatal("the formatted cell is not in the response")
202}
203
204func TestFormatRefusesWhatItCannotApply(t *testing.T) {
205 app, cookies := signIn(t)
206 s, _ := newSheet(t, app, cookies, "Budget")
207
208 for name, body := range map[string]string{
209 "unknown colour": `{"r1":0,"c1":0,"r2":0,"c2":0,"fill":"chartreuse"}`,
210 "unknown alignment": `{"r1":0,"c1":0,"r2":0,"c2":0,"align":"justify"}`,
211 // A pattern the UI never offers. Before the allowlist this was
212 // 128 bytes of free text stored verbatim, and an Excel pattern
213 // admits quoted literals — so it was an arbitrary plaintext
214 // write into the formats table through a public API.
215 "unoffered number format": `{"r1":0,"c1":0,"r2":0,"c2":0,"numfmt":"\"Overdue: \"0"}`,
216 "empty patch": `{"r1":0,"c1":0,"r2":0,"c2":0}`,
217 "negative range": `{"r1":-1,"c1":0,"r2":0,"c2":0,"bold":true}`,
218 } {
219 t.Run(name, func(t *testing.T) {
220 rec := postFormat(t, app, cookies, s, body)
221 if rec.Code != http.StatusBadRequest {
222 t.Errorf("= %d, want 400: %s", rec.Code, rec.Body.String())
223 }
224 // A refusal must not quote what it refused: a number format
225 // and a colour name are authored content (AGENTS.md).
226 if strings.Contains(rec.Body.String(), "chartreuse") ||
227 strings.Contains(rec.Body.String(), "justify") {
228 t.Errorf("the refusal echoed the input: %s", rec.Body.String())
229 }
230 })
231 }
232}
233
234func TestFormatRefusesAnOversizedRange(t *testing.T) {
235 app, cookies := signIn(t)
236 s, _ := newSheet(t, app, cookies, "Budget")
237
238 rec := postFormat(t, app, cookies, s, `{"r1":0,"c1":0,"r2":999,"c2":99,"bold":true}`)
239 if rec.Code != http.StatusRequestEntityTooLarge {
240 t.Errorf("= %d, want 413 — a hundred thousand per-cell writes in one request "+
241 "is a request that appears to hang", rec.Code)
242 }
243}
244
245func TestFormatIsRefusedForAViewer(t *testing.T) {
246 app, owner := signIn(t)
247 s, _ := newSheet(t, app, owner, "Budget")
248 guestOn(t, app, s.ID, "ada@elsewhere.example", SheetViewer)
249
250 viewer := signInAs(t, app, "ada@elsewhere.example")
251 rec := postFormat(t, app, viewer, s, `{"r1":0,"c1":0,"r2":0,"c2":0,"bold":true}`)
252 // 404, never 403: anything else lets somebody enumerate the team's
253 // sheets by status code.
254 if rec.Code != http.StatusNotFound {
255 t.Errorf("= %d, want 404", rec.Code)
256 }
257}
258