oficina / sheets Public

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

Plain git — no account needed to clone.

Download

Download this file

1// Command gencorpus regenerates internal/sheets/calc/testdata/corpus/formulas.json
2// from a real spreadsheet. Run it when adding functions; commit the
3// result.
4//
5// go run ./tools/gencorpus -out internal/sheets/calc/testdata/corpus/formulas.json
6//
7// Requires LibreOffice. `soffice` is looked up on PATH by default; pass
8// -soffice /path/to/soffice when it is installed somewhere PATH does not
9// reach (a user-space install, for instance). CI does not run this — CI
10// reads the committed file, which is why the file is hash-pinned by
11// calc.TestCorpusIsPinned (internal/sheets/calc/corpus_test.go). After a
12// successful run, review the diff, then run
13//
14// go test ./internal/sheets/calc/ -run TestCorpusIsPinned -v
15//
16// and paste the printed sha256 into corpusSHA256.
17//
18// # Mechanism
19//
20// gencorpus writes a real .ods (OpenDocument spreadsheet) with one
21// formula per row in column D. Each cell carries an ODF-syntax
22// table:formula attribute (an "of:=" prefix, semicolon argument
23// separators — ODF's own formula language, not Excel's comma-separated
24// one), which is what makes LibreOffice treat the cell as a formula to
25// (re)compute, not literal text. It then shells out to
26// `soffice --headless --convert-to csv`, which opens the document,
27// recalculates, and exports the computed values as CSV — not the
28// formula source. Each output row is paired back up with the case that
29// produced it, by row order, into the corpus JSON.
30//
31// # First real run, and what it found
32//
33// This tool was written without a LibreOffice to run it against, and its
34// first real run (LibreOffice 26.8.0.3, 2026-08-31) was treated as its
35// own first test. It found three real defects, all now fixed and all
36// documented at the code that fixes them:
37//
38// - the mimetype zip entry was written with zip.CreateHeader, whose
39// data-descriptor flag makes LibreOffice refuse the whole document
40// ("source file could not be loaded") — see writeODS;
41// - content.xml did not declare xmlns:of, without which every formula
42// evaluates to Err:510 — see contentXML;
43// - soffice was invoked with no -env:UserInstallation, which can drop
44// it into an interactive first-run flow — see runSoffice.
45//
46// Recalculation on export is not assumed: every formula cell is written
47// with a poisoned office:value cache (poisonedCacheValue) that no case
48// can legitimately produce, and checkResults fails the run if that value
49// comes back. It does not — LibreOffice recalculates on convert-to.
50package main
51
52import (
53 "flag"
54 "fmt"
55 "os"
56 "path/filepath"
57)
58
59// corpusCase is one row to feed LibreOffice: Function names the
60// spreadsheet function under test (matched against
61// testdata/corpus/volatile-excluded.txt and used for
62// TestAgainstReferenceCorpus's per-row skip), Expr is the formula text as a
63// person would type it after "=" — Excel/English syntax, comma argument
64// separators; toODFFormula (ods.go) translates to ODF's own syntax for
65// the file this tool actually writes.
66type corpusCase struct {
67 Function string
68 Expr string
69}
70
71func main() {
72 out := flag.String("out", "", "path to write the corpus JSON to")
73 soffice := flag.String("soffice", "soffice", "path to the LibreOffice `soffice` binary")
74 flag.Parse()
75 if *out == "" {
76 fmt.Fprintln(os.Stderr, "gencorpus: -out is required")
77 os.Exit(2)
78 }
79
80 tmpDir, err := os.MkdirTemp("", "gencorpus-*")
81 if err != nil {
82 fatal(err)
83 }
84 defer os.RemoveAll(tmpDir)
85
86 version, err := sofficeVersion(*soffice, tmpDir)
87 if err != nil {
88 fatal(fmt.Errorf("cannot run %q: %w (install LibreOffice, or pass -soffice /path/to/soffice)", *soffice, err))
89 }
90 fmt.Printf("gencorpus: using %s (%s)\n", version, *soffice)
91
92 // Refuse to silently narrow an existing corpus: if *out already has
93 // more entries than this run is about to produce, something regressed
94 // (a case dropped from the list above without a function actually
95 // going away) and overwriting it would replace an honest "hand-
96 // authored, unverified" corpus — or a real one — with a smaller file
97 // that still claims full coverage. See cases' own doc comment for the
98 // concrete incident this guards against.
99 if existing, err := existingEntryCount(*out); err == nil && existing > len(cases) {
100 fatal(fmt.Errorf("%s already has %d entries; this run would only produce %d — refusing to narrow the corpus (add the missing cases to gencorpus's own list, don't just let it overwrite)", *out, existing, len(cases)))
101 }
102
103 odsPath := filepath.Join(tmpDir, "corpus.ods")
104 if err := writeODS(odsPath, cases); err != nil {
105 fatal(err)
106 }
107
108 csvPath, err := runSoffice(*soffice, tmpDir, odsPath)
109 if err != nil {
110 fatal(err)
111 }
112 results, err := readResults(csvPath)
113 if err != nil {
114 fatal(err)
115 }
116 if err := checkResults(cases, results); err != nil {
117 fatal(err)
118 }
119
120 if err := writeCorpusJSON(*out, cases, results, version); err != nil {
121 fatal(err)
122 }
123 fmt.Printf("gencorpus: wrote %d entries to %s\n", len(cases), *out)
124 fmt.Println("gencorpus: next: review the diff, then run `go test ./internal/sheets/calc/ -run TestCorpusIsPinned -v` and update corpusSHA256")
125}
126
127func fatal(err error) {
128 fmt.Fprintln(os.Stderr, "gencorpus:", err)
129 os.Exit(1)
130}
131