oficina / sheets Public

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

Plain git — no account needed to clone.

Download

Download this file

1package xlsx_test
2
3import (
4 "bytes"
5 "context"
6 "io"
7 "os"
8 "path/filepath"
9 "strconv"
10 "testing"
11
12 rdb "amadan.net/rastrillo/rastrillo/db"
13 "amadan.net/rastrillo/rastrillo/migrate"
14 "github.com/xuri/excelize/v2"
15 "gorm.io/gorm"
16
17 "amadan.net/oficina/sheets/internal/sheets"
18 "amadan.net/oficina/sheets/internal/sheets/calc"
19 "amadan.net/oficina/sheets/internal/sheets/seal"
20 "amadan.net/oficina/sheets/internal/sheets/store"
21 "amadan.net/oficina/sheets/internal/sheets/xlsx"
22)
23
24// corpusDir is testdata/corpus/xlsx relative to this package — see the
25// task brief's file list. gencorpus_test.go (package xlsx, GENCORPUS=1)
26// is what writes it.
27func corpusDir(t *testing.T) string {
28 t.Helper()
29 dir := filepath.Join("..", "..", "..", "testdata", "corpus", "xlsx")
30 if _, err := os.Stat(dir); err != nil {
31 t.Fatalf("corpus dir %s: %v (run GENCORPUS=1 go test ./internal/sheets/xlsx/ -run TestGenerateCorpus)", dir, err)
32 }
33 return dir
34}
35
36// fixtureNames lists every .xlsx file in the committed corpus.
37func fixtureNames(t *testing.T) []string {
38 t.Helper()
39 entries, err := os.ReadDir(corpusDir(t))
40 if err != nil {
41 t.Fatalf("read corpus dir: %v", err)
42 }
43 var names []string
44 for _, e := range entries {
45 if !e.IsDir() && filepath.Ext(e.Name()) == ".xlsx" {
46 names = append(names, e.Name())
47 }
48 }
49 if len(names) == 0 {
50 t.Fatal("corpus dir has no .xlsx fixtures")
51 }
52 return names
53}
54
55// openTestDB mirrors internal/sheets/store's own helper of the same
56// name (helpers_test.go there) — the real cgo-free modernc driver, WAL
57// mode, the same runtime path, and the same migrate.Apply(sheets.
58// BootSchema) production boots with — duplicated rather than imported
59// because it lives in that package's _test.go file and is not exported.
60func openTestDB(t *testing.T) *gorm.DB {
61 t.Helper()
62 dir := t.TempDir()
63 d, err := rdb.Open(filepath.Join(dir, "test.db"), nil)
64 if err != nil {
65 t.Fatalf("open db: %v", err)
66 }
67 t.Cleanup(func() {
68 if err := d.Close(); err != nil {
69 t.Errorf("close db: %v", err)
70 }
71 })
72 if _, err := migrate.Apply(context.Background(), d, sheets.BootSchema); err != nil {
73 t.Fatalf("migrate: %v", err)
74 }
75 return d.G
76}
77
78// newTab creates an empty Tab (and its Sheet) in db and opens a Handle
79// on it — the shape XLSX import always starts from: a real tab with no
80// rows or columns yet, which Import's own EnsureRow/EnsureCol calls grow.
81func newTab(t *testing.T, db *gorm.DB, sealer *seal.Sealer) *store.Handle {
82 t.Helper()
83 sheet := store.Sheet{CreatedBy: 1}
84 if err := db.Create(&sheet).Error; err != nil {
85 t.Fatalf("create sheet: %v", err)
86 }
87 tab := store.Tab{SheetID: sheet.ID}
88 if err := db.Create(&tab).Error; err != nil {
89 t.Fatalf("create tab: %v", err)
90 }
91 h, err := store.Open(db, sealer, tab.ID)
92 if err != nil {
93 t.Fatalf("Open: %v", err)
94 }
95 t.Cleanup(h.Release)
96 return h
97}
98
99// importFixture reads a named corpus file and imports it into a fresh
100// tab in a fresh database.
101func importFixture(t *testing.T, name string) *store.Handle {
102 t.Helper()
103 f, err := os.Open(filepath.Join(corpusDir(t), name))
104 if err != nil {
105 t.Fatalf("open fixture %s: %v", name, err)
106 }
107 defer f.Close()
108 return importReader(t, f)
109}
110
111// importReader imports r into a fresh tab in a fresh database — used
112// both for a fixture file and for the buffer TestRoundTripIsStable
113// exports and re-imports.
114func importReader(t *testing.T, r io.Reader) *store.Handle {
115 t.Helper()
116 db := openTestDB(t)
117 h := newTab(t, db, testSealer(t))
118 if err := xlsx.Import(r, h); err != nil {
119 t.Fatalf("Import: %v", err)
120 }
121 return h
122}
123
124// testSealer returns a Sealer over an all-zero stand-in DEK — sufficient
125// for tests that care about round-trip content, not key material (the
126// same convention store's own tests use — store/helpers_test.go's
127// make32).
128func testSealer(t *testing.T) *seal.Sealer {
129 t.Helper()
130 s, err := seal.New(make([]byte, 32))
131 if err != nil {
132 t.Fatalf("seal.New: %v", err)
133 }
134 return s
135}
136
137// badFormulaFixture builds a one-cell workbook whose formula is
138// syntactically unparseable — an unterminated CONCAT call — with canary
139// embedded as the string literal a formula like this would realistically
140// carry (the task brief's own example: `"salary 50000" at B7`).
141// TestImportErrorsContainNoCellContent proves canary never reaches the
142// error Import returns.
143func badFormulaFixture(t *testing.T, canary string) *bytes.Buffer {
144 t.Helper()
145 f := excelize.NewFile()
146 sheet := f.GetSheetList()[0]
147 formula := `CONCAT("` + canary + `", A1`
148 if err := f.SetCellFormula(sheet, "A1", formula); err != nil {
149 t.Fatalf("SetCellFormula: %v", err)
150 }
151 var buf bytes.Buffer
152 if err := f.Write(&buf); err != nil {
153 t.Fatalf("write fixture: %v", err)
154 }
155 if err := f.Close(); err != nil {
156 t.Fatalf("close fixture: %v", err)
157 }
158 return &buf
159}
160
161// cellSnapshot is one cell's full round-trippable state: its value and
162// its format, keyed by spreadsheet position rather than by stable id —
163// diffTabs compares two handles built from two independent imports, so
164// their real row/col ids never match even when their content does.
165type cellSnapshot struct {
166 pos posKey
167 value string // fmt.Sprintf("%+v", calc.Value) — good enough to diff and to print
168 raw string // Handle.RawInput — catches a formula silently flattened to its computed value, which comparing Value alone cannot: the recomputed result is identical either way.
169 hasFmt bool
170 numFmt string
171 bold bool
172 italic bool
173 align string
174 fillHex string
175}
176
177type posKey struct{ Row, Col int }
178
179// snapshot walks every cell h's tab actually has (store.Handle.Cells) and
180// captures its value and format by position.
181func snapshot(t *testing.T, h *store.Handle) map[posKey]cellSnapshot {
182 t.Helper()
183 rowPos := map[uint64]int{}
184 for _, r := range h.GridRows() {
185 rowPos[r.ID] = r.Pos
186 }
187 colPos := map[uint64]int{}
188 for _, c := range h.GridCols() {
189 colPos[c.ID] = c.Pos
190 }
191 cells, err := h.Cells()
192 if err != nil {
193 t.Fatalf("Cells: %v", err)
194 }
195 out := make(map[posKey]cellSnapshot, len(cells))
196 for _, c := range cells {
197 rp, ok := rowPos[c.RowID]
198 if !ok {
199 continue
200 }
201 cp, ok := colPos[c.ColID]
202 if !ok {
203 continue
204 }
205 key := posKey{Row: rp, Col: cp}
206 raw, _ := h.RawInput(c.RowID, c.ColID)
207 snap := cellSnapshot{pos: key, value: valueString(h.Value(c.RowID, c.ColID)), raw: raw}
208 if f := h.Format(c.RowID, c.ColID); f != nil {
209 snap.hasFmt = true
210 snap.numFmt = f.NumberFmt
211 snap.bold = f.Bold
212 snap.italic = f.Italic
213 snap.align = f.Align
214 snap.fillHex = f.FillHex
215 }
216 out[key] = snap
217 }
218 return out
219}
220
221// diffTabs reports every position whose value or format differs between
222// a and b, empty for an exact match.
223func diffTabs(t *testing.T, a, b *store.Handle) string {
224 t.Helper()
225 sa, sb := snapshot(t, a), snapshot(t, b)
226
227 var buf bytes.Buffer
228 seen := map[posKey]bool{}
229 for pos, av := range sa {
230 seen[pos] = true
231 bv, ok := sb[pos]
232 if !ok {
233 buf.WriteString(sprintPos(pos, "present in first, missing in second"))
234 continue
235 }
236 if av != bv {
237 buf.WriteString(sprintPos(pos, "differs"))
238 }
239 }
240 for pos := range sb {
241 if !seen[pos] {
242 buf.WriteString(sprintPos(pos, "present in second, missing in first"))
243 }
244 }
245 return buf.String()
246}
247
248func sprintPos(pos posKey, reason string) string {
249 return "(" + itoa(pos.Row) + "," + itoa(pos.Col) + "): " + reason + "\n"
250}
251
252func itoa(n int) string {
253 if n == 0 {
254 return "0"
255 }
256 neg := n < 0
257 if neg {
258 n = -n
259 }
260 var b [20]byte
261 i := len(b)
262 for n > 0 {
263 i--
264 b[i] = byte('0' + n%10)
265 n /= 10
266 }
267 if neg {
268 i--
269 b[i] = '-'
270 }
271 return string(b[i:])
272}
273
274// valueString renders a calc.Value's meaningful field(s) only — Num for
275// KindNum, and so on — so two Values that differ only in a field Kind
276// doesn't select (e.g. a stale Str left over from reusing a struct) still
277// compare equal, matching calc.Value's own documented contract that only
278// the Kind-selected field is meaningful.
279func valueString(v calc.Value) string {
280 switch v.Kind {
281 case calc.KindNum:
282 return "Num:" + strconv.FormatFloat(v.Num, 'g', -1, 64)
283 case calc.KindStr:
284 return "Str:" + v.Str
285 case calc.KindBool:
286 return "Bool:" + strconv.FormatBool(v.Bool)
287 case calc.KindErr:
288 return "Err:" + v.Err.String()
289 default:
290 return "Empty"
291 }
292}
293