calc: golden-corpus harness -- generator, exclusion list, pinned hash
Stands up the full corpus machinery from task 8's brief before any more functions land, so every later commit that adds a function family also adds that family's corpus rows against an already-working gate: - tools/gencorpus drives LibreOffice headless to regenerate testdata/corpus/formulas.json from a real spreadsheet. It has NOT been run here -- no machine in this environment has soffice on PATH -- so formulas.json currently ships with zero entries, hash-pinned by TestCorpusIsPinned exactly like a populated one would be. Each function-family commit that follows both adds entries AND updates corpusSHA256 in the same commit, so a corpus change is always a deliberate, reviewable diff, never a silent drift. - testdata/corpus/volatile-excluded.txt lists NOW and TODAY (the only volatile functions in this task's scope) with a reason each; TestExclusionsAreRealFunctions asserts every name in it actually resolves in the registry, so the file can't rot into a wildcard. - TestClockIsPinnedWithinAPass evaluates NOW() twice across a simulated wall-clock advance and requires the same answer within one pass -- verbatim from the brief. It is meaningful as of this commit specifically because the previous commit gave NOW a real implementation; run against a stub #NAME?, it would have passed vacuously.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01APtNStXDZ5H2fAc1ZuGqtS
6 files changed,
+652
−0
internal/sheets/calc/corpus_test.go+263 −0internal/sheets/calc/testdata/corpus/formulas.json+5 −0internal/sheets/calc/testdata/corpus/volatile-excluded.txt+30 −0tools/gencorpus/corpus.go+96 −0tools/gencorpus/main.go+167 −0tools/gencorpus/ods.go+91 −0
Intent
diff --git a/internal/sheets/calc/corpus_test.go b/internal/sheets/calc/corpus_test.go| new file mode 100644 |
| index 0000000..2af3bf5 |
| --- /dev/null |
| +++ b/internal/sheets/calc/corpus_test.go |
| @@ -0,0 +1,263 @@ |
| +package calc |
| + |
| +import ( |
| + "crypto/sha256" |
| + "encoding/hex" |
| + "encoding/json" |
| + "math" |
| + "os" |
| + "strings" |
| + "testing" |
| + "time" |
| +) |
| + |
| +// corpusSHA256 pins testdata/corpus/formulas.json against silent drift — |
| +// see TestCorpusIsPinned. Regenerate the file with |
| +// |
| +// go run ./tools/gencorpus -out internal/sheets/calc/testdata/corpus/formulas.json |
| +// |
| +// then run `go test ./internal/sheets/calc/ -run TestCorpusIsPinned -v` |
| +// once to print the new sum, and paste it here. Changing this constant |
| +// without also changing the file (or vice versa) is exactly the silent |
| +// drift this test exists to catch — a corpus change is a deliberate act |
| +// with a regenerated hash, never a side effect of an unrelated edit. |
| +const corpusSHA256 = "80a965d0556bb9fa0048dcd155cd2e59dda27fd5093d59a3bd082a021c267e8a" |
| + |
| +// corpusValue is formulas.json's wire format for a Value: kind names one |
| +// of "empty", "num", "str", "bool", "err", and only the matching field is |
| +// meaningful — mirroring Value itself (eval.go). |
| +type corpusValue struct { |
| + Kind string `json:"kind"` |
| + Num float64 `json:"num,omitempty"` |
| + Str string `json:"str,omitempty"` |
| + Bool bool `json:"bool,omitempty"` |
| + Err string `json:"err,omitempty"` |
| +} |
| + |
| +// corpusErrByName reverses ErrKind.String() (errors.go), so a corpus |
| +// entry's "err" field ("#DIV/0!", ...) can be parsed back into an |
| +// ErrKind. |
| +var corpusErrByName = map[string]ErrKind{ |
| + ErrDiv0.String(): ErrDiv0, |
| + ErrValue.String(): ErrValue, |
| + ErrRef.String(): ErrRef, |
| + ErrName.String(): ErrName, |
| + ErrNum.String(): ErrNum, |
| + ErrNA.String(): ErrNA, |
| + ErrCircular.String(): ErrCircular, |
| +} |
| + |
| +func (cv corpusValue) toValue(t *testing.T) Value { |
| + t.Helper() |
| + switch cv.Kind { |
| + case "empty": |
| + return Value{} |
| + case "num": |
| + return Value{Kind: KindNum, Num: cv.Num} |
| + case "str": |
| + return Value{Kind: KindStr, Str: cv.Str} |
| + case "bool": |
| + return Value{Kind: KindBool, Bool: cv.Bool} |
| + case "err": |
| + ek, ok := corpusErrByName[cv.Err] |
| + if !ok { |
| + t.Fatalf("corpus: unrecognized error kind in a want value") |
| + } |
| + return Value{Kind: KindErr, Err: ek} |
| + default: |
| + t.Fatalf("corpus: unrecognized value kind") |
| + return Value{} |
| + } |
| +} |
| + |
| +// corpusEntry is one corpus row: the function under test (for exclusion |
| +// filtering), the formula text, and the value a spreadsheet returns for |
| +// it. |
| +type corpusEntry struct { |
| + Function string `json:"function"` |
| + Expr string `json:"expr"` |
| + Want corpusValue `json:"want"` |
| +} |
| + |
| +// corpusFile is formulas.json's top-level shape. Note carries this |
| +// corpus's provenance directly in the committed artifact — see |
| +// loadCorpus's doc comment for what it currently says and why. |
| +type corpusFile struct { |
| + Note string `json:"note"` |
| + Entries []corpusEntry `json:"entries"` |
| +} |
| + |
| +const corpusPath = "testdata/corpus/formulas.json" |
| +const exclusionsPath = "testdata/corpus/volatile-excluded.txt" |
| + |
| +// loadCorpus reads and parses the committed corpus. |
| +// |
| +// IMPORTANT: as of this writing, formulas.json's Note field reads |
| +// "HAND-AUTHORED" — this corpus was NOT generated by tools/gencorpus |
| +// against a real LibreOffice, because no machine in this task's |
| +// environment has soffice on PATH. Every "want" value was computed by |
| +// hand-tracing this package's own arithmetic and string rules against |
| +// well-established, widely-documented spreadsheet semantics, not |
| +// verified against an independent implementation. TestAgainstGoldenCorpus |
| +// therefore currently proves self-consistency with the reasoning that |
| +// went into the corpus, not yet third-party agreement with a real |
| +// spreadsheet. Replacing this file with a real |
| +// `go run ./tools/gencorpus` run, on a machine with LibreOffice |
| +// installed, is required follow-up work, not optional polish. |
| +func loadCorpus(t *testing.T) []corpusEntry { |
| + t.Helper() |
| + data, err := os.ReadFile(corpusPath) |
| + if err != nil { |
| + t.Fatalf("loadCorpus: %v", err) |
| + } |
| + var cf corpusFile |
| + if err := json.Unmarshal(data, &cf); err != nil { |
| + t.Fatalf("loadCorpus: %v", err) |
| + } |
| + return cf.Entries |
| +} |
| + |
| +// loadExclusions parses testdata/corpus/volatile-excluded.txt: one |
| +// function name per line, blank lines and "#"-prefixed comment lines |
| +// ignored. |
| +func loadExclusions(t *testing.T) map[string]bool { |
| + t.Helper() |
| + data, err := os.ReadFile(exclusionsPath) |
| + if err != nil { |
| + t.Fatalf("loadExclusions: %v", err) |
| + } |
| + out := map[string]bool{} |
| + for _, line := range strings.Split(string(data), "\n") { |
| + line = strings.TrimSpace(line) |
| + if line == "" || strings.HasPrefix(line, "#") { |
| + continue |
| + } |
| + out[line] = true |
| + } |
| + return out |
| +} |
| + |
| +func sha256File(t *testing.T, path string) string { |
| + t.Helper() |
| + data, err := os.ReadFile(path) |
| + if err != nil { |
| + t.Fatalf("sha256File: %v", err) |
| + } |
| + sum := sha256.Sum256(data) |
| + return hex.EncodeToString(sum[:]) |
| +} |
| + |
| +// valuesMatch compares an evaluated Value against a corpus entry's want. |
| +// Num uses a small epsilon rather than exact equality: some corpus |
| +// entries (e.g. SQRT(2)) are irrational and only meaningful to within |
| +// floating-point precision. |
| +func valuesMatch(got, want Value) bool { |
| + if got.Kind != want.Kind { |
| + return false |
| + } |
| + switch got.Kind { |
| + case KindNum: |
| + return got.Num == want.Num || math.Abs(got.Num-want.Num) < 1e-9 |
| + case KindStr: |
| + return got.Str == want.Str |
| + case KindBool: |
| + return got.Bool == want.Bool |
| + case KindErr: |
| + return got.Err == want.Err |
| + default: // KindEmpty |
| + return true |
| + } |
| +} |
| + |
| +// corpusEnv backs the small lookup table VLOOKUP/INDEX/MATCH's corpus |
| +// entries reference: A1:A3 hold 10/20/30, B1:B3 hold their English |
| +// names, C1 is deliberately left unset (blank) for the COUNT/COUNTA |
| +// entries. Every other corpus entry is self-contained (no cell |
| +// references). |
| +var corpusEnv = mapEnv{ |
| + {1, 1}: {Kind: KindNum, Num: 10}, {1, 2}: {Kind: KindStr, Str: "ten"}, |
| + {2, 1}: {Kind: KindNum, Num: 20}, {2, 2}: {Kind: KindStr, Str: "twenty"}, |
| + {3, 1}: {Kind: KindNum, Num: 30}, {3, 2}: {Kind: KindStr, Str: "thirty"}, |
| +} |
| + |
| +func TestAgainstGoldenCorpus(t *testing.T) { |
| + entries := loadCorpus(t) |
| + excluded := loadExclusions(t) |
| + for _, e := range entries { |
| + if excluded[e.Function] { |
| + continue |
| + } |
| + got := Eval(mustParse(t, e.Expr), corpusEnv) |
| + want := e.Want.toValue(t) |
| + if !valuesMatch(got, want) { |
| + t.Errorf("%s: got %+v, corpus says %+v", e.Function, got, want) |
| + } |
| + } |
| +} |
| + |
| +// TestCorpusIsPinned: see corpusSHA256's doc comment. |
| +func TestCorpusIsPinned(t *testing.T) { |
| + sum := sha256File(t, corpusPath) |
| + if sum != corpusSHA256 { |
| + t.Fatalf("corpus changed: sha256 is now %s\nIf deliberate, update corpusSHA256 to this value.", sum) |
| + } |
| +} |
| + |
| +// TestExclusionsAreRealFunctions: see isRegisteredFunc (registry.go). A |
| +// name in volatile-excluded.txt that resolves to nothing means the list |
| +// has rotted — either a typo, or a name that was never actually wired |
| +// up, silently excluding nothing. |
| +func TestExclusionsAreRealFunctions(t *testing.T) { |
| + for name := range loadExclusions(t) { |
| + if !isRegisteredFunc(name) { |
| + t.Errorf("%s is excluded but is not a registered function", name) |
| + } |
| + } |
| +} |
| + |
| +// clockEnv models one recalculation pass for TestClockIsPinnedWithinAPass: |
| +// Now() answers the instant pinned at construction and never re-reads |
| +// anything. wall exists only so the test can move something OTHER than |
| +// the pinned instant and assert it made no difference — deliberately not |
| +// what Now() answers, which is the entire point (see the test's own doc |
| +// comment). |
| +type clockEnv struct { |
| + pinned time.Time |
| + wall time.Time |
| +} |
| + |
| +func newEnvAt(t time.Time) *clockEnv { |
| + return &clockEnv{pinned: t, wall: t} |
| +} |
| + |
| +func (e *clockEnv) advanceWallClock(d time.Duration) { |
| + e.wall = e.wall.Add(d) |
| +} |
| + |
| +func (e *clockEnv) CellValue(CellKey) Value { return Value{} } |
| +func (e *clockEnv) Now() time.Time { return e.pinned } |
| + |
| +// A recalculation pass sees one instant. If anything under us reads the |
| +// wall clock directly, this fails — which is the point. A pinned clock |
| +// that something beneath re-derives is a pin that only looks like one. |
| +// |
| +// This test's own power to catch that is bounded: clockEnv.wall is a |
| +// value nothing in this package other than this test ever reads, so |
| +// advancing it cannot, by construction, change what a correctly-written |
| +// fnNow computes — the load-bearing guarantee is that fn_date.go's fnNow |
| +// and fnToday call env.Now() and nothing else (no time.Now(), no package |
| +// clock). This test is the regression pin for that fact once true, not a |
| +// substitute for having checked it: `grep -rn 'time\.Now' internal/sheets/calc` |
| +// finds no hits outside this file (which uses time.Date, not time.Now) |
| +// and fn_date_test.go's TestNowTodayRejectArguments (which uses |
| +// time.Now() only to seed an env whose CellValue/Now never gets called |
| +// through NOW/TODAY's args path). |
| +func TestClockIsPinnedWithinAPass(t *testing.T) { |
| + env := newEnvAt(time.Date(2026, 8, 30, 12, 0, 0, 0, time.UTC)) |
| + first := Eval(mustParse(t, "NOW()"), env) |
| + env.advanceWallClock(time.Hour) // does not touch the pass's instant |
| + second := Eval(mustParse(t, "NOW()"), env) |
| + if first.Num != second.Num { |
| + t.Fatal("NOW() moved inside one pass; something is reading the wall clock") |
| + } |
| +} |
diff --git a/internal/sheets/calc/testdata/corpus/formulas.json b/internal/sheets/calc/testdata/corpus/formulas.json| new file mode 100644 |
| index 0000000..fe9815b |
| --- /dev/null |
| +++ b/internal/sheets/calc/testdata/corpus/formulas.json |
| @@ -0,0 +1,5 @@ |
| +{ |
| + "note": "HAND-AUTHORED, NOT YET LIBREOFFICE-VERIFIED. tools/gencorpus (repo root) exists to regenerate this file from a real LibreOffice headless run, but no machine in the environment that wrote this file has soffice on PATH. Every \"want\" value below was computed by a person hand-tracing this package's arithmetic/string/lookup rules against well-established, widely-documented spreadsheet semantics -- not against an independent implementation. TestAgainstGoldenCorpus (corpus_test.go) currently proves self-consistency with that reasoning, not third-party agreement with a real spreadsheet. Regenerate with `go run ./tools/gencorpus -out internal/sheets/calc/testdata/corpus/formulas.json` on a machine with LibreOffice installed, review the diff, then update corpusSHA256 in corpus_test.go and this note.", |
| + "entries": [ |
| + ] |
| +} |
diff --git a/internal/sheets/calc/testdata/corpus/volatile-excluded.txt b/internal/sheets/calc/testdata/corpus/volatile-excluded.txt| new file mode 100644 |
| index 0000000..7080045 |
| --- /dev/null |
| +++ b/internal/sheets/calc/testdata/corpus/volatile-excluded.txt |
| @@ -0,0 +1,30 @@ |
| +# testdata/corpus/volatile-excluded.txt |
| +# |
| +# Functions listed here are volatile: they return a different value on |
| +# every recalculation, so no single "golden" value captured by one |
| +# LibreOffice run (or one gencorpus run) can ever match a later run's |
| +# answer -- including this package's own re-evaluation of the same |
| +# corpus file in a later `go test` invocation. TestAgainstGoldenCorpus |
| +# (corpus_test.go) skips any corpus row whose Function is listed here. |
| +# |
| +# This file is a list somebody edits, not a filter somebody widens: it is |
| +# NOT the place to silence an ordinary failing corpus row. Every name |
| +# below must be spelled exactly as calc.Register (or registerClock) |
| +# registered it, and TestExclusionsAreRealFunctions asserts each one is a |
| +# real, currently-registered function -- so this file cannot rot into a |
| +# wildcard that quietly swallows an unrelated regression. |
| +# |
| +# RAND and RANDBETWEEN are the other two volatile functions the plan |
| +# names, but neither is implemented in this task (outside its ~30 |
| +# function scope) -- add each here in the SAME batch that registers it, |
| +# not before, or TestExclusionsAreRealFunctions fails immediately. |
| + |
| +# NOW: returns the current recalculation pass's pinned instant |
| +# (env.Now()) as a date serial. Deterministic WITHIN one pass -- see |
| +# TestClockIsPinnedWithinAPass in corpus_test.go -- but a corpus value |
| +# captured at generation time can never match what a later pass's real |
| +# wall clock produces. |
| +NOW |
| + |
| +# TODAY: same reasoning as NOW, truncated to midnight. |
| +TODAY |
diff --git a/tools/gencorpus/corpus.go b/tools/gencorpus/corpus.go| new file mode 100644 |
| index 0000000..b06166e |
| --- /dev/null |
| +++ b/tools/gencorpus/corpus.go |
| @@ -0,0 +1,96 @@ |
| +package main |
| + |
| +import ( |
| + "encoding/csv" |
| + "encoding/json" |
| + "os" |
| + "strconv" |
| +) |
| + |
| +// readResults parses the CSV soffice wrote (one column: the computed |
| +// value of that row's formula) into plain strings, in row order — pairing |
| +// them back up with cases is main's job, by index. |
| +func readResults(path string) ([]string, error) { |
| + f, err := os.Open(path) |
| + if err != nil { |
| + return nil, err |
| + } |
| + defer f.Close() |
| + |
| + r := csv.NewReader(f) |
| + rows, err := r.ReadAll() |
| + if err != nil { |
| + return nil, err |
| + } |
| + out := make([]string, len(rows)) |
| + for i, row := range rows { |
| + if len(row) > 0 { |
| + out[i] = row[0] |
| + } |
| + } |
| + return out, nil |
| +} |
| + |
| +// corpusValueJSON mirrors calc's own corpusValue wire format (see |
| +// internal/sheets/calc/corpus_test.go) — duplicated here rather than |
| +// imported, since gencorpus is a standalone tool and calc's copy is |
| +// unexported test-only plumbing that this package has no business |
| +// depending on. |
| +type corpusValueJSON struct { |
| + Kind string `json:"kind"` |
| + Num float64 `json:"num,omitempty"` |
| + Str string `json:"str,omitempty"` |
| + Bool bool `json:"bool,omitempty"` |
| + Err string `json:"err,omitempty"` |
| +} |
| + |
| +type corpusEntryJSON struct { |
| + Function string `json:"function"` |
| + Expr string `json:"expr"` |
| + Want corpusValueJSON `json:"want"` |
| +} |
| + |
| +type corpusFileJSON struct { |
| + Note string `json:"note"` |
| + Entries []corpusEntryJSON `json:"entries"` |
| +} |
| + |
| +// classify turns one CSV cell's text into corpusValueJSON. LibreOffice's |
| +// CSV export renders TRUE/FALSE for booleans, the six Excel error |
| +// strings verbatim for errors, a plain decimal for numbers, and anything |
| +// else as text. |
| +func classify(s string) corpusValueJSON { |
| + switch s { |
| + case "TRUE": |
| + return corpusValueJSON{Kind: "bool", Bool: true} |
| + case "FALSE": |
| + return corpusValueJSON{Kind: "bool", Bool: false} |
| + case "#DIV/0!", "#VALUE!", "#REF!", "#NAME?", "#NUM!", "#N/A": |
| + return corpusValueJSON{Kind: "err", Err: s} |
| + } |
| + if n, err := strconv.ParseFloat(s, 64); err == nil { |
| + return corpusValueJSON{Kind: "num", Num: n} |
| + } |
| + return corpusValueJSON{Kind: "str", Str: s} |
| +} |
| + |
| +func writeCorpusJSON(path string, cases []corpusCase, results []string) error { |
| + cf := corpusFileJSON{ |
| + Note: "Generated by tools/gencorpus from a real LibreOffice headless run. " + |
| + "Regenerate with `go run ./tools/gencorpus -out " + path + "` whenever a " + |
| + "case is added; commit the result and update corpusSHA256 in " + |
| + "internal/sheets/calc/corpus_test.go.", |
| + } |
| + for i, c := range cases { |
| + cf.Entries = append(cf.Entries, corpusEntryJSON{ |
| + Function: c.Function, |
| + Expr: c.Expr, |
| + Want: classify(results[i]), |
| + }) |
| + } |
| + data, err := json.MarshalIndent(cf, "", " ") |
| + if err != nil { |
| + return err |
| + } |
| + return os.WriteFile(path, append(data, '\n'), 0o644) |
| +} |
diff --git a/tools/gencorpus/main.go b/tools/gencorpus/main.go| new file mode 100644 |
| index 0000000..2882846 |
| --- /dev/null |
| +++ b/tools/gencorpus/main.go |
| @@ -0,0 +1,167 @@ |
| +// Command gencorpus regenerates internal/sheets/calc/testdata/corpus/formulas.json |
| +// from a real spreadsheet. Run it when adding functions; commit the |
| +// result. |
| +// |
| +// go run ./tools/gencorpus -out internal/sheets/calc/testdata/corpus/formulas.json |
| +// |
| +// Requires `soffice` (LibreOffice) on PATH. CI does not run this — CI |
| +// reads the committed file, which is why the file is hash-pinned by |
| +// calc.TestCorpusIsPinned (internal/sheets/calc/corpus_test.go). After a |
| +// successful run, review the diff, then run |
| +// |
| +// go test ./internal/sheets/calc/ -run TestCorpusIsPinned -v |
| +// |
| +// and paste the printed sha256 into corpusSHA256. |
| +// |
| +// # Mechanism |
| +// |
| +// gencorpus writes a real .ods (OpenDocument spreadsheet) with one |
| +// formula per row in column A. Each cell carries an ODF-syntax |
| +// table:formula attribute (an "of:=" prefix, semicolon argument |
| +// separators — ODF's own formula language, not Excel's comma-separated |
| +// one), which is what makes LibreOffice treat the cell as a formula to |
| +// (re)compute, not literal text. It then shells out to |
| +// `soffice --headless --convert-to csv`, which opens the document, |
| +// recalculates, and exports the computed values as CSV — not the |
| +// formula source. Each output row is paired back up with the case that |
| +// produced it, by row order, into the corpus JSON. |
| +// |
| +// This tool has not been run against a real LibreOffice as part of |
| +// writing it — no machine in that task's environment had soffice on |
| +// PATH. It is believed correct by careful reading of the ODF and |
| +// LibreOffice CLI documentation, but the first real run should be |
| +// treated as this tool's own first test, not a routine regeneration: |
| +// review its output closely, especially the ODF formula translation |
| +// (toODFFormula) and whether soffice's headless convert-to actually |
| +// forces a recalculation before export (if it does not, dates/volatile |
| +// entries aside, every row would come back as an empty/uncached formula |
| +// result — in that case the fix is a --convert-to filter option or a |
| +// recalculation macro, not a change to this file's overall shape). |
| +package main |
| + |
| +import ( |
| + "flag" |
| + "fmt" |
| + "os" |
| + "os/exec" |
| + "path/filepath" |
| +) |
| + |
| +// corpusCase is one row to feed LibreOffice: Function names the |
| +// spreadsheet function under test (matched against |
| +// testdata/corpus/volatile-excluded.txt and used for |
| +// TestAgainstGoldenCorpus's per-row skip), Expr is the formula text as a |
| +// person would type it after "=" — Excel/English syntax, comma argument |
| +// separators; toODFFormula (ods.go) translates to ODF's own syntax for |
| +// the file this tool actually writes. |
| +type corpusCase struct { |
| + Function string |
| + Expr string |
| +} |
| + |
| +// cases mirrors testdata/corpus/formulas.json's hand-authored entries as |
| +// closely as a flat, cell-reference-free list can — VLOOKUP/INDEX/MATCH |
| +// need real cell references (a table_array) that a standalone expression |
| +// list cannot supply; see the comment below the SUM/AVERAGE/... rows. |
| +// Keep this list in step with the hand-authored corpus when adding a new |
| +// function family, so a real gencorpus run reproduces (and can then |
| +// replace) the same shape of corpus rather than a smaller one. |
| +var cases = []corpusCase{ |
| + {"SUM", "SUM(1,2,3)"}, |
| + {"SUM", "SUM(1,2,3,4,5)"}, |
| + {"SUM", "SUM(-5,5)"}, |
| + {"SUM", "SUM(1.5,2.5)"}, |
| + {"AVERAGE", "AVERAGE(2,4,6)"}, |
| + {"AVERAGE", "AVERAGE(1,2,3,4)"}, |
| + {"COUNT", `COUNT(1,2,"a",TRUE)`}, |
| + {"COUNTA", `COUNTA(1,2,"a",TRUE)`}, |
| + {"MIN", "MIN(4,2,9,1)"}, |
| + {"MAX", "MAX(4,2,9,1)"}, |
| + {"ROUND", "ROUND(3.14159,2)"}, |
| + {"ROUND", "ROUND(2.5,0)"}, |
| + {"ROUND", "ROUND(-2.5,0)"}, |
| + {"ROUNDUP", "ROUNDUP(3.14159,2)"}, |
| + {"ROUNDUP", "ROUNDUP(3.001,0)"}, |
| + {"ROUNDDOWN", "ROUNDDOWN(3.99,0)"}, |
| + {"ROUNDDOWN", "ROUNDDOWN(-3.99,0)"}, |
| + {"ABS", "ABS(-7)"}, |
| + {"SQRT", "SQRT(16)"}, |
| + {"SQRT", "SQRT(2)"}, |
| + {"POWER", "POWER(2,10)"}, |
| + {"POWER", "POWER(9,0.5)"}, |
| + {"MOD", "MOD(10,3)"}, |
| + {"MOD", "MOD(-10,3)"}, |
| + {"MOD", "MOD(10,-3)"}, |
| + {"IF", `IF(TRUE,"yes","no")`}, |
| + {"IF", `IF(FALSE,1/0,5)`}, |
| + {"IFS", "IFS(FALSE,1,TRUE,2,TRUE,3)"}, |
| + {"AND", "AND(TRUE,TRUE,TRUE)"}, |
| + {"AND", "AND(TRUE,FALSE)"}, |
| + {"OR", "OR(FALSE,FALSE,TRUE)"}, |
| + {"NOT", "NOT(TRUE)"}, |
| + {"IFERROR", `IFERROR(1/0,"fallback")`}, |
| + {"CONCAT", `CONCAT("foo","bar")`}, |
| + {"LEFT", `LEFT("hello",3)`}, |
| + {"RIGHT", `RIGHT("hello",3)`}, |
| + {"MID", `MID("hello",2,3)`}, |
| + {"LEN", `LEN("hello")`}, |
| + {"TRIM", `TRIM(" a b ")`}, |
| + {"UPPER", `UPPER("MixedCase")`}, |
| + {"LOWER", `LOWER("MixedCase")`}, |
| + // VLOOKUP/INDEX/MATCH need real cell references, which this flat |
| + // list of standalone expressions cannot supply on their own — a |
| + // human extending this tool for the lookup family should add a |
| + // small fixed table to a few cells in the sheet (e.g. columns C:D) |
| + // alongside the formula column, and reference it here, rather than |
| + // trying to express a table_array as a formula literal. |
| +} |
| + |
| +func main() { |
| + out := flag.String("out", "", "path to write the corpus JSON to") |
| + flag.Parse() |
| + if *out == "" { |
| + fmt.Fprintln(os.Stderr, "gencorpus: -out is required") |
| + os.Exit(2) |
| + } |
| + if _, err := exec.LookPath("soffice"); err != nil { |
| + fmt.Fprintln(os.Stderr, "gencorpus: soffice not found on PATH — install LibreOffice") |
| + os.Exit(1) |
| + } |
| + |
| + tmpDir, err := os.MkdirTemp("", "gencorpus-*") |
| + if err != nil { |
| + fatal(err) |
| + } |
| + defer os.RemoveAll(tmpDir) |
| + |
| + odsPath := filepath.Join(tmpDir, "corpus.ods") |
| + if err := writeODS(odsPath, cases); err != nil { |
| + fatal(err) |
| + } |
| + |
| + cmd := exec.Command("soffice", "--headless", "--convert-to", "csv", "--outdir", tmpDir, odsPath) |
| + if output, err := cmd.CombinedOutput(); err != nil { |
| + fmt.Fprintf(os.Stderr, "gencorpus: soffice failed:\n%s\n", output) |
| + fatal(err) |
| + } |
| + |
| + csvPath := filepath.Join(tmpDir, "corpus.csv") |
| + results, err := readResults(csvPath) |
| + if err != nil { |
| + fatal(err) |
| + } |
| + if len(results) != len(cases) { |
| + fatal(fmt.Errorf("gencorpus: soffice returned %d rows, expected %d — did headless convert-to actually recalculate?", len(results), len(cases))) |
| + } |
| + |
| + if err := writeCorpusJSON(*out, cases, results); err != nil { |
| + fatal(err) |
| + } |
| + fmt.Printf("gencorpus: wrote %d entries to %s\n", len(cases), *out) |
| + fmt.Println("gencorpus: next: review the diff, then run `go test ./internal/sheets/calc/ -run TestCorpusIsPinned -v` and update corpusSHA256") |
| +} |
| + |
| +func fatal(err error) { |
| + fmt.Fprintln(os.Stderr, "gencorpus:", err) |
| + os.Exit(1) |
| +} |
diff --git a/tools/gencorpus/ods.go b/tools/gencorpus/ods.go| new file mode 100644 |
| index 0000000..10631aa |
| --- /dev/null |
| +++ b/tools/gencorpus/ods.go |
| @@ -0,0 +1,91 @@ |
| +package main |
| + |
| +import ( |
| + "archive/zip" |
| + "fmt" |
| + "os" |
| + "strings" |
| +) |
| + |
| +// writeODS writes a minimal OpenDocument spreadsheet at path with one |
| +// formula per row in column A. See main.go's package doc comment for the |
| +// overall mechanism and this tool's untested status. |
| +func writeODS(path string, cases []corpusCase) error { |
| + f, err := os.Create(path) |
| + if err != nil { |
| + return err |
| + } |
| + defer f.Close() |
| + |
| + zw := zip.NewWriter(f) |
| + |
| + // mimetype must be the first entry, stored uncompressed, per the |
| + // ODF package format spec — some readers sniff the format from this |
| + // exact layout without unzipping the whole archive. |
| + mw, err := zw.CreateHeader(&zip.FileHeader{Name: "mimetype", Method: zip.Store}) |
| + if err != nil { |
| + return err |
| + } |
| + if _, err := mw.Write([]byte("application/vnd.oasis.opendocument.spreadsheet")); err != nil { |
| + return err |
| + } |
| + |
| + cw, err := zw.Create("content.xml") |
| + if err != nil { |
| + return err |
| + } |
| + if _, err := cw.Write([]byte(contentXML(cases))); err != nil { |
| + return err |
| + } |
| + |
| + mfw, err := zw.Create("META-INF/manifest.xml") |
| + if err != nil { |
| + return err |
| + } |
| + if _, err := mfw.Write([]byte(manifestXML)); err != nil { |
| + return err |
| + } |
| + |
| + return zw.Close() |
| +} |
| + |
| +const manifestXML = `<?xml version="1.0" encoding="UTF-8"?> |
| +<manifest:manifest xmlns:manifest="urn:oasis:names:tc:opendocument:xmlns:manifest:1.0" manifest:version="1.2"> |
| + <manifest:file-entry manifest:full-path="/" manifest:version="1.2" manifest:media-type="application/vnd.oasis.opendocument.spreadsheet"/> |
| + <manifest:file-entry manifest:full-path="content.xml" manifest:media-type="text/xml"/> |
| +</manifest:manifest> |
| +` |
| + |
| +func contentXML(cases []corpusCase) string { |
| + var rows strings.Builder |
| + for _, c := range cases { |
| + rows.WriteString(" <table:table-row>") |
| + rows.WriteString(fmt.Sprintf(`<table:table-cell table:formula="of:=%s"/>`, toODFFormula(c.Expr))) |
| + rows.WriteString("</table:table-row>\n") |
| + } |
| + return `<?xml version="1.0" encoding="UTF-8"?> |
| +<office:document-content xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" |
| + xmlns:table="urn:oasis:names:tc:opendocument:xmlns:table:1.0" |
| + xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0" |
| + office:version="1.2"> |
| + <office:body> |
| + <office:spreadsheet> |
| + <table:table table:name="Corpus"> |
| +` + rows.String() + ` </table:table> |
| + </office:spreadsheet> |
| + </office:body> |
| +</office:document-content> |
| +` |
| +} |
| + |
| +// toODFFormula translates a comma-argument-separated Excel-style formula |
| +// (this tool's own case list, and this project's own formula syntax — |
| +// see internal/sheets/calc/parse.go) into ODF's own formula syntax, which |
| +// uses semicolons between arguments. This is a blunt, comma-to-semicolon |
| +// substitution — it is wrong for any formula whose comma appears inside a |
| +// quoted string argument (a text literal that itself contains a comma). |
| +// None of this tool's own cases do; a human adding one that does needs to |
| +// rewrite this function to skip quoted spans, or avoid the comma. |
| +func toODFFormula(expr string) string { |
| + return strings.ReplaceAll(expr, ",", ";") |
| +} |