@veltra/sheet 2.1.1 → 2.2.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 (202) hide show
  1. package/dist/components/report/binding-badge.js +76 -0
  2. package/dist/components/report/designer/cell-coords.js +46 -0
  3. package/dist/components/report/designer/col-widths.js +15 -0
  4. package/dist/components/report/designer/conditional-rules/helpers.js +139 -0
  5. package/dist/components/report/designer/drop-highlight-overlay.js +76 -0
  6. package/dist/components/report/designer/float-panel.js +385 -0
  7. package/dist/components/report/designer/role.js +17 -0
  8. package/dist/components/report/designer/rule-preview.js +33 -0
  9. package/dist/components/report/designer/rule-row.js +269 -0
  10. package/dist/components/report/designer/rules-dialog.js +135 -0
  11. package/dist/components/report/designer/topology-overlay.js +122 -0
  12. package/dist/components/report/designer/topology.js +64 -0
  13. package/dist/components/report/designer/use-grid-overlay.js +75 -0
  14. package/dist/components/report/designer-hub.js +177 -0
  15. package/dist/components/report/field-panel-helpers.js +33 -0
  16. package/dist/components/report/field-panel.js +132 -0
  17. package/dist/components/report/filter-bar.js +74 -0
  18. package/dist/components/report/hub-connection-form.js +145 -0
  19. package/dist/components/report/hub-dataset-editor.js +394 -0
  20. package/dist/components/report/report-designer.js +343 -0
  21. package/dist/components/report/report-viewer.js +111 -0
  22. package/dist/components/report/style.d.ts +17 -0
  23. package/dist/components/report/style.js +18 -0
  24. package/dist/components/report/style2.css +869 -0
  25. package/dist/components/report/use-report-designer.js +360 -0
  26. package/dist/components/report/use-report-viewer.js +50 -0
  27. package/dist/{vue → components/sheet}/formula-bar.js +1 -1
  28. package/dist/{vue → components/sheet}/formula-suggest-list.js +1 -1
  29. package/dist/{vue → components/sheet}/import-file.js +1 -1
  30. package/dist/{vue → components/sheet}/insert-count-menu-item.js +1 -1
  31. package/dist/{vue → components/sheet}/insert-image.js +1 -1
  32. package/dist/{vue → components/sheet}/popup-helpers.js +1 -1
  33. package/dist/{vue → components/sheet}/popups/border-popup.js +1 -1
  34. package/dist/{vue → components/sheet}/popups/border-preset-glyph.js +1 -1
  35. package/dist/{vue → components/sheet}/popups/export-popup.js +2 -2
  36. package/dist/{vue → components/sheet}/popups/fill-color-popup.js +1 -1
  37. package/dist/{vue → components/sheet}/popups/find-popup.js +1 -1
  38. package/dist/{vue → components/sheet}/popups/font-color-popup.js +1 -1
  39. package/dist/{vue → components/sheet}/popups/font-size-popup.js +1 -1
  40. package/dist/{vue → components/sheet}/popups/import.worker.d.ts +1 -1
  41. package/dist/{vue → components/sheet}/popups/import.worker.js +1 -1
  42. package/dist/{vue → components/sheet}/popups/insert-image-popup.js +1 -1
  43. package/dist/{vue → components/sheet}/sheet-context-menu.js +1 -1
  44. package/dist/{vue → components/sheet}/sheet-tabs.js +1 -1
  45. package/dist/{vue → components/sheet}/sheet-toolbar.js +1 -1
  46. package/dist/{vue → components/sheet}/sheet.js +8 -1
  47. package/dist/{vue → components/sheet}/use-find-replace.js +1 -1
  48. package/dist/{vue → components/sheet}/use-formula-ref-select.js +1 -1
  49. package/dist/{vue → components/sheet}/use-sheet-grid.js +10 -2
  50. package/dist/{vue → components/sheet}/use-sheet-state.js +3 -3
  51. package/dist/{vue → components/sheet}/use-sheet-tabs-bar.js +1 -1
  52. package/dist/{vue → components/sheet}/use-tool-groups.js +2 -2
  53. package/dist/{vue → components/sheet}/use-tool-popup.js +2 -2
  54. package/dist/{vue → components/sheet}/use-toolbar-scroll.js +1 -1
  55. package/dist/index.d.ts +17 -3
  56. package/dist/index.js +13 -3
  57. package/dist/report/binding.d.ts +45 -0
  58. package/dist/report/binding.js +175 -0
  59. package/dist/report/connector.d.ts +66 -0
  60. package/dist/report/connector.js +159 -0
  61. package/dist/report/export-xlsx.d.ts +19 -0
  62. package/dist/report/export-xlsx.js +95 -0
  63. package/dist/report/filter-bar.d.ts +10 -0
  64. package/dist/report/filter-bar.js +31 -0
  65. package/dist/report/index.d.ts +12 -0
  66. package/dist/report/params.d.ts +13 -0
  67. package/dist/report/params.js +61 -0
  68. package/dist/report/render/aggregate.js +157 -0
  69. package/dist/report/render/builder.d.ts +12 -0
  70. package/dist/report/render/builder.js +139 -0
  71. package/dist/report/render/coordinate.d.ts +25 -0
  72. package/dist/report/render/coordinate.js +733 -0
  73. package/dist/report/render/index.d.ts +14 -0
  74. package/dist/report/render/index.js +21 -0
  75. package/dist/report/render/style-resolver.js +50 -0
  76. package/dist/report/render/template-index.d.ts +24 -0
  77. package/dist/report/render/template-index.js +137 -0
  78. package/dist/report/rules/evaluate.d.ts +25 -0
  79. package/dist/report/rules/evaluate.js +82 -0
  80. package/dist/report/rules/index.d.ts +2 -0
  81. package/dist/report/template.d.ts +59 -0
  82. package/dist/report/template.js +80 -0
  83. package/dist/report/types.d.ts +85 -0
  84. package/dist/tools/context.d.ts +1 -1
  85. package/dist/types/index.d.ts +3 -2
  86. package/dist/types/report-designer.d.ts +36 -0
  87. package/dist/types/report-viewer.d.ts +38 -0
  88. package/dist/types/sheet.d.ts +17 -1
  89. package/package.json +3 -5
  90. package/src/components/report/__test__/conditional-rules-helpers.test.ts +87 -0
  91. package/src/components/report/__test__/field-panel-helpers.test.ts +65 -0
  92. package/src/components/report/__test__/float-panel-position.test.ts +45 -0
  93. package/src/components/report/__test__/report-designer.test.ts +388 -0
  94. package/src/components/report/__test__/report-viewer.test.ts +334 -0
  95. package/src/components/report/__test__/topology.test.ts +143 -0
  96. package/src/components/report/__test__/use-report-designer.test.ts +565 -0
  97. package/src/components/report/binding-badge.ts +73 -0
  98. package/src/components/report/designer/cell-coords.ts +94 -0
  99. package/src/components/report/designer/col-widths.ts +33 -0
  100. package/src/components/report/designer/conditional-rules/helpers.ts +186 -0
  101. package/src/components/report/designer/drop-highlight-overlay.vue +74 -0
  102. package/src/components/report/designer/float-panel.vue +387 -0
  103. package/src/components/report/designer/role.ts +32 -0
  104. package/src/components/report/designer/rule-preview.vue +40 -0
  105. package/src/components/report/designer/rule-row.vue +252 -0
  106. package/src/components/report/designer/rules-dialog.vue +126 -0
  107. package/src/components/report/designer/topology-overlay.vue +127 -0
  108. package/src/components/report/designer/topology.ts +124 -0
  109. package/src/components/report/designer/use-grid-overlay.ts +113 -0
  110. package/src/components/report/designer-hub.vue +198 -0
  111. package/src/components/report/field-panel-helpers.ts +44 -0
  112. package/src/components/report/field-panel.vue +136 -0
  113. package/src/components/report/filter-bar.vue +77 -0
  114. package/src/components/report/hub-connection-form.vue +124 -0
  115. package/src/components/report/hub-dataset-editor.vue +369 -0
  116. package/src/components/report/index.ts +2 -0
  117. package/src/components/report/report-designer.vue +356 -0
  118. package/src/components/report/report-viewer.vue +128 -0
  119. package/src/components/report/style-designer.scss +583 -0
  120. package/src/components/report/style-hub.scss +292 -0
  121. package/src/components/report/style-viewer.scss +104 -0
  122. package/src/components/report/style.scss +3 -0
  123. package/src/components/report/style.ts +20 -0
  124. package/src/components/report/use-report-designer.ts +547 -0
  125. package/src/components/report/use-report-viewer.ts +79 -0
  126. package/src/{vue → components/sheet}/__test__/formula-bar.test.ts +37 -18
  127. package/src/{vue → components/sheet}/__test__/insert-image-popup.test.ts +1 -1
  128. package/src/{vue → components/sheet}/__test__/insert-image.test.ts +1 -1
  129. package/src/{vue → components/sheet}/__test__/sheet-component.test.ts +40 -3
  130. package/src/{vue → components/sheet}/__test__/sheet-context-menu.test.ts +1 -1
  131. package/src/{vue → components/sheet}/formula-bar.vue +1 -1
  132. package/src/{vue → components/sheet}/insert-image.ts +1 -1
  133. package/src/{vue → components/sheet}/popup-helpers.ts +1 -1
  134. package/src/{vue → components/sheet}/popups/border-popup.vue +1 -1
  135. package/src/{vue → components/sheet}/popups/export-popup.vue +2 -2
  136. package/src/{vue → components/sheet}/popups/fill-color-popup.vue +1 -1
  137. package/src/{vue → components/sheet}/popups/find-popup.vue +1 -1
  138. package/src/{vue → components/sheet}/popups/font-color-popup.vue +1 -1
  139. package/src/{vue → components/sheet}/popups/font-size-popup.vue +1 -1
  140. package/src/{vue → components/sheet}/popups/insert-image-popup.vue +1 -1
  141. package/src/{vue → components/sheet}/sheet-context-menu.ts +2 -2
  142. package/src/{vue → components/sheet}/sheet-toolbar.vue +2 -2
  143. package/src/{vue → components/sheet}/sheet.vue +4 -3
  144. package/src/{vue → components/sheet}/use-find-replace.ts +1 -1
  145. package/src/{vue → components/sheet}/use-sheet-grid.ts +7 -3
  146. package/src/{vue → components/sheet}/use-sheet-state.ts +3 -3
  147. package/src/{vue → components/sheet}/use-tool-groups.ts +2 -2
  148. package/src/{vue → components/sheet}/use-tool-popup.ts +2 -2
  149. package/src/index.ts +24 -188
  150. package/src/report/__test__/binding.test.ts +218 -0
  151. package/src/report/__test__/connector.test.ts +218 -0
  152. package/src/report/__test__/export-xlsx.test.ts +118 -0
  153. package/src/report/__test__/filter-bar.test.ts +58 -0
  154. package/src/report/__test__/params.test.ts +156 -0
  155. package/src/report/__test__/render.test.ts +728 -0
  156. package/src/report/__test__/rules.test.ts +131 -0
  157. package/src/report/__test__/template.test.ts +355 -0
  158. package/src/report/binding.ts +243 -0
  159. package/src/report/connector.ts +205 -0
  160. package/src/report/export-xlsx.ts +118 -0
  161. package/src/report/filter-bar.ts +36 -0
  162. package/src/report/index.ts +13 -0
  163. package/src/report/params.ts +96 -0
  164. package/src/report/render/__test__/aggregate.test.ts +16 -0
  165. package/src/report/render/__test__/coordinate.test.ts +405 -0
  166. package/src/report/render/aggregate.ts +240 -0
  167. package/src/report/render/builder.ts +211 -0
  168. package/src/report/render/coordinate.ts +1239 -0
  169. package/src/report/render/index.ts +30 -0
  170. package/src/report/render/style-resolver.ts +68 -0
  171. package/src/report/render/template-index.ts +200 -0
  172. package/src/report/rules/evaluate.ts +113 -0
  173. package/src/report/rules/index.ts +7 -0
  174. package/src/report/template.ts +141 -0
  175. package/src/report/types.ts +96 -0
  176. package/src/tools/builtin.ts +2 -2
  177. package/src/types/index.ts +2 -6
  178. package/src/types/report-designer.ts +36 -0
  179. package/src/types/report-viewer.ts +37 -0
  180. package/src/types/sheet.ts +22 -1
  181. /package/dist/{vue → components/sheet}/style.d.ts +0 -0
  182. /package/dist/{vue → components/sheet}/style.js +0 -0
  183. /package/dist/{vue → components/sheet}/style2.css +0 -0
  184. /package/dist/{vue → components/sheet}/use-formula-suggest.js +0 -0
  185. /package/src/{vue → components/sheet}/__test__/import-file.test.ts +0 -0
  186. /package/src/{vue → components/sheet}/__test__/insert-count-menu-item.test.ts +0 -0
  187. /package/src/{vue → components/sheet}/__test__/use-formula-ref-select.test.ts +0 -0
  188. /package/src/{vue → components/sheet}/__test__/use-formula-suggest.test.ts +0 -0
  189. /package/src/{vue → components/sheet}/formula-suggest-list.vue +0 -0
  190. /package/src/{vue → components/sheet}/import-file.ts +0 -0
  191. /package/src/{vue → components/sheet}/index.ts +0 -0
  192. /package/src/{vue → components/sheet}/insert-count-menu-item.vue +0 -0
  193. /package/src/{vue → components/sheet}/parsing.ts +0 -0
  194. /package/src/{vue → components/sheet}/popups/border-preset-glyph.vue +0 -0
  195. /package/src/{vue → components/sheet}/popups/import.worker.ts +0 -0
  196. /package/src/{vue → components/sheet}/sheet-tabs.vue +0 -0
  197. /package/src/{vue → components/sheet}/style.scss +0 -0
  198. /package/src/{vue → components/sheet}/style.ts +0 -0
  199. /package/src/{vue → components/sheet}/use-formula-ref-select.ts +0 -0
  200. /package/src/{vue → components/sheet}/use-formula-suggest.ts +0 -0
  201. /package/src/{vue → components/sheet}/use-sheet-tabs-bar.ts +0 -0
  202. /package/src/{vue → components/sheet}/use-toolbar-scroll.ts +0 -0
