| 1 | package calc |
| 2 | |
| 3 | import ( |
| 4 | "crypto/sha256" |
| 5 | "encoding/hex" |
| 6 | "encoding/json" |
| 7 | "math" |
| 8 | "os" |
| 9 | "strings" |
| 10 | "testing" |
| 11 | "time" |
| 12 | ) |
| 13 | |
| 14 | // corpusSHA256 pins testdata/corpus/formulas.json against silent drift — |
| 15 | // see TestCorpusIsPinned. Regenerate the file with |
| 16 | // |
| 17 | // go run ./tools/gencorpus -out internal/sheets/calc/testdata/corpus/formulas.json |
| 18 | // |
| 19 | // then run `go test ./internal/sheets/calc/ -run TestCorpusIsPinned -v` |
| 20 | // once to print the new sum, and paste it here. Changing this constant |
| 21 | // without also changing the file (or vice versa) is exactly the silent |
| 22 | // drift this test exists to catch — a corpus change is a deliberate act |
| 23 | // with a regenerated hash, never a side effect of an unrelated edit. |
| 24 | const corpusSHA256 = "17f74be9125efe362b0688dd9bd6d44bd624d3a84d151bf8b8ee0c5fd634e7a6" |
| 25 | |
| 26 | // corpusValue is formulas.json's wire format for a Value: kind names one |
| 27 | // of "empty", "num", "str", "bool", "err", and only the matching field is |
| 28 | // meaningful — mirroring Value itself (eval.go). |
| 29 | type corpusValue struct { |
| 30 | Kind string `json:"kind"` |
| 31 | Num float64 `json:"num,omitempty"` |
| 32 | Str string `json:"str,omitempty"` |
| 33 | Bool bool `json:"bool,omitempty"` |
| 34 | Err string `json:"err,omitempty"` |
| 35 | } |
| 36 | |
| 37 | // corpusErrByName reverses ErrKind.String() (errors.go), so a corpus |
| 38 | // entry's "err" field ("#DIV/0!", ...) can be parsed back into an |
| 39 | // ErrKind. |
| 40 | var corpusErrByName = map[string]ErrKind{ |
| 41 | ErrDiv0.String(): ErrDiv0, |
| 42 | ErrValue.String(): ErrValue, |
| 43 | ErrRef.String(): ErrRef, |
| 44 | ErrName.String(): ErrName, |
| 45 | ErrNum.String(): ErrNum, |
| 46 | ErrNA.String(): ErrNA, |
| 47 | ErrCircular.String(): ErrCircular, |
| 48 | } |
| 49 | |
| 50 | func (cv corpusValue) toValue(t *testing.T) Value { |
| 51 | t.Helper() |
| 52 | switch cv.Kind { |
| 53 | case "empty": |
| 54 | return Value{} |
| 55 | case "num": |
| 56 | return Value{Kind: KindNum, Num: cv.Num} |
| 57 | case "str": |
| 58 | return Value{Kind: KindStr, Str: cv.Str} |
| 59 | case "bool": |
| 60 | return Value{Kind: KindBool, Bool: cv.Bool} |
| 61 | case "err": |
| 62 | ek, ok := corpusErrByName[cv.Err] |
| 63 | if !ok { |
| 64 | t.Fatalf("corpus: unrecognized error kind in a want value") |
| 65 | } |
| 66 | return Value{Kind: KindErr, Err: ek} |
| 67 | default: |
| 68 | t.Fatalf("corpus: unrecognized value kind") |
| 69 | return Value{} |
| 70 | } |
| 71 | } |
| 72 | |
| 73 | // corpusEntry is one corpus row: the function under test (for exclusion |
| 74 | // filtering), the formula text, and the value a spreadsheet returns for |
| 75 | // it. |
| 76 | type corpusEntry struct { |
| 77 | Function string `json:"function"` |
| 78 | Expr string `json:"expr"` |
| 79 | Want corpusValue `json:"want"` |
| 80 | } |
| 81 | |
| 82 | // corpusFile is formulas.json's top-level shape. Note carries this |
| 83 | // corpus's provenance directly in the committed artifact — see |
| 84 | // loadCorpus's doc comment for what it currently says and why. |
| 85 | type corpusFile struct { |
| 86 | Note string `json:"note"` |
| 87 | Entries []corpusEntry `json:"entries"` |
| 88 | } |
| 89 | |
| 90 | const corpusPath = "testdata/corpus/formulas.json" |
| 91 | const exclusionsPath = "testdata/corpus/volatile-excluded.txt" |
| 92 | |
| 93 | // loadCorpus reads and parses the committed corpus. |
| 94 | // |
| 95 | // Provenance, as of 2026-08-31: this file IS now generated. Every "want" |
| 96 | // value is what LibreOffice 26.8.0.3 computed for that exact formula in |
| 97 | // a real headless `--convert-to csv` run driven by tools/gencorpus, so |
| 98 | // TestAgainstReferenceCorpus checks agreement with an independent |
| 99 | // implementation, not self-consistency with the reasoning that produced |
| 100 | // the corpus. It replaced a hand-authored file, and the regeneration |
| 101 | // changed exactly one value out of sixty: SQRT(2), from |
| 102 | // 1.4142135623730951 to 1.4142135623731, which is LibreOffice's CSV |
| 103 | // export rendering the same double at ~15 significant digits, not a |
| 104 | // disagreement (valuesMatch's 1e-9 epsilon covers it). The other 59 |
| 105 | // hand-traced values were confirmed unchanged. |
| 106 | // |
| 107 | // Two things this corpus is still NOT. It is not Excel: LibreOffice |
| 108 | // Calc differs from Excel in real places (SUM(1,2,"3") is 6 in Excel |
| 109 | // and #VALUE! in Calc, for one measured example), so agreement here is |
| 110 | // agreement with Calc. And it is not a second opinion on its own scope |
| 111 | // — a value no case asks about is not covered by anything. The |
| 112 | // Excel-facing cross-check is TestAgainstExcelizeOracle |
| 113 | // (internal/sheets/xlsx/oracle_test.go), against a third engine. |
| 114 | func loadCorpus(t *testing.T) []corpusEntry { |
| 115 | t.Helper() |
| 116 | data, err := os.ReadFile(corpusPath) |
| 117 | if err != nil { |
| 118 | t.Fatalf("loadCorpus: %v", err) |
| 119 | } |
| 120 | var cf corpusFile |
| 121 | if err := json.Unmarshal(data, &cf); err != nil { |
| 122 | t.Fatalf("loadCorpus: %v", err) |
| 123 | } |
| 124 | return cf.Entries |
| 125 | } |
| 126 | |
| 127 | // loadExclusions parses testdata/corpus/volatile-excluded.txt: one |
| 128 | // function name per line, blank lines and "#"-prefixed comment lines |
| 129 | // ignored. |
| 130 | func loadExclusions(t *testing.T) map[string]bool { |
| 131 | t.Helper() |
| 132 | data, err := os.ReadFile(exclusionsPath) |
| 133 | if err != nil { |
| 134 | t.Fatalf("loadExclusions: %v", err) |
| 135 | } |
| 136 | out := map[string]bool{} |
| 137 | for _, line := range strings.Split(string(data), "\n") { |
| 138 | line = strings.TrimSpace(line) |
| 139 | if line == "" || strings.HasPrefix(line, "#") { |
| 140 | continue |
| 141 | } |
| 142 | out[line] = true |
| 143 | } |
| 144 | return out |
| 145 | } |
| 146 | |
| 147 | func sha256File(t *testing.T, path string) string { |
| 148 | t.Helper() |
| 149 | data, err := os.ReadFile(path) |
| 150 | if err != nil { |
| 151 | t.Fatalf("sha256File: %v", err) |
| 152 | } |
| 153 | sum := sha256.Sum256(data) |
| 154 | return hex.EncodeToString(sum[:]) |
| 155 | } |
| 156 | |
| 157 | // valuesMatch compares an evaluated Value against a corpus entry's want. |
| 158 | // Num uses a small epsilon rather than exact equality: some corpus |
| 159 | // entries (e.g. SQRT(2)) are irrational and only meaningful to within |
| 160 | // floating-point precision. |
| 161 | func valuesMatch(got, want Value) bool { |
| 162 | if got.Kind != want.Kind { |
| 163 | return false |
| 164 | } |
| 165 | switch got.Kind { |
| 166 | case KindNum: |
| 167 | return got.Num == want.Num || math.Abs(got.Num-want.Num) < 1e-9 |
| 168 | case KindStr: |
| 169 | return got.Str == want.Str |
| 170 | case KindBool: |
| 171 | return got.Bool == want.Bool |
| 172 | case KindErr: |
| 173 | return got.Err == want.Err |
| 174 | default: // KindEmpty |
| 175 | return true |
| 176 | } |
| 177 | } |
| 178 | |
| 179 | // corpusEnv backs the small lookup table VLOOKUP/INDEX/MATCH's corpus |
| 180 | // entries reference: A1:A3 hold 10/20/30, B1:B3 hold their English |
| 181 | // names, C1 is deliberately left unset (blank) for the COUNT/COUNTA |
| 182 | // entries. Every other corpus entry is self-contained (no cell |
| 183 | // references). |
| 184 | var corpusEnv = mapEnv{ |
| 185 | {1, 1}: {Kind: KindNum, Num: 10}, {1, 2}: {Kind: KindStr, Str: "ten"}, |
| 186 | {2, 1}: {Kind: KindNum, Num: 20}, {2, 2}: {Kind: KindStr, Str: "twenty"}, |
| 187 | {3, 1}: {Kind: KindNum, Num: 30}, {3, 2}: {Kind: KindStr, Str: "thirty"}, |
| 188 | } |
| 189 | |
| 190 | func TestAgainstReferenceCorpus(t *testing.T) { |
| 191 | entries := loadCorpus(t) |
| 192 | excluded := loadExclusions(t) |
| 193 | for _, e := range entries { |
| 194 | if excluded[e.Function] { |
| 195 | continue |
| 196 | } |
| 197 | got := Eval(mustParse(t, e.Expr), corpusEnv) |
| 198 | want := e.Want.toValue(t) |
| 199 | if !valuesMatch(got, want) { |
| 200 | t.Errorf("%s: got %+v, corpus says %+v", e.Function, got, want) |
| 201 | } |
| 202 | } |
| 203 | } |
| 204 | |
| 205 | // TestCorpusIsPinned: see corpusSHA256's doc comment. |
| 206 | func TestCorpusIsPinned(t *testing.T) { |
| 207 | sum := sha256File(t, corpusPath) |
| 208 | if sum != corpusSHA256 { |
| 209 | t.Fatalf("corpus changed: sha256 is now %s\nIf deliberate, update corpusSHA256 to this value.", sum) |
| 210 | } |
| 211 | } |
| 212 | |
| 213 | // TestExclusionsAreRealFunctions: see isRegisteredFunc (registry.go). A |
| 214 | // name in volatile-excluded.txt that resolves to nothing means the list |
| 215 | // has rotted — either a typo, or a name that was never actually wired |
| 216 | // up, silently excluding nothing. |
| 217 | func TestExclusionsAreRealFunctions(t *testing.T) { |
| 218 | for name := range loadExclusions(t) { |
| 219 | if !isRegisteredFunc(name) { |
| 220 | t.Errorf("%s is excluded but is not a registered function", name) |
| 221 | } |
| 222 | } |
| 223 | } |
| 224 | |
| 225 | // clockEnv models one recalculation pass for TestClockIsPinnedWithinAPass: |
| 226 | // Now() always answers the instant pinned at construction, full stop. An |
| 227 | // earlier version of this type also carried a "wall clock" field that a |
| 228 | // test method advanced between the two Eval calls below, mimicking the |
| 229 | // brief's own pseudocode — but nothing in this package ever read that |
| 230 | // field, so advancing it could not, by construction, change the |
| 231 | // outcome: the test would have passed identically with that call |
| 232 | // deleted. A test whose structure implies a mechanism ("advancing the |
| 233 | // wall clock exercises something") it does not actually have is the |
| 234 | // same defect class this task's own corpus mistake was — see |
| 235 | // TestClockIsPinnedWithinAPass's doc comment for what the real guard is. |
| 236 | type clockEnv time.Time |
| 237 | |
| 238 | func newEnvAt(t time.Time) clockEnv { return clockEnv(t) } |
| 239 | func (e clockEnv) CellValue(CellKey) Value { return Value{} } |
| 240 | func (e clockEnv) Now() time.Time { return time.Time(e) } |
| 241 | |
| 242 | // A recalculation pass sees one instant: two calls to NOW() inside one |
| 243 | // pass must agree. This assertion's own reach is bounded — two Eval |
| 244 | // calls in immediate succession would very likely produce the same |
| 245 | // answer even if fn_date.go called the real wall clock directly, since a |
| 246 | // test runs in microseconds, so agreement here cannot by itself |
| 247 | // distinguish "correctly pinned" from "happened to run fast twice in a |
| 248 | // row." The actual guard is structural, not this assertion: fn_date.go's |
| 249 | // fnNow and fnToday call env.Now() and nothing else (no time.Now(), no |
| 250 | // package-level clock) — `grep -rn 'time\.Now' internal/sheets/calc` |
| 251 | // finds no hit in any non-test file (fn_date_test.go's own use seeds a |
| 252 | // pinned env; it never reaches NOW()/TODAY()'s own code path). This test |
| 253 | // is the regression pin for that fact staying true, not a substitute for |
| 254 | // having checked it. |
| 255 | func TestClockIsPinnedWithinAPass(t *testing.T) { |
| 256 | env := newEnvAt(time.Date(2026, 8, 30, 12, 0, 0, 0, time.UTC)) |
| 257 | first := Eval(mustParse(t, "NOW()"), env) |
| 258 | second := Eval(mustParse(t, "NOW()"), env) |
| 259 | if first.Num != second.Num { |
| 260 | t.Fatal("NOW() moved inside one pass; something is reading the wall clock") |
| 261 | } |
| 262 | } |
| 263 | |