@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,172 @@
1
+ import type {
2
+ ParsedSpreadsheet,
3
+ ParsedSheet,
4
+ ParsedRow,
5
+ ParsedCell,
6
+ ParsedColumn,
7
+ } from "./types";
8
+
9
+ const MAX_ROWS = 10_000;
10
+
11
+ export async function parseCsvFile(url: string, signal?: AbortSignal): Promise<ParsedSpreadsheet> {
12
+ const response = await fetch(url, { signal });
13
+ if (!response.ok) {
14
+ throw new Error(`Failed to fetch file: ${response.status} ${response.statusText}`);
15
+ }
16
+
17
+ const text = await response.text();
18
+ const sheet = parseCsvText(text);
19
+
20
+ return { sheets: [sheet], activeSheetIndex: 0 };
21
+ }
22
+
23
+ export function parseCsvText(text: string): ParsedSheet {
24
+ if (!text.trim()) {
25
+ return {
26
+ name: "Sheet1",
27
+ columns: [],
28
+ rows: [],
29
+ mergedCells: [],
30
+ totalRowCount: 0,
31
+ truncated: false,
32
+ defaultRowHeight: 20,
33
+ defaultColWidth: 8,
34
+ showGridLines: true,
35
+ conditionalFormats: [],
36
+ images: [],
37
+ rowOutlineLevels: new Map(),
38
+ colOutlineLevels: new Map(),
39
+ };
40
+ }
41
+
42
+ const delimiter = detectDelimiter(text);
43
+ const rawRows = parseRows(text, delimiter);
44
+
45
+ const totalRowCount = rawRows.length;
46
+ const truncated = totalRowCount > MAX_ROWS;
47
+ const visibleRows = truncated ? rawRows.slice(0, MAX_ROWS) : rawRows;
48
+
49
+ // Determine column count from widest row
50
+ let colCount = 0;
51
+ for (const row of visibleRows) {
52
+ if (row.length > colCount) colCount = row.length;
53
+ }
54
+
55
+ // Track max content width per column for auto-sizing
56
+ const maxContentWidth = Array.from<number>({ length: colCount }).fill(0);
57
+
58
+ const rows: ParsedRow[] = visibleRows.map((rawRow, i) => {
59
+ const cells: ParsedCell[] = [];
60
+ for (let c = 0; c < colCount; c++) {
61
+ const value = rawRow[c] ?? "";
62
+ cells.push({
63
+ column: c + 1,
64
+ value,
65
+ content: { type: "plain", text: value },
66
+ style: {},
67
+ });
68
+
69
+ // Sample first 200 rows for width computation
70
+ if (i < 200 && value.length > maxContentWidth[c]) {
71
+ maxContentWidth[c] = value.length;
72
+ }
73
+ }
74
+ return { index: i + 1, height: 20, cells, hidden: false };
75
+ });
76
+
77
+ const columns: ParsedColumn[] = [];
78
+ for (let c = 0; c < colCount; c++) {
79
+ const width = Math.max(8, Math.min(maxContentWidth[c] + 2, 40));
80
+ columns.push({ index: c + 1, width, hidden: false });
81
+ }
82
+
83
+ return {
84
+ name: "Sheet1",
85
+ columns,
86
+ rows,
87
+ mergedCells: [],
88
+ totalRowCount,
89
+ truncated,
90
+ defaultRowHeight: 20,
91
+ defaultColWidth: 8,
92
+ showGridLines: true,
93
+ conditionalFormats: [],
94
+ images: [],
95
+ rowOutlineLevels: new Map(),
96
+ colOutlineLevels: new Map(),
97
+ };
98
+ }
99
+
100
+ function detectDelimiter(text: string): string {
101
+ // Check first few lines for tab vs comma prevalence
102
+ const sample = text.slice(0, 2000);
103
+ const tabs = (sample.match(/\t/g) ?? []).length;
104
+ const commas = (sample.match(/,/g) ?? []).length;
105
+ return tabs > commas ? "\t" : ",";
106
+ }
107
+
108
+ /**
109
+ * Parse CSV text into rows of string arrays.
110
+ * Handles quoted fields (RFC 4180), mixed line endings.
111
+ */
112
+ function parseRows(text: string, delimiter: string): string[][] {
113
+ const rows: string[][] = [];
114
+ let currentRow: string[] = [];
115
+ let currentField = "";
116
+ let inQuotes = false;
117
+ let i = 0;
118
+
119
+ while (i < text.length) {
120
+ const char = text[i];
121
+
122
+ if (inQuotes) {
123
+ if (char === '"') {
124
+ // Check for escaped quote ("")
125
+ if (i + 1 < text.length && text[i + 1] === '"') {
126
+ currentField += '"';
127
+ i += 2;
128
+ } else {
129
+ inQuotes = false;
130
+ i++;
131
+ }
132
+ } else {
133
+ currentField += char;
134
+ i++;
135
+ }
136
+ } else {
137
+ if (char === '"') {
138
+ inQuotes = true;
139
+ i++;
140
+ } else if (char === delimiter) {
141
+ currentRow.push(currentField);
142
+ currentField = "";
143
+ i++;
144
+ } else if (char === "\r") {
145
+ // Handle \r\n and \r
146
+ currentRow.push(currentField);
147
+ currentField = "";
148
+ rows.push(currentRow);
149
+ currentRow = [];
150
+ i++;
151
+ if (i < text.length && text[i] === "\n") i++;
152
+ } else if (char === "\n") {
153
+ currentRow.push(currentField);
154
+ currentField = "";
155
+ rows.push(currentRow);
156
+ currentRow = [];
157
+ i++;
158
+ } else {
159
+ currentField += char;
160
+ i++;
161
+ }
162
+ }
163
+ }
164
+
165
+ // Push last field and row
166
+ if (currentField || currentRow.length > 0) {
167
+ currentRow.push(currentField);
168
+ rows.push(currentRow);
169
+ }
170
+
171
+ return rows;
172
+ }
@@ -0,0 +1,95 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { findValidationForCell, validateCellValue } from "./data_validation";
3
+ import type { ParsedDataValidation } from "./ooxml_chart_types";
4
+
5
+ function makeDv(overrides: Partial<ParsedDataValidation> & { ref: string; type: ParsedDataValidation["type"] }): ParsedDataValidation {
6
+ return {
7
+ showDropdown: true,
8
+ ...overrides,
9
+ };
10
+ }
11
+
12
+ describe("findValidationForCell", () => {
13
+ it("matches single cell ref", () => {
14
+ const dv = makeDv({ ref: "B2", type: "list", formula1: "A,B" });
15
+ expect(findValidationForCell([dv], 2, 2)).toBe(dv);
16
+ expect(findValidationForCell([dv], 1, 1)).toBeUndefined();
17
+ });
18
+
19
+ it("matches range ref", () => {
20
+ const dv = makeDv({ ref: "A1:C5", type: "whole", operator: "greaterThan", formula1: "0" });
21
+ expect(findValidationForCell([dv], 3, 2)).toBe(dv);
22
+ expect(findValidationForCell([dv], 6, 1)).toBeUndefined();
23
+ });
24
+
25
+ it("handles multi-range refs", () => {
26
+ const dv = makeDv({ ref: "A1:A5 C1:C5", type: "list", formula1: "X,Y" });
27
+ expect(findValidationForCell([dv], 3, 1)).toBe(dv);
28
+ expect(findValidationForCell([dv], 3, 3)).toBe(dv);
29
+ expect(findValidationForCell([dv], 3, 2)).toBeUndefined();
30
+ });
31
+ });
32
+
33
+ describe("validateCellValue", () => {
34
+ it("validates list membership", () => {
35
+ const dv = makeDv({ ref: "A1", type: "list", formula1: "Red,Green,Blue" });
36
+ expect(validateCellValue("Red", dv)).toBe(true);
37
+ expect(validateCellValue("Yellow", dv)).toBe(false);
38
+ });
39
+
40
+ it("validates whole number range (between)", () => {
41
+ const dv = makeDv({ ref: "A1", type: "whole", operator: "between", formula1: "1", formula2: "10" });
42
+ expect(validateCellValue("5", dv)).toBe(true);
43
+ expect(validateCellValue("0", dv)).toBe(false);
44
+ expect(validateCellValue("11", dv)).toBe(false);
45
+ expect(validateCellValue("3.5", dv)).toBe(false); // not integer
46
+ });
47
+
48
+ it("validates decimal greaterThan", () => {
49
+ const dv = makeDv({ ref: "A1", type: "decimal", operator: "greaterThan", formula1: "5.5" });
50
+ expect(validateCellValue("6.0", dv)).toBe(true);
51
+ expect(validateCellValue("5.5", dv)).toBe(false);
52
+ expect(validateCellValue("abc", dv)).toBe(false);
53
+ });
54
+
55
+ it("validates text length", () => {
56
+ const dv = makeDv({ ref: "A1", type: "textLength", operator: "lessThanOrEqual", formula1: "5" });
57
+ expect(validateCellValue("hi", dv)).toBe(true);
58
+ expect(validateCellValue("toolong", dv)).toBe(false);
59
+ });
60
+
61
+ it("accepts empty values", () => {
62
+ const dv = makeDv({ ref: "A1", type: "whole", operator: "greaterThan", formula1: "0" });
63
+ expect(validateCellValue("", dv)).toBe(true);
64
+ });
65
+
66
+ it("passes for missing operator (defaults to between)", () => {
67
+ const dv = makeDv({ ref: "A1", type: "whole", formula1: "1", formula2: "10" });
68
+ expect(validateCellValue("5", dv)).toBe(true);
69
+ expect(validateCellValue("15", dv)).toBe(false);
70
+ });
71
+
72
+ it("validates notBetween", () => {
73
+ const dv = makeDv({ ref: "A1", type: "decimal", operator: "notBetween", formula1: "1", formula2: "10" });
74
+ expect(validateCellValue("0.5", dv)).toBe(true);
75
+ expect(validateCellValue("5", dv)).toBe(false);
76
+ });
77
+
78
+ it("validates equal / notEqual", () => {
79
+ const dv1 = makeDv({ ref: "A1", type: "whole", operator: "equal", formula1: "42" });
80
+ expect(validateCellValue("42", dv1)).toBe(true);
81
+ expect(validateCellValue("43", dv1)).toBe(false);
82
+
83
+ const dv2 = makeDv({ ref: "A1", type: "whole", operator: "notEqual", formula1: "42" });
84
+ expect(validateCellValue("43", dv2)).toBe(true);
85
+ expect(validateCellValue("42", dv2)).toBe(false);
86
+ });
87
+
88
+ it("returns true for date/time/custom types", () => {
89
+ const dv1 = makeDv({ ref: "A1", type: "date" });
90
+ expect(validateCellValue("anything", dv1)).toBe(true);
91
+
92
+ const dv2 = makeDv({ ref: "A1", type: "custom" });
93
+ expect(validateCellValue("anything", dv2)).toBe(true);
94
+ });
95
+ });
@@ -0,0 +1,114 @@
1
+ // =============================================================================
2
+ // Data Validation — Cell value validation logic
3
+ // =============================================================================
4
+
5
+ import type { ParsedDataValidation } from "./ooxml_chart_types";
6
+ import { colLettersToNum } from "./excel_utils";
7
+
8
+ // =============================================================================
9
+ // Find validation rule for a cell
10
+ // =============================================================================
11
+
12
+ /** Find the first data validation rule that applies to the given cell. */
13
+ export function findValidationForCell(
14
+ validations: ParsedDataValidation[],
15
+ row: number,
16
+ col: number,
17
+ ): ParsedDataValidation | undefined {
18
+ for (const dv of validations) {
19
+ const ranges = dv.ref.split(/\s+/);
20
+ for (const range of ranges) {
21
+ // Single cell: "A1"
22
+ const singleMatch = range.match(/^([A-Z]+)(\d+)$/);
23
+ if (singleMatch) {
24
+ const c = colLettersToNum(singleMatch[1]);
25
+ const r = parseInt(singleMatch[2], 10);
26
+ if (r === row && c === col) return dv;
27
+ continue;
28
+ }
29
+ // Range: "A1:C10"
30
+ const rangeMatch = range.match(/^([A-Z]+)(\d+):([A-Z]+)(\d+)$/);
31
+ if (rangeMatch) {
32
+ const sc = colLettersToNum(rangeMatch[1]);
33
+ const sr = parseInt(rangeMatch[2], 10);
34
+ const ec = colLettersToNum(rangeMatch[3]);
35
+ const er = parseInt(rangeMatch[4], 10);
36
+ if (row >= sr && row <= er && col >= sc && col <= ec) return dv;
37
+ }
38
+ }
39
+ }
40
+ return undefined;
41
+ }
42
+
43
+ // =============================================================================
44
+ // Validate a cell value against a rule
45
+ // =============================================================================
46
+
47
+ /** Check whether a string value passes the validation rule. Returns true if valid. */
48
+ export function validateCellValue(
49
+ value: string,
50
+ dv: ParsedDataValidation,
51
+ ): boolean {
52
+ if (dv.type === "none") return true;
53
+ if (!value && dv.type !== "textLength") return true; // empty values pass (Excel behavior)
54
+
55
+ switch (dv.type) {
56
+ case "list": {
57
+ if (!dv.formula1) return true;
58
+ const items = dv.formula1.split(",").map((v) => v.trim().replace(/^"|"$/g, ""));
59
+ return items.includes(value);
60
+ }
61
+ case "whole": {
62
+ const num = Number(value);
63
+ if (!Number.isInteger(num)) return false;
64
+ return checkNumericOperator(num, dv);
65
+ }
66
+ case "decimal": {
67
+ const num = Number(value);
68
+ if (isNaN(num)) return false;
69
+ return checkNumericOperator(num, dv);
70
+ }
71
+ case "textLength": {
72
+ const len = value.length;
73
+ return checkNumericOperator(len, dv);
74
+ }
75
+ // date, time, custom — accept for now (would need formula engine)
76
+ case "date":
77
+ case "time":
78
+ case "custom":
79
+ return true;
80
+ default:
81
+ return true;
82
+ }
83
+ }
84
+
85
+ // =============================================================================
86
+ // Helpers
87
+ // =============================================================================
88
+
89
+ function checkNumericOperator(value: number, dv: ParsedDataValidation): boolean {
90
+ const f1 = dv.formula1 !== undefined ? Number(dv.formula1) : NaN;
91
+ const f2 = dv.formula2 !== undefined ? Number(dv.formula2) : NaN;
92
+ const op = dv.operator ?? "between";
93
+
94
+ switch (op) {
95
+ case "between":
96
+ return !isNaN(f1) && !isNaN(f2) && value >= f1 && value <= f2;
97
+ case "notBetween":
98
+ return !isNaN(f1) && !isNaN(f2) && (value < f1 || value > f2);
99
+ case "equal":
100
+ return !isNaN(f1) && value === f1;
101
+ case "notEqual":
102
+ return !isNaN(f1) && value !== f1;
103
+ case "greaterThan":
104
+ return !isNaN(f1) && value > f1;
105
+ case "lessThan":
106
+ return !isNaN(f1) && value < f1;
107
+ case "greaterThanOrEqual":
108
+ return !isNaN(f1) && value >= f1;
109
+ case "lessThanOrEqual":
110
+ return !isNaN(f1) && value <= f1;
111
+ default:
112
+ return true;
113
+ }
114
+ }
@@ -0,0 +1,309 @@
1
+ import { SelectionModel, normalizeRange } from "./selection_model";
2
+ import { CommandHistory } from "./command_history";
3
+ import type { Command } from "./command_history";
4
+ import type { SelectionEvent } from "./selection_model";
5
+
6
+ // =============================================================================
7
+ // SelectionModel
8
+ // =============================================================================
9
+
10
+ describe("SelectionModel", () => {
11
+ test("starts at A1", () => {
12
+ const sm = new SelectionModel(100, 26);
13
+ expect(sm.getActiveCellRef()).toBe("A1");
14
+ expect(sm.getState().activeCell).toEqual({ row: 1, col: 1 });
15
+ });
16
+
17
+ test("click sets active cell", () => {
18
+ const sm = new SelectionModel(100, 26);
19
+ sm.click(5, 3);
20
+ expect(sm.getState().activeCell).toEqual({ row: 5, col: 3 });
21
+ expect(sm.getActiveCellRef()).toBe("C5");
22
+ });
23
+
24
+ test("click clamps to bounds", () => {
25
+ const sm = new SelectionModel(10, 5);
26
+ sm.click(15, 8);
27
+ expect(sm.getState().activeCell).toEqual({ row: 10, col: 5 });
28
+ });
29
+
30
+ test("shift+click extends selection", () => {
31
+ const sm = new SelectionModel(100, 26);
32
+ sm.click(2, 2);
33
+ sm.click(5, 5, true);
34
+ const range = sm.getState().ranges[0];
35
+ expect(range.start).toEqual({ row: 2, col: 2 });
36
+ expect(range.end).toEqual({ row: 5, col: 5 });
37
+ expect(sm.getSelectionRef()).toBe("B2:E5");
38
+ });
39
+
40
+ test("ctrl+click adds range", () => {
41
+ const sm = new SelectionModel(100, 26);
42
+ sm.click(1, 1);
43
+ sm.click(3, 3, false, true);
44
+ expect(sm.getState().ranges).toHaveLength(2);
45
+ });
46
+
47
+ test("move navigates active cell", () => {
48
+ const sm = new SelectionModel(100, 26);
49
+ sm.move(1, 0); // down
50
+ expect(sm.getState().activeCell).toEqual({ row: 2, col: 1 });
51
+ sm.move(0, 1); // right
52
+ expect(sm.getState().activeCell).toEqual({ row: 2, col: 2 });
53
+ sm.move(-1, 0); // up
54
+ expect(sm.getState().activeCell).toEqual({ row: 1, col: 2 });
55
+ });
56
+
57
+ test("move clamps at boundaries", () => {
58
+ const sm = new SelectionModel(10, 5);
59
+ sm.move(-1, 0); // try up from row 1
60
+ expect(sm.getState().activeCell.row).toBe(1);
61
+ sm.move(0, -1); // try left from col 1
62
+ expect(sm.getState().activeCell.col).toBe(1);
63
+ });
64
+
65
+ test("shift+move extends selection", () => {
66
+ const sm = new SelectionModel(100, 26);
67
+ sm.click(3, 3);
68
+ sm.move(2, 2, true);
69
+ const range = sm.getState().ranges[0];
70
+ expect(range.start).toEqual({ row: 3, col: 3 });
71
+ expect(range.end).toEqual({ row: 5, col: 5 });
72
+ });
73
+
74
+ test("goTo moves to specific cell", () => {
75
+ const sm = new SelectionModel(100, 26);
76
+ sm.goTo(10, 5);
77
+ expect(sm.getState().activeCell).toEqual({ row: 10, col: 5 });
78
+ expect(sm.getSelectionRef()).toBe("E10");
79
+ });
80
+
81
+ test("drag selection", () => {
82
+ const sm = new SelectionModel(100, 26);
83
+ sm.dragStart(2, 2);
84
+ expect(sm.getState().dragging).toBe(true);
85
+ sm.dragMove(5, 5);
86
+ const range = sm.getState().ranges[0];
87
+ expect(range.end).toEqual({ row: 5, col: 5 });
88
+ sm.dragEnd();
89
+ expect(sm.getState().dragging).toBe(false);
90
+ });
91
+
92
+ test("edit mode", () => {
93
+ const sm = new SelectionModel(100, 26);
94
+ const events: SelectionEvent[] = [];
95
+ sm.onChange = (e) => events.push(e);
96
+
97
+ sm.startEdit("Hello");
98
+ expect(sm.getState().editing).toBe(true);
99
+ expect(sm.getState().editText).toBe("Hello");
100
+
101
+ sm.updateEditText("Hello World");
102
+ expect(sm.getState().editText).toBe("Hello World");
103
+
104
+ sm.commitEdit();
105
+ expect(sm.getState().editing).toBe(false);
106
+ expect(events.find((e) => e.type === "edit_end")).toBeDefined();
107
+ });
108
+
109
+ test("escape cancels edit", () => {
110
+ const sm = new SelectionModel(100, 26);
111
+ const events: SelectionEvent[] = [];
112
+ sm.onChange = (e) => events.push(e);
113
+
114
+ sm.startEdit("Hello");
115
+ sm.cancelEdit();
116
+
117
+ expect(sm.getState().editing).toBe(false);
118
+ const endEvent = events.find((e) => e.type === "edit_end");
119
+ expect(endEvent).toBeDefined();
120
+ if (endEvent?.type === "edit_end") {
121
+ expect(endEvent.commit).toBe(false);
122
+ }
123
+ });
124
+
125
+ test("click during edit commits the edit", () => {
126
+ const sm = new SelectionModel(100, 26);
127
+ const events: SelectionEvent[] = [];
128
+ sm.onChange = (e) => events.push(e);
129
+
130
+ sm.startEdit("Test");
131
+ sm.click(5, 5);
132
+
133
+ const endEvent = events.find((e) => e.type === "edit_end");
134
+ expect(endEvent).toBeDefined();
135
+ expect(sm.getState().activeCell).toEqual({ row: 5, col: 5 });
136
+ });
137
+
138
+ test("fires events on changes", () => {
139
+ const sm = new SelectionModel(100, 26);
140
+ const events: SelectionEvent[] = [];
141
+ sm.onChange = (e) => events.push(e);
142
+
143
+ sm.click(3, 3);
144
+ expect(events.some((e) => e.type === "active_cell_changed")).toBe(true);
145
+ expect(events.some((e) => e.type === "selection_changed")).toBe(true);
146
+ });
147
+
148
+ test("selectRange sets multi-cell selection", () => {
149
+ const sm = new SelectionModel(100, 26);
150
+ sm.selectRange(2, 2, 5, 4);
151
+ expect(sm.getState().activeCell).toEqual({ row: 2, col: 2 });
152
+ const range = sm.getState().ranges[0];
153
+ expect(range.start).toEqual({ row: 2, col: 2 });
154
+ expect(range.end).toEqual({ row: 5, col: 4 });
155
+ expect(sm.getSelectionRef()).toBe("B2:D5");
156
+ });
157
+
158
+ test("selectRange clamps to bounds", () => {
159
+ const sm = new SelectionModel(10, 5);
160
+ sm.selectRange(1, 1, 20, 10);
161
+ expect(sm.getState().activeCell).toEqual({ row: 1, col: 1 });
162
+ const range = sm.getState().ranges[0];
163
+ expect(range.end).toEqual({ row: 10, col: 5 });
164
+ });
165
+
166
+ test("selectRange commits editing first", () => {
167
+ const sm = new SelectionModel(100, 26);
168
+ const events: SelectionEvent[] = [];
169
+ sm.onChange = (e) => events.push(e);
170
+ sm.startEdit("test");
171
+ sm.selectRange(2, 2, 5, 4);
172
+ expect(sm.getState().editing).toBe(false);
173
+ expect(events.some((e) => e.type === "edit_end")).toBe(true);
174
+ });
175
+
176
+ test("selectRange fires both events", () => {
177
+ const sm = new SelectionModel(100, 26);
178
+ const events: SelectionEvent[] = [];
179
+ sm.onChange = (e) => events.push(e);
180
+ sm.selectRange(2, 2, 5, 4);
181
+ expect(events.some((e) => e.type === "active_cell_changed")).toBe(true);
182
+ expect(events.some((e) => e.type === "selection_changed")).toBe(true);
183
+ });
184
+ });
185
+
186
+ describe("normalizeRange", () => {
187
+ test("normalizes reversed range", () => {
188
+ const range = normalizeRange({
189
+ start: { row: 5, col: 5 },
190
+ end: { row: 1, col: 1 },
191
+ });
192
+ expect(range.start).toEqual({ row: 1, col: 1 });
193
+ expect(range.end).toEqual({ row: 5, col: 5 });
194
+ });
195
+
196
+ test("keeps already-normalized range", () => {
197
+ const range = normalizeRange({
198
+ start: { row: 1, col: 1 },
199
+ end: { row: 5, col: 5 },
200
+ });
201
+ expect(range.start).toEqual({ row: 1, col: 1 });
202
+ expect(range.end).toEqual({ row: 5, col: 5 });
203
+ });
204
+ });
205
+
206
+ // =============================================================================
207
+ // CommandHistory
208
+ // =============================================================================
209
+
210
+ describe("CommandHistory", () => {
211
+ function makeCommand(
212
+ desc: string,
213
+ onExecute: () => void,
214
+ onUndo: () => void,
215
+ ): Command {
216
+ return { description: desc, execute: onExecute, undo: onUndo };
217
+ }
218
+
219
+ test("execute adds to undo stack", () => {
220
+ const h = new CommandHistory();
221
+ let value = 0;
222
+ h.execute(makeCommand("set 1", () => { value = 1; }, () => { value = 0; }));
223
+ expect(value).toBe(1);
224
+ expect(h.canUndo).toBe(true);
225
+ expect(h.canRedo).toBe(false);
226
+ });
227
+
228
+ test("undo reverses command", () => {
229
+ const h = new CommandHistory();
230
+ let value = 0;
231
+ h.execute(makeCommand("set 1", () => { value = 1; }, () => { value = 0; }));
232
+ h.undo();
233
+ expect(value).toBe(0);
234
+ expect(h.canUndo).toBe(false);
235
+ expect(h.canRedo).toBe(true);
236
+ });
237
+
238
+ test("redo re-applies command", () => {
239
+ const h = new CommandHistory();
240
+ let value = 0;
241
+ h.execute(makeCommand("set 1", () => { value = 1; }, () => { value = 0; }));
242
+ h.undo();
243
+ h.redo();
244
+ expect(value).toBe(1);
245
+ expect(h.canUndo).toBe(true);
246
+ expect(h.canRedo).toBe(false);
247
+ });
248
+
249
+ test("new command clears redo stack", () => {
250
+ const h = new CommandHistory();
251
+ let value = 0;
252
+ h.execute(makeCommand("set 1", () => { value = 1; }, () => { value = 0; }));
253
+ h.undo();
254
+ h.execute(makeCommand("set 2", () => { value = 2; }, () => { value = 0; }));
255
+ expect(h.canRedo).toBe(false);
256
+ expect(value).toBe(2);
257
+ });
258
+
259
+ test("multiple undo/redo", () => {
260
+ const h = new CommandHistory();
261
+ const log: number[] = [];
262
+ h.execute(makeCommand("push 1", () => log.push(1), () => log.pop()));
263
+ h.execute(makeCommand("push 2", () => log.push(2), () => log.pop()));
264
+ h.execute(makeCommand("push 3", () => log.push(3), () => log.pop()));
265
+ expect(log).toEqual([1, 2, 3]);
266
+
267
+ h.undo();
268
+ expect(log).toEqual([1, 2]);
269
+ h.undo();
270
+ expect(log).toEqual([1]);
271
+ h.redo();
272
+ expect(log).toEqual([1, 2]);
273
+ });
274
+
275
+ test("max size trims oldest entries", () => {
276
+ const h = new CommandHistory(3);
277
+ for (let i = 0; i < 5; i++) {
278
+ h.execute(makeCommand(`cmd ${i}`, () => {}, () => {}));
279
+ }
280
+ expect(h.undoSize).toBe(3);
281
+ });
282
+
283
+ test("undo/redo return descriptions", () => {
284
+ const h = new CommandHistory();
285
+ h.execute(makeCommand("Set value", () => {}, () => {}));
286
+ expect(h.undoDescription).toBe("Set value");
287
+ const desc = h.undo();
288
+ expect(desc).toBe("Set value");
289
+ expect(h.redoDescription).toBe("Set value");
290
+ });
291
+
292
+ test("clear empties stacks", () => {
293
+ const h = new CommandHistory();
294
+ h.execute(makeCommand("cmd", () => {}, () => {}));
295
+ h.clear();
296
+ expect(h.canUndo).toBe(false);
297
+ expect(h.canRedo).toBe(false);
298
+ });
299
+
300
+ test("undo on empty returns undefined", () => {
301
+ const h = new CommandHistory();
302
+ expect(h.undo()).toBeUndefined();
303
+ });
304
+
305
+ test("redo on empty returns undefined", () => {
306
+ const h = new CommandHistory();
307
+ expect(h.redo()).toBeUndefined();
308
+ });
309
+ });