@lotics/xlsx 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/package.json +30 -0
  2. package/src/auto_sum.test.ts +71 -0
  3. package/src/auto_sum.ts +83 -0
  4. package/src/canvas_cf.ts +135 -0
  5. package/src/canvas_chart.ts +687 -0
  6. package/src/canvas_fill.ts +156 -0
  7. package/src/canvas_images.ts +99 -0
  8. package/src/canvas_layout.test.ts +159 -0
  9. package/src/canvas_layout.ts +239 -0
  10. package/src/canvas_renderer.ts +1010 -0
  11. package/src/canvas_shape.ts +242 -0
  12. package/src/canvas_sparkline.ts +163 -0
  13. package/src/canvas_text.ts +454 -0
  14. package/src/cell_editor.ts +558 -0
  15. package/src/clipboard.test.ts +145 -0
  16. package/src/clipboard.ts +341 -0
  17. package/src/command_history.ts +102 -0
  18. package/src/csv_parser.test.ts +130 -0
  19. package/src/csv_parser.ts +172 -0
  20. package/src/data_validation.test.ts +95 -0
  21. package/src/data_validation.ts +114 -0
  22. package/src/editing_layer.test.ts +309 -0
  23. package/src/excel_cf_evaluate.test.ts +293 -0
  24. package/src/excel_cf_evaluate.ts +719 -0
  25. package/src/excel_cf_icons.ts +108 -0
  26. package/src/excel_cf_types.ts +67 -0
  27. package/src/excel_numfmt.test.ts +607 -0
  28. package/src/excel_numfmt.ts +1033 -0
  29. package/src/excel_parser.test.ts +1061 -0
  30. package/src/excel_parser.ts +393 -0
  31. package/src/excel_pattern_fills.ts +64 -0
  32. package/src/excel_table_styles.ts +160 -0
  33. package/src/excel_utils.test.ts +108 -0
  34. package/src/excel_utils.ts +162 -0
  35. package/src/fast-formula-parser.d.ts +53 -0
  36. package/src/fill_logic.ts +257 -0
  37. package/src/fill_patterns.test.ts +90 -0
  38. package/src/fill_patterns.ts +71 -0
  39. package/src/flash_fill.test.ts +75 -0
  40. package/src/flash_fill.ts +221 -0
  41. package/src/formula_deps.ts +189 -0
  42. package/src/formula_engine.test.ts +348 -0
  43. package/src/formula_engine.ts +401 -0
  44. package/src/formula_highlight.test.ts +81 -0
  45. package/src/formula_highlight.ts +139 -0
  46. package/src/formula_suggest.ts +100 -0
  47. package/src/hidden_sheets.test.ts +49 -0
  48. package/src/index.ts +149 -0
  49. package/src/keyboard_nav.test.ts +394 -0
  50. package/src/keyboard_nav.ts +891 -0
  51. package/src/move_logic.ts +63 -0
  52. package/src/numfmt_type.test.ts +158 -0
  53. package/src/numfmt_type.ts +231 -0
  54. package/src/ooxml_cell_format.ts +70 -0
  55. package/src/ooxml_chart.test.ts +85 -0
  56. package/src/ooxml_chart.ts +347 -0
  57. package/src/ooxml_chart_types.ts +207 -0
  58. package/src/ooxml_color.ts +339 -0
  59. package/src/ooxml_drawing.test.ts +87 -0
  60. package/src/ooxml_drawing.ts +287 -0
  61. package/src/ooxml_pivot.test.ts +195 -0
  62. package/src/ooxml_pivot.ts +468 -0
  63. package/src/ooxml_pivot_types.ts +165 -0
  64. package/src/ooxml_shape.ts +355 -0
  65. package/src/ooxml_sheet.ts +1271 -0
  66. package/src/ooxml_styles.ts +556 -0
  67. package/src/ooxml_table.test.ts +70 -0
  68. package/src/ooxml_table.ts +131 -0
  69. package/src/ooxml_workbook.test.ts +40 -0
  70. package/src/ooxml_workbook.ts +259 -0
  71. package/src/ooxml_zip.ts +33 -0
  72. package/src/pivot_model.ts +237 -0
  73. package/src/pivot_recompute.test.ts +210 -0
  74. package/src/pivot_recompute.ts +413 -0
  75. package/src/selection_model.ts +364 -0
  76. package/src/spreadsheet_commands.test.ts +772 -0
  77. package/src/spreadsheet_commands.ts +1805 -0
  78. package/src/structured_refs.test.ts +128 -0
  79. package/src/structured_refs.ts +160 -0
  80. package/src/style_helpers.test.ts +33 -0
  81. package/src/style_helpers.ts +30 -0
  82. package/src/template_builder.test.ts +139 -0
  83. package/src/template_builder.ts +36 -0
  84. package/src/types.ts +239 -0
  85. package/src/workbook_model.test.ts +536 -0
  86. package/src/workbook_model.ts +911 -0
  87. package/src/xlsx_round_trip.test.ts +279 -0
  88. package/src/xlsx_writer.test.ts +488 -0
  89. package/src/xlsx_writer.ts +1549 -0
  90. package/src/xml_entities.ts +23 -0
