@veltra/sheet 2.0.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 (137) hide show
  1. package/dist/core/address.d.ts +60 -0
  2. package/dist/core/address.js +142 -0
  3. package/dist/core/address.js.map +1 -0
  4. package/dist/core/cell-store.d.ts +61 -0
  5. package/dist/core/cell-store.js +135 -0
  6. package/dist/core/cell-store.js.map +1 -0
  7. package/dist/core/command/default-registry.d.ts +10 -0
  8. package/dist/core/command/default-registry.js +18 -0
  9. package/dist/core/command/default-registry.js.map +1 -0
  10. package/dist/core/command/history.d.ts +64 -0
  11. package/dist/core/command/history.js +127 -0
  12. package/dist/core/command/history.js.map +1 -0
  13. package/dist/core/command/merge-cells.d.ts +21 -0
  14. package/dist/core/command/merge-cells.js +74 -0
  15. package/dist/core/command/merge-cells.js.map +1 -0
  16. package/dist/core/command/registry.d.ts +17 -0
  17. package/dist/core/command/registry.js +26 -0
  18. package/dist/core/command/registry.js.map +1 -0
  19. package/dist/core/command/set-cell-formula.d.ts +17 -0
  20. package/dist/core/command/set-cell-formula.js +29 -0
  21. package/dist/core/command/set-cell-formula.js.map +1 -0
  22. package/dist/core/command/set-cell-value.d.ts +22 -0
  23. package/dist/core/command/set-cell-value.js +35 -0
  24. package/dist/core/command/set-cell-value.js.map +1 -0
  25. package/dist/core/command/types.d.ts +68 -0
  26. package/dist/core/events.js +29 -0
  27. package/dist/core/events.js.map +1 -0
  28. package/dist/core/formula/ast.d.ts +58 -0
  29. package/dist/core/formula/ast.js +38 -0
  30. package/dist/core/formula/ast.js.map +1 -0
  31. package/dist/core/formula/dependency-graph.d.ts +70 -0
  32. package/dist/core/formula/dependency-graph.js +269 -0
  33. package/dist/core/formula/dependency-graph.js.map +1 -0
  34. package/dist/core/formula/errors.d.ts +25 -0
  35. package/dist/core/formula/errors.js +36 -0
  36. package/dist/core/formula/errors.js.map +1 -0
  37. package/dist/core/formula/evaluator.d.ts +41 -0
  38. package/dist/core/formula/evaluator.js +154 -0
  39. package/dist/core/formula/evaluator.js.map +1 -0
  40. package/dist/core/formula/functions.d.ts +30 -0
  41. package/dist/core/formula/functions.js +209 -0
  42. package/dist/core/formula/functions.js.map +1 -0
  43. package/dist/core/formula/parser.d.ts +7 -0
  44. package/dist/core/formula/parser.js +208 -0
  45. package/dist/core/formula/parser.js.map +1 -0
  46. package/dist/core/formula/tokenizer.d.ts +37 -0
  47. package/dist/core/formula/tokenizer.js +146 -0
  48. package/dist/core/formula/tokenizer.js.map +1 -0
  49. package/dist/core/merge-manager.d.ts +64 -0
  50. package/dist/core/merge-manager.js +111 -0
  51. package/dist/core/merge-manager.js.map +1 -0
  52. package/dist/core/selection.d.ts +31 -0
  53. package/dist/core/selection.js +62 -0
  54. package/dist/core/selection.js.map +1 -0
  55. package/dist/core/sheet.d.ts +105 -0
  56. package/dist/core/sheet.js +202 -0
  57. package/dist/core/sheet.js.map +1 -0
  58. package/dist/core/workbook.d.ts +43 -0
  59. package/dist/core/workbook.js +77 -0
  60. package/dist/core/workbook.js.map +1 -0
  61. package/dist/grid/sheet-grid.d.ts +59 -0
  62. package/dist/grid/sheet-grid.js +230 -0
  63. package/dist/grid/sheet-grid.js.map +1 -0
  64. package/dist/index.d.ts +28 -0
  65. package/dist/index.js +25 -0
  66. package/dist/tools/builtin.js +72 -0
  67. package/dist/tools/builtin.js.map +1 -0
  68. package/dist/tools/context.d.ts +57 -0
  69. package/dist/tools/context.js +43 -0
  70. package/dist/tools/context.js.map +1 -0
  71. package/dist/tools/registry.d.ts +68 -0
  72. package/dist/tools/registry.js +77 -0
  73. package/dist/tools/registry.js.map +1 -0
  74. package/dist/types/index.d.ts +2 -0
  75. package/dist/types/sheet.d.ts +43 -0
  76. package/dist/vue/index.d.ts +2 -0
  77. package/dist/vue/sheet.js +185 -0
  78. package/dist/vue/sheet.js.map +1 -0
  79. package/dist/vue/sheet.vue.d.ts +24 -0
  80. package/dist/vue/style.d.ts +1 -0
  81. package/dist/vue/style.js +1 -0
  82. package/dist/vue/style2.css +93 -0
  83. package/package.json +37 -0
  84. package/src/core/__test__/address.test.ts +113 -0
  85. package/src/core/__test__/cell-store.test.ts +97 -0
  86. package/src/core/__test__/command.test.ts +377 -0
  87. package/src/core/__test__/formula-adversarial.test.ts +265 -0
  88. package/src/core/__test__/formula-engine.test.ts +470 -0
  89. package/src/core/__test__/formula-functions.test.ts +178 -0
  90. package/src/core/__test__/formula-parser.test.ts +160 -0
  91. package/src/core/__test__/history.test.ts +225 -0
  92. package/src/core/__test__/merge-manager.test.ts +106 -0
  93. package/src/core/__test__/sheet.test.ts +142 -0
  94. package/src/core/address.ts +166 -0
  95. package/src/core/cell-store.ts +173 -0
  96. package/src/core/command/default-registry.ts +15 -0
  97. package/src/core/command/history.ts +167 -0
  98. package/src/core/command/index.ts +7 -0
  99. package/src/core/command/merge-cells.ts +74 -0
  100. package/src/core/command/registry.ts +30 -0
  101. package/src/core/command/set-cell-formula.ts +29 -0
  102. package/src/core/command/set-cell-value.ts +38 -0
  103. package/src/core/command/types.ts +73 -0
  104. package/src/core/events.ts +37 -0
  105. package/src/core/formula/ast.ts +66 -0
  106. package/src/core/formula/dependency-graph.ts +329 -0
  107. package/src/core/formula/errors.ts +41 -0
  108. package/src/core/formula/evaluator.ts +219 -0
  109. package/src/core/formula/functions.ts +266 -0
  110. package/src/core/formula/index.ts +7 -0
  111. package/src/core/formula/parser.ts +198 -0
  112. package/src/core/formula/tokenizer.ts +160 -0
  113. package/src/core/merge-manager.ts +158 -0
  114. package/src/core/selection.ts +66 -0
  115. package/src/core/sheet.ts +257 -0
  116. package/src/core/workbook.ts +99 -0
  117. package/src/env.d.ts +7 -0
  118. package/src/grid/__test__/canvas-mock.ts +49 -0
  119. package/src/grid/__test__/setup.ts +3 -0
  120. package/src/grid/__test__/sheet-grid-formula.test.ts +140 -0
  121. package/src/grid/__test__/sheet-grid.test.ts +177 -0
  122. package/src/grid/sheet-grid.ts +283 -0
  123. package/src/index.ts +16 -0
  124. package/src/tools/__test__/builtin.test.ts +128 -0
  125. package/src/tools/__test__/context.test.ts +157 -0
  126. package/src/tools/__test__/registry.test.ts +102 -0
  127. package/src/tools/builtin.ts +75 -0
  128. package/src/tools/context.ts +106 -0
  129. package/src/tools/index.ts +2 -0
  130. package/src/tools/registry.ts +129 -0
  131. package/src/types/index.ts +1 -0
  132. package/src/types/sheet.ts +41 -0
  133. package/src/vue/__test__/sheet-component.test.ts +267 -0
  134. package/src/vue/index.ts +1 -0
  135. package/src/vue/sheet.vue +195 -0
  136. package/src/vue/style.scss +94 -0
  137. package/src/vue/style.ts +1 -0
