| 1 | package sheets |
| 2 | |
| 3 | import ( |
| 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. |
| 14 | func formatPath(s SheetSummary) string { |
| 15 | return fmt.Sprintf("/api/sheets/%d/tabs/%d/format", s.ID, s.TabID) |
| 16 | } |
| 17 | |
| 18 | func 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. |
| 28 | func 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 | |
| 48 | func 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 | |
| 77 | func 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 | |
| 98 | func 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. |
| 122 | func 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 | |
| 147 | func 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 | |
| 182 | func 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 | |
| 204 | func 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 | |
| 234 | func 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 | |
| 245 | func 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 | |