@@ -0,0 +1,96 @@
1
+ import type { QueryParamDef, QueryParamType } from './types'
2
+
3
+ /** 从 SQL 提取 `${param}` 占位符 id(ADR-0002 决策 2:前端纯函数) */
4
+ export function extractParamIds(sql: string): string[] {
5
+ const ids: string[] = []
6
+ const re = /\$\{([^}]+)\}/g
7
+ let match: RegExpExecArray | null
8
+ while ((match = re.exec(sql)) !== null) {
9
+ const id = match[1]!.trim()
10
+ if (id && !ids.includes(id)) ids.push(id)
11
+ }
12
+ return ids
13
+ }
14
+
15
+ function escapeRegExp(value: string): string {
16
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
17
+ }
18
+
19
+ /** 从 SQL 上下文推断参数控件类型(BETWEEN/LIKE/数值比较) */
20
+ function inferParamTypeFromSql(sql: string, paramId: string): QueryParamType {
21
+ const betweenSingle = new RegExp(
22
+ `\\bBETWEEN\\s+\\$\\{${escapeRegExp(paramId)}\\}(?!\\s+AND)`,
23
+ 'i'
24
+ )
25
+ if (betweenSingle.test(sql)) return 'date-range'
26
+
27
+ const betweenPair = new RegExp(
28
+ `\\bBETWEEN\\s+\\$\\{${escapeRegExp(paramId)}\\}\\s+AND\\s+\\$\\{([^}]+)\\}`,
29
+ 'i'
30
+ )
31
+ const pairMatch = betweenPair.exec(sql)
32
+ if (pairMatch && pairMatch[1]!.trim() !== paramId) return 'date'
33
+
34
+ const like = new RegExp(`\\bLIKE\\s+\\$\\{${escapeRegExp(paramId)}\\}`, 'i')
35
+ if (like.test(sql)) return 'text'
36
+
37
+ const numericCompare = new RegExp(
38
+ `[<>=]+\\s+\\$\\{${escapeRegExp(paramId)}\\}|\\$\\{${escapeRegExp(paramId)}\\}\\s*[<>=]+`,
39
+ 'i'
40
+ )
41
+ if (numericCompare.test(sql)) return 'number'
42
+
43
+ return 'text'
44
+ }
45
+
46
+ function defaultValueForType(type: QueryParamType): unknown {
47
+ switch (type) {
48
+ case 'number':
49
+ return 0
50
+ case 'date-range':
51
+ return ['', '']
52
+ case 'select':
53
+ return ''
54
+ default:
55
+ return ''
56
+ }
57
+ }
58
+
59
+ /**
60
+ * 从 SQL 构建查询参数定义:提取 `${param}` 占位符 + 上下文类型推断,
61
+ * 再叠加数据集编辑器的元数据覆盖(label / 类型 / 默认值 / 选项)。
62
+ */
63
+ export function buildParamDefs(
64
+ sql: string,
65
+ overrides?: Record<string, Partial<Omit<QueryParamDef, 'id'>>>
66
+ ): QueryParamDef[] {
67
+ const ids = extractParamIds(sql)
68
+ return ids.map((id) => {
69
+ const inferred = inferParamTypeFromSql(sql, id)
70
+ const override = overrides?.[id]
71
+ return {
72
+ id,
73
+ label: override?.label ?? id,
74
+ type: override?.type ?? inferred,
75
+ defaultValue: override?.defaultValue ?? defaultValueForType(override?.type ?? inferred),
76
+ options: override?.options
77
+ }
78
+ })
79
+ }
80
+
81
+ /** 按绑定的数据集 id 合并查询参数(同名参数先见为准) */
82
+ export function resolveBoundDatasetParams(
83
+ queryParams: QueryParamDef[],
84
+ boundDatasetIds: readonly string[],
85
+ getDatasetParams: (datasetId: string) => QueryParamDef[]
86
+ ): QueryParamDef[] {
87
+ const map = new Map<string, QueryParamDef>()
88
+ for (const datasetId of boundDatasetIds) {
89
+ const params = getDatasetParams(datasetId)
90
+ for (const param of params) {
91
+ if (!map.has(param.id)) map.set(param.id, param)
92
+ }
93
+ }
94
+ if (map.size > 0) return Array.from(map.values())
95
+ return queryParams
96
+ }
@@ -0,0 +1,16 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import { aggregateField } from '../aggregate'
4
+
5
+ describe('aggregateField', () => {
6
+ it('avg 空集返回 undefined,sum 空集仍为 0', () => {
7
+ expect(aggregateField([], 'amount', 'avg')).toBeUndefined()
8
+ expect(aggregateField([], 'amount', 'sum')).toBe(0)
9
+ })
10
+
11
+ it('max / min 对数值集求值', () => {
12
+ const rows = [{ amount: 10 }, { amount: 30 }, { amount: 20 }]
13
+ expect(aggregateField(rows, 'amount', 'max')).toBe(30)
14
+ expect(aggregateField(rows, 'amount', 'min')).toBe(10)
15
+ })
16
+ })
@@ -0,0 +1,405 @@
1
+ import { Sheet } from '@veltra/sheet-core'
2
+ import { describe, expect, it } from 'vitest'
3
+
4
+ import { REPORT_META_NAMESPACE, createReportBinding } from '../../binding'
5
+ import type { DatasetRecords, ReportBinding } from '../../types'
6
+ import {
7
+ computeExpansionLayout,
8
+ enumerateExpansionInstances,
9
+ physicalColSpan,
10
+ physicalRowSpan,
11
+ placementsAt
12
+ } from '../coordinate'
13
+ import { TemplateStructureError, buildTemplateIndex } from '../template-index'
14
+
15
+ // ---- fixtures ----
16
+
17
+ const ORDERS: DatasetRecords = {
18
+ orders: [
19
+ { customer: '甲', region: '华东', orderNo: 'O-1', amount: 100 },
20
+ { customer: '甲', region: '华东', orderNo: 'O-2', amount: 200 },
21
+ { customer: '乙', region: '华南', orderNo: 'O-3', amount: 300 },
22
+ { customer: '丙', region: '华北', orderNo: 'O-4', amount: 400 }
23
+ ]
24
+ }
25
+
26
+ const MATRIX: DatasetRecords = {
27
+ sales: [
28
+ { region: '华东', category: 'A', amount: 10 },
29
+ { region: '华东', category: 'B', amount: 20 },
30
+ { region: '华南', category: 'A', amount: 30 },
31
+ { region: '华南', category: 'B', amount: 40 }
32
+ ]
33
+ }
34
+
35
+ const MULTI_DS: DatasetRecords = { orders: ORDERS.orders!, customers: [{ id: 'C1' }, { id: 'C2' }] }
36
+
37
+ function bind(sheet: Sheet, addr: { row: number; col: number }, binding: ReportBinding): void {
38
+ sheet.setCellMeta(addr, REPORT_META_NAMESPACE, binding)
39
+ }
40
+
41
+ function snapshot(sheet: Sheet) {
42
+ return sheet.snapshot()
43
+ }
44
+
45
+ function layoutOf(sheet: Sheet, data: DatasetRecords = ORDERS) {
46
+ const index = buildTemplateIndex(snapshot(sheet))
47
+ return computeExpansionLayout(index, data)
48
+ }
49
+
50
+ function placementRange(
51
+ layout: ReturnType<typeof layoutOf>,
52
+ row: number,
53
+ col: number,
54
+ listIndex = 0
55
+ ) {
56
+ const items = placementsAt(layout, { row, col })
57
+ const hit = items[listIndex] ?? items[0]
58
+ expect(hit, `逻辑格 R${row}C${col} 无映射`).toBeDefined()
59
+ return hit!.physical
60
+ }
61
+
62
+ // ---- template-index ----
63
+
64
+ describe('buildTemplateIndex', () => {
65
+ it('父格不存在时抛可读错误', () => {
66
+ const sheet = new Sheet()
67
+ const binding = createReportBinding({ id: 'orders', label: 'o', fields: [] }, 'customer')
68
+ binding.rowParent = { row: 0, col: 9 }
69
+ bind(sheet, { row: 1, col: 0 }, binding)
70
+
71
+ expect(() => buildTemplateIndex(snapshot(sheet))).toThrow(TemplateStructureError)
72
+ expect(() => buildTemplateIndex(snapshot(sheet))).toThrow(/J1/)
73
+ })
74
+
75
+ it('父链成环时抛可读错误', () => {
76
+ const sheet = new Sheet()
77
+ const a = createReportBinding({ id: 'orders', label: 'o', fields: [] }, 'customer')
78
+ const b = createReportBinding({ id: 'orders', label: 'o', fields: [] }, 'region')
79
+ a.rowParent = { row: 2, col: 0 }
80
+ b.rowParent = { row: 1, col: 0 }
81
+ bind(sheet, { row: 1, col: 0 }, a)
82
+ bind(sheet, { row: 2, col: 0 }, b)
83
+
84
+ expect(() => buildTemplateIndex(snapshot(sheet))).toThrow(/父链存在环/)
85
+ })
86
+ })
87
+
88
+ // ---- enumerateExpansionInstances ----
89
+
90
+ describe('enumerateExpansionInstances', () => {
91
+ const groupBinding: ReportBinding = {
92
+ dataset: 'orders',
93
+ field: 'customer',
94
+ expand: 'down',
95
+ aggregate: 'group'
96
+ }
97
+
98
+ it('group 取去重值数', () => {
99
+ const instances = enumerateExpansionInstances(groupBinding, ORDERS, {})
100
+ expect(instances).toHaveLength(3)
101
+ expect(instances.map((i) => i.value)).toEqual(['甲', '乙', '丙'])
102
+ })
103
+
104
+ it('list 取记录数', () => {
105
+ const listBinding: ReportBinding = {
106
+ dataset: 'orders',
107
+ field: 'orderNo',
108
+ expand: 'down',
109
+ aggregate: 'list'
110
+ }
111
+ expect(enumerateExpansionInstances(listBinding, ORDERS, {})).toHaveLength(4)
112
+ })
113
+
114
+ it('expand none 恒为 1 个实例', () => {
115
+ const noneBinding: ReportBinding = {
116
+ dataset: 'orders',
117
+ field: 'amount',
118
+ expand: 'none',
119
+ aggregate: 'sum'
120
+ }
121
+ expect(enumerateExpansionInstances(noneBinding, ORDERS, {})).toHaveLength(1)
122
+ })
123
+
124
+ it('空数据集 group 产出 0 实例', () => {
125
+ expect(enumerateExpansionInstances(groupBinding, { orders: [] }, {})).toHaveLength(0)
126
+ })
127
+ })
128
+
129
+ // ---- coordinate geometry ----
130
+
131
+ describe('computeExpansionLayout', () => {
132
+ it('单级纵向:分组头 merge 跨度 = 明细行数,静态表头不被推移', () => {
133
+ const sheet = new Sheet()
134
+ sheet.setCells([
135
+ { addr: { row: 0, col: 0 }, data: { v: '客户' } },
136
+ { addr: { row: 0, col: 1 }, data: { v: '订单号' } }
137
+ ])
138
+ const group = createReportBinding({ id: 'orders', label: 'o', fields: [] }, 'customer')
139
+ group.aggregate = 'group'
140
+ group.expand = 'down'
141
+ bind(sheet, { row: 1, col: 0 }, group)
142
+
143
+ const detail = createReportBinding({ id: 'orders', label: 'o', fields: [] }, 'orderNo')
144
+ detail.aggregate = 'list'
145
+ detail.expand = 'down'
146
+ detail.rowParent = { row: 1, col: 0 }
147
+ bind(sheet, { row: 1, col: 1 }, detail)
148
+
149
+ const layout = layoutOf(sheet)
150
+ // 表头 1 + 甲2 + 乙1 + 丙1 = 5 行
151
+ expect(layout.rowCount).toBe(5)
152
+
153
+ const header = placementRange(layout, 0, 0)
154
+ expect(header.start).toEqual({ row: 0, col: 0 })
155
+
156
+ const groupA = placementsAt(layout, { row: 1, col: 0 }).find((p) => p.rowPath[0] === 0)!
157
+ expect(physicalRowSpan(groupA)).toBe(2)
158
+ expect(groupA.physical.start.row).toBe(1)
159
+
160
+ const groupB = placementsAt(layout, { row: 1, col: 0 }).find((p) => p.rowPath[0] === 1)!
161
+ expect(physicalRowSpan(groupB)).toBe(1)
162
+ expect(groupB.physical.start.row).toBe(3)
163
+ })
164
+
165
+ it('多级纵向嵌套:子分组在父实例内展开', () => {
166
+ const sheet = new Sheet()
167
+ const data: DatasetRecords = {
168
+ orders: [
169
+ { region: '华东', customer: '甲', orderNo: '1' },
170
+ { region: '华东', customer: '甲', orderNo: '2' },
171
+ { region: '华东', customer: '乙', orderNo: '3' },
172
+ { region: '华南', customer: '丙', orderNo: '4' }
173
+ ]
174
+ }
175
+
176
+ const region = createReportBinding({ id: 'orders', label: 'o', fields: [] }, 'region')
177
+ region.aggregate = 'group'
178
+ region.expand = 'down'
179
+ bind(sheet, { row: 1, col: 0 }, region)
180
+
181
+ const customer = createReportBinding({ id: 'orders', label: 'o', fields: [] }, 'customer')
182
+ customer.aggregate = 'group'
183
+ customer.expand = 'down'
184
+ customer.rowParent = { row: 1, col: 0 }
185
+ bind(sheet, { row: 2, col: 0 }, customer)
186
+
187
+ const detail = createReportBinding({ id: 'orders', label: 'o', fields: [] }, 'orderNo')
188
+ detail.aggregate = 'list'
189
+ detail.expand = 'down'
190
+ detail.rowParent = { row: 2, col: 0 }
191
+ bind(sheet, { row: 2, col: 1 }, detail)
192
+
193
+ const layout = layoutOf(sheet, data)
194
+ // 华东(甲2+乙1) + 华南(丙1) = 3 组块 × 各自明细 = 4 明细行
195
+ expect(layout.rowCount).toBe(4)
196
+
197
+ const eastCustomer = placementsAt(layout, { row: 2, col: 0 }).find(
198
+ (p) => p.rowPath[0] === 0 && p.rowPath[1] === 0
199
+ )!
200
+ expect(physicalRowSpan(eastCustomer)).toBe(2)
201
+ })
202
+
203
+ it('单级横向:列分组向右延展', () => {
204
+ const sheet = new Sheet()
205
+ sheet.setCells([{ addr: { row: 0, col: 0 }, data: { v: '品类' } }])
206
+
207
+ const category = createReportBinding({ id: 'sales', label: 's', fields: [] }, 'category')
208
+ category.aggregate = 'group'
209
+ category.expand = 'right'
210
+ bind(sheet, { row: 0, col: 1 }, category)
211
+
212
+ const layout = layoutOf(sheet, MATRIX)
213
+ expect(layout.colCount).toBe(3) // 标题 + A/B 两列
214
+ const headers = placementsAt(layout, { row: 0, col: 1 })
215
+ expect(headers).toHaveLength(2)
216
+ expect(headers[0]!.physical.start.col).toBe(1)
217
+ expect(headers[1]!.physical.start.col).toBe(2)
218
+ expect(physicalColSpan(headers[0]!)).toBe(1)
219
+ })
220
+
221
+ it('多级列头:colParent 链逐级向右嵌套', () => {
222
+ const sheet = new Sheet()
223
+ const data: DatasetRecords = {
224
+ sales: [
225
+ { year: '2024', quarter: 'Q1', amount: 1 },
226
+ { year: '2024', quarter: 'Q2', amount: 2 },
227
+ { year: '2025', quarter: 'Q1', amount: 3 }
228
+ ]
229
+ }
230
+
231
+ const year = createReportBinding({ id: 'sales', label: 's', fields: [] }, 'year')
232
+ year.aggregate = 'group'
233
+ year.expand = 'right'
234
+ bind(sheet, { row: 0, col: 0 }, year)
235
+
236
+ const quarter = createReportBinding({ id: 'sales', label: 's', fields: [] }, 'quarter')
237
+ quarter.aggregate = 'group'
238
+ quarter.expand = 'right'
239
+ quarter.colParent = { row: 0, col: 0 }
240
+ bind(sheet, { row: 1, col: 0 }, quarter)
241
+
242
+ const layout = layoutOf(sheet, data)
243
+ const yearPlacements = placementsAt(layout, { row: 0, col: 0 })
244
+ expect(yearPlacements).toHaveLength(2)
245
+ expect(physicalColSpan(yearPlacements[0]!)).toBe(2)
246
+ expect(physicalColSpan(yearPlacements[1]!)).toBe(1)
247
+
248
+ const quarterPlacements = placementsAt(layout, { row: 1, col: 0 })
249
+ expect(quarterPlacements).toHaveLength(3)
250
+ })
251
+
252
+ it('行列同时展开:交叉格按双父实例落位', () => {
253
+ const sheet = new Sheet()
254
+ sheet.setCells([
255
+ { addr: { row: 0, col: 0 }, data: { v: '标题' } },
256
+ { addr: { row: 2, col: 0 }, data: { v: '合计' } }
257
+ ])
258
+
259
+ const category = createReportBinding({ id: 'sales', label: 's', fields: [] }, 'category')
260
+ category.aggregate = 'group'
261
+ category.expand = 'right'
262
+ bind(sheet, { row: 0, col: 1 }, category)
263
+
264
+ const region = createReportBinding({ id: 'sales', label: 's', fields: [] }, 'region')
265
+ region.aggregate = 'group'
266
+ region.expand = 'down'
267
+ bind(sheet, { row: 1, col: 0 }, region)
268
+
269
+ const cross = createReportBinding({ id: 'sales', label: 's', fields: [] }, 'amount')
270
+ cross.aggregate = 'sum'
271
+ cross.expand = 'none'
272
+ cross.rowParent = { row: 1, col: 0 }
273
+ cross.colParent = { row: 0, col: 1 }
274
+ bind(sheet, { row: 1, col: 1 }, cross)
275
+
276
+ const layout = layoutOf(sheet, MATRIX)
277
+ expect(layout.rowCount).toBe(4) // 表头 + 2 地区 + 合计
278
+ expect(layout.colCount).toBe(3) // 角 + 2 品类
279
+
280
+ const crossCells = placementsAt(layout, { row: 1, col: 1 })
281
+ expect(crossCells).toHaveLength(4)
282
+ expect(crossCells.map((p) => p.physical.start)).toEqual(
283
+ expect.arrayContaining([
284
+ { row: 1, col: 1 },
285
+ { row: 1, col: 2 },
286
+ { row: 2, col: 1 },
287
+ { row: 2, col: 2 }
288
+ ])
289
+ )
290
+ })
291
+
292
+ it('mergeSpan: false 时扩展格不合并,逐格占位', () => {
293
+ const sheet = new Sheet()
294
+ const group = createReportBinding({ id: 'orders', label: 'o', fields: [] }, 'customer')
295
+ group.aggregate = 'group'
296
+ group.expand = 'down'
297
+ group.mergeSpan = false
298
+ bind(sheet, { row: 1, col: 0 }, group)
299
+
300
+ const detail = createReportBinding({ id: 'orders', label: 'o', fields: [] }, 'orderNo')
301
+ detail.aggregate = 'list'
302
+ detail.expand = 'down'
303
+ detail.rowParent = { row: 1, col: 0 }
304
+ bind(sheet, { row: 1, col: 1 }, detail)
305
+
306
+ const layout = layoutOf(sheet)
307
+ const groupCells = placementsAt(layout, { row: 1, col: 0 })
308
+ expect(groupCells.every((p) => !p.mergeSpan)).toBe(true)
309
+ expect(groupCells.every((p) => physicalRowSpan(p) === 1)).toBe(true)
310
+ expect(groupCells).toHaveLength(4)
311
+ })
312
+
313
+ it('静态格在扩展带内随明细重复占位', () => {
314
+ const sheet = new Sheet()
315
+ sheet.setCells([{ addr: { row: 1, col: 2 }, data: { v: '备注' } }])
316
+
317
+ const group = createReportBinding({ id: 'orders', label: 'o', fields: [] }, 'customer')
318
+ group.aggregate = 'group'
319
+ group.expand = 'down'
320
+ bind(sheet, { row: 1, col: 0 }, group)
321
+
322
+ const detail = createReportBinding({ id: 'orders', label: 'o', fields: [] }, 'orderNo')
323
+ detail.aggregate = 'list'
324
+ detail.expand = 'down'
325
+ detail.rowParent = { row: 1, col: 0 }
326
+ bind(sheet, { row: 1, col: 1 }, detail)
327
+
328
+ const layout = layoutOf(sheet)
329
+ const notes = placementsAt(layout, { row: 1, col: 2 })
330
+ expect(notes).toHaveLength(4)
331
+ expect(notes.map((p) => p.physical.start.row)).toEqual([0, 1, 2, 3])
332
+ })
333
+
334
+ it('空展开:扩展格 0 跨度,子树不占位,静态表头仍映射', () => {
335
+ const sheet = new Sheet()
336
+ sheet.setCells([{ addr: { row: 0, col: 0 }, data: { v: '客户' } }])
337
+
338
+ const group = createReportBinding({ id: 'orders', label: 'o', fields: [] }, 'customer')
339
+ group.aggregate = 'group'
340
+ group.expand = 'down'
341
+ bind(sheet, { row: 1, col: 0 }, group)
342
+
343
+ const detail = createReportBinding({ id: 'orders', label: 'o', fields: [] }, 'orderNo')
344
+ detail.aggregate = 'list'
345
+ detail.expand = 'down'
346
+ detail.rowParent = { row: 1, col: 0 }
347
+ bind(sheet, { row: 1, col: 1 }, detail)
348
+
349
+ const layout = layoutOf(sheet, { orders: [] })
350
+ expect(layout.rowCount).toBe(1)
351
+ expect(placementsAt(layout, { row: 0, col: 0 })).toHaveLength(1)
352
+ expect(placementsAt(layout, { row: 1, col: 0 })).toHaveLength(0)
353
+ })
354
+
355
+ it('跨数据集:父格过滤不同数据集时不串联', () => {
356
+ const sheet = new Sheet()
357
+ sheet.setCells([{ addr: { row: 0, col: 5 }, data: { v: '客户ID' } }])
358
+
359
+ const group = createReportBinding({ id: 'orders', label: 'o', fields: [] }, 'customer')
360
+ group.aggregate = 'group'
361
+ group.expand = 'down'
362
+ bind(sheet, { row: 1, col: 0 }, group)
363
+
364
+ const detail = createReportBinding({ id: 'orders', label: 'o', fields: [] }, 'orderNo')
365
+ detail.aggregate = 'list'
366
+ detail.expand = 'down'
367
+ detail.rowParent = { row: 1, col: 0 }
368
+ bind(sheet, { row: 1, col: 1 }, detail)
369
+
370
+ const customers = createReportBinding({ id: 'customers', label: 'c', fields: [] }, 'id')
371
+ customers.aggregate = 'list'
372
+ customers.expand = 'down'
373
+ bind(sheet, { row: 4, col: 5 }, customers)
374
+
375
+ const layout = layoutOf(sheet, MULTI_DS)
376
+ // 订单 4 明细行 + 表头 1 + 客户列表 2 行
377
+ expect(layout.rowCount).toBe(4 + 1 + 2)
378
+ const customerIds = placementsAt(layout, { row: 4, col: 5 })
379
+ expect(customerIds).toHaveLength(2)
380
+ expect(customerIds[0]!.physical.start.row).toBe(5)
381
+ })
382
+
383
+ it('模板合并区域映射到物理表头行', () => {
384
+ const sheet = new Sheet()
385
+ sheet.setCells([
386
+ { addr: { row: 0, col: 0 }, data: { v: '客户' } },
387
+ { addr: { row: 0, col: 1 }, data: { v: '订单号' } }
388
+ ])
389
+ sheet.mergeCells({ start: { row: 0, col: 0 }, end: { row: 0, col: 1 } })
390
+
391
+ const group = createReportBinding({ id: 'orders', label: 'o', fields: [] }, 'customer')
392
+ group.aggregate = 'group'
393
+ group.expand = 'down'
394
+ bind(sheet, { row: 1, col: 0 }, group)
395
+
396
+ const detail = createReportBinding({ id: 'orders', label: 'o', fields: [] }, 'orderNo')
397
+ detail.aggregate = 'list'
398
+ detail.expand = 'down'
399
+ detail.rowParent = { row: 1, col: 0 }
400
+ bind(sheet, { row: 1, col: 1 }, detail)
401
+
402
+ const layout = layoutOf(sheet)
403
+ expect(layout.mappedMerges).toEqual([{ start: { row: 0, col: 0 }, end: { row: 0, col: 1 } }])
404
+ })
405
+ })