niuma-ui 1.0.0 → 1.0.2

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 (159) hide show
  1. package/CHANGELOG.md +50 -1
  2. package/NOTICE +2 -2
  3. package/package.json +13 -5
  4. package/src/__tests__/RsButton.spec.ts +47 -0
  5. package/src/__tests__/RsCard.spec.ts +160 -1
  6. package/src/__tests__/RsConfirmDialog.spec.ts +127 -0
  7. package/src/__tests__/RsContextMenu.spec.ts +12 -0
  8. package/src/__tests__/RsDateTimePicker.spec.ts +11 -0
  9. package/src/__tests__/RsDescriptions.spec.ts +50 -0
  10. package/src/__tests__/RsDialog.spec.ts +117 -0
  11. package/src/__tests__/RsDrawer.spec.ts +96 -3
  12. package/src/__tests__/RsDynamicTags.spec.ts +52 -0
  13. package/src/__tests__/RsForm.spec.ts +245 -1
  14. package/src/__tests__/RsInput.spec.ts +15 -1
  15. package/src/__tests__/RsMenu.spec.ts +406 -14
  16. package/src/__tests__/RsPagination.spec.ts +67 -2
  17. package/src/__tests__/RsSelect.spec.ts +42 -1
  18. package/src/__tests__/RsSwitch.spec.ts +44 -0
  19. package/src/__tests__/RsTable.a11y.spec.ts +83 -0
  20. package/src/__tests__/RsTable.dom-baseline.spec.ts +87 -0
  21. package/src/__tests__/RsTable.perf.spec.ts +23 -4
  22. package/src/__tests__/RsTable.spec.ts +63 -8
  23. package/src/__tests__/RsTable.ssr.spec.ts +71 -0
  24. package/src/__tests__/RsTable.summary.spec.ts +45 -0
  25. package/src/__tests__/RsTabs.spec.ts +102 -4
  26. package/src/__tests__/RsTerminal.spec.ts +1 -1
  27. package/src/__tests__/RsTimePicker.spec.ts +11 -0
  28. package/src/__tests__/RsUpload.spec.ts +62 -6
  29. package/src/__tests__/createRsDialog.spec.ts +145 -0
  30. package/src/__tests__/form-rules.spec.ts +76 -0
  31. package/src/__tests__/perf-budgets.ts +32 -0
  32. package/src/__tests__/rs-table-api.spec.ts +206 -0
  33. package/src/__tests__/rs-table-compat-matrix.spec.ts +108 -0
  34. package/src/__tests__/rs-table-echarts-adapter.spec.ts +43 -0
  35. package/src/__tests__/rs-table-grid-nav.spec.ts +63 -0
  36. package/src/__tests__/rs-table-isolation.spec.ts +158 -0
  37. package/src/__tests__/split-pane-utils.spec.ts +21 -0
  38. package/src/__tests__/table-chart-utils.spec.ts +88 -0
  39. package/src/__tests__/table-summary-utils.spec.ts +62 -0
  40. package/src/__tests__/table-utils.spec.ts +124 -0
  41. package/src/__tests__/theme.spec.ts +25 -0
  42. package/src/__tests__/useRsTableColumnResize.spec.ts +25 -0
  43. package/src/__tests__/useRsTableCore.perf.spec.ts +170 -0
  44. package/src/components/RsAlert.vue +212 -0
  45. package/src/components/RsButton.vue +145 -21
  46. package/src/components/RsCard.vue +200 -16
  47. package/src/components/RsConfirmDialog.vue +215 -28
  48. package/src/components/RsContextMenu.vue +51 -13
  49. package/src/components/RsDatePicker.vue +267 -34
  50. package/src/components/RsDateTimePicker.vue +19 -2
  51. package/src/components/RsDescriptions.vue +186 -0
  52. package/src/components/RsDescriptionsItem.vue +32 -0
  53. package/src/components/RsDialog.vue +363 -51
  54. package/src/components/RsDivider.vue +89 -0
  55. package/src/components/RsDrawer.vue +558 -33
  56. package/src/components/RsDropdown.vue +33 -2
  57. package/src/components/RsDynamicTags.vue +517 -0
  58. package/src/components/RsForm.vue +97 -8
  59. package/src/components/RsInput.vue +80 -82
  60. package/src/components/RsInputNumber.vue +64 -23
  61. package/src/components/RsLabel.vue +20 -5
  62. package/src/components/RsLoadingBar.vue +151 -0
  63. package/src/components/RsMenu.vue +73 -15
  64. package/src/components/RsMenuItems.vue +147 -9
  65. package/src/components/RsPagination.vue +93 -14
  66. package/src/components/RsRadio.vue +81 -0
  67. package/src/components/RsRadioItem.vue +156 -0
  68. package/src/components/RsSelect.vue +67 -58
  69. package/src/components/RsSplitPane.vue +69 -1
  70. package/src/components/RsSwitch.vue +179 -0
  71. package/src/components/RsTable.vue +653 -2555
  72. package/src/components/RsTabs.vue +415 -83
  73. package/src/components/RsTag.vue +191 -0
  74. package/src/components/RsTerminal.vue +3 -3
  75. package/src/components/RsTimePicker.vue +44 -5
  76. package/src/components/RsTimePickerColumns.vue +136 -77
  77. package/src/components/RsToaster.vue +58 -18
  78. package/src/components/RsTree.vue +39 -21
  79. package/src/components/RsUpload.vue +145 -12
  80. package/src/components/RsVNodeHost.vue +20 -0
  81. package/src/components/button-utils.ts +39 -0
  82. package/src/components/context-menu-utils.ts +6 -0
  83. package/src/components/date-picker-utils.ts +84 -4
  84. package/src/components/descriptions-utils.ts +24 -0
  85. package/src/components/dialog-utils.ts +147 -0
  86. package/src/components/dialog-window.ts +74 -9
  87. package/src/components/drawer-utils.ts +73 -0
  88. package/src/components/form-rules.ts +257 -0
  89. package/src/components/form-utils.ts +52 -2
  90. package/src/components/menu-utils.ts +11 -0
  91. package/src/components/overlay-utils.ts +3 -0
  92. package/src/components/radio-utils.ts +12 -0
  93. package/src/components/reka.ts +5 -0
  94. package/src/components/select-utils.ts +21 -2
  95. package/src/components/split-pane-utils.ts +65 -12
  96. package/src/components/table/RsTableBody.vue +217 -0
  97. package/src/components/table/RsTableBodyRow.vue +90 -1
  98. package/src/components/table/RsTableColGroup.vue +41 -0
  99. package/src/components/table/RsTableHeader.vue +158 -0
  100. package/src/components/table/RsTableSummaryRow.vue +52 -0
  101. package/src/components/table/rs-table-api.ts +99 -0
  102. package/src/components/table/rs-table-compat-matrix.ts +71 -0
  103. package/src/components/table/rs-table-echarts-adapter.ts +81 -0
  104. package/src/components/table/rs-table-feature-host.ts +178 -0
  105. package/src/components/table/rs-table-grid-nav.ts +122 -0
  106. package/src/components/table/rs-table-header-types.ts +47 -0
  107. package/src/components/table/rs-table-module-registry.ts +72 -0
  108. package/src/components/table/rs-table-props.ts +274 -0
  109. package/src/components/table/rs-table-view-context.ts +258 -0
  110. package/src/components/table/rs-table.css +84 -6
  111. package/src/components/table/table-chart-utils.ts +165 -0
  112. package/src/components/table/table-features.ts +284 -0
  113. package/src/components/table/table-summary-utils.ts +194 -0
  114. package/src/components/table-utils.ts +452 -2
  115. package/src/components/tabs-utils.ts +142 -2
  116. package/src/components/terminal-utils.ts +1 -1
  117. package/src/components/terminal-wheel.ts +1 -1
  118. package/src/components/time-picker-utils.ts +76 -3
  119. package/src/components/upload-utils.ts +122 -0
  120. package/src/components/virtual-list-utils.ts +15 -2
  121. package/src/composables/assembleRsTableApi.ts +79 -0
  122. package/src/composables/bindRsTableViewContext.ts +269 -0
  123. package/src/composables/createRsDialog.ts +357 -0
  124. package/src/composables/createRsTableEditEmitBridge.ts +38 -0
  125. package/src/composables/useRsLoadingBar.ts +36 -0
  126. package/src/composables/useRsTable.ts +134 -0
  127. package/src/composables/useRsTableA11y.ts +52 -0
  128. package/src/composables/useRsTableChartBridge.ts +79 -0
  129. package/src/composables/useRsTableColumnLayout.ts +320 -0
  130. package/src/composables/useRsTableColumnResize.ts +244 -0
  131. package/src/composables/useRsTableColumnVirtual.ts +180 -0
  132. package/src/composables/useRsTableColumns.ts +200 -0
  133. package/src/composables/useRsTableContextMenu.ts +257 -0
  134. package/src/composables/useRsTableCore.ts +256 -0
  135. package/src/composables/useRsTableEditActions.ts +606 -0
  136. package/src/composables/useRsTableEditLayer.ts +251 -0
  137. package/src/composables/useRsTableEngine.ts +357 -0
  138. package/src/composables/useRsTableGridKeyboard.ts +123 -0
  139. package/src/composables/useRsTableHeadless.ts +137 -0
  140. package/src/composables/useRsTableInteraction.ts +433 -0
  141. package/src/composables/useRsTableScrollHost.ts +75 -0
  142. package/src/composables/useRsTableScrollLayout.ts +255 -0
  143. package/src/composables/useRsTableSelectionSource.ts +89 -0
  144. package/src/composables/useRsTableShell.ts +416 -0
  145. package/src/composables/useRsTableShellChrome.ts +170 -0
  146. package/src/composables/useRsTableSummary.ts +113 -0
  147. package/src/composables/useRsTableViewProvide.ts +263 -0
  148. package/src/composables/useRsTableVirtual.ts +154 -0
  149. package/src/composables/useRsTabsNav.ts +4 -0
  150. package/src/composables/useTableEdit.ts +1 -1
  151. package/src/icons/lucide.ts +2 -2
  152. package/src/index.ts +309 -9
  153. package/src/locale/messages.ts +30 -0
  154. package/src/styles.css +305 -52
  155. package/src/theme/brand.example.css +21 -2
  156. package/src/theme/presets.ts +4 -0
  157. package/src/theme/types.ts +67 -0
  158. package/src/components/RsTableColgroup.vue +0 -17
  159. package/src/components/RsTableHeaderRow.vue +0 -30
