oficina / sheets Public

Clone
git clone https://amadan.net/oficina/sheets

Plain git — no account needed to clone.

Download

Download this file

1package calc
2
3import (
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.
24const 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).
29type 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.
40var 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
50func (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.
76type 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.
85type corpusFile struct {
86 Note string `json:"note"`
87 Entries []corpusEntry `json:"entries"`
88}
89
90const corpusPath = "testdata/corpus/formulas.json"
91const 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.
114func 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.
130func 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
147func 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.
161func 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).
184var 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
190func 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.
206func 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.
217func 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.
236type clockEnv time.Time
237
238func newEnvAt(t time.Time) clockEnv { return clockEnv(t) }
239func (e clockEnv) CellValue(CellKey) Value { return Value{} }
240func (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.
255func 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