@@ -0,0 +1,160 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import { parseRange, type CellAddress } from '../address'
4
+ import { parseFormula } from '../formula/parser'
5
+ import { FormulaParseError } from '../formula/tokenizer'
6
+ import { Sheet } from '../sheet'
7
+ import { Workbook } from '../workbook'
8
+
9
+ const A1 = { row: 0, col: 0 }
10
+ const B1 = { row: 0, col: 1 }
11
+ const B2 = { row: 1, col: 1 }
12
+
13
+ /** 写入公式并读回 CellData */
14
+ function calc(sheet: Sheet, addr: CellAddress, formula: string) {
15
+ sheet.setCellFormula(addr, formula)
16
+ return sheet.getCellData(addr)
17
+ }
18
+
19
+ describe('公式解析:AST 形态', () => {
20
+ it('二元优先级:1+2*3 → 1+(2*3)', () => {
21
+ expect(parseFormula('1+2*3')).toEqual({
22
+ kind: 'binary',
23
+ op: '+',
24
+ left: { kind: 'number', value: 1 },
25
+ right: {
26
+ kind: 'binary',
27
+ op: '*',
28
+ left: { kind: 'number', value: 2 },
29
+ right: { kind: 'number', value: 3 }
30
+ }
31
+ })
32
+ })
33
+
34
+ it('括号改变优先级:(1+2)*3', () => {
35
+ const ast = parseFormula('(1+2)*3')
36
+ expect(ast).toMatchObject({ kind: 'binary', op: '*', left: { op: '+' } })
37
+ })
38
+
39
+ it('单元格引用 / 区域 / 跨表 / 带引号表名', () => {
40
+ expect(parseFormula('A1')).toEqual({ kind: 'cell', sheet: undefined, addr: A1 })
41
+ expect(parseFormula('A1:B9')).toEqual({
42
+ kind: 'range',
43
+ sheet: undefined,
44
+ range: parseRange('A1:B9')
45
+ })
46
+ expect(parseFormula('Sheet2!A1')).toEqual({ kind: 'cell', sheet: 'Sheet2', addr: A1 })
47
+ expect(parseFormula("'My Sheet'!A1")).toEqual({ kind: 'cell', sheet: 'My Sheet', addr: A1 })
48
+ expect(parseFormula("'S2'!A1:B2")).toEqual({
49
+ kind: 'range',
50
+ sheet: 'S2',
51
+ range: parseRange('A1:B2')
52
+ })
53
+ })
54
+
55
+ it('绝对引用 $A$1 等价 A1', () => {
56
+ expect(parseFormula('$A$1')).toEqual({ kind: 'cell', sheet: undefined, addr: A1 })
57
+ })
58
+
59
+ it('一元负号紧于幂次(Excel 行为):-2^2 → (-2)^2', () => {
60
+ const ast = parseFormula('-2^2')
61
+ expect(ast).toMatchObject({
62
+ kind: 'binary',
63
+ op: '^',
64
+ left: { kind: 'unary', op: '-', operand: { kind: 'number', value: 2 } }
65
+ })
66
+ })
67
+
68
+ it('幂次右结合:2^3^2 → 2^(3^2)', () => {
69
+ const ast = parseFormula('2^3^2')
70
+ expect(ast).toMatchObject({
71
+ kind: 'binary',
72
+ op: '^',
73
+ left: { kind: 'number', value: 2 },
74
+ right: { kind: 'binary', op: '^' }
75
+ })
76
+ })
77
+
78
+ it('百分号后缀与函数调用', () => {
79
+ expect(parseFormula('50%')).toEqual({ kind: 'percent', operand: { kind: 'number', value: 50 } })
80
+ expect(parseFormula('SUM(A1:A10, 2)')).toMatchObject({
81
+ kind: 'call',
82
+ name: 'SUM',
83
+ args: [{ kind: 'range' }, { kind: 'number', value: 2 }]
84
+ })
85
+ })
86
+
87
+ it('布尔字面量与未知名称', () => {
88
+ expect(parseFormula('TRUE')).toEqual({ kind: 'boolean', value: true })
89
+ expect(parseFormula('false')).toEqual({ kind: 'boolean', value: false })
90
+ // 列限 3 字母:Sheet2 不是合法单元格引用 → 未知名称节点
91
+ expect(parseFormula('Sheet2')).toEqual({ kind: 'name', name: 'Sheet2' })
92
+ })
93
+
94
+ it('非法输入抛 FormulaParseError', () => {
95
+ for (const text of ['1+', 'SUM(A1', '*2', 'A1:', "'OnlyName'", '1 2', '()']) {
96
+ expect(() => parseFormula(text), text).toThrow(FormulaParseError)
97
+ }
98
+ // 含连续点的标识符是合法形态(未知名称),求值为 #NAME? 而非解析失败
99
+ const sheet = new Sheet()
100
+ expect(calc(sheet, A1, '=A1..B2')).toMatchObject({ v: '#NAME?', t: 'e' })
101
+ })
102
+ })
103
+
104
+ describe('公式解析:求值(经 Sheet)', () => {
105
+ it('=1+2*3 → 7;=(1+2)*3 → 9', () => {
106
+ const sheet = new Sheet()
107
+ expect(calc(sheet, A1, '=1+2*3')).toMatchObject({ f: '1+2*3', v: 7, t: 'n' })
108
+ expect(calc(sheet, B1, '=(1+2)*3')).toMatchObject({ v: 9 })
109
+ })
110
+
111
+ it('=A1+B2 引用求值;空格按 0', () => {
112
+ const sheet = new Sheet()
113
+ sheet.setCellValue(A1, 1)
114
+ sheet.setCellValue(B2, 2)
115
+ expect(calc(sheet, B1, '=A1+B2')).toMatchObject({ v: 3 })
116
+ // C1 为空格 → 按 0
117
+ expect(calc(sheet, { row: 0, col: 3 }, '=A1+C1')).toMatchObject({ v: 1 })
118
+ })
119
+
120
+ it('=SUM(A1:A10) 区域聚合', () => {
121
+ const sheet = new Sheet()
122
+ sheet.setCellValue(A1, 5)
123
+ sheet.setCellValue({ row: 9, col: 0 }, 7)
124
+ expect(calc(sheet, B1, '=SUM(A1:A10)')).toMatchObject({ v: 12 })
125
+ })
126
+
127
+ it('=Sheet2!A1*2 跨表引用', () => {
128
+ const workbook = new Workbook()
129
+ const sheet1 = workbook.activeSheet
130
+ const sheet2 = workbook.addSheet('Sheet2')
131
+ sheet2.setCellValue(A1, 21)
132
+ expect(calc(sheet1, A1, '=Sheet2!A1*2')).toMatchObject({ v: 42 })
133
+ })
134
+
135
+ it("='My Sheet'!A1 带引号表名", () => {
136
+ const workbook = new Workbook()
137
+ const sheet1 = workbook.activeSheet
138
+ const named = workbook.addSheet('My Sheet')
139
+ named.setCellValue(A1, 'hello')
140
+ expect(calc(sheet1, A1, "='My Sheet'!A1")).toMatchObject({ v: 'hello', t: 'str' })
141
+ })
142
+
143
+ it('幂/一元/百分号/拼接/比较', () => {
144
+ const sheet = new Sheet()
145
+ expect(calc(sheet, A1, '=2^3^2')).toMatchObject({ v: 512 })
146
+ expect(calc(sheet, B1, '=-2^2')).toMatchObject({ v: 4 })
147
+ expect(calc(sheet, { row: 0, col: 2 }, '=10%+1')).toMatchObject({ v: 1.1 })
148
+ expect(calc(sheet, { row: 0, col: 3 }, '="a"&"b"&1')).toMatchObject({ v: 'ab1' })
149
+ expect(calc(sheet, { row: 0, col: 4 }, '=1+1=2')).toMatchObject({ v: true, t: 'b' })
150
+ expect(calc(sheet, { row: 0, col: 5 }, '=2<>2')).toMatchObject({ v: false })
151
+ expect(calc(sheet, { row: 0, col: 6 }, '=TRUE+1')).toMatchObject({ v: 2 })
152
+ })
153
+
154
+ it('未知名称 → #NAME?;解析失败 → #ERROR!(f 仍保留原文)', () => {
155
+ const sheet = new Sheet()
156
+ expect(calc(sheet, A1, '=Sheet2')).toMatchObject({ f: 'Sheet2', v: '#NAME?', t: 'e' })
157
+ expect(calc(sheet, B1, '=1+')).toMatchObject({ f: '1+', v: '#ERROR!', t: 'e' })
158
+ expect(calc(sheet, B2, '=SUM(A1')).toMatchObject({ v: '#ERROR!', t: 'e' })
159
+ })
160
+ })
@@ -0,0 +1,225 @@
1
+ import { describe, expect, it, vi } from 'vitest'
2
+
3
+ import type { CellAddress } from '../address'
4
+ import type { CellData } from '../cell-store'
5
+ import { HistoryManager } from '../command/history'
6
+ import type { CellPatch, Mutation, Patch, PatchDirection } from '../command/types'
7
+
8
+ /** 构造一个可区分的 cell 补丁 */
9
+ function cellPatch(row: number, before?: CellData, after?: CellData): CellPatch {
10
+ const addr: CellAddress = { row, col: 0 }
11
+ return { kind: 'cell', addr, before, after }
12
+ }
13
+
14
+ /** 单补丁 mutation(undo/redo 共享补丁,方向决定应用哪一侧) */
15
+ function mutationOf(...patches: Patch[]): Mutation {
16
+ return { redo: patches, undo: [...patches].reverse() }
17
+ }
18
+
19
+ function createHistory(capacity?: number) {
20
+ const applyPatch = vi.fn<(patch: Patch, direction: PatchDirection) => void>()
21
+ const history = new HistoryManager(applyPatch, capacity)
22
+ return { history, applyPatch }
23
+ }
24
+
25
+ describe('HistoryManager 基本流转', () => {
26
+ it('push → undo 应用 undo 补丁 → redo 应用 redo 补丁', () => {
27
+ const { history, applyPatch } = createHistory()
28
+ const patch = cellPatch(0, undefined, { v: 'x', t: 's' })
29
+
30
+ expect(history.canUndo).toBe(false)
31
+ expect(history.canRedo).toBe(false)
32
+
33
+ history.push([mutationOf(patch)])
34
+ expect(history.canUndo).toBe(true)
35
+
36
+ expect(history.undo()).toBe(true)
37
+ expect(applyPatch).toHaveBeenLastCalledWith(patch, 'undo')
38
+ expect(history.canUndo).toBe(false)
39
+ expect(history.canRedo).toBe(true)
40
+
41
+ expect(history.redo()).toBe(true)
42
+ expect(applyPatch).toHaveBeenLastCalledWith(patch, 'redo')
43
+ expect(history.canUndo).toBe(true)
44
+ expect(history.canRedo).toBe(false)
45
+ })
46
+
47
+ it('空栈 undo/redo 返回 false,不调用 applyPatch', () => {
48
+ const { history, applyPatch } = createHistory()
49
+ expect(history.undo()).toBe(false)
50
+ expect(history.redo()).toBe(false)
51
+ expect(applyPatch).not.toHaveBeenCalled()
52
+ })
53
+
54
+ it('undo 按 entry 逆序、mutation.undo 列表序应用补丁', () => {
55
+ const { history, applyPatch } = createHistory()
56
+ const a = cellPatch(0, undefined, { v: 'a' })
57
+ const b = cellPatch(1, undefined, { v: 'b' })
58
+ const c = cellPatch(2, undefined, { v: 'c' })
59
+ const m1: Mutation = { redo: [a], undo: [a] }
60
+ const m2: Mutation = { redo: [b, c], undo: [c, b] }
61
+
62
+ history.push([m1, m2])
63
+ history.undo()
64
+
65
+ expect(applyPatch.mock.calls).toEqual([
66
+ [c, 'undo'],
67
+ [b, 'undo'],
68
+ [a, 'undo']
69
+ ])
70
+
71
+ applyPatch.mockClear()
72
+ history.redo()
73
+ expect(applyPatch.mock.calls).toEqual([
74
+ [a, 'redo'],
75
+ [b, 'redo'],
76
+ [c, 'redo']
77
+ ])
78
+ })
79
+
80
+ it('新命令清空 redo 栈', () => {
81
+ const { history } = createHistory()
82
+ history.push([mutationOf(cellPatch(0, undefined, { v: 'a' }))])
83
+ history.undo()
84
+ expect(history.canRedo).toBe(true)
85
+
86
+ history.push([mutationOf(cellPatch(1, undefined, { v: 'b' }))])
87
+ expect(history.canRedo).toBe(false)
88
+ expect(history.redo()).toBe(false)
89
+ })
90
+
91
+ it('空 mutation 列表直接忽略', () => {
92
+ const { history } = createHistory()
93
+ history.push([])
94
+ expect(history.canUndo).toBe(false)
95
+ })
96
+ })
97
+
98
+ describe('HistoryManager 容量上限', () => {
99
+ it('超出容量淘汰最旧条目', () => {
100
+ const { history } = createHistory(3)
101
+ for (let i = 0; i < 5; i++) {
102
+ history.push([mutationOf(cellPatch(i, undefined, { v: i }))])
103
+ }
104
+ expect(history.undoSize).toBe(3)
105
+
106
+ // 只能 undo 最近 3 条;最旧 2 条已被淘汰
107
+ expect(history.undo()).toBe(true)
108
+ expect(history.undo()).toBe(true)
109
+ expect(history.undo()).toBe(true)
110
+ expect(history.undo()).toBe(false)
111
+
112
+ // redo 栈一致性:3 条全部可重做
113
+ expect(history.redoSize).toBe(3)
114
+ expect(history.redo()).toBe(true)
115
+ expect(history.redo()).toBe(true)
116
+ expect(history.redo()).toBe(true)
117
+ expect(history.redo()).toBe(false)
118
+ })
119
+ })
120
+
121
+ describe('HistoryManager 事务', () => {
122
+ it('事务内多次 push 合并为一个 undo 单元', () => {
123
+ const { history, applyPatch } = createHistory()
124
+ const a = cellPatch(0, undefined, { v: 'a' })
125
+ const b = cellPatch(1, undefined, { v: 'b' })
126
+
127
+ history.beginTransaction()
128
+ history.push([mutationOf(a)])
129
+ history.push([mutationOf(b)])
130
+ // 提交前不入栈
131
+ expect(history.canUndo).toBe(false)
132
+ history.commit()
133
+
134
+ expect(history.undoSize).toBe(1)
135
+ history.undo()
136
+ expect(applyPatch.mock.calls).toEqual([
137
+ [b, 'undo'],
138
+ [a, 'undo']
139
+ ])
140
+ })
141
+
142
+ it('嵌套事务拍平到最外层', () => {
143
+ const { history } = createHistory()
144
+ history.beginTransaction()
145
+ history.push([mutationOf(cellPatch(0, undefined, { v: 'a' }))])
146
+ history.beginTransaction()
147
+ history.push([mutationOf(cellPatch(1, undefined, { v: 'b' }))])
148
+ history.commit()
149
+ // 内层 commit 后仍未入栈
150
+ expect(history.canUndo).toBe(false)
151
+ history.commit()
152
+
153
+ expect(history.undoSize).toBe(1)
154
+ })
155
+
156
+ it('没有进行中事务时 commit 抛错', () => {
157
+ const { history } = createHistory()
158
+ expect(() => history.commit()).toThrow('没有进行中的事务')
159
+ })
160
+
161
+ it('rollback 还原缓冲中的变更,不影响 undo/redo 栈', () => {
162
+ const { history, applyPatch } = createHistory()
163
+ const before = cellPatch(0, undefined, { v: 'old' })
164
+ history.push([mutationOf(before)])
165
+
166
+ const a = cellPatch(1, undefined, { v: 'a' })
167
+ const b = cellPatch(2, undefined, { v: 'b' })
168
+ history.beginTransaction()
169
+ history.push([mutationOf(a)])
170
+ history.push([mutationOf(b)])
171
+ history.rollback()
172
+
173
+ // 缓冲被逆序回放 undo
174
+ expect(applyPatch.mock.calls).toEqual([
175
+ [b, 'undo'],
176
+ [a, 'undo']
177
+ ])
178
+ // 事务已结束,缓冲丢弃;栈中只剩最初一条
179
+ expect(history.inTransaction).toBe(false)
180
+ expect(history.undoSize).toBe(1)
181
+ expect(history.canRedo).toBe(false)
182
+ })
183
+
184
+ it('事务进行中 undo/redo 返回 false', () => {
185
+ const { history, applyPatch } = createHistory()
186
+ history.push([mutationOf(cellPatch(0, undefined, { v: 'a' }))])
187
+ history.beginTransaction()
188
+ expect(history.undo()).toBe(false)
189
+ expect(history.redo()).toBe(false)
190
+ history.commit()
191
+ expect(applyPatch).not.toHaveBeenCalled()
192
+ })
193
+ })
194
+
195
+ describe('HistoryManager 事件与清理', () => {
196
+ it('change 事件携带 canUndo/canRedo 状态', () => {
197
+ const { history } = createHistory()
198
+ const handler = vi.fn()
199
+ history.onChange(handler)
200
+
201
+ history.push([mutationOf(cellPatch(0, undefined, { v: 'a' }))])
202
+ expect(handler).toHaveBeenLastCalledWith({ canUndo: true, canRedo: false })
203
+
204
+ history.undo()
205
+ expect(handler).toHaveBeenLastCalledWith({ canUndo: false, canRedo: true })
206
+
207
+ history.redo()
208
+ expect(handler).toHaveBeenLastCalledWith({ canUndo: true, canRedo: false })
209
+ })
210
+
211
+ it('clear 清空全部历史与事务', () => {
212
+ const { history } = createHistory()
213
+ history.push([mutationOf(cellPatch(0, undefined, { v: 'a' }))])
214
+ history.undo()
215
+ history.beginTransaction()
216
+ history.push([mutationOf(cellPatch(1, undefined, { v: 'b' }))])
217
+
218
+ history.clear()
219
+ expect(history.canUndo).toBe(false)
220
+ expect(history.canRedo).toBe(false)
221
+ expect(history.inTransaction).toBe(false)
222
+ // clear 后事务状态已复位,commit 视为无事务
223
+ expect(() => history.commit()).toThrow()
224
+ })
225
+ })
@@ -0,0 +1,106 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import { parseRange } from '../address'
4
+ import { MergeManager } from '../merge-manager'
5
+
6
+ const B2 = { row: 1, col: 1 }
7
+ const C3 = { row: 2, col: 2 }
8
+ const A1 = { row: 0, col: 0 }
9
+
10
+ describe('MergeManager 基本合并', () => {
11
+ it('merge(B2:C3) 后 getCellInfo 三种 kind 区分正确', () => {
12
+ const mgr = new MergeManager()
13
+ mgr.merge(parseRange('B2:C3')!)
14
+
15
+ expect(mgr.getCellInfo(B2)).toEqual({
16
+ kind: 'merged-anchor',
17
+ anchor: B2,
18
+ mergeRange: parseRange('B2:C3')!
19
+ })
20
+ expect(mgr.getCellInfo(C3)).toEqual({
21
+ kind: 'merged-covered',
22
+ anchor: B2,
23
+ mergeRange: parseRange('B2:C3')!
24
+ })
25
+ expect(mgr.getCellInfo(A1)).toEqual({ kind: 'normal', anchor: A1 })
26
+ })
27
+
28
+ it('resolveAnchor / isCovered / getMergeAt', () => {
29
+ const mgr = new MergeManager()
30
+ mgr.merge(parseRange('B2:C3')!)
31
+
32
+ expect(mgr.resolveAnchor(C3)).toEqual(B2)
33
+ expect(mgr.resolveAnchor(B2)).toEqual(B2)
34
+ expect(mgr.resolveAnchor(A1)).toEqual(A1)
35
+
36
+ expect(mgr.isCovered(C3)).toBe(true)
37
+ expect(mgr.isCovered(B2)).toBe(false)
38
+ expect(mgr.isMerged(B2)).toBe(true)
39
+
40
+ expect(mgr.getMergeAt(C3)).toEqual(parseRange('B2:C3'))
41
+ expect(mgr.getMergeAt(A1)).toBeUndefined()
42
+ })
43
+ })
44
+
45
+ describe('MergeManager 嵌套场景', () => {
46
+ it('已有 B2:C3,再 merge C3:D4 → 旧合并解除,新合并 = 包围盒 B2:D4,锚点 B2', () => {
47
+ const mgr = new MergeManager()
48
+ mgr.merge(parseRange('B2:C3')!)
49
+ const result = mgr.merge(parseRange('C3:D4')!)
50
+
51
+ expect(result.range).toEqual(parseRange('B2:D4'))
52
+ expect(result.removed).toEqual([parseRange('B2:C3')])
53
+ expect(mgr.getMerges()).toEqual([parseRange('B2:D4')])
54
+
55
+ // 旧区域内所有格都解析到新锚点
56
+ expect(mgr.getCellInfo(B2).kind).toBe('merged-anchor')
57
+ expect(mgr.getCellInfo(C3)).toMatchObject({ kind: 'merged-covered', anchor: B2 })
58
+ expect(mgr.getCellInfo({ row: 3, col: 3 })).toMatchObject({
59
+ kind: 'merged-covered',
60
+ anchor: B2
61
+ })
62
+ })
63
+ })
64
+
65
+ describe('MergeManager 边界', () => {
66
+ it('单行合并与单列合并', () => {
67
+ const mgr = new MergeManager()
68
+ mgr.merge(parseRange('B2:D2')!)
69
+ expect(mgr.getCellInfo({ row: 1, col: 3 })).toMatchObject({
70
+ kind: 'merged-covered',
71
+ anchor: B2
72
+ })
73
+
74
+ const mgr2 = new MergeManager()
75
+ mgr2.merge(parseRange('B2:B5')!)
76
+ expect(mgr2.getCellInfo({ row: 4, col: 1 })).toMatchObject({
77
+ kind: 'merged-covered',
78
+ anchor: B2
79
+ })
80
+ })
81
+
82
+ it('相邻但不重叠的合并互不影响', () => {
83
+ const mgr = new MergeManager()
84
+ mgr.merge(parseRange('B2:C3')!)
85
+ const result = mgr.merge(parseRange('D2:D3')!)
86
+
87
+ expect(result.range).toEqual(parseRange('D2:D3'))
88
+ expect(result.removed).toEqual([])
89
+ expect(mgr.size).toBe(2)
90
+ expect(mgr.getCellInfo({ row: 1, col: 3 })).toMatchObject({
91
+ kind: 'merged-anchor',
92
+ anchor: { row: 1, col: 3 }
93
+ })
94
+ expect(mgr.getCellInfo(B2).kind).toBe('merged-anchor')
95
+ })
96
+
97
+ it('unmerge 解除相交合并', () => {
98
+ const mgr = new MergeManager()
99
+ mgr.merge(parseRange('B2:C3')!)
100
+ const removed = mgr.unmerge(parseRange('C3:C3')!)
101
+
102
+ expect(removed).toEqual([parseRange('B2:C3')])
103
+ expect(mgr.size).toBe(0)
104
+ expect(mgr.getCellInfo(C3)).toEqual({ kind: 'normal', anchor: C3 })
105
+ })
106
+ })
@@ -0,0 +1,142 @@
1
+ import { describe, expect, it, vi } from 'vitest'
2
+
3
+ import { parseRange } from '../address'
4
+ import { Sheet } from '../sheet'
5
+ import { Workbook } from '../workbook'
6
+
7
+ const B2 = { row: 1, col: 1 }
8
+ const C3 = { row: 2, col: 2 }
9
+
10
+ describe('Sheet 合并值语义', () => {
11
+ it('B2、C3 各有值 → 合并后仅 B2 值保留;getCellData(C3) = undefined;getDisplayValue(C3) = B2 的值', () => {
12
+ const sheet = new Sheet()
13
+ sheet.setCellValue(B2, 'keep')
14
+ sheet.setCellValue(C3, 'drop')
15
+
16
+ sheet.mergeCells(parseRange('B2:C3')!)
17
+
18
+ expect(sheet.getDisplayValue(B2)).toBe('keep')
19
+ expect(sheet.getCellData(C3)).toBeUndefined()
20
+ expect(sheet.getDisplayValue(C3)).toBe('keep')
21
+ })
22
+
23
+ it('嵌套合并:已有 B2:C3,再 merge C3:D4 → 包围盒 B2:D4,数据按保留规则落 B2', () => {
24
+ const sheet = new Sheet()
25
+ sheet.setCellValue(B2, 'v-b2')
26
+ sheet.mergeCells(parseRange('B2:C3')!)
27
+ sheet.setCellValue({ row: 3, col: 3 }, 'v-d4')
28
+
29
+ const finalRange = sheet.mergeCells(parseRange('C3:D4')!)
30
+
31
+ expect(finalRange).toEqual(parseRange('B2:D4'))
32
+ expect(sheet.getCellInfo(B2).kind).toBe('merged-anchor')
33
+ expect(sheet.getDisplayValue(B2)).toBe('v-b2')
34
+ expect(sheet.getDisplayValue({ row: 3, col: 3 })).toBe('v-b2')
35
+ expect(sheet.getCellData({ row: 3, col: 3 })).toBeUndefined()
36
+ })
37
+
38
+ it('左上方向第一个有值格优先:锚点无值时取行主序首个有值格', () => {
39
+ const sheet = new Sheet()
40
+ sheet.setCellValue(C3, 'first-valued')
41
+
42
+ sheet.mergeCells(parseRange('B2:C3')!)
43
+
44
+ expect(sheet.getDisplayValue(B2)).toBe('first-valued')
45
+ expect(sheet.getCellData(C3)).toBeUndefined()
46
+ })
47
+
48
+ it('unmerge 后仅锚点留值', () => {
49
+ const sheet = new Sheet()
50
+ sheet.setCellValue(B2, 'keep')
51
+ sheet.setCellValue(C3, 'drop')
52
+ sheet.mergeCells(parseRange('B2:C3')!)
53
+
54
+ sheet.unmergeCells(parseRange('B2:C3')!)
55
+
56
+ expect(sheet.getCellInfo(B2)).toEqual({ kind: 'normal', anchor: B2 })
57
+ expect(sheet.getCellData(B2)).toMatchObject({ v: 'keep' })
58
+ expect(sheet.getCellData(C3)).toBeUndefined()
59
+ })
60
+
61
+ it('merge-change 事件', () => {
62
+ const sheet = new Sheet()
63
+ const handler = vi.fn()
64
+ sheet.on('merge-change', handler)
65
+
66
+ sheet.mergeCells(parseRange('B2:C3')!)
67
+ expect(handler).toHaveBeenCalledWith({ range: parseRange('B2:C3') })
68
+ })
69
+ })
70
+
71
+ describe('Sheet 数据与选区', () => {
72
+ it('setCellValue / getCellData / cell-change 事件', () => {
73
+ const sheet = new Sheet()
74
+ const handler = vi.fn()
75
+ sheet.on('cell-change', handler)
76
+
77
+ sheet.setCellValue(B2, 42)
78
+ expect(sheet.getCellData(B2)).toEqual({ v: 42, t: 'n' })
79
+ expect(handler).toHaveBeenCalledWith({ addr: B2 })
80
+
81
+ sheet.setCellValue(B2, null)
82
+ expect(sheet.getCellData(B2)).toBeUndefined()
83
+ })
84
+
85
+ it('selectCell(C3)(被覆盖)→ activeCell = B2', () => {
86
+ const sheet = new Sheet()
87
+ sheet.mergeCells(parseRange('B2:C3')!)
88
+
89
+ sheet.selectCell(C3)
90
+
91
+ expect(sheet.getSelection().activeCell).toEqual(B2)
92
+ })
93
+
94
+ it('selection-change 事件', () => {
95
+ const sheet = new Sheet()
96
+ const handler = vi.fn()
97
+ sheet.on('selection-change', handler)
98
+
99
+ sheet.selectCell(B2)
100
+ expect(handler).toHaveBeenCalledWith({ activeCell: B2, ranges: [{ start: B2, end: B2 }] })
101
+ })
102
+
103
+ it('对被覆盖格写入 → 落到锚点', () => {
104
+ const sheet = new Sheet()
105
+ sheet.mergeCells(parseRange('B2:C3')!)
106
+
107
+ sheet.setCellValue(C3, 'via-covered')
108
+ expect(sheet.getCellData(B2)).toMatchObject({ v: 'via-covered' })
109
+ expect(sheet.getCellData(C3)).toBeUndefined()
110
+ })
111
+ })
112
+
113
+ describe('Workbook', () => {
114
+ it('默认一个 sheet,可增删激活', () => {
115
+ const wb = new Workbook()
116
+ expect(wb.sheetCount).toBe(1)
117
+ expect(wb.activeSheet.name).toBe('Sheet1')
118
+
119
+ const s2 = wb.addSheet()
120
+ expect(s2.name).toBe('Sheet2')
121
+ expect(wb.activeSheet.name).toBe('Sheet1')
122
+
123
+ expect(wb.activateSheet('Sheet2')).toBe(true)
124
+ expect(wb.activeSheet).toBe(s2)
125
+
126
+ expect(wb.removeSheet('Sheet2')).toBe(true)
127
+ expect(wb.activeSheet.name).toBe('Sheet1')
128
+
129
+ // 至少保留一个
130
+ expect(wb.removeSheet('Sheet1')).toBe(false)
131
+ })
132
+
133
+ it('激活切换事件', () => {
134
+ const wb = new Workbook()
135
+ wb.addSheet('Data')
136
+ const handler = vi.fn()
137
+ wb.on('active-sheet-change', handler)
138
+
139
+ wb.activateSheet('Data')
140
+ expect(handler).toHaveBeenCalledWith({ sheet: wb.getSheet('Data'), index: 1 })
141
+ })
142
+ })