| 1 | package sheets |
| 2 | |
| 3 | import ( |
| 4 | "net/http" |
| 5 | "net/http/httptest" |
| 6 | "strings" |
| 7 | "testing" |
| 8 | "time" |
| 9 | ) |
| 10 | |
| 11 | // listBody fetches the sheet list as the signed-in viewer. |
| 12 | func listBody(t *testing.T, app *App, cookies []*http.Cookie) string { |
| 13 | t.Helper() |
| 14 | rec := do(t, app, as(httptest.NewRequest(http.MethodGet, "/sheets", nil), cookies)) |
| 15 | if rec.Code != http.StatusOK { |
| 16 | t.Fatalf("GET /sheets = %d", rec.Code) |
| 17 | } |
| 18 | return rec.Body.String() |
| 19 | } |
| 20 | |
| 21 | // firstPreview cuts the first thumbnail out of a rendered list. |
| 22 | // |
| 23 | // It anchors on the SLOT and not on "<svg", which is the mistake this |
| 24 | // helper exists to stop anybody making twice: the layout's account menu |
| 25 | // renders a chevron icon further up the page, so a search for the first |
| 26 | // <svg> finds that one, and every assertion about the thumbnail then |
| 27 | // passes or fails on the wrong element. |
| 28 | func firstPreview(t *testing.T, page string) string { |
| 29 | t.Helper() |
| 30 | i := strings.Index(page, "data-preview-slot") |
| 31 | if i < 0 { |
| 32 | t.Fatal("the list rendered no thumbnail slot at all, so this test proves nothing") |
| 33 | } |
| 34 | rest := page[i:] |
| 35 | start := strings.Index(rest, "<svg") |
| 36 | end := strings.Index(rest, "</svg>") |
| 37 | if start < 0 || end < 0 { |
| 38 | t.Fatalf("the thumbnail slot holds no SVG: %.200s", rest) |
| 39 | } |
| 40 | return rest[start : end+6] |
| 41 | } |
| 42 | |
| 43 | // TestAPreviewNeverCarriesACell is the gate on the property the whole |
| 44 | // design rests on. |
| 45 | // |
| 46 | // The thumbnail is built from which cells EXIST and what formats they |
| 47 | // carry — never from a cell's value. That is what makes it safe to |
| 48 | // cache, safe to render on every list load, and safe from the one rule |
| 49 | // without needing an exemption or an argument. A refactor that reached |
| 50 | // for the value to make the bars prettier would break it silently: the |
| 51 | // picture would look better and nobody would notice what it now |
| 52 | // contained. |
| 53 | // |
| 54 | // The fixture words are deliberately unusual. "3228.33" would be a |
| 55 | // weak canary — a coordinate or a width could produce those digits by |
| 56 | // accident — so the values here are strings nothing else in an SVG of |
| 57 | // rectangles could plausibly emit. |
| 58 | func TestAPreviewNeverCarriesACell(t *testing.T) { |
| 59 | app, cookies := signIn(t) |
| 60 | _, cells := newSheet(t, app, cookies, "Ledger") |
| 61 | |
| 62 | secrets := []string{"Zanzibar", "Quixotry", "Vellichor"} |
| 63 | for i, word := range secrets { |
| 64 | body := `{"r":` + itoa(i) + `,"c":0,"raw":"` + word + `"}` |
| 65 | if rec := postCell(t, app, cookies, cells, body); rec.Code != http.StatusOK { |
| 66 | t.Fatalf("write %q = %d", word, rec.Code) |
| 67 | } |
| 68 | } |
| 69 | |
| 70 | svg := firstPreview(t, listBody(t, app, cookies)) |
| 71 | |
| 72 | for _, word := range secrets { |
| 73 | if strings.Contains(svg, word) { |
| 74 | t.Errorf("the thumbnail contains the cell value %q — a preview is built "+ |
| 75 | "from the sheet's shape, never its contents, and that is the whole "+ |
| 76 | "reason it needs no seal reasoning", word) |
| 77 | } |
| 78 | } |
| 79 | // It must still be a picture OF something: a test that passes |
| 80 | // because nothing is drawn would pass forever. |
| 81 | if strings.Count(svg, "<rect") < 4 { |
| 82 | t.Errorf("the thumbnail has %d rects for a sheet with three filled cells — "+ |
| 83 | "it is not drawing the shape it is supposed to", strings.Count(svg, "<rect")) |
| 84 | } |
| 85 | } |
| 86 | |
| 87 | // itoa is strconv.Itoa for a single digit, which is all this file |
| 88 | // needs; a full import for three row numbers is noise. |
| 89 | func itoa(i int) string { return string(rune('0' + i)) } |
| 90 | |
| 91 | // TestAnEmptySheetPreviewsAsAnEmptyBox pins the honest answer for a |
| 92 | // sheet with nothing in it: the box, not a fabricated picture. |
| 93 | func TestAnEmptySheetPreviewsAsAnEmptyBox(t *testing.T) { |
| 94 | app, cookies := signIn(t) |
| 95 | newSheet(t, app, cookies, "Nothing yet") |
| 96 | |
| 97 | svg := firstPreview(t, listBody(t, app, cookies)) |
| 98 | if n := strings.Count(svg, "<rect"); n != 1 { |
| 99 | t.Errorf("an empty sheet drew %d rects, want 1 (the border box) — a preview "+ |
| 100 | "that invents content is worse than one that admits there is none", n) |
| 101 | } |
| 102 | } |
| 103 | |
| 104 | // TestThePreviewCacheHitsAndThenMisses is the test that would have |
| 105 | // caught the trap Docs named: comparing time.Time with == instead of |
| 106 | // comparing UnixNano int64s. |
| 107 | // |
| 108 | // A cache that never hits is invisible — every load simply re-renders |
| 109 | // and looks correct — so the only way to know is to ask it. |
| 110 | func TestThePreviewCacheHitsAndThenMisses(t *testing.T) { |
| 111 | c := newPreviewCache() |
| 112 | at := time.Now().UTC() |
| 113 | |
| 114 | c.put(7, at.UnixNano(), "<svg/>") |
| 115 | |
| 116 | if _, ok := c.get(7, at.UnixNano()); !ok { |
| 117 | t.Error("a cache entry did not hit for the timestamp it was stored under — " + |
| 118 | "which is what happens when times are compared with == rather than by " + |
| 119 | "UnixNano, and it fails silently by re-rendering forever") |
| 120 | } |
| 121 | if _, ok := c.get(7, at.Add(time.Nanosecond).UnixNano()); ok { |
| 122 | t.Error("an entry hit for a NEWER timestamp — an edit would not invalidate it, " + |
| 123 | "and the list would show a preview of the sheet as it used to be") |
| 124 | } |
| 125 | if _, ok := c.get(8, at.UnixNano()); ok { |
| 126 | t.Error("an entry hit for a different sheet") |
| 127 | } |
| 128 | } |
| 129 | |
| 130 | // TestThePreviewCacheIsBounded proves the eviction is real. An unbounded |
| 131 | // map keyed on sheet id is a slow leak, and the only symptom is memory. |
| 132 | func TestThePreviewCacheIsBounded(t *testing.T) { |
| 133 | c := newPreviewCache() |
| 134 | for i := 0; i < maxPreviewEntries+10; i++ { |
| 135 | c.put(uint64(i), int64(i), "<svg/>") |
| 136 | } |
| 137 | c.mu.RLock() |
| 138 | n := len(c.at) |
| 139 | c.mu.RUnlock() |
| 140 | if n > maxPreviewEntries { |
| 141 | t.Errorf("the cache holds %d entries, past its %d bound", n, maxPreviewEntries) |
| 142 | } |
| 143 | } |
| 144 | |
| 145 | // TestAFillReachesThePreview checks the picture actually carries the |
| 146 | // formatting it claims to — including that a colour this app applied is |
| 147 | // emitted as an intent, so the thumbnail follows the reader's theme the |
| 148 | // way the grid does. |
| 149 | func TestAFillReachesThePreview(t *testing.T) { |
| 150 | app, cookies := signIn(t) |
| 151 | s, _ := newSheet(t, app, cookies, "Coloured") |
| 152 | |
| 153 | if rec := postFormat(t, app, cookies, s, `{"r1":0,"c1":0,"r2":0,"c2":2,"fill":"green"}`); rec.Code != http.StatusOK { |
| 154 | t.Fatalf("fill = %d: %s", rec.Code, rec.Body.String()) |
| 155 | } |
| 156 | |
| 157 | svg := firstPreview(t, listBody(t, app, cookies)) |
| 158 | if !strings.Contains(svg, "oklch(var(--sheet-fill-l)") { |
| 159 | t.Error("the thumbnail has no theme-resolved fill — a colour this app applied " + |
| 160 | "is hue and chroma with no lightness, so a baked hex here would be a " + |
| 161 | "light-mode swatch sitting on a dark list") |
| 162 | } |
| 163 | } |
| 164 | |
| 165 | // TestSafeHexRefusesWhatIsNotAColour guards the one place a value from |
| 166 | // an uploaded document reaches an SVG attribute. |
| 167 | func TestSafeHexRefusesWhatIsNotAColour(t *testing.T) { |
| 168 | for _, bad := range []string{ |
| 169 | "", "red", "#FFEB9C", "FFEB9", "FFEB9CC", "url(x)", `"/><script>`, |
| 170 | "FFEB9G", "oklch(1 0 0)", |
| 171 | } { |
| 172 | if got := safeHex(bad); got != "" { |
| 173 | t.Errorf("safeHex(%q) = %q, want empty", bad, got) |
| 174 | } |
| 175 | } |
| 176 | if got := safeHex("FFEB9C"); got != "#FFEB9C" { |
| 177 | t.Errorf("safeHex(6 digits) = %q", got) |
| 178 | } |
| 179 | // XLSX writes ARGB; the alpha is dropped, as it is everywhere else |
| 180 | // this app handles a fill hex. |
| 181 | if got := safeHex("FFFFEB9C"); got != "#FFEB9C" { |
| 182 | t.Errorf("safeHex(8 digits) = %q", got) |
| 183 | } |
| 184 | } |
| 185 | |