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: 'P', suffix: 'S' },
})
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: '',
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()
})
})