@@ -1,4 +1,4 @@
1
- import { describe, expect, it } from 'vitest'
1
+ import { describe, expect, it, vi } from 'vitest'
2
2
  import { h } from 'vue'
3
3
  import { mount } from '@vue/test-utils'
4
4
  import RsConfigProvider from '../components/RsConfigProvider.vue'
@@ -8,6 +8,7 @@ import {
8
8
  isFileAccepted,
9
9
  mergeUploadFiles,
10
10
  removeUploadFileAt,
11
+ resolveUploadFileIcon,
11
12
  validateUploadFiles,
12
13
  } from '../components/upload-utils'
13
14
 
@@ -63,6 +64,17 @@ describe('upload-utils', () => {
63
64
  const files = [createFile('a.txt', 'a'), createFile('b.txt', 'b')]
64
65
  expect(removeUploadFileAt(files, 0).map((file) => file.name)).toEqual(['b.txt'])
65
66
  })
67
+
68
+ it('resolves list icons by extension and mime', () => {
69
+ expect(resolveUploadFileIcon('localhost.pem')).toBe('file-key')
70
+ expect(resolveUploadFileIcon('localhost.key')).toBe('key-round')
71
+ expect(resolveUploadFileIcon('photo.png')).toBe('file-image')
72
+ expect(resolveUploadFileIcon('app.ts')).toBe('file-code')
73
+ expect(resolveUploadFileIcon('data.xlsx')).toBe('file-spreadsheet')
74
+ expect(resolveUploadFileIcon('pack.zip')).toBe('file-archive')
75
+ expect(resolveUploadFileIcon(createFile('blob.bin', 'x', 'image/png'))).toBe('file-image')
76
+ expect(resolveUploadFileIcon('unknown.xyz')).toBe('file')
77
+ })
66
78
  })
