niuma-ui 1.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.
- package/CHANGELOG.md +29 -0
- package/LICENSE +202 -0
- package/NOTICE +25 -0
- package/README.en.md +99 -0
- package/README.md +161 -0
- package/package.json +106 -0
- package/src/__tests__/RsBadge.spec.ts +40 -0
- package/src/__tests__/RsBreadcrumb.spec.ts +172 -0
- package/src/__tests__/RsButton.spec.ts +108 -0
- package/src/__tests__/RsCalendarGrid.spec.ts +82 -0
- package/src/__tests__/RsCard.spec.ts +97 -0
- package/src/__tests__/RsCheckbox.spec.ts +36 -0
- package/src/__tests__/RsCodeEditor.spec.ts +111 -0
- package/src/__tests__/RsConfirmDialog.spec.ts +158 -0
- package/src/__tests__/RsContainer.spec.ts +89 -0
- package/src/__tests__/RsContextMenu.spec.ts +118 -0
- package/src/__tests__/RsDatePicker.spec.ts +88 -0
- package/src/__tests__/RsDateTimePicker.spec.ts +69 -0
- package/src/__tests__/RsDialog.spec.ts +404 -0
- package/src/__tests__/RsDrawer.spec.ts +145 -0
- package/src/__tests__/RsDropdown.spec.ts +135 -0
- package/src/__tests__/RsEmpty.spec.ts +99 -0
- package/src/__tests__/RsForm.spec.ts +173 -0
- package/src/__tests__/RsIcon.spec.ts +244 -0
- package/src/__tests__/RsInput.spec.ts +269 -0
- package/src/__tests__/RsInputNumber.spec.ts +118 -0
- package/src/__tests__/RsLabel.spec.ts +106 -0
- package/src/__tests__/RsLink.spec.ts +49 -0
- package/src/__tests__/RsLoading.spec.ts +84 -0
- package/src/__tests__/RsMarkdown.spec.ts +143 -0
- package/src/__tests__/RsMenu.spec.ts +119 -0
- package/src/__tests__/RsNewComponents.spec.ts +17 -0
- package/src/__tests__/RsPagination.spec.ts +158 -0
- package/src/__tests__/RsPopover.spec.ts +126 -0
- package/src/__tests__/RsScrollbar.spec.ts +53 -0
- package/src/__tests__/RsSelect.spec.ts +291 -0
- package/src/__tests__/RsSidebar.spec.ts +119 -0
- package/src/__tests__/RsSplitPane.spec.ts +177 -0
- package/src/__tests__/RsStatCard.spec.ts +79 -0
- package/src/__tests__/RsSteps.spec.ts +125 -0
- package/src/__tests__/RsTable.perf.spec.ts +103 -0
- package/src/__tests__/RsTable.spec.ts +1135 -0
- package/src/__tests__/RsTabs.spec.ts +328 -0
- package/src/__tests__/RsTerminal.spec.ts +218 -0
- package/src/__tests__/RsTimePicker.spec.ts +77 -0
- package/src/__tests__/RsToaster.spec.ts +95 -0
- package/src/__tests__/RsToolbar.spec.ts +75 -0
- package/src/__tests__/RsTooltip.spec.ts +111 -0
- package/src/__tests__/RsTree.spec.ts +572 -0
- package/src/__tests__/RsUpload.spec.ts +160 -0
- package/src/__tests__/RsVirtualList.spec.ts +77 -0
- package/src/__tests__/date-picker-utils.spec.ts +70 -0
- package/src/__tests__/dialog-viewport.spec.ts +38 -0
- package/src/__tests__/iso-local-datetime.spec.ts +58 -0
- package/src/__tests__/rs-clipboard.spec.ts +142 -0
- package/src/__tests__/setup.ts +12 -0
- package/src/__tests__/split-pane-utils.spec.ts +136 -0
- package/src/__tests__/table-drag.spec.ts +35 -0
- package/src/__tests__/table-edit-utils.spec.ts +194 -0
- package/src/__tests__/table-utils.spec.ts +286 -0
- package/src/__tests__/theme.spec.ts +22 -0
- package/src/__tests__/tree-utils.spec.ts +234 -0
- package/src/__tests__/useTableCellTooltip.spec.ts +101 -0
- package/src/components/RsAvatar.vue +115 -0
- package/src/components/RsBadge.vue +59 -0
- package/src/components/RsBreadcrumb.vue +65 -0
- package/src/components/RsBreadcrumbItems.vue +36 -0
- package/src/components/RsButton.vue +393 -0
- package/src/components/RsCalendarGrid.vue +284 -0
- package/src/components/RsCard.vue +237 -0
- package/src/components/RsCheckbox.vue +189 -0
- package/src/components/RsCodeBlock.vue +278 -0
- package/src/components/RsCodeEditor.vue +597 -0
- package/src/components/RsConfigProvider.vue +92 -0
- package/src/components/RsConfirmDialog.vue +178 -0
- package/src/components/RsContainer.vue +235 -0
- package/src/components/RsContextMenu.vue +238 -0
- package/src/components/RsContextMenuItems.vue +77 -0
- package/src/components/RsDatePicker.vue +756 -0
- package/src/components/RsDateTimePicker.vue +42 -0
- package/src/components/RsDialog.vue +486 -0
- package/src/components/RsDrawer.vue +175 -0
- package/src/components/RsDropdown.vue +252 -0
- package/src/components/RsDropdownItems.vue +90 -0
- package/src/components/RsEmpty.vue +107 -0
- package/src/components/RsForm.vue +131 -0
- package/src/components/RsIcon.vue +104 -0
- package/src/components/RsInput.vue +1101 -0
- package/src/components/RsInputNumber.vue +532 -0
- package/src/components/RsLabel.vue +54 -0
- package/src/components/RsLink.vue +106 -0
- package/src/components/RsLoading.vue +171 -0
- package/src/components/RsMarkdown.vue +329 -0
- package/src/components/RsMenu.vue +256 -0
- package/src/components/RsMenuItems.vue +125 -0
- package/src/components/RsMonacoEditor.vue +541 -0
- package/src/components/RsPagination.vue +184 -0
- package/src/components/RsPopover.vue +77 -0
- package/src/components/RsProseEditor.vue +160 -0
- package/src/components/RsScrollbar.vue +171 -0
- package/src/components/RsSelect.vue +1045 -0
- package/src/components/RsSidebar.vue +121 -0
- package/src/components/RsSidebarGroup.vue +32 -0
- package/src/components/RsSidebarItem.vue +72 -0
- package/src/components/RsSplitPane.vue +619 -0
- package/src/components/RsStatCard.vue +85 -0
- package/src/components/RsSteps.vue +138 -0
- package/src/components/RsTable.vue +2664 -0
- package/src/components/RsTableColgroup.vue +17 -0
- package/src/components/RsTableHeaderRow.vue +30 -0
- package/src/components/RsTabs.vue +951 -0
- package/src/components/RsTerminal.vue +697 -0
- package/src/components/RsTimePicker.vue +554 -0
- package/src/components/RsTimePickerColumns.vue +226 -0
- package/src/components/RsToaster.vue +396 -0
- package/src/components/RsToolbar.vue +135 -0
- package/src/components/RsTooltip.vue +142 -0
- package/src/components/RsTooltipProvider.vue +20 -0
- package/src/components/RsTree.vue +1248 -0
- package/src/components/RsUpload.vue +279 -0
- package/src/components/RsVirtualList.vue +217 -0
- package/src/components/avatar-utils.ts +19 -0
- package/src/components/breadcrumb-utils.ts +19 -0
- package/src/components/class-utils.ts +29 -0
- package/src/components/code-editor-utils.ts +106 -0
- package/src/components/code-mirror-completion.ts +40 -0
- package/src/components/code-mirror-diagnostics.ts +52 -0
- package/src/components/code-mirror-ghost.ts +158 -0
- package/src/components/code-mirror-inline-edit.ts +55 -0
- package/src/components/code-mirror-intel.ts +81 -0
- package/src/components/code-mirror-lang.ts +128 -0
- package/src/components/context-menu-utils.ts +20 -0
- package/src/components/date-picker-utils.ts +284 -0
- package/src/components/date-range-validation.ts +29 -0
- package/src/components/date-validation.ts +38 -0
- package/src/components/dialog-viewport.ts +48 -0
- package/src/components/dialog-window.ts +253 -0
- package/src/components/dropdown-utils.ts +31 -0
- package/src/components/form-utils.ts +77 -0
- package/src/components/input-number-utils.ts +128 -0
- package/src/components/input-rules.ts +84 -0
- package/src/components/loading-utils.ts +8 -0
- package/src/components/markdown-utils.ts +126 -0
- package/src/components/menu-utils.ts +48 -0
- package/src/components/overlay-utils.ts +47 -0
- package/src/components/pagination-utils.ts +38 -0
- package/src/components/popover-utils.ts +8 -0
- package/src/components/reka.ts +106 -0
- package/src/components/resolve-radius.ts +22 -0
- package/src/components/resolve-size.ts +29 -0
- package/src/components/scrollbar-utils.ts +10 -0
- package/src/components/select-utils.ts +72 -0
- package/src/components/split-pane-utils.ts +187 -0
- package/src/components/steps-utils.ts +22 -0
- package/src/components/table/RsTableBodyRow.vue +287 -0
- package/src/components/table/RsTableCell.vue +239 -0
- package/src/components/table/RsTableCellContent.ts +18 -0
- package/src/components/table/RsTableCellEditor.vue +351 -0
- package/src/components/table/RsTableEditGutter.vue +54 -0
- package/src/components/table/RsTableHeaderFilter.vue +107 -0
- package/src/components/table/RsTableRowStatus.vue +69 -0
- package/src/components/table/rs-table.css +1141 -0
- package/src/components/table/table-context-menu.ts +53 -0
- package/src/components/table/table-edit-utils.ts +496 -0
- package/src/components/table/table-keys.ts +12 -0
- package/src/components/table-drag.ts +215 -0
- package/src/components/table-utils.ts +950 -0
- package/src/components/tabs-utils.ts +107 -0
- package/src/components/terminal-utils.ts +301 -0
- package/src/components/terminal-wheel.ts +49 -0
- package/src/components/time-picker-utils.ts +167 -0
- package/src/components/time-range-validation.ts +23 -0
- package/src/components/time-validation.ts +20 -0
- package/src/components/tree-utils.ts +518 -0
- package/src/components/upload-utils.ts +55 -0
- package/src/components/virtual-list-utils.ts +17 -0
- package/src/composables/useClickOutside.ts +22 -0
- package/src/composables/useRsConfig.ts +91 -0
- package/src/composables/useRsI18n.ts +22 -0
- package/src/composables/useRsTabsNav.ts +187 -0
- package/src/composables/useRsToast.ts +48 -0
- package/src/composables/useTableCellTooltip.ts +239 -0
- package/src/composables/useTableEdit.ts +271 -0
- package/src/composables/useTableRowHighlight.ts +50 -0
- package/src/dev/vite-codemirror-deps.ts +20 -0
- package/src/icons/custom/api.ts +54 -0
- package/src/icons/custom/clickhouse.ts +52 -0
- package/src/icons/custom/dameng.ts +83 -0
- package/src/icons/custom/ftp.ts +61 -0
- package/src/icons/custom/kingbase.ts +52 -0
- package/src/icons/custom/mongodb.ts +50 -0
- package/src/icons/custom/mysql.ts +51 -0
- package/src/icons/custom/oracle.ts +52 -0
- package/src/icons/custom/redis.ts +51 -0
- package/src/icons/custom/sqlite.ts +50 -0
- package/src/icons/custom/sqlserver.ts +52 -0
- package/src/icons/custom/vastbase.ts +57 -0
- package/src/icons/lucide.ts +78 -0
- package/src/icons/registry.ts +35 -0
- package/src/index.ts +425 -0
- package/src/lib/iso-local-datetime.ts +152 -0
- package/src/lib/rs-dayjs.ts +56 -0
- package/src/locale/messages.ts +362 -0
- package/src/locale/types.ts +5 -0
- package/src/monaco/debug-decorations.css +20 -0
- package/src/monaco/debug-decorations.ts +70 -0
- package/src/monaco/index.ts +22 -0
- package/src/monaco/languages/index.ts +21 -0
- package/src/monaco/languages/mongodb-shell.ts +125 -0
- package/src/monaco/languages/types.ts +55 -0
- package/src/monaco/setup.ts +101 -0
- package/src/styles.css +1040 -0
- package/src/theme/apply.ts +12 -0
- package/src/theme/brand.example.css +37 -0
- package/src/theme/presets.ts +69 -0
- package/src/theme/types.ts +81 -0
- package/src/utils/rs-clipboard.ts +272 -0
- package/src/vite-env.d.ts +7 -0
- package/vite-plugins/monaco-zh-nls.ts +101 -0
- package/vite-plugins/silence-antlr-parse-console.ts +29 -0
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
import { describe, expect, it, beforeEach, afterEach } from 'vitest'
|
|
2
|
+
import { flushPromises, mount } from '@vue/test-utils'
|
|
3
|
+
import { defineComponent, h, ref } from 'vue'
|
|
4
|
+
import RsConfigProvider from '../components/RsConfigProvider.vue'
|
|
5
|
+
import RsForm from '../components/RsForm.vue'
|
|
6
|
+
import RsSelect from '../components/RsSelect.vue'
|
|
7
|
+
import { filterSelectOptions } from '../components/select-utils'
|
|
8
|
+
|
|
9
|
+
describe('RsSelect', () => {
|
|
10
|
+
const options = [
|
|
11
|
+
{ label: 'GPT-4o', value: 'gpt-4o' },
|
|
12
|
+
{ label: 'Claude', value: 'claude' },
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
beforeEach(() => {
|
|
16
|
+
HTMLElement.prototype.scrollIntoView = () => {}
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
afterEach(() => {
|
|
20
|
+
document.body.innerHTML = ''
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('renders trigger with custom placeholder', () => {
|
|
24
|
+
const wrapper = mount(RsSelect, {
|
|
25
|
+
props: { options, modelValue: '', placeholder: '选择模型' },
|
|
26
|
+
})
|
|
27
|
+
expect(wrapper.find('.rs-select__trigger').exists()).toBe(true)
|
|
28
|
+
expect(wrapper.find('.rs-select__placeholder').text()).toBe('选择模型')
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('uses zh-CN default placeholder', () => {
|
|
32
|
+
const wrapper = mount(RsSelect, {
|
|
33
|
+
props: { options, modelValue: '' },
|
|
34
|
+
})
|
|
35
|
+
expect(wrapper.find('.rs-select__placeholder').text()).toBe('请选择')
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('uses en-US default placeholder inside RsConfigProvider', () => {
|
|
39
|
+
const wrapper = mount(RsConfigProvider, {
|
|
40
|
+
props: { locale: 'en-US' },
|
|
41
|
+
slots: {
|
|
42
|
+
default: () => h(RsSelect, { options, modelValue: '' }),
|
|
43
|
+
},
|
|
44
|
+
})
|
|
45
|
+
expect(wrapper.find('.rs-select__placeholder').text()).toBe('Select')
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('forwards v-model through Reka ComboboxRoot', async () => {
|
|
49
|
+
const wrapper = mount(RsSelect, {
|
|
50
|
+
props: { options, modelValue: '' },
|
|
51
|
+
})
|
|
52
|
+
const root = wrapper.getComponent({ name: 'ComboboxRoot' })
|
|
53
|
+
await root.vm.$emit('update:modelValue', 'claude')
|
|
54
|
+
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['claude'])
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('shows selected option label in single mode', () => {
|
|
58
|
+
const wrapper = mount(RsSelect, {
|
|
59
|
+
props: { options, modelValue: 'claude' },
|
|
60
|
+
})
|
|
61
|
+
expect(wrapper.find('.rs-select__single-label').text()).toBe('Claude')
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('applies modifier classes for multiple and searchable', () => {
|
|
65
|
+
const wrapper = mount(RsSelect, {
|
|
66
|
+
props: { options, modelValue: [], multiple: true, searchable: true },
|
|
67
|
+
})
|
|
68
|
+
expect(wrapper.find('.rs-select').classes()).toContain('rs-select--multiple')
|
|
69
|
+
expect(wrapper.find('.rs-select').classes()).toContain('rs-select--searchable')
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('does not render search input while dropdown is closed', () => {
|
|
73
|
+
const wrapper = mount(RsSelect, {
|
|
74
|
+
props: { options, modelValue: '', searchable: true },
|
|
75
|
+
})
|
|
76
|
+
expect(wrapper.find('.rs-select__search').exists()).toBe(false)
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('shows clear button when clearable and has value', () => {
|
|
80
|
+
const wrapper = mount(RsSelect, {
|
|
81
|
+
props: { options, modelValue: 'gpt-4o', clearable: true },
|
|
82
|
+
})
|
|
83
|
+
expect(wrapper.find('.rs-select__clear').exists()).toBe(true)
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('clears value when clear button is clicked', async () => {
|
|
87
|
+
const wrapper = mount(RsSelect, {
|
|
88
|
+
props: { options, modelValue: 'gpt-4o', clearable: true },
|
|
89
|
+
})
|
|
90
|
+
await wrapper.find('.rs-select__clear').trigger('click')
|
|
91
|
+
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([''])
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('supports multiple mode tags', () => {
|
|
95
|
+
const wrapper = mount(RsSelect, {
|
|
96
|
+
props: {
|
|
97
|
+
options,
|
|
98
|
+
modelValue: ['gpt-4o', 'claude'],
|
|
99
|
+
multiple: true,
|
|
100
|
+
},
|
|
101
|
+
})
|
|
102
|
+
expect(wrapper.find('.rs-select__tag').exists()).toBe(true)
|
|
103
|
+
expect(wrapper.findAll('.rs-select__tag')).toHaveLength(2)
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('removes tag in multiple mode', async () => {
|
|
107
|
+
const wrapper = mount(RsSelect, {
|
|
108
|
+
props: {
|
|
109
|
+
options,
|
|
110
|
+
modelValue: ['gpt-4o', 'claude'],
|
|
111
|
+
multiple: true,
|
|
112
|
+
},
|
|
113
|
+
})
|
|
114
|
+
await wrapper.find('.rs-select__tag-remove').trigger('click')
|
|
115
|
+
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([['claude']])
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
it('setValue via defineExpose updates model', async () => {
|
|
119
|
+
const wrapper = mount(RsSelect, {
|
|
120
|
+
props: { options, modelValue: '' },
|
|
121
|
+
})
|
|
122
|
+
const exposed = wrapper.vm as InstanceType<typeof RsSelect>
|
|
123
|
+
exposed.setValue('claude')
|
|
124
|
+
await flushPromises()
|
|
125
|
+
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual(['claude'])
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
it('setValue supports multiple mode', async () => {
|
|
129
|
+
const wrapper = mount(RsSelect, {
|
|
130
|
+
props: { options, modelValue: [], multiple: true },
|
|
131
|
+
})
|
|
132
|
+
const exposed = wrapper.vm as InstanceType<typeof RsSelect>
|
|
133
|
+
exposed.setValue(['gpt-4o', 'claude'])
|
|
134
|
+
await flushPromises()
|
|
135
|
+
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([['gpt-4o', 'claude']])
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
it('shows loading status text when dropdown is open', async () => {
|
|
139
|
+
const wrapper = mount(RsSelect, {
|
|
140
|
+
props: { options, modelValue: '', loading: true, searchable: true },
|
|
141
|
+
attachTo: document.body,
|
|
142
|
+
})
|
|
143
|
+
await wrapper.find('.rs-select__trigger').trigger('click')
|
|
144
|
+
await flushPromises()
|
|
145
|
+
expect(document.body.querySelector('.rs-select__status')?.textContent).toBe('加载中…')
|
|
146
|
+
wrapper.unmount()
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
it('uses custom loading text when dropdown is open', async () => {
|
|
150
|
+
const wrapper = mount(RsSelect, {
|
|
151
|
+
props: {
|
|
152
|
+
options,
|
|
153
|
+
modelValue: '',
|
|
154
|
+
loading: true,
|
|
155
|
+
loadingText: 'Fetching…',
|
|
156
|
+
searchable: true,
|
|
157
|
+
},
|
|
158
|
+
attachTo: document.body,
|
|
159
|
+
})
|
|
160
|
+
await wrapper.find('.rs-select__trigger').trigger('click')
|
|
161
|
+
await flushPromises()
|
|
162
|
+
expect(document.body.querySelector('.rs-select__status')?.textContent).toBe('Fetching…')
|
|
163
|
+
wrapper.unmount()
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
it('disables trigger when disabled', () => {
|
|
167
|
+
const wrapper = mount(RsSelect, {
|
|
168
|
+
props: { options, modelValue: 'gpt-4o', disabled: true },
|
|
169
|
+
})
|
|
170
|
+
expect(wrapper.find('.rs-select__trigger').attributes('data-disabled')).toBeDefined()
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
it('validates required field inside RsForm', async () => {
|
|
174
|
+
const Host = defineComponent({
|
|
175
|
+
components: { RsForm, RsSelect },
|
|
176
|
+
setup() {
|
|
177
|
+
const formRef = ref<InstanceType<typeof RsForm> | null>(null)
|
|
178
|
+
const role = ref('')
|
|
179
|
+
return { formRef, role, roleOptions: options }
|
|
180
|
+
},
|
|
181
|
+
template: `
|
|
182
|
+
<RsForm ref="formRef">
|
|
183
|
+
<RsSelect v-model="role" :options="roleOptions" required />
|
|
184
|
+
</RsForm>
|
|
185
|
+
`,
|
|
186
|
+
})
|
|
187
|
+
const wrapper = mount(Host, { attachTo: document.body })
|
|
188
|
+
await flushPromises()
|
|
189
|
+
const invalid = await wrapper.vm.formRef!.validate()
|
|
190
|
+
expect(invalid.valid).toBe(false)
|
|
191
|
+
wrapper.vm.role = 'claude'
|
|
192
|
+
await flushPromises()
|
|
193
|
+
const valid = await wrapper.vm.formRef!.validate()
|
|
194
|
+
expect(valid.valid).toBe(true)
|
|
195
|
+
wrapper.unmount()
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
it('enables manual filter when remote', () => {
|
|
199
|
+
const wrapper = mount(RsSelect, {
|
|
200
|
+
props: { options, modelValue: '', searchable: true, remote: true },
|
|
201
|
+
})
|
|
202
|
+
const root = wrapper.getComponent({ name: 'ComboboxRoot' })
|
|
203
|
+
expect(root.props('ignoreFilter')).toBe(true)
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
it('creatable enables search and commits typed value on Enter', async () => {
|
|
207
|
+
const wrapper = mount(RsSelect, {
|
|
208
|
+
props: { options, modelValue: '', creatable: true },
|
|
209
|
+
attachTo: document.body,
|
|
210
|
+
})
|
|
211
|
+
expect(wrapper.find('.rs-select').classes()).toContain('rs-select--searchable')
|
|
212
|
+
expect(wrapper.find('.rs-select').classes()).toContain('rs-select--creatable')
|
|
213
|
+
|
|
214
|
+
await wrapper.find('.rs-select__trigger').trigger('click')
|
|
215
|
+
await flushPromises()
|
|
216
|
+
const input = document.querySelector('.rs-select__search') as HTMLInputElement | null
|
|
217
|
+
expect(input).toBeTruthy()
|
|
218
|
+
input!.value = 'CITEXT'
|
|
219
|
+
input!.dispatchEvent(new Event('input', { bubbles: true }))
|
|
220
|
+
await flushPromises()
|
|
221
|
+
|
|
222
|
+
const createItem = document.querySelector('.rs-select__item--create')
|
|
223
|
+
expect(createItem?.textContent).toContain('CITEXT')
|
|
224
|
+
|
|
225
|
+
input!.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }))
|
|
226
|
+
await flushPromises()
|
|
227
|
+
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual(['CITEXT'])
|
|
228
|
+
wrapper.unmount()
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
it('creatable does not offer create when query matches an option', async () => {
|
|
232
|
+
const wrapper = mount(RsSelect, {
|
|
233
|
+
props: { options, modelValue: '', creatable: true },
|
|
234
|
+
attachTo: document.body,
|
|
235
|
+
})
|
|
236
|
+
await wrapper.find('.rs-select__trigger').trigger('click')
|
|
237
|
+
await flushPromises()
|
|
238
|
+
const input = document.querySelector('.rs-select__search') as HTMLInputElement | null
|
|
239
|
+
input!.value = 'claude'
|
|
240
|
+
input!.dispatchEvent(new Event('input', { bubbles: true }))
|
|
241
|
+
await flushPromises()
|
|
242
|
+
expect(document.querySelector('.rs-select__item--create')).toBeNull()
|
|
243
|
+
wrapper.unmount()
|
|
244
|
+
})
|
|
245
|
+
|
|
246
|
+
it('defaults to content wider than trigger; matchTriggerWidth locks equal width', async () => {
|
|
247
|
+
const wrapper = mount(RsSelect, {
|
|
248
|
+
props: { options, modelValue: '' },
|
|
249
|
+
attachTo: document.body,
|
|
250
|
+
})
|
|
251
|
+
await wrapper.find('.rs-select__trigger').trigger('click')
|
|
252
|
+
await flushPromises()
|
|
253
|
+
const content = document.querySelector('.rs-select__content')
|
|
254
|
+
expect(content).toBeTruthy()
|
|
255
|
+
expect(content!.classList.contains('rs-select__content--match-trigger')).toBe(false)
|
|
256
|
+
|
|
257
|
+
await wrapper.setProps({ matchTriggerWidth: true })
|
|
258
|
+
await flushPromises()
|
|
259
|
+
const matched = document.querySelector('.rs-select__content')
|
|
260
|
+
expect(matched!.classList.contains('rs-select__content--match-trigger')).toBe(true)
|
|
261
|
+
wrapper.unmount()
|
|
262
|
+
})
|
|
263
|
+
})
|
|
264
|
+
|
|
265
|
+
describe('select-utils', () => {
|
|
266
|
+
const contains = (text: string, search: string) =>
|
|
267
|
+
text.toLowerCase().includes(search.toLowerCase())
|
|
268
|
+
|
|
269
|
+
it('filterSelectOptions filters flat and grouped options', () => {
|
|
270
|
+
const grouped = [
|
|
271
|
+
{
|
|
272
|
+
label: '前端',
|
|
273
|
+
options: [
|
|
274
|
+
{ label: 'Vue', value: 'vue' },
|
|
275
|
+
{ label: 'React', value: 'react' },
|
|
276
|
+
],
|
|
277
|
+
},
|
|
278
|
+
{
|
|
279
|
+
label: '后端',
|
|
280
|
+
options: [{ label: 'Go', value: 'go' }],
|
|
281
|
+
},
|
|
282
|
+
]
|
|
283
|
+
const result = filterSelectOptions(grouped, 'vue', contains)
|
|
284
|
+
expect(result).toHaveLength(1)
|
|
285
|
+
expect(result[0]).toMatchObject({ label: '前端' })
|
|
286
|
+
if ('options' in result[0]) {
|
|
287
|
+
expect(result[0].options).toHaveLength(1)
|
|
288
|
+
expect(result[0].options[0]?.value).toBe('vue')
|
|
289
|
+
}
|
|
290
|
+
})
|
|
291
|
+
})
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { mount } from '@vue/test-utils'
|
|
3
|
+
import { h } from 'vue'
|
|
4
|
+
import RsSidebar from '../components/RsSidebar.vue'
|
|
5
|
+
import RsSidebarGroup from '../components/RsSidebarGroup.vue'
|
|
6
|
+
import RsSidebarItem from '../components/RsSidebarItem.vue'
|
|
7
|
+
|
|
8
|
+
describe('RsSidebar', () => {
|
|
9
|
+
it('renders aside with title and subtitle', () => {
|
|
10
|
+
const wrapper = mount(RsSidebar, {
|
|
11
|
+
props: { title: '弱水', subtitle: 'Studio' },
|
|
12
|
+
})
|
|
13
|
+
expect(wrapper.element.tagName).toBe('ASIDE')
|
|
14
|
+
expect(wrapper.find('.rs-sidebar__title').text()).toBe('弱水')
|
|
15
|
+
expect(wrapper.find('.rs-sidebar__subtitle').text()).toBe('Studio')
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it.each(['sm', 'md', 'lg'] as const)('applies %s width class', (width) => {
|
|
19
|
+
const wrapper = mount(RsSidebar, { props: { width } })
|
|
20
|
+
expect(wrapper.classes()).toContain(`rs-sidebar--${width}`)
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('toggles collapsed via collapse button', async () => {
|
|
24
|
+
const wrapper = mount(RsSidebar, {
|
|
25
|
+
props: { title: '弱水', collapsible: true, collapsed: false },
|
|
26
|
+
})
|
|
27
|
+
expect(wrapper.classes()).not.toContain('rs-sidebar--collapsed')
|
|
28
|
+
await wrapper.find('.rs-sidebar__collapse').trigger('click')
|
|
29
|
+
expect(wrapper.emitted('update:collapsed')?.pop()).toEqual([true])
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('hides subtitle when collapsed', () => {
|
|
33
|
+
const wrapper = mount(RsSidebar, {
|
|
34
|
+
props: { title: '弱水', subtitle: 'Studio', collapsed: true },
|
|
35
|
+
})
|
|
36
|
+
expect(wrapper.classes()).toContain('rs-sidebar--collapsed')
|
|
37
|
+
expect(wrapper.find('.rs-sidebar__subtitle').exists()).toBe(false)
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('renders custom header slot', () => {
|
|
41
|
+
const wrapper = mount(RsSidebar, {
|
|
42
|
+
slots: { header: '<div class="custom-header">自定义顶栏</div>' },
|
|
43
|
+
})
|
|
44
|
+
expect(wrapper.find('.custom-header').text()).toBe('自定义顶栏')
|
|
45
|
+
expect(wrapper.find('.rs-sidebar__title').exists()).toBe(false)
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('renders footer slot with collapsed scope', () => {
|
|
49
|
+
const wrapper = mount(RsSidebar, {
|
|
50
|
+
props: { collapsed: true },
|
|
51
|
+
slots: {
|
|
52
|
+
footer: ({ collapsed }: { collapsed: boolean }) =>
|
|
53
|
+
h('div', { class: 'footer-state' }, collapsed ? 'folded' : 'open'),
|
|
54
|
+
},
|
|
55
|
+
})
|
|
56
|
+
expect(wrapper.find('.footer-state').text()).toBe('folded')
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('passes collapsed to default slot scope', () => {
|
|
60
|
+
const wrapper = mount(RsSidebar, {
|
|
61
|
+
props: { collapsed: true },
|
|
62
|
+
slots: {
|
|
63
|
+
default: ({ collapsed }: { collapsed: boolean }) =>
|
|
64
|
+
h('div', { class: 'body-state' }, collapsed ? 'yes' : 'no'),
|
|
65
|
+
},
|
|
66
|
+
})
|
|
67
|
+
expect(wrapper.find('.body-state').text()).toBe('yes')
|
|
68
|
+
})
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
describe('RsSidebarGroup', () => {
|
|
72
|
+
it('renders group title', () => {
|
|
73
|
+
const wrapper = mount(RsSidebarGroup, {
|
|
74
|
+
props: { title: '导航' },
|
|
75
|
+
slots: { default: '<button class="item">项</button>' },
|
|
76
|
+
})
|
|
77
|
+
expect(wrapper.find('.rs-sidebar-group__title').text()).toBe('导航')
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('hides group title when collapsed', () => {
|
|
81
|
+
const wrapper = mount(RsSidebarGroup, {
|
|
82
|
+
props: { title: '导航', collapsed: true },
|
|
83
|
+
})
|
|
84
|
+
expect(wrapper.find('.rs-sidebar-group__title').exists()).toBe(false)
|
|
85
|
+
})
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
describe('RsSidebarItem', () => {
|
|
89
|
+
it('renders label and icon', () => {
|
|
90
|
+
const wrapper = mount(RsSidebarItem, {
|
|
91
|
+
props: { label: '仪表盘', icon: 'layout-dashboard' },
|
|
92
|
+
})
|
|
93
|
+
expect(wrapper.find('.rs-sidebar-item__label').text()).toBe('仪表盘')
|
|
94
|
+
expect(wrapper.find('.rs-sidebar-item__icon').exists()).toBe(true)
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it('applies active class', () => {
|
|
98
|
+
const wrapper = mount(RsSidebarItem, {
|
|
99
|
+
props: { label: '仪表盘', active: true },
|
|
100
|
+
})
|
|
101
|
+
expect(wrapper.classes()).toContain('rs-sidebar-item--active')
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('disables interaction when disabled', () => {
|
|
105
|
+
const wrapper = mount(RsSidebarItem, {
|
|
106
|
+
props: { label: '不可用', disabled: true },
|
|
107
|
+
})
|
|
108
|
+
expect(wrapper.find('button').attributes('disabled')).toBeDefined()
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('hides label and sets title when collapsed', () => {
|
|
112
|
+
const wrapper = mount(RsSidebarItem, {
|
|
113
|
+
props: { label: '设置', collapsed: true },
|
|
114
|
+
})
|
|
115
|
+
expect(wrapper.find('.rs-sidebar-item__label').exists()).toBe(false)
|
|
116
|
+
expect(wrapper.attributes('title')).toBe('设置')
|
|
117
|
+
expect(wrapper.classes()).toContain('rs-sidebar-item--collapsed')
|
|
118
|
+
})
|
|
119
|
+
})
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { h } from 'vue'
|
|
3
|
+
import { mount } from '@vue/test-utils'
|
|
4
|
+
import RsSplitPane from '../components/RsSplitPane.vue'
|
|
5
|
+
import type { RsSplitPaneItem } from '../components/split-pane-utils'
|
|
6
|
+
|
|
7
|
+
const twoPanes: RsSplitPaneItem[] = [{ key: 'a' }, { key: 'b' }]
|
|
8
|
+
|
|
9
|
+
function mountSplit(props: Record<string, unknown> = {}) {
|
|
10
|
+
return mount(RsSplitPane, {
|
|
11
|
+
props: { panes: twoPanes, ...props },
|
|
12
|
+
slots: {
|
|
13
|
+
a: '<div class="pane-a">A</div>',
|
|
14
|
+
b: '<div class="pane-b">B</div>',
|
|
15
|
+
},
|
|
16
|
+
})
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe('RsSplitPane', () => {
|
|
20
|
+
it('renders one pane per item plus n-1 resizers', () => {
|
|
21
|
+
const wrapper = mountSplit()
|
|
22
|
+
expect(wrapper.findAll('.rs-split__pane')).toHaveLength(2)
|
|
23
|
+
expect(wrapper.findAll('.rs-split__resizer')).toHaveLength(1)
|
|
24
|
+
expect(wrapper.find('.pane-a').text()).toBe('A')
|
|
25
|
+
expect(wrapper.find('.pane-b').text()).toBe('B')
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('defaults to an equal split and reflects it on the separator', () => {
|
|
29
|
+
const wrapper = mountSplit()
|
|
30
|
+
const resizer = wrapper.find('.rs-split__resizer')
|
|
31
|
+
expect(resizer.element.tagName).toBe('HR')
|
|
32
|
+
expect(resizer.attributes('aria-valuenow')).toBe('50')
|
|
33
|
+
expect(resizer.attributes('aria-orientation')).toBe('vertical')
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('applies the orientation modifier class', () => {
|
|
37
|
+
expect(mountSplit().classes()).toContain('rs-split--horizontal')
|
|
38
|
+
expect(mountSplit({ orientation: 'vertical' }).classes()).toContain('rs-split--vertical')
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('resizes with the keyboard along the active axis', async () => {
|
|
42
|
+
const wrapper = mountSplit({ keyboardStep: 4 })
|
|
43
|
+
await wrapper.find('.rs-split__resizer').trigger('keydown', { key: 'ArrowRight' })
|
|
44
|
+
expect(wrapper.emitted('update:sizes')?.pop()).toEqual([[54, 46]])
|
|
45
|
+
expect(wrapper.emitted('resize-end')).toBeTruthy()
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('ignores cross-axis arrow keys', async () => {
|
|
49
|
+
const wrapper = mountSplit({ orientation: 'horizontal' })
|
|
50
|
+
await wrapper.find('.rs-split__resizer').trigger('keydown', { key: 'ArrowDown' })
|
|
51
|
+
expect(wrapper.emitted('update:sizes')).toBeFalsy()
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('jumps to bounds with Home / End', async () => {
|
|
55
|
+
const wrapper = mountSplit()
|
|
56
|
+
await wrapper.find('.rs-split__resizer').trigger('keydown', { key: 'End' })
|
|
57
|
+
expect(wrapper.emitted('update:sizes')?.pop()).toEqual([[100, 0]])
|
|
58
|
+
await wrapper.find('.rs-split__resizer').trigger('keydown', { key: 'Home' })
|
|
59
|
+
expect(wrapper.emitted('update:sizes')?.pop()).toEqual([[0, 100]])
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('does not resize when disabled and removes the separator from tab order', async () => {
|
|
63
|
+
const wrapper = mountSplit({ disabled: true })
|
|
64
|
+
const resizer = wrapper.find('.rs-split__resizer')
|
|
65
|
+
expect(resizer.attributes('tabindex')).toBe('-1')
|
|
66
|
+
expect(wrapper.classes()).toContain('rs-split--disabled')
|
|
67
|
+
await resizer.trigger('keydown', { key: 'ArrowRight' })
|
|
68
|
+
expect(wrapper.emitted('update:sizes')).toBeFalsy()
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('renders the grip only when withHandle is set', () => {
|
|
72
|
+
expect(mountSplit().find('.rs-split__grip').exists()).toBe(false)
|
|
73
|
+
expect(mountSplit({ withHandle: true }).find('.rs-split__grip').exists()).toBe(true)
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('honours controlled v-model sizes', () => {
|
|
77
|
+
const wrapper = mountSplit({ sizes: [70, 30] })
|
|
78
|
+
expect(wrapper.find('.rs-split__resizer').attributes('aria-valuenow')).toBe('70')
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('reacts to external size updates', async () => {
|
|
82
|
+
const wrapper = mountSplit({ sizes: [50, 50] })
|
|
83
|
+
await wrapper.setProps({ sizes: [20, 80] })
|
|
84
|
+
expect(wrapper.find('.rs-split__resizer').attributes('aria-valuenow')).toBe('20')
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('exposes collapse / expand / reset controls', async () => {
|
|
88
|
+
const wrapper = mount(RsSplitPane, {
|
|
89
|
+
props: {
|
|
90
|
+
panes: [
|
|
91
|
+
{ key: 'a', size: 40, min: 20, collapsible: true, collapsedSize: 0 },
|
|
92
|
+
{ key: 'b', size: 60 },
|
|
93
|
+
] satisfies RsSplitPaneItem[],
|
|
94
|
+
},
|
|
95
|
+
slots: { a: 'A', b: 'B' },
|
|
96
|
+
})
|
|
97
|
+
const vm = wrapper.vm as unknown as {
|
|
98
|
+
collapse: (key: string) => void
|
|
99
|
+
expand: (key: string, size?: number) => void
|
|
100
|
+
reset: () => void
|
|
101
|
+
getSizes: () => number[]
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
vm.collapse('a')
|
|
105
|
+
await wrapper.vm.$nextTick()
|
|
106
|
+
expect(vm.getSizes()).toEqual([0, 100])
|
|
107
|
+
expect(wrapper.emitted('collapse')?.pop()).toEqual(['a'])
|
|
108
|
+
|
|
109
|
+
vm.expand('a', 30)
|
|
110
|
+
await wrapper.vm.$nextTick()
|
|
111
|
+
expect(vm.getSizes()).toEqual([30, 70])
|
|
112
|
+
expect(wrapper.emitted('expand')?.pop()).toEqual(['a'])
|
|
113
|
+
|
|
114
|
+
vm.collapse('a')
|
|
115
|
+
await wrapper.vm.$nextTick()
|
|
116
|
+
vm.expand('a')
|
|
117
|
+
await wrapper.vm.$nextTick()
|
|
118
|
+
// 未传 toSize 时应还原折叠前宽度,而不是落到 min
|
|
119
|
+
expect(vm.getSizes()).toEqual([30, 70])
|
|
120
|
+
|
|
121
|
+
vm.reset()
|
|
122
|
+
await wrapper.vm.$nextTick()
|
|
123
|
+
expect(vm.getSizes()).toEqual([40, 60])
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
it('toggles collapse via keyboard on the separator', async () => {
|
|
127
|
+
const wrapper = mount(RsSplitPane, {
|
|
128
|
+
props: {
|
|
129
|
+
panes: [
|
|
130
|
+
{ key: 'a', size: 40, min: 20, collapsible: true, collapsedSize: 0 },
|
|
131
|
+
{ key: 'b', size: 60 },
|
|
132
|
+
] satisfies RsSplitPaneItem[],
|
|
133
|
+
},
|
|
134
|
+
slots: { a: 'A', b: 'B' },
|
|
135
|
+
})
|
|
136
|
+
await wrapper.find('.rs-split__resizer').trigger('keydown', { key: 'Enter' })
|
|
137
|
+
expect(wrapper.emitted('collapse')?.pop()).toEqual(['a'])
|
|
138
|
+
await wrapper.find('.rs-split__resizer').trigger('keydown', { key: 'Enter' })
|
|
139
|
+
expect(wrapper.emitted('expand')?.pop()).toEqual(['a'])
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
it('does not mutate nested split panes when resizing the outer separator', async () => {
|
|
143
|
+
const outer = mount(RsSplitPane, {
|
|
144
|
+
props: {
|
|
145
|
+
orientation: 'vertical',
|
|
146
|
+
panes: [{ key: 'top', size: 70 }, { key: 'bottom', size: 30 }],
|
|
147
|
+
sizes: [70, 30],
|
|
148
|
+
keyboardStep: 10,
|
|
149
|
+
},
|
|
150
|
+
slots: {
|
|
151
|
+
top: () =>
|
|
152
|
+
h(
|
|
153
|
+
RsSplitPane,
|
|
154
|
+
{
|
|
155
|
+
panes: [{ key: 'left' }, { key: 'right' }],
|
|
156
|
+
sizes: [40, 60],
|
|
157
|
+
},
|
|
158
|
+
{
|
|
159
|
+
left: () => h('div', { class: 'inner-left' }, 'L'),
|
|
160
|
+
right: () => h('div', { class: 'inner-right' }, 'R'),
|
|
161
|
+
},
|
|
162
|
+
),
|
|
163
|
+
bottom: () => h('div', { class: 'bottom-pane' }, 'B'),
|
|
164
|
+
},
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
const resizers = outer.findAll('.rs-split__resizer')
|
|
168
|
+
expect(resizers).toHaveLength(2)
|
|
169
|
+
expect(resizers[0]!.attributes('aria-valuenow')).toBe('40')
|
|
170
|
+
expect(resizers[1]!.attributes('aria-valuenow')).toBe('70')
|
|
171
|
+
|
|
172
|
+
await resizers[1]!.trigger('keydown', { key: 'ArrowDown' })
|
|
173
|
+
|
|
174
|
+
expect(resizers[0]!.attributes('aria-valuenow')).toBe('40')
|
|
175
|
+
expect(outer.emitted('update:sizes')?.pop()).toEqual([[80, 20]])
|
|
176
|
+
})
|
|
177
|
+
})
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { mount } from '@vue/test-utils'
|
|
3
|
+
import RsStatCard from '../components/RsStatCard.vue'
|
|
4
|
+
|
|
5
|
+
const accents = ['primary', 'success', 'warning', 'danger', 'info'] as const
|
|
6
|
+
|
|
7
|
+
describe('RsStatCard', () => {
|
|
8
|
+
it('renders label and value', () => {
|
|
9
|
+
const wrapper = mount(RsStatCard, {
|
|
10
|
+
props: { label: '今日调用', value: 12480 },
|
|
11
|
+
})
|
|
12
|
+
expect(wrapper.find('.rs-stat-card__label').text()).toBe('今日调用')
|
|
13
|
+
expect(wrapper.find('.rs-stat-card__value').text()).toBe('12480')
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
it('renders as section with base class', () => {
|
|
17
|
+
const wrapper = mount(RsStatCard, {
|
|
18
|
+
props: { label: '指标', value: 1 },
|
|
19
|
+
})
|
|
20
|
+
expect(wrapper.element.tagName).toBe('SECTION')
|
|
21
|
+
expect(wrapper.classes()).toContain('rs-stat-card')
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('uses primary accent by default', () => {
|
|
25
|
+
const wrapper = mount(RsStatCard, {
|
|
26
|
+
props: { label: '指标', value: 1 },
|
|
27
|
+
})
|
|
28
|
+
expect(wrapper.classes()).toContain('rs-stat-card--primary')
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it.each(accents)('applies %s accent class', (accent) => {
|
|
32
|
+
const wrapper = mount(RsStatCard, {
|
|
33
|
+
props: { label: '指标', value: 1, accent },
|
|
34
|
+
})
|
|
35
|
+
expect(wrapper.classes()).toContain(`rs-stat-card--${accent}`)
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('renders description when provided', () => {
|
|
39
|
+
const wrapper = mount(RsStatCard, {
|
|
40
|
+
props: {
|
|
41
|
+
label: '错误率',
|
|
42
|
+
value: '0.12%',
|
|
43
|
+
description: '过去 24 小时',
|
|
44
|
+
},
|
|
45
|
+
})
|
|
46
|
+
expect(wrapper.find('.rs-stat-card__description').text()).toBe('过去 24 小时')
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('omits description when not provided', () => {
|
|
50
|
+
const wrapper = mount(RsStatCard, {
|
|
51
|
+
props: { label: '指标', value: 1 },
|
|
52
|
+
})
|
|
53
|
+
expect(wrapper.find('.rs-stat-card__description').exists()).toBe(false)
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('shows skeleton instead of value when loading', () => {
|
|
57
|
+
const wrapper = mount(RsStatCard, {
|
|
58
|
+
props: { label: '活跃用户', value: 3842, loading: true },
|
|
59
|
+
})
|
|
60
|
+
expect(wrapper.find('.rs-stat-card__skeleton').exists()).toBe(true)
|
|
61
|
+
expect(wrapper.find('.rs-stat-card__value').exists()).toBe(false)
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('renders value slot', () => {
|
|
65
|
+
const wrapper = mount(RsStatCard, {
|
|
66
|
+
props: { label: '营收' },
|
|
67
|
+
slots: { value: '<span class="custom-value">¥ 128万</span>' },
|
|
68
|
+
})
|
|
69
|
+
expect(wrapper.find('.custom-value').text()).toBe('¥ 128万')
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('renders default slot for extra content', () => {
|
|
73
|
+
const wrapper = mount(RsStatCard, {
|
|
74
|
+
props: { label: '存储', value: '68%' },
|
|
75
|
+
slots: { default: '<div class="extra">进度条</div>' },
|
|
76
|
+
})
|
|
77
|
+
expect(wrapper.find('.extra').text()).toBe('进度条')
|
|
78
|
+
})
|
|
79
|
+
})
|