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() }) })