67
79
 
68
80
  describe('RsUpload', () => {
@@ -149,12 +161,56 @@ describe('RsUpload', () => {
149
161
  expect(wrapper.find('.rs-upload__dropzone').classes()).toContain('rs-upload__dropzone--disabled')
150
162
  })
151
163
 
152
- it('binds accept and multiple attributes', () => {
164
+ it('hides file list when showFileList is false', () => {
153
165
  const wrapper = mount(RsUpload, {
154
- props: { accept: 'image/*', multiple: true },
166
+ props: {
167
+ modelValue: [createFile('a.txt', 'a')],
168
+ showFileList: false,
169
+ },
170
+ })
171
+ expect(wrapper.find('.rs-upload__list').exists()).toBe(false)
172
+ })
173
+
174
+ it('shows download action and emits download', async () => {
175
+ const createObjectURL = vi.fn(() => 'blob:mock')
176
+ const revokeObjectURL = vi.fn()
177
+ vi.stubGlobal('URL', { createObjectURL, revokeObjectURL })
178
+
179
+ const file = createFile('a.txt', 'hello')
180
+ const wrapper = mount(RsUpload, {
181
+ props: {
182
+ modelValue: [file],
183
+ showDownload: true,
184
+ },
185
+ })
186
+ expect(wrapper.find('.rs-upload__file-action').exists()).toBe(true)
187
+ await wrapper.find('.rs-upload__file-action').trigger('click')
188
+ expect(wrapper.emitted('download')?.[0]?.[0]).toBe(file)
189
+ expect(createObjectURL).toHaveBeenCalled()
190
+
191
+ vi.unstubAllGlobals()
192
+ })
193
+
194
+ it('accepts dropped files', async () => {
195
+ const wrapper = mount(RsUpload, {
196
+ props: { modelValue: [] },
197
+ })
198
+ const file = createFile('drop.txt', 'x')
199
+ await wrapper.find('.rs-upload__dropzone').trigger('drop', {
200
+ dataTransfer: { files: [file] },
201
+ })
202
+ expect(wrapper.emitted('update:modelValue')?.[0]?.[0]).toEqual([file])
203
+ })
204
+
205
+ it('hides dropzone when full and hideDropzoneWhenFull', () => {
206
+ const wrapper = mount(RsUpload, {
207
+ props: {
208
+ modelValue: [createFile('a.txt', 'a')],
209
+ maxCount: 1,
210
+ hideDropzoneWhenFull: true,
211
+ },
155
212
  })
156
- const input = wrapper.find('input')
157
- expect(input.attributes('accept')).toBe('image/*')
158
- expect(input.attributes('multiple')).toBeDefined()
213
+ expect(wrapper.find('.rs-upload__dropzone').exists()).toBe(false)
214
+ expect(wrapper.find('.rs-upload__file-name').text()).toBe('a.txt')
159
215
  })
160
216
  })
@@ -0,0 +1,145 @@
1
+ import { h } from 'vue'
2
+ import { afterEach, describe, expect, it } from 'vitest'
3
+ import { flushPromises } from '@vue/test-utils'
4
+ import { openRsDialog, rsConfirm } from '../composables/createRsDialog'
5
+
6
+ describe('createRsDialog', () => {
7
+ afterEach(() => {
8
+ document.body.querySelectorAll('.rs-dialog__content, .rs-confirm-dialog__content').forEach((el) => {
9
+ el.closest('body > div')?.remove()
10
+ })
11
+ })
12
+
13
+ it('rsConfirm resolves true when confirm clicked', async () => {
14
+ const promise = rsConfirm({
15
+ title: '删除?',
16
+ description: '不可撤销',
17
+ showOverlay: false,
18
+ })
19
+ await flushPromises()
20
+ const confirmBtn = Array.from(document.body.querySelectorAll('.rs-confirm-dialog__footer button')).at(
21
+ -1,
22
+ ) as HTMLButtonElement
23
+ expect(confirmBtn).toBeTruthy()
24
+ confirmBtn.click()
25
+ await flushPromises()
26
+ await expect(promise).resolves.toBe(true)
27
+ })
28
+
29
+ it('rsConfirm resolves false when cancel clicked', async () => {
30
+ const promise = rsConfirm({
31
+ title: '取消测试',
32
+ showOverlay: false,
33
+ })
34
+ await flushPromises()
35
+ const cancelBtn = document.body.querySelector(
36
+ '.rs-confirm-dialog__footer button',
37
+ ) as HTMLButtonElement
38
+ cancelBtn.click()
39
+ await flushPromises()
40
+ await expect(promise).resolves.toBe(false)
41
+ })
42
+
43
+ it('rsConfirm passes subtitle and width', async () => {
44
+ const promise = rsConfirm({
45
+ title: '删除?',
46
+ subtitle: '不可恢复',
47
+ description: '确定删除吗?',
48
+ width: 500,
49
+ showOverlay: false,
50
+ })
51
+ await flushPromises()
52
+ const content = document.body.querySelector('.rs-confirm-dialog__content') as HTMLElement
53
+ expect(content?.textContent).toContain('不可恢复')
54
+ expect(content?.textContent).toContain('确定删除吗?')
55
+ expect(content.style.maxWidth).toBe('500px')
56
+ const cancelBtn = document.body.querySelector(
57
+ '.rs-confirm-dialog__footer button',
58
+ ) as HTMLButtonElement
59
+ cancelBtn.click()
60
+ await flushPromises()
61
+ await expect(promise).resolves.toBe(false)
62
+ })
63
+
64
+ it('rsConfirm waits for async onConfirm before resolving', async () => {
65
+ let release!: () => void
66
+ const gate = new Promise<void>((resolve) => {
67
+ release = resolve
68
+ })
69
+ const promise = rsConfirm({
70
+ title: '异步',
71
+ showOverlay: false,
72
+ onConfirm: () => gate,
73
+ })
74
+ await flushPromises()
75
+ const confirmBtn = Array.from(document.body.querySelectorAll('.rs-confirm-dialog__footer button')).at(
76
+ -1,
77
+ ) as HTMLButtonElement
78
+ confirmBtn.click()
79
+ await flushPromises()
80
+ expect(confirmBtn.getAttribute('disabled')).not.toBeNull()
81
+ release()
82
+ await flushPromises()
83
+ await expect(promise).resolves.toBe(true)
84
+ })
85
+
86
+ it('openRsDialog mounts builtin footer and can destroy', async () => {
87
+ const handle = openRsDialog({
88
+ title: '命令式',
89
+ showOverlay: false,
90
+ body: () => '临时内容',
91
+ })
92
+ await flushPromises()
93
+ const content = document.body.querySelector('.rs-dialog__content')
94
+ expect(content?.textContent).toContain('命令式')
95
+ expect(content?.classList.contains('rs-dialog__content--window')).toBe(true)
96
+ expect(document.body.querySelector('.rs-dialog__footer')).not.toBeNull()
97
+ handle.destroy()
98
+ await flushPromises()
99
+ expect(document.body.querySelector('.rs-dialog__content')).toBeNull()
100
+ })
101
+
102
+ it('rsConfirm.info uses single confirm button', async () => {
103
+ const promise = rsConfirm.info({
104
+ title: '提示',
105
+ description: '仅确认',
106
+ showOverlay: false,
107
+ })
108
+ await flushPromises()
109
+ const buttons = document.body.querySelectorAll('.rs-confirm-dialog__footer button')
110
+ expect(buttons).toHaveLength(1)
111
+ ;(buttons[0] as HTMLButtonElement).click()
112
+ await flushPromises()
113
+ await expect(promise).resolves.toBe(true)
114
+ })
115
+
116
+ it('rsConfirm.warning accepts string shorthand', async () => {
117
+ const promise = rsConfirm.warning('确定删除吗?')
118
+ await flushPromises()
119
+ const content = document.body.querySelector('.rs-confirm-dialog__content')
120
+ expect(content?.textContent).toContain('确定删除吗?')
121
+ const cancelBtn = document.body.querySelector(
122
+ '.rs-confirm-dialog__footer button',
123
+ ) as HTMLButtonElement
124
+ cancelBtn.click()
125
+ await flushPromises()
126
+ await expect(promise).resolves.toBe(false)
127
+ })
128
+
129
+ it('rsConfirm supports extra render content', async () => {
130
+ const promise = rsConfirm({
131
+ title: '带额外',
132
+ description: '正文',
133
+ showOverlay: false,
134
+ extra: () => h('div', { class: 'extra-probe' }, 'list-item'),
135
+ })
136
+ await flushPromises()
137
+ expect(document.body.querySelector('.extra-probe')?.textContent).toContain('list-item')
138
+ const cancelBtn = document.body.querySelector(
139
+ '.rs-confirm-dialog__footer button',
140
+ ) as HTMLButtonElement
141
+ cancelBtn.click()
142
+ await flushPromises()
143
+ await expect(promise).resolves.toBe(false)
144
+ })
145
+ })
@@ -0,0 +1,76 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import {
3
+ buildLocalInputRules,
4
+ matchFormRuleTrigger,
5
+ normalizeFormRules,
6
+ runFormFieldRules,
7
+ } from '../components/form-rules'
8
+
9
+ describe('form-rules', () => {
10
+ it('normalizes single rule and arrays', () => {
11
+ expect(normalizeFormRules({ required: true })).toHaveLength(1)
12
+ expect(normalizeFormRules([{ required: true }, { min: 2 }])).toHaveLength(2)
13
+ expect(normalizeFormRules()).toEqual([])
14
+ })
15
+
16
+ it('matches trigger filters', () => {
17
+ expect(matchFormRuleTrigger({ required: true }, 'blur')).toBe(true)
18
+ expect(matchFormRuleTrigger({ required: true, trigger: 'blur' }, 'change')).toBe(false)
19
+ expect(matchFormRuleTrigger({ required: true, trigger: ['blur', 'change'] }, 'change')).toBe(
20
+ true,
21
+ )
22
+ })
23
+
24
+ it('validates required / min / pattern / email type', async () => {
25
+ const required = await runFormFieldRules('', [{ required: true, message: '必填' }])
26
+ expect(required).toEqual({ valid: false, message: '必填' })
27
+
28
+ const min = await runFormFieldRules('ab', [{ min: 3, message: '短' }])
29
+ expect(min.valid).toBe(false)
30
+
31
+ const email = await runFormFieldRules('bad', [{ type: 'email', message: '邮箱错' }])
32
+ expect(email.message).toBe('邮箱错')
33
+
34
+ const ok = await runFormFieldRules('a@b.com', [{ type: 'email' }])
35
+ expect(ok.valid).toBe(true)
36
+
37
+ const pattern = await runFormFieldRules('x', [
38
+ { pattern: /^\d+$/, message: '数字' },
39
+ ])
40
+ expect(pattern.message).toBe('数字')
41
+ })
42
+
43
+ it('supports async validator', async () => {
44
+ const result = await runFormFieldRules('admin', [
45
+ {
46
+ async validator(value) {
47
+ if (String(value) === 'admin') return '占用'
48
+ return true
49
+ },
50
+ },
51
+ ])
52
+ expect(result).toEqual({ valid: false, message: '占用' })
53
+ })
54
+
55
+ it('builds local input rules', () => {
56
+ const rules = buildLocalInputRules({
57
+ required: true,
58
+ rule: 'email',
59
+ validator: () => true,
60
+ })
61
+ expect(rules).toHaveLength(3)
62
+ expect(rules[0]?.required).toBe(true)
63
+ expect(rules[1]?.type).toBe('email')
64
+ })
65
+
66
+ it('supports message as function', async () => {
67
+ const result = await runFormFieldRules('x', [
68
+ {
69
+ min: 3,
70
+ message: ({ value }) => `当前「${String(value)}」太短`,
71
+ },
72
+ ])
73
+ expect(result.valid).toBe(false)
74
+ expect(result.message).toBe('当前「x」太短')
75
+ })
76
+ })
@@ -0,0 +1,32 @@
1
+ /**
2
+ * RsTable / Core 性能门槛(CI 与本地共用)。
3
+ *
4
+ * CI(process.env.CI)用更紧预算,防止 jsdom 抖动掩盖回归;
5
+ * 本地默认略松,避免开发机噪音误杀。
6
+ */
7
+
8
+ const isCi = Boolean(process.env.CI)
9
+
10
+ function budget(localMs: number, ciMs: number): number {
11
+ return isCi ? ciMs : localMs
12
+ }
13
+
14
+ /** mount 类门槛(ms) */
15
+ export const RS_TABLE_PERF_BUDGETS = {
16
+ /** 3000 行只读虚拟表 mount */
17
+ mountReadonly3k: budget(8000, 5000),
18
+ /** 双表各 1500 行 mount */
19
+ mountDual1k5: budget(12000, 8000),
20
+ /**
21
+ * 可编辑相对只读的倍率上限(另加 slackMs)。
22
+ * editableElapsed < readonlyElapsed * ratio + slackMs
23
+ */
24
+ editableVsReadonlyRatio: 3,
25
+ editableVsReadonlySlackMs: budget(2000, 1500),
26
+ } as const
27
+
28
+ export function assertWithinBudget(elapsedMs: number, limitMs: number, label: string): void {
29
+ if (elapsedMs >= limitMs) {
30
+ throw new Error(`[perf] ${label}: ${elapsedMs.toFixed(1)}ms >= budget ${limitMs}ms`)
31
+ }
32
+ }
@@ -0,0 +1,206 @@
1
+ import { mount } from '@vue/test-utils'
2
+ import { describe, expect, it, beforeEach } from 'vitest'
3
+ import RsTable from '../components/RsTable.vue'
4
+ import { createRsTableApi, isRsTableApi, RS_TABLE_API_VERSION } from '../components/table/rs-table-api'
5
+ import { createRsTableFeatureHost } from '../components/table/rs-table-feature-host'
6
+ import { RsTableModuleRegistry, resolveInstanceFeatures } from '../components/table/rs-table-module-registry'
7
+ import {
8
+ createAnalyticsTableFeature,
9
+ createChartSeriesTableFeature,
10
+ createContextMenuTableFeature,
11
+ } from '../components/table/table-features'
12
+ import type { RsTableRowData } from '../components/table-utils'
13
+ import { useRsTable } from '../composables/useRsTable'
14
+
15
+ type Row = RsTableRowData & { id: string; name: string }
16
+
17
+ function stubAnalytics() {
18
+ return {
19
+ getViewRows: () => [] as Row[],
20
+ getSelectedRows: () => [] as Row[],
21
+ getAnalyticsSnapshot: () => ({
22
+ sourceRows: [] as Row[],
23
+ viewRows: [] as Row[],
24
+ selectedRows: [] as Row[],
25
+ sourceMode: 'view' as const,
26
+ selectedCount: 0,
27
+ viewCount: 0,
28
+ }),
29
+ subscribeAnalytics: () => () => undefined,
30
+ }
31
+ }
32
+
33
+ describe('RsTableApi / FeatureHost / ModuleRegistry', () => {
34
+ beforeEach(() => {
35
+ RsTableModuleRegistry.clear()
36
+ })
37
+
38
+ it('createRsTableApi 冻结 apiVersion 与查询方法', () => {
39
+ const api = createRsTableApi<Row>({
40
+ getRowByKey: () => undefined,
41
+ getRowByIndex: () => undefined,
42
+ getColumnValues: () => [],
43
+ getCellValue: () => undefined,
44
+ getViewRows: () => [],
45
+ getSelectedRows: () => [],
46
+ getSelectedRowKeys: () => [],
47
+ getAnalyticsSnapshot: stubAnalytics().getAnalyticsSnapshot,
48
+ subscribeAnalytics: stubAnalytics().subscribeAnalytics,
49
+ getHighlightedRowKey: () => undefined,
50
+ setHighlightedRowKey: () => undefined,
51
+ cancelCellEdit: () => undefined,
52
+ cancelAllEdits: () => undefined,
53
+ getDirtyCellKeys: () => [],
54
+ getCellError: () => undefined,
55
+ setCellError: () => undefined,
56
+ stageCell: () => undefined,
57
+ rejectRowEdit: () => undefined,
58
+ undoEdit: () => undefined,
59
+ redoEdit: () => undefined,
60
+ commitRowEdits: () => undefined,
61
+ rollbackRowEdits: () => undefined,
62
+ })
63
+ expect(api.apiVersion).toBe(RS_TABLE_API_VERSION)
64
+ expect(isRsTableApi(api)).toBe(true)
65
+ expect(isRsTableApi({})).toBe(false)
66
+ })
67
+
68
+ it('FeatureHost 收集 contextMenu 贡献并 merge', () => {
69
+ const host = createRsTableFeatureHost<Row>()
70
+ const ctx = host.createContext(stubAnalytics())
71
+ host.setup(
72
+ [
73
+ {
74
+ id: 'export-row',
75
+ setup(featureCtx) {
76
+ featureCtx.contributeContextMenuItems?.((items) => {
77
+ items.push({ key: 'export', label: '导出' })
78
+ })
79
+ },
80
+ },
81
+ ],
82
+ ctx,
83
+ )
84
+ const merged = host.mergeContextMenuItems(
85
+ [{ key: 'copy', label: '复制' }],
86
+ { row: null, selectedRows: [] },
87
+ )
88
+ expect(merged.map((i) => i.key)).toEqual(['copy', '__ctx-feature-sep', 'export'])
89
+ expect(host.getActiveFeatureIds()).toEqual(['export-row'])
90
+ })
91
+
92
+ it('ModuleRegistry 全局注册并被 resolveInstanceFeatures 合并', () => {
93
+ const analytics = createAnalyticsTableFeature<Row>({
94
+ onSnapshot: () => undefined,
95
+ })
96
+ RsTableModuleRegistry.register(analytics)
97
+ expect(RsTableModuleRegistry.listIds()).toContain('analytics')
98
+
99
+ const resolved = resolveInstanceFeatures<Row>([
100
+ { id: 'local', setup() {} },
101
+ ])
102
+ expect(resolved.map((f) => f.id).sort()).toEqual(['analytics', 'local'])
103
+
104
+ const override = resolveInstanceFeatures<Row>([
105
+ {
106
+ id: 'analytics',
107
+ setup() {},
108
+ },
109
+ ])
110
+ expect(override.find((f) => f.id === 'analytics')?.setup).not.toBe(analytics.setup)
111
+ })
112
+
113
+ it('useRsTable.bindFeatures 合并内置与全局模块', () => {
114
+ RsTableModuleRegistry.register({
115
+ id: 'plugin-a',
116
+ setup() {},
117
+ })
118
+ const surface = useRsTable<Row>()
119
+ const dispose = surface.bindFeatures({
120
+ builtinIds: ['selection'],
121
+ instanceFeatures: [{ id: 'local', setup() {} }],
122
+ ...stubAnalytics(),
123
+ })
124
+ expect(surface.getActiveFeatureIds()).toEqual(
125
+ expect.arrayContaining(['selection', 'plugin-a', 'local']),
126
+ )
127
+ dispose()
128
+ expect(surface.getActiveFeatureIds()).toEqual([])
129
+ })
130
+
131
+ it('createChartSeriesTableFeature 产出 series 并登记 overlay', () => {
132
+ const host = createRsTableFeatureHost<Row>()
133
+ const seriesSeen: string[] = []
134
+ const ctx = host.createContext({
135
+ ...stubAnalytics(),
136
+ getAnalyticsSnapshot: () => ({
137
+ sourceRows: [
138
+ { id: '1', name: 'a' },
139
+ { id: '2', name: 'a' },
140
+ { id: '3', name: 'b' },
141
+ ] as Row[],
142
+ viewRows: [],
143
+ selectedRows: [],
144
+ sourceMode: 'view',
145
+ selectedCount: 0,
146
+ viewCount: 0,
147
+ }),
148
+ subscribeAnalytics: (listener) => {
149
+ listener({
150
+ sourceRows: [
151
+ { id: '1', name: 'a' },
152
+ { id: '2', name: 'a' },
153
+ { id: '3', name: 'b' },
154
+ ] as Row[],
155
+ viewRows: [],
156
+ selectedRows: [],
157
+ sourceMode: 'view',
158
+ selectedCount: 0,
159
+ viewCount: 0,
160
+ })
161
+ return () => undefined
162
+ },
163
+ })
164
+ host.setup(
165
+ [
166
+ createChartSeriesTableFeature<Row>({
167
+ seriesDefs: [{ id: 'by-name', kind: 'pie', categoryField: 'name' }],
168
+ onSeries: (series) => {
169
+ seriesSeen.push(...series.map((s) => s.id))
170
+ },
171
+ }),
172
+ ],
173
+ ctx,
174
+ )
175
+ expect(seriesSeen).toEqual(['by-name'])
176
+ expect(host.getOverlayContributions().map((i) => i.key)).toEqual(['chart'])
177
+ })
178
+
179
+ it('RsTable expose 符合 RsTableApi 契约', async () => {
180
+ const wrapper = mount(RsTable, {
181
+ props: {
182
+ columns: [{ key: 'name', title: 'Name' }],
183
+ data: [{ id: '1', name: 'a' }],
184
+ rowKey: 'id',
185
+ selectable: true,
186
+ features: [
187
+ createContextMenuTableFeature({
188
+ items: [{ key: 'ping', label: 'Ping' }],
189
+ }),
190
+ ],
191
+ },
192
+ })
193
+ await wrapper.vm.$nextTick()
194
+ const api = wrapper.vm as unknown as {
195
+ apiVersion: string
196
+ getViewRows: () => Row[]
197
+ getSelectedRowKeys: () => string[]
198
+ getActiveFeatureIds: () => string[]
199
+ }
200
+ expect(api.apiVersion).toBe(RS_TABLE_API_VERSION)
201
+ expect(isRsTableApi(api)).toBe(true)
202
+ expect(api.getViewRows().length).toBe(1)
203
+ expect(api.getSelectedRowKeys()).toEqual([])
204
+ expect(api.getActiveFeatureIds()).toEqual(expect.arrayContaining(['contextMenuExtra']))
205
+ })
206
+ })
@@ -0,0 +1,108 @@
1
+ import { mount } from '@vue/test-utils'
2
+ import { describe, expect, it } from 'vitest'
3
+ import RsTable from '../components/RsTable.vue'
4
+ import {
5
+ RS_TABLE_API_VERSION,
6
+ isRsTableApi,
7
+ } from '../components/table/rs-table-api'
8
+ import {
9
+ RS_TABLE_API_OPTIONAL_METHODS,
10
+ RS_TABLE_API_REQUIRED_METHODS,
11
+ RS_TABLE_COMPAT_API_VERSION,
12
+ RS_TABLE_DEFAULTED_PROP_KEYS,
13
+ RS_TABLE_STABLE_EMITS,
14
+ } from '../components/table/rs-table-compat-matrix'
15
+ import { RS_TABLE_PROP_DEFAULTS } from '../components/table/rs-table-props'
16
+
17
+ describe('RsTable 兼容矩阵(自动化)', () => {
18
+ it('契约版本与 Api 常量一致', () => {
19
+ expect(RS_TABLE_COMPAT_API_VERSION).toBe(RS_TABLE_API_VERSION)
20
+ expect(RS_TABLE_API_VERSION).toBe('1.0.0')
21
+ })
22
+
23
+ it('PROP_DEFAULTS 键与矩阵锁定列表一致', () => {
24
+ expect(Object.keys(RS_TABLE_PROP_DEFAULTS).sort()).toEqual(RS_TABLE_DEFAULTED_PROP_KEYS)
25
+ })
26
+
27
+ it('expose 满足必选 Api 方法且可选方法为函数或 undefined', async () => {
28
+ const wrapper = mount(RsTable, {
29
+ props: {
30
+ columns: [{ key: 'name', title: 'Name' }],
31
+ data: [{ id: '1', name: 'a' }],
32
+ rowKey: 'id',
33
+ selectable: true,
34
+ },
35
+ })
36
+ await wrapper.vm.$nextTick()
37
+ const api = wrapper.vm as unknown as Record<string, unknown>
38
+ expect(isRsTableApi(api)).toBe(true)
39
+ expect(api.apiVersion).toBe(RS_TABLE_API_VERSION)
40
+
41
+ for (const method of RS_TABLE_API_REQUIRED_METHODS) {
42
+ expect(typeof api[method], `required ${method}`).toBe('function')
43
+ }
44
+ for (const method of RS_TABLE_API_OPTIONAL_METHODS) {
45
+ const value = api[method]
46
+ expect(
47
+ value === undefined || typeof value === 'function',
48
+ `optional ${method}`,
49
+ ).toBe(true)
50
+ }
51
+
52
+ // 冒烟:查询路径可调用
53
+ expect((api.getViewRows as () => unknown[])()).toHaveLength(1)
54
+ expect((api.getSelectedRowKeys as () => string[])()).toEqual([])
55
+ wrapper.unmount()
56
+ })
57
+
58
+ it('稳定 emit 名仍可被组件声明(defineEmits 面)', () => {
59
+ // 通过挂载监听稳定事件,确保名称未从运行时面消失
60
+ const wrapper = mount(RsTable, {
61
+ props: {
62
+ columns: [{ key: 'name', title: 'Name' }],
63
+ data: [{ id: '1', name: 'a' }],
64
+ rowKey: 'id',
65
+ },
66
+ })
67
+ for (const name of RS_TABLE_STABLE_EMITS) {
68
+ expect(wrapper.emitted(name) === undefined || Array.isArray(wrapper.emitted(name))).toBe(
69
+ true,
70
+ )
71
+ }
72
+ // 触发一次 rowClick 验证核心 emit 通路
73
+ const row = wrapper.find('.rs-table__row')
74
+ if (row.exists()) {
75
+ void row.trigger('click')
76
+ }
77
+ wrapper.unmount()
78
+ })
79
+
80
+ it('必选 Api 方法列表快照(防静默删减)', () => {
81
+ expect([...RS_TABLE_API_REQUIRED_METHODS]).toMatchInlineSnapshot(`
82
+ [
83
+ "getRowByKey",
84
+ "getRowByIndex",
85
+ "getColumnValues",
86
+ "getCellValue",
87
+ "getViewRows",
88
+ "getSelectedRows",
89
+ "getSelectedRowKeys",
90
+ "getAnalyticsSnapshot",
91
+ "subscribeAnalytics",
92
+ "getHighlightedRowKey",
93
+ "setHighlightedRowKey",
94
+ "cancelCellEdit",
95
+ "cancelAllEdits",
96
+ "getDirtyCellKeys",
97
+ "getCellError",
98
+ "setCellError",
99
+ "stageCell",
100
+ "rejectRowEdit",
101
+ "undoEdit",
102
+ "redoEdit",
103
+ "commitRowEdits",
104
+ "rollbackRowEdits",
105
+ ]
106
+ `)
107
+ })
108
+ })