@@ -0,0 +1,536 @@
1
+ import {
2
+ StyleRegistry,
3
+ SheetModel,
4
+ WorkbookModel,
5
+ loadWorkbookFromSnapshot,
6
+ } from "./workbook_model";
7
+ import type { ParsedSpreadsheet, CellStyle } from "./types";
8
+ import { colNumToLetters, refToRowCol, rowColToRef } from "./excel_utils";
9
+
10
+ // =============================================================================
11
+ // excel_utils
12
+ // =============================================================================
13
+
14
+ describe("colNumToLetters", () => {
15
+ test("single letter columns", () => {
16
+ expect(colNumToLetters(1)).toBe("A");
17
+ expect(colNumToLetters(26)).toBe("Z");
18
+ });
19
+
20
+ test("multi-letter columns", () => {
21
+ expect(colNumToLetters(27)).toBe("AA");
22
+ expect(colNumToLetters(28)).toBe("AB");
23
+ expect(colNumToLetters(702)).toBe("ZZ");
24
+ expect(colNumToLetters(703)).toBe("AAA");
25
+ });
26
+ });
27
+
28
+ describe("refToRowCol", () => {
29
+ test("parses simple refs", () => {
30
+ expect(refToRowCol("A1")).toEqual({ row: 1, col: 1 });
31
+ expect(refToRowCol("Z99")).toEqual({ row: 99, col: 26 });
32
+ expect(refToRowCol("AA100")).toEqual({ row: 100, col: 27 });
33
+ });
34
+
35
+ test("parses absolute refs", () => {
36
+ expect(refToRowCol("$A$1")).toEqual({ row: 1, col: 1 });
37
+ expect(refToRowCol("$AB$50")).toEqual({ row: 50, col: 28 });
38
+ });
39
+
40
+ test("returns undefined for invalid refs", () => {
41
+ expect(refToRowCol("")).toBeUndefined();
42
+ expect(refToRowCol("123")).toBeUndefined();
43
+ });
44
+ });
45
+
46
+ describe("rowColToRef", () => {
47
+ test("produces A1-style refs", () => {
48
+ expect(rowColToRef(1, 1)).toBe("A1");
49
+ expect(rowColToRef(99, 26)).toBe("Z99");
50
+ expect(rowColToRef(100, 27)).toBe("AA100");
51
+ });
52
+ });
53
+
54
+ // =============================================================================
55
+ // StyleRegistry
56
+ // =============================================================================
57
+
58
+ describe("StyleRegistry", () => {
59
+ test("starts with empty default style at index 0", () => {
60
+ const reg = new StyleRegistry();
61
+ expect(reg.get(0)).toEqual({});
62
+ expect(reg.size).toBe(1);
63
+ });
64
+
65
+ test("interns a new style and returns index", () => {
66
+ const reg = new StyleRegistry();
67
+ const style: CellStyle = { fontBold: true, fontSize: 14 };
68
+ const idx = reg.intern(style);
69
+ expect(idx).toBe(1);
70
+ expect(reg.get(idx)).toEqual({ fontBold: true, fontSize: 14 });
71
+ });
72
+
73
+ test("deduplicates identical styles", () => {
74
+ const reg = new StyleRegistry();
75
+ const s1 = { fontBold: true, fontSize: 14 };
76
+ const s2 = { fontBold: true, fontSize: 14 };
77
+ const idx1 = reg.intern(s1);
78
+ const idx2 = reg.intern(s2);
79
+ expect(idx1).toBe(idx2);
80
+ expect(reg.size).toBe(2); // default + one interned
81
+ });
82
+
83
+ test("different styles get different indices", () => {
84
+ const reg = new StyleRegistry();
85
+ const idx1 = reg.intern({ fontBold: true });
86
+ const idx2 = reg.intern({ fontItalic: true });
87
+ expect(idx1).not.toBe(idx2);
88
+ expect(reg.size).toBe(3);
89
+ });
90
+
91
+ test("empty style returns index 0", () => {
92
+ const reg = new StyleRegistry();
93
+ expect(reg.intern({})).toBe(0);
94
+ });
95
+
96
+ test("merge creates new style from base + override", () => {
97
+ const reg = new StyleRegistry();
98
+ const baseIdx = reg.intern({ fontBold: true, fontSize: 12 });
99
+ const mergedIdx = reg.merge(baseIdx, { fontItalic: true });
100
+ expect(mergedIdx).not.toBe(baseIdx);
101
+ expect(reg.get(mergedIdx)).toEqual({
102
+ fontBold: true,
103
+ fontSize: 12,
104
+ fontItalic: true,
105
+ });
106
+ });
107
+
108
+ test("merge with same properties deduplicates", () => {
109
+ const reg = new StyleRegistry();
110
+ const idx1 = reg.intern({ fontBold: true, fontItalic: true });
111
+ const baseIdx = reg.intern({ fontBold: true });
112
+ const mergedIdx = reg.merge(baseIdx, { fontItalic: true });
113
+ expect(mergedIdx).toBe(idx1);
114
+ });
115
+
116
+ test("get returns empty style for invalid index", () => {
117
+ const reg = new StyleRegistry();
118
+ expect(reg.get(999)).toEqual({});
119
+ expect(reg.get(-1)).toEqual({});
120
+ });
121
+ });
122
+
123
+ // =============================================================================
124
+ // SheetModel
125
+ // =============================================================================
126
+
127
+ describe("SheetModel", () => {
128
+ test("constructor sets name", () => {
129
+ const sheet = new SheetModel("Sheet1");
130
+ expect(sheet.name).toBe("Sheet1");
131
+ });
132
+
133
+ test("starts with no cells", () => {
134
+ const sheet = new SheetModel("Sheet1");
135
+ expect(sheet.cells.size).toBe(0);
136
+ expect(sheet.getCell("A1")).toBeUndefined();
137
+ });
138
+
139
+ test("setCell creates a cell", () => {
140
+ const sheet = new SheetModel("Sheet1");
141
+ const cell = sheet.set("A1", 42);
142
+ expect(cell.value).toBe(42);
143
+ expect(cell.displayValue).toBe("42");
144
+ expect(cell.styleIndex).toBe(0);
145
+ expect(sheet.getCell("A1")).toBe(cell);
146
+ });
147
+
148
+ test("setCell with formula", () => {
149
+ const sheet = new SheetModel("Sheet1");
150
+ const cell = sheet.set("A3", 30, undefined, undefined, "SUM(A1:A2)");
151
+ expect(cell.formula).toBe("SUM(A1:A2)");
152
+ });
153
+
154
+ test("setCell with string value", () => {
155
+ const sheet = new SheetModel("Sheet1");
156
+ const cell = sheet.set("A1", "Hello");
157
+ expect(cell.value).toBe("Hello");
158
+ expect(cell.displayValue).toBe("Hello");
159
+ });
160
+
161
+ test("setCell with boolean value", () => {
162
+ const sheet = new SheetModel("Sheet1");
163
+ const cell = sheet.set("A1", true);
164
+ expect(cell.displayValue).toBe("TRUE");
165
+ });
166
+
167
+ test("setCell with null value", () => {
168
+ const sheet = new SheetModel("Sheet1");
169
+ const cell = sheet.set("A1", null);
170
+ expect(cell.displayValue).toBe("");
171
+ });
172
+
173
+ test("setCell with number format", () => {
174
+ const sheet = new SheetModel("Sheet1");
175
+ const cell = sheet.set("A1", 0.5, undefined, "0.00%");
176
+ expect(cell.displayValue).toBe("50.00%");
177
+ });
178
+
179
+ test("deleteCell removes a cell", () => {
180
+ const sheet = new SheetModel("Sheet1");
181
+ sheet.set("A1", 42);
182
+ expect(sheet.deleteCell("A1")).toBe(true);
183
+ expect(sheet.getCell("A1")).toBeUndefined();
184
+ });
185
+
186
+ test("deleteCell returns false for non-existent cell", () => {
187
+ const sheet = new SheetModel("Sheet1");
188
+ expect(sheet.deleteCell("A1")).toBe(false);
189
+ });
190
+
191
+ test("getUsedRange computes bounding box", () => {
192
+ const sheet = new SheetModel("Sheet1");
193
+ sheet.set("B2", 1);
194
+ sheet.set("D5", 2);
195
+ sheet.set("C3", 3);
196
+
197
+ const range = sheet.getUsedRange();
198
+ expect(range).toEqual({ minRow: 2, maxRow: 5, minCol: 2, maxCol: 4 });
199
+ });
200
+
201
+ test("getUsedRange returns 1,1 for empty sheet", () => {
202
+ const sheet = new SheetModel("Sheet1");
203
+ expect(sheet.getUsedRange()).toEqual({ minRow: 1, maxRow: 1, minCol: 1, maxCol: 1 });
204
+ });
205
+ });
206
+
207
+ // =============================================================================
208
+ // WorkbookModel
209
+ // =============================================================================
210
+
211
+ describe("WorkbookModel", () => {
212
+ test("constructor sets properties", () => {
213
+ const styles = new StyleRegistry();
214
+ const sheet = new SheetModel("Sheet1");
215
+ const wb = new WorkbookModel({
216
+ sheets: [sheet],
217
+ activeSheetIndex: 0,
218
+ styles,
219
+ });
220
+ expect(wb.sheets).toHaveLength(1);
221
+ expect(wb.activeSheetIndex).toBe(0);
222
+ expect(wb.date1904).toBe(false);
223
+ });
224
+
225
+ test("emit calls onChange", () => {
226
+ const styles = new StyleRegistry();
227
+ const wb = new WorkbookModel({
228
+ sheets: [new SheetModel("Sheet1")],
229
+ activeSheetIndex: 0,
230
+ styles,
231
+ });
232
+
233
+ const events: ChangeEvent[] = [];
234
+ wb.onChange = (e) => events.push(e);
235
+
236
+ wb.emit({ type: "cell_value", sheet: 0, ref: "A1", oldValue: null, newValue: 42 });
237
+ expect(events).toHaveLength(1);
238
+ expect(events[0].type).toBe("cell_value");
239
+ });
240
+
241
+ test("emit is safe without onChange", () => {
242
+ const styles = new StyleRegistry();
243
+ const wb = new WorkbookModel({
244
+ sheets: [new SheetModel("Sheet1")],
245
+ activeSheetIndex: 0,
246
+ styles,
247
+ });
248
+ // Should not throw
249
+ wb.emit({ type: "cell_value", sheet: 0, ref: "A1", oldValue: null, newValue: 42 });
250
+ });
251
+ });
252
+
253
+ // =============================================================================
254
+ // loadWorkbookFromSnapshot + toSnapshot round-trip
255
+ // =============================================================================
256
+
257
+ describe("loadWorkbookFromSnapshot", () => {
258
+ function makeSnapshot(): ParsedSpreadsheet {
259
+ return {
260
+ sheets: [
261
+ {
262
+ name: "Sales",
263
+ columns: [
264
+ { index: 1, width: 10, hidden: false },
265
+ { index: 2, width: 15, hidden: false },
266
+ ],
267
+ rows: [
268
+ {
269
+ index: 1,
270
+ height: 20,
271
+ hidden: false,
272
+ cells: [
273
+ {
274
+ column: 1,
275
+ value: "Product",
276
+ content: { type: "plain", text: "Product" },
277
+ style: { fontBold: true },
278
+ },
279
+ {
280
+ column: 2,
281
+ value: "Revenue",
282
+ content: { type: "plain", text: "Revenue" },
283
+ style: { fontBold: true },
284
+ },
285
+ ],
286
+ },
287
+ {
288
+ index: 2,
289
+ height: 15,
290
+ hidden: false,
291
+ cells: [
292
+ {
293
+ column: 1,
294
+ value: "Widget",
295
+ content: { type: "plain", text: "Widget" },
296
+ style: {},
297
+ },
298
+ {
299
+ column: 2,
300
+ value: "1234",
301
+ content: { type: "plain", text: "1234" },
302
+ style: {},
303
+ formula: "SUM(C2:C10)",
304
+ },
305
+ ],
306
+ },
307
+ ],
308
+ mergedCells: [{ startRow: 1, startCol: 1, endRow: 1, endCol: 2 }],
309
+ totalRowCount: 2,
310
+ truncated: false,
311
+ defaultRowHeight: 15,
312
+ defaultColWidth: 8,
313
+ showGridLines: true,
314
+ freezePane: { frozenRows: 1, frozenCols: 0 },
315
+ conditionalFormats: [],
316
+ images: [],
317
+ rowOutlineLevels: new Map(),
318
+ colOutlineLevels: new Map(),
319
+ },
320
+ ],
321
+ activeSheetIndex: 0,
322
+ };
323
+ }
324
+
325
+ test("loads cells into sparse map", () => {
326
+ const wb = loadWorkbookFromSnapshot(makeSnapshot());
327
+ const sheet = wb.sheets[0];
328
+ expect(sheet.cells.size).toBe(4);
329
+ expect(sheet.getCell("A1")?.displayValue).toBe("Product");
330
+ expect(sheet.getCell("B2")?.displayValue).toBe("1234");
331
+ });
332
+
333
+ test("preserves formulas", () => {
334
+ const wb = loadWorkbookFromSnapshot(makeSnapshot());
335
+ expect(wb.sheets[0].getCell("B2")?.formula).toBe("SUM(C2:C10)");
336
+ });
337
+
338
+ test("interns styles into registry", () => {
339
+ const wb = loadWorkbookFromSnapshot(makeSnapshot());
340
+ const boldIdx = wb.sheets[0].getCell("A1")!.styleIndex;
341
+ const plainIdx = wb.sheets[0].getCell("A2")!.styleIndex;
342
+ expect(boldIdx).not.toBe(plainIdx);
343
+ expect(wb.styles.get(boldIdx)).toEqual({ fontBold: true });
344
+ expect(wb.styles.get(plainIdx)).toEqual({});
345
+ });
346
+
347
+ test("deduplicates same style across cells", () => {
348
+ const wb = loadWorkbookFromSnapshot(makeSnapshot());
349
+ const a1Style = wb.sheets[0].getCell("A1")!.styleIndex;
350
+ const b1Style = wb.sheets[0].getCell("B1")!.styleIndex;
351
+ expect(a1Style).toBe(b1Style); // Both bold
352
+ });
353
+
354
+ test("preserves metadata", () => {
355
+ const wb = loadWorkbookFromSnapshot(makeSnapshot());
356
+ const sheet = wb.sheets[0];
357
+ expect(sheet.name).toBe("Sales");
358
+ expect(sheet.freeze).toEqual({ row: 1, col: 0 });
359
+ expect(sheet.mergedCells).toEqual(["A1:B1"]);
360
+ expect(sheet.colWidths.get(1)).toBe(10);
361
+ expect(sheet.colWidths.get(2)).toBe(15);
362
+ expect(sheet.rowHeights.get(1)).toBe(20);
363
+ expect(sheet.view.showGridLines).toBe(true);
364
+ });
365
+
366
+ test("preserves numeric raw values", () => {
367
+ const wb = loadWorkbookFromSnapshot(makeSnapshot());
368
+ expect(wb.sheets[0].getCell("B2")?.value).toBe(1234);
369
+ });
370
+
371
+ test("preserves string raw values", () => {
372
+ const wb = loadWorkbookFromSnapshot(makeSnapshot());
373
+ expect(wb.sheets[0].getCell("A2")?.value).toBe("Widget");
374
+ });
375
+ });
376
+
377
+ describe("toSnapshot round-trip", () => {
378
+ test("round-trips cell data", () => {
379
+ const original = {
380
+ sheets: [{
381
+ name: "Test",
382
+ columns: [{ index: 1, width: 8, hidden: false }],
383
+ rows: [{
384
+ index: 1,
385
+ height: 15,
386
+ hidden: false,
387
+ cells: [{
388
+ column: 1,
389
+ value: "Hello",
390
+ content: { type: "plain" as const, text: "Hello" },
391
+ style: { fontBold: true },
392
+ }],
393
+ }],
394
+ mergedCells: [],
395
+ totalRowCount: 1,
396
+ truncated: false,
397
+ defaultRowHeight: 15,
398
+ defaultColWidth: 8,
399
+ showGridLines: true,
400
+ conditionalFormats: [],
401
+ images: [],
402
+ rowOutlineLevels: new Map<number, number>(),
403
+ colOutlineLevels: new Map<number, number>(),
404
+ }],
405
+ activeSheetIndex: 0,
406
+ };
407
+
408
+ const wb = loadWorkbookFromSnapshot(original);
409
+ const snapshot = wb.toSnapshot();
410
+
411
+ expect(snapshot.sheets).toHaveLength(1);
412
+ expect(snapshot.sheets[0].name).toBe("Test");
413
+ expect(snapshot.sheets[0].rows).toHaveLength(1);
414
+ expect(snapshot.sheets[0].rows[0].cells).toHaveLength(1);
415
+ expect(snapshot.sheets[0].rows[0].cells[0].value).toBe("Hello");
416
+ expect(snapshot.sheets[0].rows[0].cells[0].style.fontBold).toBe(true);
417
+ });
418
+
419
+ test("round-trips merged cells", () => {
420
+ const original: ParsedSpreadsheet = {
421
+ sheets: [{
422
+ name: "Test",
423
+ columns: [],
424
+ rows: [{
425
+ index: 1,
426
+ height: 15,
427
+ hidden: false,
428
+ cells: [{
429
+ column: 1,
430
+ value: "Merged",
431
+ content: { type: "plain", text: "Merged" },
432
+ style: {},
433
+ }],
434
+ }],
435
+ mergedCells: [{ startRow: 1, startCol: 1, endRow: 1, endCol: 3 }],
436
+ totalRowCount: 1,
437
+ truncated: false,
438
+ defaultRowHeight: 15,
439
+ defaultColWidth: 8,
440
+ showGridLines: true,
441
+ conditionalFormats: [],
442
+ images: [],
443
+ rowOutlineLevels: new Map(),
444
+ colOutlineLevels: new Map(),
445
+ }],
446
+ activeSheetIndex: 0,
447
+ };
448
+
449
+ const wb = loadWorkbookFromSnapshot(original);
450
+ const snapshot = wb.toSnapshot();
451
+
452
+ expect(snapshot.sheets[0].mergedCells).toHaveLength(1);
453
+ expect(snapshot.sheets[0].mergedCells[0]).toEqual({
454
+ startRow: 1,
455
+ startCol: 1,
456
+ endRow: 1,
457
+ endCol: 3,
458
+ });
459
+ });
460
+
461
+ test("round-trips freeze pane", () => {
462
+ const original: ParsedSpreadsheet = {
463
+ sheets: [{
464
+ name: "Test",
465
+ columns: [],
466
+ rows: [],
467
+ mergedCells: [],
468
+ totalRowCount: 0,
469
+ truncated: false,
470
+ defaultRowHeight: 15,
471
+ defaultColWidth: 8,
472
+ showGridLines: true,
473
+ freezePane: { frozenRows: 2, frozenCols: 1 },
474
+ conditionalFormats: [],
475
+ images: [],
476
+ rowOutlineLevels: new Map(),
477
+ colOutlineLevels: new Map(),
478
+ }],
479
+ activeSheetIndex: 0,
480
+ };
481
+
482
+ const wb = loadWorkbookFromSnapshot(original);
483
+ const snapshot = wb.toSnapshot();
484
+ expect(snapshot.sheets[0].freezePane).toEqual({ frozenRows: 2, frozenCols: 1 });
485
+ });
486
+ });
487
+
488
+ // =============================================================================
489
+ // Mutation tests
490
+ // =============================================================================
491
+
492
+ describe("WorkbookModel mutations", () => {
493
+ test("setCell on existing cell overwrites it", () => {
494
+ const styles = new StyleRegistry();
495
+ const sheet = new SheetModel("Sheet1");
496
+ new WorkbookModel({ sheets: [sheet], activeSheetIndex: 0, styles });
497
+
498
+ sheet.set("A1", 10);
499
+ expect(sheet.getCell("A1")?.value).toBe(10);
500
+
501
+ sheet.set("A1", 20);
502
+ expect(sheet.getCell("A1")?.value).toBe(20);
503
+ });
504
+
505
+ test("toSnapshot reflects mutations", () => {
506
+ const styles = new StyleRegistry();
507
+ const sheet = new SheetModel("Sheet1");
508
+ const wb = new WorkbookModel({ sheets: [sheet], activeSheetIndex: 0, styles });
509
+
510
+ sheet.set("A1", "Hello");
511
+ sheet.set("B1", 42);
512
+
513
+ const snapshot = wb.toSnapshot();
514
+ expect(snapshot.sheets[0].rows).toHaveLength(1);
515
+ expect(snapshot.sheets[0].rows[0].cells).toHaveLength(2);
516
+ expect(snapshot.sheets[0].rows[0].cells[0].value).toBe("Hello");
517
+ expect(snapshot.sheets[0].rows[0].cells[1].value).toBe("42");
518
+ });
519
+
520
+ test("deleteCell is reflected in toSnapshot", () => {
521
+ const styles = new StyleRegistry();
522
+ const sheet = new SheetModel("Sheet1");
523
+ const wb = new WorkbookModel({ sheets: [sheet], activeSheetIndex: 0, styles });
524
+
525
+ sheet.set("A1", "Hello");
526
+ sheet.set("A2", "World");
527
+ sheet.deleteCell("A1");
528
+
529
+ const snapshot = wb.toSnapshot();
530
+ expect(snapshot.sheets[0].rows).toHaveLength(1);
531
+ expect(snapshot.sheets[0].rows[0].index).toBe(2);
532
+ });
533
+ });
534
+
535
+ // Import ChangeEvent type
536
+ import type { ChangeEvent } from "./workbook_model";