20260819173221

This commit is contained in:
oneao committed 2026-08-19 17:32:25 +08:00
1 parent 1ffc8e1ac9
commit a0a4273f4c
326 files changed
+301646 -2314

No files matched your search

@@ -0,0 +1,149 @@
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import Button from '@/components/ui/button/button.vue'
import Tag from '@/components/ui/tag/tag.vue'
import Switch from '@/components/ui/switch/switch.vue'
import Radio from '@/components/ui/radio/radio.vue'
import RadioGroup from '@/components/ui/radio/radio-group.vue'
import Checkbox from '@/components/ui/checkbox/checkbox.vue'
import Spin from '@/components/ui/spin/spin.vue'
describe('Button 组件', () => {
it('默认渲染 primary 按钮并显示插槽内容', () => {
const wrapper = mount(Button, { slots: { default: '提交' } })
expect(wrapper.find('button').classes()).toContain('button-primary')
expect(wrapper.text()).toContain('提交')
})
it('type/block/loading 映射 class', () => {
const wrapper = mount(Button, { props: { type: 'danger', block: true, loading: true } })
const btn = wrapper.find('button')
expect(btn.classes()).toContain('button-danger')
expect(btn.classes()).toContain('button-block')
expect(btn.classes()).toContain('button-loading')
})
it('loading 或 disabled 时禁用原生按钮', () => {
const loading = mount(Button, { props: { loading: true } })
expect(loading.find('button').attributes('disabled')).toBeDefined()
const disabled = mount(Button, { props: { disabled: true } })
expect(disabled.find('button').attributes('disabled')).toBeDefined()
})
it('htmlType 透传原生 type', () => {
const wrapper = mount(Button, { props: { htmlType: 'submit' } })
expect(wrapper.find('button').attributes('type')).toBe('submit')
})
})
describe('Tag 组件', () => {
it('默认渲染 default 变体', () => {
const wrapper = mount(Tag, { slots: { default: '标签' } })
expect(wrapper.text()).toContain('标签')
})
it('variant 映射 class', () => {
const wrapper = mount(Tag, { props: { variant: 'danger' } })
expect(wrapper.classes()).toContain('tag-danger')
})
it('closable 点击关闭:emit close 并隐藏', async () => {
const wrapper = mount(Tag, { props: { closable: true } })
await wrapper.find('[role="button"]').trigger('click')
expect(wrapper.emitted('close')).toHaveLength(1)
expect(wrapper.classes()).toContain('tag-hidden')
})
it('disabled 时点击不关闭', async () => {
const wrapper = mount(Tag, { props: { closable: true, disabled: true } })
await wrapper.find('[role="button"]').trigger('click')
expect(wrapper.emitted('close')).toBeUndefined()
})
it('href 渲染为链接', () => {
const wrapper = mount(Tag, { props: { href: '/x' } })
expect(wrapper.find('a').exists()).toBe(true)
})
})
describe('Switch 组件', () => {
it('点击触发 update:modelValue 与 change', async () => {
const wrapper = mount(Switch, { props: { modelValue: false } })
await wrapper.find('[role="switch"]').trigger('click')
expect(wrapper.emitted('update:modelValue')[0]).toEqual([true])
expect(wrapper.emitted('change')[0][0]).toBe(true)
})
it('checked 状态映射 class 与 aria-checked', () => {
const wrapper = mount(Switch, { props: { modelValue: true } })
expect(wrapper.find('[role="switch"]').classes()).toContain('switch-checked')
expect(wrapper.find('[role="switch"]').attributes('aria-checked')).toBe('true')
})
it('disabled 时点击不触发', async () => {
const wrapper = mount(Switch, { props: { disabled: true } })
await wrapper.find('[role="switch"]').trigger('click')
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
})
})
describe('RadioGroup + Radio 组件', () => {
it('选中 radio 触发 v-model 更新', async () => {
const real = mount({
components: { RadioGroup, Radio },
template: '<RadioGroup v-model="v"><Radio value="a" /><Radio value="b" /></RadioGroup>',
data: () => ({ v: 'a' }),
})
const radios = real.findAll('input[type="radio"]')
expect(radios[0].element.checked).toBe(true)
await radios[1].setValue()
expect(real.vm.v).toBe('b')
})
it('独立 Radio 未分组时默认不选中', () => {
const wrapper = mount(Radio, { props: { value: 'x' } })
expect(wrapper.find('input').element.checked).toBe(false)
})
})
describe('Checkbox 组件', () => {
it('勾选触发 update:modelValue 与 change', async () => {
const wrapper = mount(Checkbox, { props: { modelValue: false } })
await wrapper.find('input[type="checkbox"]').setValue(true)
expect(wrapper.emitted('update:modelValue')[0]).toEqual([true])
expect(wrapper.emitted('change')[0][0]).toBe(true)
})
it('checked/indeterminate/disabled 映射 class', () => {
const checked = mount(Checkbox, { props: { modelValue: true } })
expect(checked.classes()).toContain('checkbox-checked')
const semi = mount(Checkbox, { props: { indeterminate: true, modelValue: false } })
expect(semi.classes()).toContain('checkbox-indeterminate')
const disabled = mount(Checkbox, { props: { disabled: true } })
expect(disabled.classes()).toContain('checkbox-disabled')
})
})
describe('Spin 组件', () => {
it('默认加载中显示 aria-busy', () => {
const wrapper = mount(Spin)
expect(wrapper.attributes('aria-busy')).toBe('true')
})
it('spinning=false 不显示', () => {
const wrapper = mount(Spin, { props: { spinning: false } })
expect(wrapper.attributes('aria-busy')).toBe('false')
})
it('delay 延迟生效:立即不 active,延时后 active', async () => {
const wrapper = mount(Spin, { props: { spinning: true, delay: 50 } })
expect(wrapper.attributes('aria-busy')).toBe('false')
await new Promise((r) => setTimeout(r, 80))
expect(wrapper.attributes('aria-busy')).toBe('true')
})
it('description 渲染加载文案', () => {
const wrapper = mount(Spin, { props: { description: '加载中...' } })
expect(wrapper.text()).toContain('加载中...')
})
})
@@ -0,0 +1,411 @@
import { describe, it, expect, vi } from 'vitest'
import { nextTick } from 'vue'
import { mount } from '@vue/test-utils'
import Input from '@/components/ui/input/input.vue'
const inputEl = (wrapper) => wrapper.find('[data-slot="input"]')
const textareaEl = (wrapper) => wrapper.find('[data-slot="textarea"]')
const clearBtn = (wrapper) => wrapper.find('button[aria-label="清除"]')
const resizeHandle = (wrapper) => wrapper.find('[role="slider"]')
describe('Input 组件 · default 模式', () => {
it('渲染 input 并透传 value', () => {
const wrapper = mount(Input, { props: { modelValue: 'abc' } })
expect(inputEl(wrapper).element.value).toBe('abc')
})
it('Number 类型的 modelValue 正常渲染', () => {
const wrapper = mount(Input, { props: { modelValue: 123 } })
expect(inputEl(wrapper).element.value).toBe('123')
})
it('输入触发 update:modelValue', async () => {
const wrapper = mount(Input, { props: { modelValue: '' } })
await inputEl(wrapper).setValue('hello')
expect(wrapper.emitted('update:modelValue')[0]).toEqual(['hello'])
})
it('change 事件转发', async () => {
const wrapper = mount(Input, { props: { modelValue: '' } })
await inputEl(wrapper).setValue('x')
await inputEl(wrapper).trigger('change')
expect(wrapper.emitted('change')[0]).toEqual(['x'])
})
it('focus/blur 触发事件并切换聚焦态', async () => {
const wrapper = mount(Input)
await inputEl(wrapper).trigger('focus')
expect(wrapper.emitted('focus')).toHaveLength(1)
expect(wrapper.classes()).toContain('input-focused')
await inputEl(wrapper).trigger('blur')
expect(wrapper.emitted('blur')).toHaveLength(1)
expect(wrapper.classes()).not.toContain('input-focused')
})
it('maxlength 透传原生属性', () => {
const wrapper = mount(Input, { props: { maxlength: 20 } })
expect(inputEl(wrapper).attributes('maxlength')).toBe('20')
})
it('disabled/readonly 反映到原生属性与 wrapper class', () => {
const d = mount(Input, { props: { disabled: true } })
expect(inputEl(d).attributes('disabled')).toBeDefined()
expect(d.classes()).toContain('input-disabled')
const r = mount(Input, { props: { readonly: true } })
expect(inputEl(r).attributes('readonly')).toBeDefined()
expect(r.classes()).toContain('input-readonly')
})
it('status=error 映射 aria-invalid 与 class', () => {
const wrapper = mount(Input, { props: { status: 'error' } })
expect(inputEl(wrapper).attributes('aria-invalid')).toBe('true')
expect(wrapper.classes()).toContain('input-error')
})
it('status=warning 映射 class 且不设置 aria-invalid', () => {
const wrapper = mount(Input, { props: { status: 'warning' } })
expect(wrapper.classes()).toContain('input-warning')
expect(inputEl(wrapper).attributes('aria-invalid')).toBeUndefined()
})
it('prefix/suffix 插槽渲染', () => {
const wrapper = mount(Input, {
slots: { prefix: '<span class="pfx">P</span>', suffix: '<span class="sfx">S</span>' },
})
expect(wrapper.find('.pfx').exists()).toBe(true)
expect(wrapper.find('.sfx').exists()).toBe(true)
})
it('allowClear:有值但未聚焦/未悬停时不显示清除按钮', () => {
const wrapper = mount(Input, { props: { modelValue: 'v', allowClear: true } })
expect(clearBtn(wrapper).exists()).toBe(false)
})
it('allowClear:悬停时显示清除按钮,点击清空', async () => {
const wrapper = mount(Input, { props: { modelValue: 'v', allowClear: true } })
await wrapper.trigger('mouseenter')
expect(clearBtn(wrapper).exists()).toBe(true)
await clearBtn(wrapper).trigger('click')
expect(wrapper.emitted('update:modelValue')[0]).toEqual([''])
expect(wrapper.emitted('clear')).toHaveLength(1)
})
it('allowClear:disabled 时即使悬停也不显示清除按钮', async () => {
const wrapper = mount(Input, { props: { modelValue: 'v', allowClear: true, disabled: true } })
await wrapper.trigger('mouseenter')
expect(clearBtn(wrapper).exists()).toBe(false)
})
it('allowClear:readonly 时即使悬停也不显示清除按钮', async () => {
const wrapper = mount(Input, { props: { modelValue: 'v', allowClear: true, readonly: true } })
await wrapper.trigger('mouseenter')
expect(clearBtn(wrapper).exists()).toBe(false)
})
it('allowClear:mouseleave 后清除按钮隐藏', async () => {
const wrapper = mount(Input, { props: { modelValue: 'v', allowClear: true } })
await wrapper.trigger('mouseenter')
expect(clearBtn(wrapper).exists()).toBe(true)
await wrapper.trigger('mouseleave')
expect(clearBtn(wrapper).exists()).toBe(false)
})
it('showCount 显示字数统计(无上限仅长度)', () => {
const wrapper = mount(Input, { props: { modelValue: 'abc', showCount: true } })
expect(wrapper.text()).toContain('3')
})
it('showCount 配合 maxlength 显示 当前 / 上限', () => {
const wrapper = mount(Input, { props: { modelValue: 'abc', showCount: true, maxlength: 10 } })
expect(wrapper.text()).toContain('3 / 10')
})
it('type 校验器拒绝非法模式', () => {
const spy = vi.spyOn(console, 'warn').mockImplementation(() => {})
mount(Input, { props: { type: 'foo' } })
expect(spy).toHaveBeenCalled()
spy.mockRestore()
})
})
describe('Input 组件 · password 模式', () => {
it('默认 type=password,点击切换明文,再点击切回密文', async () => {
const wrapper = mount(Input, { props: { type: 'password', modelValue: 'pw' } })
const input = inputEl(wrapper)
expect(input.attributes('type')).toBe('password')
await wrapper.find('button[aria-label="显示密码"]').trigger('click')
expect(input.attributes('type')).toBe('text')
expect(input.element.value).toBe('pw')
await wrapper.find('button[aria-label="隐藏密码"]').trigger('click')
expect(input.attributes('type')).toBe('password')
})
it('切换按钮 aria-label/aria-pressed 随状态变化', async () => {
const wrapper = mount(Input, { props: { type: 'password' } })
const toggle = wrapper.find('button[aria-label="显示密码"]')
expect(toggle.attributes('aria-pressed')).toBe('false')
await toggle.trigger('click')
expect(wrapper.find('button[aria-label="隐藏密码"]').attributes('aria-pressed')).toBe('true')
})
it('disabled 时切换按钮禁用', () => {
const wrapper = mount(Input, { props: { type: 'password', disabled: true } })
expect(wrapper.find('button[aria-label="显示密码"]').attributes('disabled')).toBeDefined()
})
it('readonly 时切换按钮禁用', () => {
const wrapper = mount(Input, { props: { type: 'password', readonly: true } })
expect(wrapper.find('button[aria-label="显示密码"]').attributes('disabled')).toBeDefined()
})
it('password 模式仍支持字数统计', () => {
const wrapper = mount(Input, { props: { type: 'password', modelValue: 'abc', showCount: true } })
expect(wrapper.text()).toContain('3')
})
})
describe('Input 组件 · number 模式', () => {
it('过滤非法字符:字母被剔除,只保留数字与单个小数点', async () => {
const wrapper = mount(Input, { props: { type: 'number', modelValue: '' } })
await inputEl(wrapper).setValue('12a.b.c')
expect(wrapper.emitted('update:modelValue')[0]).toEqual(['12.'])
})
it('过滤多余小数点:12.3.4 归一化为 12.34', async () => {
const wrapper = mount(Input, { props: { type: 'number', modelValue: '' } })
await inputEl(wrapper).setValue('12.3.4')
expect(wrapper.emitted('update:modelValue')[0]).toEqual(['12.34'])
})
it('纯字母输入被清空', async () => {
const wrapper = mount(Input, { props: { type: 'number', modelValue: '' } })
await inputEl(wrapper).setValue('abc')
expect(wrapper.emitted('update:modelValue')[0]).toEqual([''])
})
it('保留小数中间态:输入 "12." 不被截断', async () => {
const wrapper = mount(Input, { props: { type: 'number', modelValue: '' } })
await inputEl(wrapper).setValue('12.')
expect(wrapper.emitted('update:modelValue')[0]).toEqual(['12.'])
})
it('输入 "." 保留中间态,失焦后提交为空串', async () => {
const real = mount({
components: { Input },
template: '<Input v-model="v" type="number" />',
data: () => ({ v: '' }),
})
const input = real.find('[data-slot="input"]')
await input.setValue('.')
expect(real.vm.v).toBe('.')
await input.trigger('blur')
expect(real.vm.v).toBe('')
})
it('失焦提交转为 Number:3.50 → 3.5', async () => {
const wrapper = mount(Input, { props: { type: 'number', modelValue: '' } })
await inputEl(wrapper).setValue('3.50')
await inputEl(wrapper).trigger('blur')
expect(wrapper.emitted('update:modelValue').at(-1)).toEqual([3.5])
})
it('失焦后原生输入框显示规范化值', async () => {
const wrapper = mount(Input, { props: { type: 'number', modelValue: '' } })
await inputEl(wrapper).setValue('3.50')
await inputEl(wrapper).trigger('blur')
expect(inputEl(wrapper).element.value).toBe('3.5')
})
it('change 事件提交 Number 值', async () => {
const wrapper = mount(Input, { props: { type: 'number', modelValue: '' } })
await inputEl(wrapper).setValue('3.50')
await inputEl(wrapper).trigger('change')
expect(wrapper.emitted('change')[0]).toEqual([3.5])
})
it('值未变化时失焦不重复触发 update:modelValue', async () => {
const wrapper = mount(Input, { props: { type: 'number', modelValue: 3.5 } })
await inputEl(wrapper).setValue('3.5')
expect(wrapper.emitted('update:modelValue')).toHaveLength(1)
await inputEl(wrapper).trigger('blur')
expect(wrapper.emitted('update:modelValue')).toHaveLength(1)
})
it('空值失焦不重复触发 update:modelValue', async () => {
const wrapper = mount(Input, { props: { type: 'number', modelValue: '' } })
await inputEl(wrapper).setValue('')
await inputEl(wrapper).trigger('blur')
expect(wrapper.emitted('update:modelValue')).toHaveLength(1)
})
it('透传 inputmode=decimal 与 number 校验 pattern', () => {
const wrapper = mount(Input, { props: { type: 'number' } })
expect(inputEl(wrapper).attributes('inputmode')).toBe('decimal')
expect(inputEl(wrapper).attributes('pattern')).toBe('[0-9]*[.]?[0-9]*')
})
it('Number 类型 modelValue 渲染为字符串', () => {
const wrapper = mount(Input, { props: { type: 'number', modelValue: 1.28 } })
expect(inputEl(wrapper).element.value).toBe('1.28')
})
})
describe('Input 组件 · textarea 模式', () => {
it('渲染 textarea 并转发输入', async () => {
const wrapper = mount(Input, { props: { type: 'textarea', modelValue: 'multi' } })
expect(textareaEl(wrapper).element.value).toBe('multi')
await textareaEl(wrapper).setValue('new')
expect(wrapper.emitted('update:modelValue')[0]).toEqual(['new'])
})
it('change 事件转发', async () => {
const wrapper = mount(Input, { props: { type: 'textarea', modelValue: '' } })
await textareaEl(wrapper).setValue('x')
await textareaEl(wrapper).trigger('change')
expect(wrapper.emitted('change')[0]).toEqual(['x'])
})
it('focus/blur 触发事件', async () => {
const wrapper = mount(Input, { props: { type: 'textarea' } })
await textareaEl(wrapper).trigger('focus')
expect(wrapper.emitted('focus')).toHaveLength(1)
await textareaEl(wrapper).trigger('blur')
expect(wrapper.emitted('blur')).toHaveLength(1)
})
it('rows 透传(默认 3,可自定义)', () => {
const d = mount(Input, { props: { type: 'textarea' } })
expect(textareaEl(d).attributes('rows')).toBe('3')
const custom = mount(Input, { props: { type: 'textarea', rows: 6 } })
expect(textareaEl(custom).attributes('rows')).toBe('6')
})
it('maxlength 透传', () => {
const wrapper = mount(Input, { props: { type: 'textarea', maxlength: 100 } })
expect(textareaEl(wrapper).attributes('maxlength')).toBe('100')
})
it('disabled/readonly 反映到原生属性', () => {
const d = mount(Input, { props: { type: 'textarea', disabled: true } })
expect(textareaEl(d).attributes('disabled')).toBeDefined()
const r = mount(Input, { props: { type: 'textarea', readonly: true } })
expect(textareaEl(r).attributes('readonly')).toBeDefined()
})
it('status=error 映射 aria-invalid', () => {
const wrapper = mount(Input, { props: { type: 'textarea', status: 'error' } })
expect(textareaEl(wrapper).attributes('aria-invalid')).toBe('true')
})
it('textarea 无清除按钮', () => {
const wrapper = mount(Input, { props: { type: 'textarea', modelValue: 'x', allowClear: true } })
expect(clearBtn(wrapper).exists()).toBe(false)
})
it('showCount 配合 maxlength 显示 当前 / 上限', () => {
const wrapper = mount(Input, {
props: { type: 'textarea', modelValue: 'abc', showCount: true, maxlength: 100 },
})
expect(wrapper.text()).toContain('3 / 100')
})
it('可编辑时显示拖拽调整手柄', () => {
const wrapper = mount(Input, { props: { type: 'textarea' } })
expect(resizeHandle(wrapper).exists()).toBe(true)
})
it('disabled 时不显示拖拽调整手柄', () => {
const wrapper = mount(Input, { props: { type: 'textarea', disabled: true } })
expect(resizeHandle(wrapper).exists()).toBe(false)
})
it('readonly 时不显示拖拽调整手柄', () => {
const wrapper = mount(Input, { props: { type: 'textarea', readonly: true } })
expect(resizeHandle(wrapper).exists()).toBe(false)
})
it('键盘调整高度:ArrowDown 增高 / ArrowUp 降低 / Home 还原', async () => {
const wrapper = mount(Input, { props: { type: 'textarea' } })
const handle = resizeHandle(wrapper)
const ta = textareaEl(wrapper)
await handle.trigger('keydown', { key: 'ArrowDown' })
await nextTick()
expect(ta.element.style.height).toBe('20px')
await handle.trigger('keydown', { key: 'ArrowDown' })
await nextTick()
expect(ta.element.style.height).toBe('40px')
await handle.trigger('keydown', { key: 'ArrowUp' })
await nextTick()
expect(ta.element.style.height).toBe('20px')
await handle.trigger('keydown', { key: 'Home' })
await nextTick()
expect(ta.element.style.height).toBe('0px')
})
it('键盘调整不会低于默认高度', async () => {
const wrapper = mount(Input, { props: { type: 'textarea' } })
await resizeHandle(wrapper).trigger('keydown', { key: 'ArrowUp' })
await nextTick()
expect(textareaEl(wrapper).element.style.height).toBe('0px')
})
it('拖拽手柄忽略非调整键', async () => {
const wrapper = mount(Input, { props: { type: 'textarea' } })
await resizeHandle(wrapper).trigger('keydown', { key: 'Enter' })
await nextTick()
expect(textareaEl(wrapper).element.style.height).toBe('')
})
it('textarea 模式切换后高度状态重置', async () => {
const wrapper = mount(Input, { props: { type: 'textarea' } })
await resizeHandle(wrapper).trigger('keydown', { key: 'ArrowDown' })
await nextTick()
expect(textareaEl(wrapper).element.style.height).toBe('20px')
await wrapper.setProps({ type: 'default' })
await wrapper.setProps({ type: 'textarea' })
await nextTick()
expect(textareaEl(wrapper).element.style.height).toBe('')
})
it('拖拽调整高度:pointermove 更新高度', async () => {
const wrapper = mount(Input, { props: { type: 'textarea' } })
const handleEl = resizeHandle(wrapper).element
handleEl.dispatchEvent(new MouseEvent('pointerdown', { clientY: 100, bubbles: true }))
document.dispatchEvent(new MouseEvent('pointermove', { clientY: 150, bubbles: true }))
await vi.waitFor(() => {
expect(textareaEl(wrapper).element.style.height).toBe('50px')
})
document.dispatchEvent(new MouseEvent('pointerup'))
wrapper.unmount()
})
it('拖拽未渲染帧即结束:立即应用待定高度', async () => {
const wrapper = mount(Input, { props: { type: 'textarea' } })
const handleEl = resizeHandle(wrapper).element
handleEl.dispatchEvent(new MouseEvent('pointerdown', { clientY: 100, bubbles: true }))
document.dispatchEvent(new MouseEvent('pointermove', { clientY: 130, bubbles: true }))
// 同步结束拖拽:rAF 帧尚未渲染,flushResizeFrame 应取消帧并应用待定高度
document.dispatchEvent(new MouseEvent('pointerup', { clientY: 130, bubbles: true }))
await nextTick()
expect(textareaEl(wrapper).element.style.height).toBe('30px')
wrapper.unmount()
})
it('拖拽移动小于 2px 忽略', async () => {
const wrapper = mount(Input, { props: { type: 'textarea' } })
const handleEl = resizeHandle(wrapper).element
handleEl.dispatchEvent(new MouseEvent('pointerdown', { clientY: 100, bubbles: true }))
document.dispatchEvent(new MouseEvent('pointermove', { clientY: 101, bubbles: true }))
document.dispatchEvent(new MouseEvent('pointerup', { clientY: 101, bubbles: true }))
await nextTick()
expect(textareaEl(wrapper).element.style.height).toBe('')
wrapper.unmount()
})
})