oficina / sheets Public

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

Plain git — no account needed to clone.

Download

Download this file

1package store_test
2
3import (
4 "path/filepath"
5 "testing"
6
7 "amadan.net/oficina/sheets/internal/sheets/calc"
8 "amadan.net/oficina/sheets/internal/sheets/seal"
9 "amadan.net/oficina/sheets/internal/sheets/store"
10)
11
12// Set on an id pair this tab has no row or column for must fail rather
13// than silently writing a cell nothing addresses.
14func TestSetOnUnknownCellFails(t *testing.T) {
15 db, sealer, tabID := seeded(t)
16 h, err := store.Open(db, sealer, tabID)
17 if err != nil {
18 t.Fatalf("Open: %v", err)
19 }
20 defer h.Release()
21
22 if err := h.Set(999999, colA, "1", actor); err == nil {
23 t.Fatal("Set against an unknown row id: want error, got nil")
24 }
25}
26
27// Blanking a cell deletes its row (cells are sparse — models.go) and
28// propagates the change to anything that referenced it.
29func TestSetBlankDeletesRowAndPropagates(t *testing.T) {
30 db, sealer, tabID := seeded(t)
31 h, err := store.Open(db, sealer, tabID)
32 if err != nil {
33 t.Fatalf("Open: %v", err)
34 }
35 defer h.Release()
36
37 if err := h.Set(rowB, colA, "=A1*2", actor); err != nil {
38 t.Fatalf("Set B1: %v", err)
39 }
40 if got := h.Value(rowB, colA); got.Kind != calc.KindNum || got.Num != 10 {
41 t.Fatalf("B1 before blanking = %+v, want 10", got)
42 }
43
44 if err := h.Set(rowA, colA, "", actor); err != nil {
45 t.Fatalf("Set A1 blank: %v", err)
46 }
47
48 var count int64
49 db.Model(&store.Cell{}).Where("row_id = ? AND col_id = ?", rowA, colA).Count(&count)
50 if count != 0 {
51 t.Fatalf("A1's row still exists after blanking; cells must be sparse")
52 }
53
54 // Arithmetic against an empty cell reads it as 0 (calc's own
55 // convention), so B1 = A1*2 becomes 0, not an error or a stale 10.
56 if got := h.Value(rowB, colA); got.Kind != calc.KindNum || got.Num != 0 {
57 t.Fatalf("B1 after blanking A1 = %+v, want KindNum 0", got)
58 }
59}
60
61// Format returns the interned format a cell's FormatID points at, and nil
62// for a cell with none.
63func TestFormatReturnsInternedFormat(t *testing.T) {
64 db, sealer, tabID := seeded(t)
65 h, err := store.Open(db, sealer, tabID)
66 if err != nil {
67 t.Fatalf("Open: %v", err)
68 }
69 defer h.Release()
70
71 if f := h.Format(rowA, colA); f != nil {
72 t.Fatalf("A1 has no FormatID; want nil, got %+v", f)
73 }
74
75 fmtRow := &store.Format{Digest: "test-digest", NumberFmt: "0.00"}
76 if err := db.Create(fmtRow).Error; err != nil {
77 t.Fatalf("create format: %v", err)
78 }
79 if err := db.Model(&store.Cell{}).
80 Where("row_id = ? AND col_id = ?", rowA, colA).
81 Update("format_id", fmtRow.ID).Error; err != nil {
82 t.Fatalf("assign format: %v", err)
83 }
84
85 got := h.Format(rowA, colA)
86 if got == nil || got.NumberFmt != "0.00" {
87 t.Fatalf("Format(A1) = %+v, want NumberFmt 0.00", got)
88 }
89}
90
91// A released handle is evicted immediately: the next Open rebuilds from
92// the database rather than resuming stale in-memory state. Two live
93// Opens for the same tab share one handle and its refcount.
94func TestReleaseEvictsAndReopenSeesFreshState(t *testing.T) {
95 db, sealer, tabID := seeded(t)
96
97 h1, err := store.Open(db, sealer, tabID)
98 if err != nil {
99 t.Fatalf("Open: %v", err)
100 }
101 h1b, err := store.Open(db, sealer, tabID)
102 if err != nil {
103 t.Fatalf("second Open: %v", err)
104 }
105 if h1 != h1b {
106 t.Fatal("two Opens for the same live tab returned different handles; refcounted sharing is broken")
107 }
108 h1b.Release() // refcount back to 1; h1 must still be usable
109
110 if got := h1.Value(rowA, colA); got.Kind != calc.KindNum || got.Num != 5 {
111 t.Fatalf("A1 via still-open handle = %+v, want 5", got)
112 }
113 h1.Release() // refcount to 0: evicted
114
115 // Mutate directly, bypassing the handle entirely — a released handle
116 // must not still be answering Value() from stale memory, and the next
117 // Open must reflect this write.
118 db.Model(&store.Cell{}).
119 Where("row_id = ? AND col_id = ?", rowA, colA).
120 Updates(map[string]any{"input_version": 5, "cache_version": 5})
121
122 h2, err := store.Open(db, sealer, tabID)
123 if err != nil {
124 t.Fatalf("reopen: %v", err)
125 }
126 defer h2.Release()
127 if h2 == h1 {
128 t.Fatal("reopen after full release returned the evicted handle instead of a fresh one")
129 }
130}
131
132// A dependent that is perfectly consistent on its own (its CacheVersion
133// already matches its own InputVersion) can still get swept into a stale
134// PRECEDENT's recovery pass — Open's recovery walks forward from a stale
135// origin to every transitive dependent, regardless of whether each one
136// was independently flagged. Stamping such a dependent with the stale
137// origin's OWN (possibly much older) InputVersion would regress its
138// CacheVersion below its own InputVersion, manufacturing a fresh — if
139// self-correcting — false positive on the very next Open. This pins that
140// recovery uses one version stamp for the whole pass (newer than every
141// version it read) rather than the triggering cell's own, so a
142// consistent dependent's version can only advance, never regress.
143func TestRecoveryDoesNotRegressAConsistentDependentsVersion(t *testing.T) {
144 dir := t.TempDir()
145 db := openTestDB(t, filepath.Join(dir, "test.db"))
146 s, err := seal.New(make32())
147 if err != nil {
148 t.Fatal(err)
149 }
150
151 const localTab, localRowA, localRowC, localCol uint64 = 1, 1, 2, 1
152 if err := db.Create(&store.Tab{ID: localTab, SheetID: 1}).Error; err != nil {
153 t.Fatalf("seed tab: %v", err)
154 }
155 if err := db.Create([]*store.Row{
156 {ID: localRowA, TabID: localTab, Position: 0},
157 {ID: localRowC, TabID: localTab, Position: 1},
158 }).Error; err != nil {
159 t.Fatalf("seed rows: %v", err)
160 }
161 if err := db.Create(&store.Col{ID: localCol, TabID: localTab, Position: 0}).Error; err != nil {
162 t.Fatalf("seed col: %v", err)
163 }
164
165 sealInput := func(raw string) []byte {
166 b, err := s.Seal("cell.input", []byte(raw))
167 if err != nil {
168 t.Fatalf("seal input: %v", err)
169 }
170 return b
171 }
172 blankCache, err := s.Seal("cell.cached", nil)
173 if err != nil {
174 t.Fatal(err)
175 }
176
177 // A1: consistent at rest (InputVersion 5 == CacheVersion 5).
178 if err := db.Create(&store.Cell{
179 TabID: localTab, RowID: localRowA, ColID: localCol,
180 InputSealed: sealInput("1"), InputVersion: 5,
181 CachedSealed: blankCache, CacheVersion: 5,
182 }).Error; err != nil {
183 t.Fatalf("seed A1: %v", err)
184 }
185 // C1 (at row localRowC): also consistent at rest, and at a HIGHER
186 // version than A1 — the shape a formula edited well after its
187 // precedent takes.
188 if err := db.Create(&store.Cell{
189 TabID: localTab, RowID: localRowC, ColID: localCol,
190 InputSealed: sealInput("=A1+1"), InputVersion: 10,
191 CachedSealed: blankCache, CacheVersion: 10,
192 }).Error; err != nil {
193 t.Fatalf("seed C1: %v", err)
194 }
195
196 // Simulate a crash that bumped A1's InputVersion without rewriting
197 // C1's cache — the same shape as the brief's own crash test, but with
198 // the bump smaller than C1's already-consistent version, which is
199 // exactly what exposes a stamp-regression bug.
200 if err := db.Model(&store.Cell{}).
201 Where("row_id = ? AND col_id = ?", localRowA, localCol).
202 Update("input_version", 6).Error; err != nil {
203 t.Fatalf("tamper A1: %v", err)
204 }
205
206 h, err := store.Open(db, s, localTab)
207 if err != nil {
208 t.Fatalf("Open: %v", err)
209 }
210 defer h.Release()
211
212 var c store.Cell
213 db.Where("row_id = ? AND col_id = ?", localRowC, localCol).First(&c)
214 if c.CacheVersion < c.InputVersion {
215 t.Fatalf("C1 regressed: CacheVersion %d < its own InputVersion %d", c.CacheVersion, c.InputVersion)
216 }
217 if got := h.Value(localRowC, localCol); got.Kind != calc.KindNum || got.Num != 2 {
218 t.Fatalf("C1 = %+v, want KindNum 2 (A1=1, C1=A1+1)", got)
219 }
220}
221