Files
2026-08-19 17:32:25 +08:00

412 lines
17 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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()
})
})