| 1 | package sheets |
| 2 | |
| 3 | import ( |
| 4 | "net/http" |
| 5 | "net/http/httptest" |
| 6 | "strconv" |
| 7 | "testing" |
| 8 | ) |
| 9 | |
| 10 | // The tests for spec §4.2's translation pass — "Copy / drag-fill → |
| 11 | // Translation pass at paste time. Relative refs are re-resolved by |
| 12 | // offset from the paste anchor; absolute ($A$1) refs resolve to the |
| 13 | // same id." |
| 14 | // |
| 15 | // calc.Translate has existed, with tests, since the engine landed. What |
| 16 | // did not exist was any caller. Measured in a browser on 2026-09-03: |
| 17 | // filling `=A1*2` down B1:B3 wrote `=A1*2` into all three cells and the |
| 18 | // column read 20, 20, 20 where it should read 20, 40, 60. Every Go test |
| 19 | // passed, because every Go test wrote a cell rather than copying one. |
| 20 | // |
| 21 | // So these tests are written at the HTTP boundary, which is where the |
| 22 | // offset now arrives, and not at calc — calc was never wrong. |
| 23 | |
| 24 | // pasteInto POSTs a copied cell to (r, c), naming the cell it came from. |
| 25 | func pasteInto(t *testing.T, app *App, cookies []*http.Cookie, cells string, r, c, fromR, fromC int, raw string) gridResponse { |
| 26 | t.Helper() |
| 27 | body := `{"r":` + strconv.Itoa(r) + `,"c":` + strconv.Itoa(c) + |
| 28 | `,"fromR":` + strconv.Itoa(fromR) + `,"fromC":` + strconv.Itoa(fromC) + |
| 29 | `,"raw":` + strconv.Quote(raw) + `}` |
| 30 | rec := postCell(t, app, cookies, cells, body) |
| 31 | if rec.Code != http.StatusOK { |
| 32 | t.Fatalf("paste to %d,%d = %d: %s", r, c, rec.Code, rec.Body.String()) |
| 33 | } |
| 34 | return decodeGrid(t, rec) |
| 35 | } |
| 36 | |
| 37 | func TestCopyingAFormulaDownOffsetsItsRelativeReferences(t *testing.T) { |
| 38 | app, cookies := signIn(t) |
| 39 | _, cells := newSheet(t, app, cookies, "Fill") |
| 40 | |
| 41 | for r, v := range []string{"10", "20", "30"} { |
| 42 | postCell(t, app, cookies, cells, `{"r":`+strconv.Itoa(r)+`,"c":0,"raw":"`+v+`"}`) |
| 43 | } |
| 44 | postCell(t, app, cookies, cells, `{"r":0,"c":1,"raw":"=A1*2"}`) |
| 45 | |
| 46 | // B1 filled down into B2 and B3 — one row and two rows below its |
| 47 | // origin, so A1 becomes A2 and A3. |
| 48 | for _, tc := range []struct { |
| 49 | row int |
| 50 | wantRaw, wantDisplay string |
| 51 | }{ |
| 52 | {1, "=A2*2", "40"}, |
| 53 | {2, "=A3*2", "60"}, |
| 54 | } { |
| 55 | got := pasteInto(t, app, cookies, cells, tc.row, 1, 0, 1, "=A1*2") |
| 56 | if len(got.Cells) == 0 { |
| 57 | t.Fatalf("row %d: the paste returned no cells", tc.row) |
| 58 | } |
| 59 | cell := got.Cells[0] |
| 60 | if cell.Raw != tc.wantRaw { |
| 61 | t.Errorf("filled into row %d: raw = %q, want %q — the formula bar "+ |
| 62 | "must show the reference the cell actually uses", |
| 63 | tc.row, cell.Raw, tc.wantRaw) |
| 64 | } |
| 65 | if cell.Display != tc.wantDisplay { |
| 66 | t.Errorf("filled into row %d: display = %q, want %q", |
| 67 | tc.row, cell.Display, tc.wantDisplay) |
| 68 | } |
| 69 | } |
| 70 | } |
| 71 | |
| 72 | func TestCopyingAFormulaAcrossOffsetsTheColumn(t *testing.T) { |
| 73 | app, cookies := signIn(t) |
| 74 | _, cells := newSheet(t, app, cookies, "Across") |
| 75 | |
| 76 | postCell(t, app, cookies, cells, `{"r":0,"c":0,"raw":"7"}`) |
| 77 | postCell(t, app, cookies, cells, `{"r":0,"c":1,"raw":"9"}`) |
| 78 | postCell(t, app, cookies, cells, `{"r":1,"c":0,"raw":"=A1+1"}`) |
| 79 | |
| 80 | got := pasteInto(t, app, cookies, cells, 1, 1, 1, 0, "=A1+1") |
| 81 | if got.Cells[0].Raw != "=B1+1" { |
| 82 | t.Errorf("copied one column right: raw = %q, want %q", got.Cells[0].Raw, "=B1+1") |
| 83 | } |
| 84 | if got.Cells[0].Display != "10" { |
| 85 | t.Errorf("display = %q, want %q", got.Cells[0].Display, "10") |
| 86 | } |
| 87 | } |
| 88 | |
| 89 | func TestAnAbsoluteReferenceSurvivesTheCopy(t *testing.T) { |
| 90 | app, cookies := signIn(t) |
| 91 | _, cells := newSheet(t, app, cookies, "Absolute") |
| 92 | |
| 93 | postCell(t, app, cookies, cells, `{"r":0,"c":0,"raw":"100"}`) |
| 94 | postCell(t, app, cookies, cells, `{"r":0,"c":1,"raw":"3"}`) |
| 95 | postCell(t, app, cookies, cells, `{"r":1,"c":1,"raw":"5"}`) |
| 96 | |
| 97 | // =$A$1*B1 copied down: the absolute half must not move and the |
| 98 | // relative half must. That is the whole meaning of the dollar sign, |
| 99 | // and a Translate that offset both would still pass a test written |
| 100 | // with only relative references in it. |
| 101 | got := pasteInto(t, app, cookies, cells, 1, 2, 0, 2, "=$A$1*B1") |
| 102 | if got.Cells[0].Raw != "=$A$1*B2" { |
| 103 | t.Errorf("raw = %q, want %q — $A$1 must stay put and B1 must move", |
| 104 | got.Cells[0].Raw, "=$A$1*B2") |
| 105 | } |
| 106 | if got.Cells[0].Display != "500" { |
| 107 | t.Errorf("display = %q, want %q", got.Cells[0].Display, "500") |
| 108 | } |
| 109 | } |
| 110 | |
| 111 | func TestPastingInPlaceChangesNothing(t *testing.T) { |
| 112 | app, cookies := signIn(t) |
| 113 | _, cells := newSheet(t, app, cookies, "InPlace") |
| 114 | |
| 115 | postCell(t, app, cookies, cells, `{"r":0,"c":0,"raw":"4"}`) |
| 116 | got := pasteInto(t, app, cookies, cells, 1, 1, 1, 1, "=A1*2") |
| 117 | if got.Cells[0].Raw != "=A1*2" { |
| 118 | t.Errorf("a zero offset rewrote the formula: %q", got.Cells[0].Raw) |
| 119 | } |
| 120 | } |
| 121 | |
| 122 | func TestALiteralIsCopiedVerbatim(t *testing.T) { |
| 123 | app, cookies := signIn(t) |
| 124 | _, cells := newSheet(t, app, cookies, "Literal") |
| 125 | |
| 126 | // Text that looks like it might contain a reference. Only a cell |
| 127 | // whose raw input starts with "=" is a formula, and translating |
| 128 | // anything else would corrupt the one kind of cell that cannot |
| 129 | // possibly need it. |
| 130 | got := pasteInto(t, app, cookies, cells, 5, 5, 0, 0, "A1 and B2") |
| 131 | if got.Cells[0].Raw != "A1 and B2" { |
| 132 | t.Errorf("a text cell was translated: %q", got.Cells[0].Raw) |
| 133 | } |
| 134 | } |
| 135 | |
| 136 | // TestCopyingAFormulaOffTheGridGivesREF pins the edge Excel names. |
| 137 | // |
| 138 | // =A1 copied one column left has nowhere to point. Rendering it anyway |
| 139 | // is the dangerous outcome and was the one this code had before the |
| 140 | // check: calc.ColLabel(-1) returns the empty string, so `=A1` rendered |
| 141 | // as `=1` — a formula silently replaced by the number one, which looks |
| 142 | // like a value somebody typed. |
| 143 | func TestCopyingAFormulaOffTheGridGivesREF(t *testing.T) { |
| 144 | app, cookies := signIn(t) |
| 145 | _, cells := newSheet(t, app, cookies, "OffGrid") |
| 146 | |
| 147 | for _, tc := range []struct { |
| 148 | name string |
| 149 | r, c, fromR, fromC int |
| 150 | raw string |
| 151 | }{ |
| 152 | {"one column left of column A", 0, 0, 0, 1, "=A1"}, |
| 153 | {"one row above row 1", 0, 0, 1, 0, "=A1"}, |
| 154 | {"inside a call", 0, 0, 2, 0, "=SUM(A1:A3)"}, |
| 155 | } { |
| 156 | got := pasteInto(t, app, cookies, cells, tc.r, tc.c, tc.fromR, tc.fromC, tc.raw) |
| 157 | if len(got.Cells) == 0 { |
| 158 | t.Fatalf("%s: the paste returned no cells", tc.name) |
| 159 | } |
| 160 | if got.Cells[0].Display != "#REF!" { |
| 161 | t.Errorf("%s: display = %q, want %q", tc.name, got.Cells[0].Display, "#REF!") |
| 162 | } |
| 163 | } |
| 164 | } |
| 165 | |
| 166 | // TestAnUntranslatableFormulaIsStillWritten is the other half of the |
| 167 | // case above, and the reason it is a separate test: the failure mode |
| 168 | // worth guarding is not "the cell says #REF!" but "the write was |
| 169 | // refused and the cell kept whatever was in it", which would leave a |
| 170 | // paste half-applied. |
| 171 | func TestAnUntranslatableFormulaIsStillWritten(t *testing.T) { |
| 172 | app, cookies := signIn(t) |
| 173 | _, cells := newSheet(t, app, cookies, "Refused") |
| 174 | |
| 175 | postCell(t, app, cookies, cells, `{"r":0,"c":0,"raw":"keep me"}`) |
| 176 | pasteInto(t, app, cookies, cells, 0, 0, 1, 0, "=A1") |
| 177 | |
| 178 | read := decodeGrid(t, do(t, app, as(httptest.NewRequest(http.MethodGet, cells, nil), cookies))) |
| 179 | for _, c := range read.Cells { |
| 180 | if c.R == 0 && c.C == 0 { |
| 181 | if c.Display == "keep me" { |
| 182 | t.Fatal("the paste was refused and the old value survived — a paste " + |
| 183 | "that silently skips a cell is worse than one that writes #REF!") |
| 184 | } |
| 185 | return |
| 186 | } |
| 187 | } |
| 188 | t.Fatal("A1 is absent after the paste") |
| 189 | } |
| 190 | |
| 191 | // TestAnOutOfRangeOriginIsRefused: fromR/fromC are attacker-controlled |
| 192 | // like every other field on this route, and a wild offset would ask |
| 193 | // calc to translate by millions and render a reference nothing can |
| 194 | // parse back. |
| 195 | func TestAnOutOfRangeOriginIsRefused(t *testing.T) { |
| 196 | app, cookies := signIn(t) |
| 197 | _, cells := newSheet(t, app, cookies, "Hostile") |
| 198 | |
| 199 | for _, body := range []string{ |
| 200 | `{"r":0,"c":0,"fromR":-1,"fromC":0,"raw":"=A1"}`, |
| 201 | `{"r":0,"c":0,"fromR":0,"fromC":-5,"raw":"=A1"}`, |
| 202 | `{"r":0,"c":0,"fromR":99999999,"fromC":0,"raw":"=A1"}`, |
| 203 | } { |
| 204 | if rec := postCell(t, app, cookies, cells, body); rec.Code != http.StatusBadRequest { |
| 205 | t.Errorf("%s = %d, want 400", body, rec.Code) |
| 206 | } |
| 207 | } |
| 208 | } |
| 209 | |