| 1 | package guards |
| 2 | |
| 3 | import ( |
| 4 | "os" |
| 5 | "path/filepath" |
| 6 | "regexp" |
| 7 | "strings" |
| 8 | "testing" |
| 9 | ) |
| 10 | |
| 11 | // The vendored stylesheets, and ours. |
| 12 | const ( |
| 13 | vendorGrid = "internal/sheets/static/vendor/jspreadsheet.css" |
| 14 | vendorMenu = "internal/sheets/static/vendor/jsuites.css" |
| 15 | ourSheet = "internal/sheets/static/sheets.css" |
| 16 | ) |
| 17 | |
| 18 | // vendorPaint is every vendored rule that paints in a hard-coded colour |
| 19 | // and that this app can actually render. |
| 20 | // |
| 21 | // sheets.css restated the vendored greys for the grid, the headers and |
| 22 | // the range fill, and stopped there. It did not restate the four |
| 23 | // highlight-* borders, the four copying-* borders, the four selection-* |
| 24 | // borders, .jss_corner, or the selected row's header — so in dark mode |
| 25 | // the range fill was drawn and its outline was not: |
| 26 | // |
| 27 | // - highlight-right/-bottom border 1px solid #000 → invisible on #111418. |
| 28 | // - highlight-top/-left their border is overwritten by our own |
| 29 | // `td { border-top-color: var(--rst-line) }`, one specificity step |
| 30 | // higher, so the outline became an ordinary grid line — while their |
| 31 | // decorative `box-shadow: -1px 0 #ccc` survived and became the one |
| 32 | // bright edge on screen. A selection lit down its left side and |
| 33 | // nowhere else. |
| 34 | // - tr.selected > td:first-child #dcdcdc → a light slab in a dark |
| 35 | // grid, carrying --rst-text-muted (#a8b0ba) at 1.60:1. |
| 36 | // - .jss_corner a black knob ringed in white → reads inverted. |
| 37 | // - jsuites' .jcontextmenu #fff with #555 text → a white card on a |
| 38 | // dark grid, on every cell's right-click. |
| 39 | // |
| 40 | // The row header is the instructive one. sheets.css DID write an |
| 41 | // override for it — `tbody > tr > td:first-child.selected` — but |
| 42 | // Jspreadsheet puts `.selected` on the <tr>, not on its first cell, so |
| 43 | // the rule matched nothing and looked like a fix. A CSS rule that |
| 44 | // matches nothing fails exactly like a rule nobody wrote. |
| 45 | // |
| 46 | // Each entry is checked in BOTH directions: the vendored selector must |
| 47 | // still exist (v5 renamed every `.jexcel` to `.jss_*` once already, and a |
| 48 | // stale list like this one is how that rename goes unnoticed a second |
| 49 | // time), and ours must restate it. |
| 50 | // |
| 51 | // Vendored rules with hard-coded colours that are deliberately NOT here. |
| 52 | // Each reason is checkable against the code, and if one stops holding, |
| 53 | // the rule belongs in the list: |
| 54 | // |
| 55 | // .readonly no column sets readOnly and nothing calls |
| 56 | // setReadOnly; grid.js contains neither word. |
| 57 | // .jss_freezed grid.js passes no freezeColumns or freezeRows. |
| 58 | // .jss_pagination a sheet is one page; pagination is off. |
| 59 | // .jupload, editors we do not enable — the cell editor is the |
| 60 | // .jss_richtext plain text one. |
| 61 | // .jss_content ::-webkit-scrollbar-track/-thumb, #eee and #666. |
| 62 | // scrollbar Measured in Chromium: .jss_content computes |
| 63 | // `overflow: visible` with scrollWidth == |
| 64 | // clientWidth, so it never has a scrollbar of its |
| 65 | // own. The grid scrolls on OUR [data-sheet-grid] |
| 66 | // (overflow: auto), whose bars the browser paints |
| 67 | // from color-scheme. |
| 68 | var vendorPaint = []struct { |
| 69 | file string // the vendored stylesheet it lives in |
| 70 | vendor string // must still exist in that stylesheet |
| 71 | restated string // sheets.css must carry this selector, in a RULE not a comment |
| 72 | }{ |
| 73 | {vendorGrid, ".jss_worksheet > thead > tr > td.selected", "> thead > tr > td.selected"}, |
| 74 | {vendorGrid, ".jss_worksheet > tbody > tr.selected > td:first-child", "> tbody > tr.selected > td:first-child"}, |
| 75 | |
| 76 | {vendorGrid, ".jss_worksheet .highlight ", "> tbody > tr > td.highlight "}, |
| 77 | {vendorGrid, ".jss_worksheet .highlight-top", "> tbody > tr > td.highlight-top"}, |
| 78 | {vendorGrid, ".jss_worksheet .highlight-left", "> tbody > tr > td.highlight-left"}, |
| 79 | {vendorGrid, ".jss_worksheet .highlight-right", "> tbody > tr > td.highlight-right"}, |
| 80 | {vendorGrid, ".jss_worksheet .highlight-bottom", "> tbody > tr > td.highlight-bottom"}, |
| 81 | {vendorGrid, ".jss_worksheet .highlight-selected", "> tbody > tr > td.highlight-selected"}, |
| 82 | {vendorGrid, ".jss_worksheet > tbody > tr > td.highlight > a", "> tbody > tr > td.highlight > a"}, |
| 83 | |
| 84 | {vendorGrid, ".jss_worksheet .selection\n", "> tbody > tr > td.selection "}, |
| 85 | {vendorGrid, ".jss_worksheet .selection-top", "> tbody > tr > td.selection-top"}, |
| 86 | {vendorGrid, ".jss_worksheet .selection-bottom", "> tbody > tr > td.selection-bottom"}, |
| 87 | {vendorGrid, ".jss_worksheet .selection-left", "> tbody > tr > td.selection-left"}, |
| 88 | {vendorGrid, ".jss_worksheet .selection-right", "> tbody > tr > td.selection-right"}, |
| 89 | |
| 90 | {vendorGrid, ".jss_worksheet .copying-top", "> tbody > tr > td.copying-top"}, |
| 91 | {vendorGrid, ".jss_worksheet .copying-bottom", "> tbody > tr > td.copying-bottom"}, |
| 92 | {vendorGrid, ".jss_worksheet .copying-left", "> tbody > tr > td.copying-left"}, |
| 93 | {vendorGrid, ".jss_worksheet .copying-right", "> tbody > tr > td.copying-right"}, |
| 94 | |
| 95 | {vendorGrid, ".jss_corner", "[data-sheet-grid] .jss_corner"}, |
| 96 | |
| 97 | {vendorMenu, ".jcontextmenu {", "[data-sheet-grid] .jcontextmenu {"}, |
| 98 | {vendorMenu, ".jcontextmenu > div a", "[data-sheet-grid] .jcontextmenu > div a"}, |
| 99 | {vendorMenu, ".jcontextmenu > div:hover", "[data-sheet-grid] .jcontextmenu > div:hover"}, |
| 100 | {vendorMenu, ".jcontextmenu .jcontextmenu-disabled a", ".jcontextmenu .jcontextmenu-disabled a"}, |
| 101 | {vendorMenu, ".jcontextmenu hr", "[data-sheet-grid] .jcontextmenu hr"}, |
| 102 | } |
| 103 | |
| 104 | // comments strips /* … */ so a selector NAMED in an explanatory comment |
| 105 | // cannot stand in for the rule itself. |
| 106 | // |
| 107 | // This is not hypothetical tidying. The first draft of these tests |
| 108 | // matched bare `.highlight-right` against the whole file, and the |
| 109 | // paragraph above the rules contains the line |
| 110 | // `.highlight-right/-bottom border: 1px solid #000` — so deleting the |
| 111 | // actual rule left every guard green and the reported bug back. The |
| 112 | // prose describing a bug matched the test looking for its fix. |
| 113 | var comments = regexp.MustCompile(`(?s)/\*.*?\*/`) |
| 114 | |
| 115 | func rulesOnly(t *testing.T, rel string) string { |
| 116 | t.Helper() |
| 117 | body, err := os.ReadFile(filepath.Join(repoRoot(t), rel)) |
| 118 | if err != nil { |
| 119 | t.Fatalf("guards: read %s: %v", rel, err) |
| 120 | } |
| 121 | // The vendored files are CRLF and ours is LF; normalise so an entry |
| 122 | // above can anchor on a line break without caring which. |
| 123 | return comments.ReplaceAllString(strings.ReplaceAll(string(body), "\r\n", "\n"), "") |
| 124 | } |
| 125 | |
| 126 | func TestVendorPaintIsRestated(t *testing.T) { |
| 127 | ours := rulesOnly(t, ourSheet) |
| 128 | vendors := map[string]string{ |
| 129 | vendorGrid: rulesOnly(t, vendorGrid), |
| 130 | vendorMenu: rulesOnly(t, vendorMenu), |
| 131 | } |
| 132 | |
| 133 | for _, c := range vendorPaint { |
| 134 | if !strings.Contains(vendors[c.file], c.vendor) { |
| 135 | t.Errorf("%s: no rule for %q — the vendored library renamed or dropped it "+ |
| 136 | "(v5 renamed every .jexcel to .jss_*). Re-derive this list from the "+ |
| 137 | "vendored stylesheet; deleting the entry hides the rule, it does not fix it", |
| 138 | c.file, c.vendor) |
| 139 | continue |
| 140 | } |
| 141 | if !strings.Contains(ours, c.restated) { |
| 142 | t.Errorf("%s: %s is painted in a hard-coded colour by %s and no RULE here restates it — "+ |
| 143 | "a literal does not follow the person's light/dark choice, so this surface "+ |
| 144 | "is wrong in one of the two schemes. (Comments are stripped before this "+ |
| 145 | "check: naming the selector in prose does not count.)", |
| 146 | ourSheet, c.restated, c.file) |
| 147 | } |
| 148 | } |
| 149 | } |
| 150 | |
| 151 | // The vendored stylesheets are linked from sheet.html's head block, |
| 152 | // which layout.html renders AFTER sheets.css — so the library is later |
| 153 | // in source order and an EQUAL-specificity restatement loses. Ours has |
| 154 | // to be strictly higher, which for the grid means carrying the same |
| 155 | // `> tbody > tr > td` the grid-line rule carries. That is why every |
| 156 | // `restated` string above is the full scoped selector rather than the |
| 157 | // bare class: written short, the rule parses, uses a token, and paints |
| 158 | // nothing. |
| 159 | // |
| 160 | // The limit of this check, stated so nobody trusts it further than it |
| 161 | // goes: it compares strings, not specificity. If the grid-line rule at |
| 162 | // the top of sheets.css ever gains a step of its own — an added class, |
| 163 | // a `:not()`, a misused `:where()` — these selectors stay green and stop |
| 164 | // winning. Recount by hand when you touch that rule. |
| 165 | func TestVendorLinksLoadAfterOurs(t *testing.T) { |
| 166 | root := repoRoot(t) |
| 167 | layout, err := os.ReadFile(filepath.Join(root, "internal/sheets/templates/layout.html")) |
| 168 | if err != nil { |
| 169 | t.Skipf("guards: read layout.html: %v", err) |
| 170 | } |
| 171 | sheet, err := os.ReadFile(filepath.Join(root, "internal/sheets/templates/sheet.html")) |
| 172 | if err != nil { |
| 173 | t.Skipf("guards: read sheet.html: %v", err) |
| 174 | } |
| 175 | ours := strings.Index(string(layout), "sheets.css") |
| 176 | head := strings.Index(string(layout), `"head"`) |
| 177 | if ours < 0 || head < 0 { |
| 178 | t.Skip("guards: layout.html no longer links sheets.css before a head block") |
| 179 | } |
| 180 | if !strings.Contains(string(sheet), "jspreadsheet.css") { |
| 181 | t.Skip("guards: sheet.html no longer links the vendored stylesheet") |
| 182 | } |
| 183 | if ours > head { |
| 184 | t.Errorf("layout.html: sheets.css is linked after the head block that carries the "+ |
| 185 | "vendored stylesheets (%d > %d). That reverses the load order these overrides "+ |
| 186 | "were counted against — recheck every restatement in %s, since ties now go "+ |
| 187 | "to us and some may be over-specified", ours, head, ourSheet) |
| 188 | } |
| 189 | } |
| 190 | |
| 191 | // TestOurStylesheetHasNoColourLiterals is the other half of the token |
| 192 | // rule, and the one the token-name check cannot make. |
| 193 | // |
| 194 | // csstokens_test.go proves every `var(--rst-…)` we use is defined. It |
| 195 | // says nothing about a declaration that skips var() altogether: |
| 196 | // `border-right-color: #7ba7ff` passes that check, passes the |
| 197 | // restatement check above, and is wrong in light mode forever. |
| 198 | func TestOurStylesheetHasNoColourLiterals(t *testing.T) { |
| 199 | css := rulesOnly(t, ourSheet) |
| 200 | // oklch() and color() are here because the app now paints cell fills |
| 201 | // with them. A LITERAL oklch — `oklch(0.92 0.08 145)` — is exactly |
| 202 | // the defect this test exists for and the original pattern could not |
| 203 | // see it. The legitimate uses all resolve a custom property, so the |
| 204 | // check is for a bare number where the lightness goes. |
| 205 | literal := regexp.MustCompile(`(?i)(#[0-9a-f]{3,8}\b|\brgba?\(|\bhsla?\(|` + |
| 206 | `\b(?:oklch|oklab|lch|lab|color)\(\s*[0-9.]|` + |
| 207 | `:\s*(red|blue|green|black|white|gray|grey|orange|yellow|purple)\b)`) |
| 208 | for i, line := range strings.Split(css, "\n") { |
| 209 | if m := literal.FindString(line); m != "" { |
| 210 | t.Errorf("%s:%d: colour literal %q — every colour here is a token, never a "+ |
| 211 | "literal, because a literal cannot follow the person's light/dark choice. "+ |
| 212 | "Line: %s", ourSheet, i+1, m, strings.TrimSpace(line)) |
| 213 | } |
| 214 | } |
| 215 | } |
| 216 | |