oficina / sheets Public

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

Plain git — no account needed to clone.

Download

Download this file

1// Cross-checking against excelize — why this lives here, not in calc.
2//
3// calc/testdata/corpus/formulas.json (internal/sheets/calc) is generated
4// by driving a real LibreOffice headless (26.8.0.3, as of 2026-08-31),
5// so the formula engine is checked against an independent implementation
6// rather than its own author's reasoning. It was hand-authored for a
7// while, and that corpus proved self-consistency, not conformance: it
8// shipped, for a while, with COUNT(1,2,"a",TRUE) expecting 2 (a wrong
9// implementation's answer) until a human reviewer noticed real
10// spreadsheets return 3 — a mistake the corpus, checking only itself,
11// could not have caught.
12//
13// A LibreOffice-generated corpus closes that gap but opens a narrower
14// one: LibreOffice Calc is not Excel. Measured directly, Calc returns
15// #VALUE! for SUM(1,2,"3") where Excel (and this engine, and excelize)
16// return 6. So conformance to Calc is not conformance to Excel, and a
17// second independent engine is still worth having — which is what this
18// file is.
19//
20// excelize (github.com/xuri/excelize/v2, already in go.mod for XLSX
21// import/export) ships its own formula engine, CalcCellValue — a
22// different codebase by different authors, independent of ours by
23// construction. TestAgainstExcelizeOracle below drives it against every
24// non-volatile corpus entry and cross-checks its answer against calc's
25// live Eval — not against the corpus's "want" field, which is what
26// TestAgainstReferenceCorpus (calc package) already checks and is
27// exactly the self-consistency this harness exists to get past.
28//
29// This lives in internal/sheets/xlsx, not internal/sheets/calc, for one
30// mechanical reason and one architectural one:
31// - internal/guardstest/excelize_confinement_test.go fails any file outside
32// internal/sheets/xlsx (any depth) that imports excelize, with no
33// exemption for _test.go files. A file in calc that imported excelize
34// — even test-only — would fail that guard.
35// - xlsx already imports both excelize and calc (import.go, export.go)
36// for exactly the same reason this harness needs both: it is the one
37// package whose whole job is bridging excelize's world and ours
38// (import.go's package doc: "excelize is confined to this package
39// deliberately ... letting any other package import it would couple
40// our storage to theirs"). A cross-check between the two engines is
41// that same bridging job, just for verification instead of import.
42//
43// A brand-new internal/oracle package was the other option considered:
44// rejected because it would need excelizeHome widened in the guard to
45// cover it, for no benefit over reusing the tree that already exists for
46// exactly this pairing of dependencies.
47package xlsx_test
48
49import (
50 "math"
51 "strconv"
52 "testing"
53 "time"
54
55 "amadan.net/oficina/sheets/internal/sheets/calc"
56)
57
58// oracleEnv is calc.Env backed by the identical fixed lookup table
59// computeOracleAnswer's excelize workbook sets up (oracle_excelize_test.go):
60// A1:A3 hold 10/20/30, B1:B3 their English names, C1 deliberately unset.
61// Now is pinned rather than wired to the real clock because NOW/TODAY are
62// excluded from this comparison anyway (loadOracleExclusions) — nothing
63// here ever reads it for a real answer, but calc.Env requires it.
64type oracleEnv map[calc.CellKey]calc.Value
65
66func (e oracleEnv) CellValue(k calc.CellKey) calc.Value { return e[k] }
67func (e oracleEnv) Now() time.Time { return time.Date(2026, 8, 30, 12, 0, 0, 0, time.UTC) }
68
69var oracleCellEnv = oracleEnv{
70 {RowID: 1, ColID: 1}: {Kind: calc.KindNum, Num: 10},
71 {RowID: 1, ColID: 2}: {Kind: calc.KindStr, Str: "ten"},
72 {RowID: 2, ColID: 1}: {Kind: calc.KindNum, Num: 20},
73 {RowID: 2, ColID: 2}: {Kind: calc.KindStr, Str: "twenty"},
74 {RowID: 3, ColID: 1}: {Kind: calc.KindNum, Num: 30},
75 {RowID: 3, ColID: 2}: {Kind: calc.KindStr, Str: "thirty"},
76}
77
78// renderCalcValue renders a calc.Value the same way an excelize
79// CalcCellValue answer would read as text, so the two sides of the
80// comparison — and oracleDivergences' recorded strings — are directly
81// comparable and directly printable.
82func renderCalcValue(v calc.Value) string {
83 switch v.Kind {
84 case calc.KindNum:
85 return strconv.FormatFloat(v.Num, 'g', -1, 64)
86 case calc.KindStr:
87 return v.Str
88 case calc.KindBool:
89 if v.Bool {
90 return "TRUE"
91 }
92 return "FALSE"
93 case calc.KindErr:
94 return v.Err.String()
95 default: // calc.KindEmpty
96 return ""
97 }
98}
99
100// agreesWithOracle compares our engine's Value against excelize's raw
101// answer string. Numeric comparison tolerates a small epsilon — matching
102// calc's own corpus_test.go valuesMatch — because excelize's
103// CalcCellValue renders floats to fewer significant digits than Go's
104// full round-trip precision (observed directly: SQRT(2) comes back as
105// "1.4142135623731", 13 significant digits, not
106// "1.4142135623730951"), which is a rendering difference, not a
107// disagreement about the value.
108func agreesWithOracle(our calc.Value, oracleVal string) bool {
109 switch our.Kind {
110 case calc.KindNum:
111 f, err := strconv.ParseFloat(oracleVal, 64)
112 if err != nil {
113 return false
114 }
115 return f == our.Num || math.Abs(f-our.Num) < 1e-9
116 case calc.KindBool:
117 want := "FALSE"
118 if our.Bool {
119 want = "TRUE"
120 }
121 return oracleVal == want
122 case calc.KindErr:
123 return oracleVal == our.Err.String()
124 case calc.KindStr:
125 return oracleVal == our.Str
126 default: // calc.KindEmpty
127 return oracleVal == ""
128 }
129}
130
131// mustParseOracle parses expr with calc.Parse, failing the test on a
132// parse error — every corpus expression is already known-parseable
133// (TestAgainstReferenceCorpus parses the same file), so a failure here
134// means the corpus and this harness have drifted apart.
135func mustParseOracle(t *testing.T, expr string) calc.Node {
136 t.Helper()
137 n, err := calc.Parse(expr)
138 if err != nil {
139 t.Fatalf("mustParseOracle(%q): %v", expr, err)
140 }
141 return n
142}
143
144// TestAgainstExcelizeOracle is the cross-check itself. For every
145// non-volatile corpus entry it evaluates the SAME expression with calc's
146// own engine and with excelize's, and requires them to agree UNLESS the
147// exact disagreement is already reviewed in oracleDivergences.
148//
149// Three outcomes per entry, all counted and none silently dropped:
150// - agree: the common case.
151// - known divergence: logged with its reasoned note, not a failure —
152// but the recorded strings are checked against what both engines
153// currently say, so the record cannot rot out from under a later
154// change to either engine.
155// - unavailable: excelize could not produce a real answer at all
156// (unimplemented function, parse rejection) — logged explicitly as
157// its own category, never merged into "agree" or silently skipped.
158//
159// Anything else — a disagreement with no matching entry in
160// oracleDivergences — fails the test outright.
161func TestAgainstExcelizeOracle(t *testing.T) {
162 entries := loadOracleCorpus(t)
163 excluded := loadOracleExclusions(t)
164 known := indexOracleDivergences(t)
165
166 var agree, unavailable, knownDivergent, compared int
167 for _, e := range entries {
168 if excluded[e.Function] {
169 continue
170 }
171 compared++
172
173 ourVal := calc.Eval(mustParseOracle(t, e.Expr), oracleCellEnv)
174 ourStr := renderCalcValue(ourVal)
175
176 oa := computeOracleAnswer(t, e.Expr)
177 if !oa.Available {
178 unavailable++
179 t.Logf("ORACLE UNAVAILABLE: %s %q: %s", e.Function, e.Expr, oa.Reason)
180 continue
181 }
182
183 if agreesWithOracle(ourVal, oa.Value) {
184 agree++
185 continue
186 }
187
188 d, isKnown := known[e.Expr]
189 if !isKnown {
190 t.Errorf("NEW DISAGREEMENT: %s %q: ours=%q excelize=%q — not in oracleDivergences (oracle_divergences_test.go); investigate before adding it there, do not assume we are wrong", e.Function, e.Expr, ourStr, oa.Value)
191 continue
192 }
193 if d.OurAnswer != ourStr || d.OracleAnswer != oa.Value {
194 t.Errorf("STALE DIVERGENCE RECORD: %s %q: recorded ours=%q/excelize=%q, now ours=%q/excelize=%q — update oracle_divergences_test.go (either the disagreement is gone, or it changed shape and needs re-review)", e.Function, e.Expr, d.OurAnswer, d.OracleAnswer, ourStr, oa.Value)
195 continue
196 }
197 knownDivergent++
198 t.Logf("KNOWN DIVERGENCE: %s %q: ours=%q excelize=%q — %s", e.Function, e.Expr, ourStr, oa.Value, d.Note)
199 }
200
201 t.Logf("oracle cross-check: %d entries compared (%d volatile excluded), agree=%d known-divergence=%d unavailable=%d",
202 compared, len(entries)-compared, agree, knownDivergent, unavailable)
203}
204
205// indexOracleDivergences keys oracleDivergences by Expr and fails fast
206// on a duplicate key — two records for the same expression is itself a
207// list-rot bug (which one is authoritative?), not a valid state.
208func indexOracleDivergences(t *testing.T) map[string]oracleDivergence {
209 t.Helper()
210 out := make(map[string]oracleDivergence, len(oracleDivergences))
211 for _, d := range oracleDivergences {
212 if _, dup := out[d.Expr]; dup {
213 t.Fatalf("oracleDivergences: duplicate entry for expr %q", d.Expr)
214 }
215 out[d.Expr] = d
216 }
217 return out
218}
219
220// TestOracleDivergencesMatchCorpus fails on an oracleDivergences entry
221// whose Expr does not match any current corpus entry — an orphaned
222// divergence record (the corpus changed under it, or it was mistyped) is
223// the same kind of silent rot TestExclusionsAreRealFunctions
224// (calc/corpus_test.go) guards against for the exclusion list.
225func TestOracleDivergencesMatchCorpus(t *testing.T) {
226 entries := loadOracleCorpus(t)
227 exprs := make(map[string]bool, len(entries))
228 for _, e := range entries {
229 exprs[e.Expr] = true
230 }
231 for _, d := range oracleDivergences {
232 if !exprs[d.Expr] {
233 t.Errorf("oracleDivergences: %q does not match any corpus entry — stale record", d.Expr)
234 }
235 }
236}
237