import { describe, it, expect, vi, afterEach } from 'vitest' import { mount } from '@vue/test-utils' import { nextTick, ref } from 'vue' import DatePicker from '@/components/ui/date/date-picker.vue' /** 用真 v-model 宿主,才能验证 emit 出的是字符串并回流到输入框 */ function mountPicker(props = {}) { return mount( { components: { DatePicker }, setup() { const value = ref(props.modelValue ?? '') return { value, format: props.format, valueFormat: props.valueFormat, disabled: Boolean(props.disabled), } }, template: ` `, }, { attachTo: document.body }, ) } function inputOf(wrapper) { return wrapper.find('.fms-date-input') } /** emit 来自子组件,宿主只负责 v-model 回流 */ function pickerOf(wrapper) { return wrapper.findComponent(DatePicker) } function lastEmitted(wrapper) { return pickerOf(wrapper).emitted('update:modelValue')?.at(-1)?.[0] } /** 把光标停到指定位置后按下方向键,模拟「微调当前段」 */ async function stepAt(input, pos, key) { input.element.setSelectionRange(pos, pos) await input.trigger('keydown', { key }) await nextTick() } afterEach(() => { document.body.innerHTML = '' }) describe('DatePicker 手动输入', () => { it('输入框不再 readonly,type 仍为 text', () => { const wrapper = mountPicker({ format: 'YYYY-MM-DD' }) const input = inputOf(wrapper) expect(input.attributes('readonly')).toBeUndefined() expect(input.attributes('type')).toBe('text') wrapper.unmount() }) it('键入合法整串后失焦提交为该格式的字符串', async () => { const wrapper = mountPicker({ format: 'YYYY-MM-DD' }) const input = inputOf(wrapper) await input.setValue('2026-09-26') await input.trigger('blur') expect(lastEmitted(wrapper)).toBe('2026-09-26') wrapper.unmount() }) it('斜杠、点号与中文年月日都能提交', async () => { for (const text of ['2026/09/26', '2026.09.26', '2026年09月26日']) { const wrapper = mountPicker({ format: 'YYYY-MM-DD' }) const input = inputOf(wrapper) await input.setValue(text) await input.trigger('blur') expect(lastEmitted(wrapper), text).toBe('2026-09-26') wrapper.unmount() } }) it('Enter 提交当前文本并关闭面板', async () => { const wrapper = mountPicker({ format: 'YYYY-MM-DD' }) const input = inputOf(wrapper) await input.trigger('click') await nextTick() expect(wrapper.find('.fms-date-picker').classes()).toContain('is-open') await input.setValue('2026-09-26') await input.trigger('keydown', { key: 'Enter' }) await nextTick() expect(lastEmitted(wrapper)).toBe('2026-09-26') expect(wrapper.find('.fms-date-picker').classes()).not.toContain('is-open') wrapper.unmount() }) it('datetime:format 决定展示,valueFormat 决定提交格式(秒会被补齐)', async () => { const wrapper = mountPicker({ format: 'YYYY-MM-DD HH:mm', valueFormat: 'YYYY-MM-DD HH:mm:ss', }) const input = inputOf(wrapper) await input.setValue('2026-09-26 14:30') await input.trigger('blur') expect(lastEmitted(wrapper)).toBe('2026-09-26 14:30:00') wrapper.unmount() }) it('非法输入失焦:不 emit,文本回退到已提交值,并标记错误态', async () => { const wrapper = mountPicker({ format: 'YYYY-MM-DD', modelValue: '2026-01-01' }) const input = inputOf(wrapper) await input.setValue('2026-13-45') await input.trigger('blur') expect(pickerOf(wrapper).emitted('update:modelValue')).toBeFalsy() expect(input.element.value).toBe('2026-01-01') expect(wrapper.find('.fms-date-picker').classes()).toContain('is-invalid') expect(input.attributes('aria-invalid')).toBe('true') wrapper.unmount() }) it('错误态 1500ms 后自动清除', async () => { vi.useFakeTimers() const wrapper = mountPicker({ format: 'YYYY-MM-DD', modelValue: '2026-01-01' }) const input = inputOf(wrapper) await input.setValue('abc') await input.trigger('blur') expect(wrapper.find('.fms-date-picker').classes()).toContain('is-invalid') vi.advanceTimersByTime(1500) await nextTick() expect(wrapper.find('.fms-date-picker').classes()).not.toContain('is-invalid') wrapper.unmount() vi.useRealTimers() }) it('文本与已提交值相同时失焦不重复 emit', async () => { const wrapper = mountPicker({ format: 'YYYY-MM-DD', modelValue: '2026-09-26' }) const input = inputOf(wrapper) await input.setValue('2026-09-26') await input.trigger('blur') expect(pickerOf(wrapper).emitted('update:modelValue')).toBeFalsy() wrapper.unmount() }) it('清空文本失焦等同清除值', async () => { const wrapper = mountPicker({ format: 'YYYY-MM-DD', modelValue: '2026-09-26' }) const input = inputOf(wrapper) await input.setValue('') await input.trigger('blur') expect(lastEmitted(wrapper)).toBe('') wrapper.unmount() }) it('Esc 还原为已提交文本并关闭面板', async () => { const wrapper = mountPicker({ format: 'YYYY-MM-DD', modelValue: '2026-09-26' }) const input = inputOf(wrapper) await input.trigger('click') await nextTick() await input.setValue('2027-01-01') await input.trigger('keydown', { key: 'Escape' }) await nextTick() expect(pickerOf(wrapper).emitted('update:modelValue')).toBeFalsy() expect(input.element.value).toBe('2026-09-26') expect(wrapper.find('.fms-date-picker').classes()).not.toContain('is-open') wrapper.unmount() }) it('Space 作为普通字符输入,不打开面板', async () => { const wrapper = mountPicker({ format: 'YYYY-MM-DD HH:mm', modelValue: '2026-09-26 14:30' }) const input = inputOf(wrapper) await input.setValue('2026-09-26 14:30') await input.trigger('keydown', { key: ' ', code: 'Space' }) await nextTick() expect(wrapper.find('.fms-date-picker').classes()).not.toContain('is-open') wrapper.unmount() }) }) describe('DatePicker 方向键微调当前段', () => { it('年份段 ArrowUp +1', async () => { const wrapper = mountPicker({ format: 'YYYY-MM-DD', modelValue: '2026-09-26' }) const input = inputOf(wrapper) await input.trigger('focus') await stepAt(input, 2, 'ArrowUp') expect(input.element.value).toBe('2027-09-26') wrapper.unmount() }) it('月份段 ArrowDown -1', async () => { const wrapper = mountPicker({ format: 'YYYY-MM-DD', modelValue: '2026-09-26' }) const input = inputOf(wrapper) await input.trigger('focus') await stepAt(input, 6, 'ArrowDown') expect(input.element.value).toBe('2026-08-26') wrapper.unmount() }) it('日段 ArrowUp 跨月进位', async () => { const wrapper = mountPicker({ format: 'YYYY-MM-DD', modelValue: '2026-09-30' }) const input = inputOf(wrapper) await input.trigger('focus') await stepAt(input, 9, 'ArrowUp') expect(input.element.value).toBe('2026-10-01') wrapper.unmount() }) it('时分秒微调只改当前段且日期不变', async () => { const wrapper = mountPicker({ format: 'YYYY-MM-DD HH:mm:ss', modelValue: '2026-09-26 23:10:00', }) const input = inputOf(wrapper) await input.trigger('focus') await stepAt(input, 12, 'ArrowUp') // 小时段回绕 expect(input.element.value).toBe('2026-09-26 00:10:00') await stepAt(input, 15, 'ArrowDown') // 分钟段 expect(input.element.value).toBe('2026-09-26 00:09:00') wrapper.unmount() }) it('光标不在任何段上时不拦截方向键、不改文本', async () => { // ddd 是不可步进的 token(编译为跳读片段),文本尾部的星期不产生任何段 const wrapper = mountPicker({ format: 'YYYY-MM-DD ddd', modelValue: '2026-09-26' }) const input = inputOf(wrapper) await input.trigger('focus') const before = input.element.value expect(before).toMatch(/^2026-09-26 \S+$/) await stepAt(input, before.length, 'ArrowUp') expect(input.element.value).toBe(before) wrapper.unmount() }) it('微调结果失焦后提交为新值', async () => { const wrapper = mountPicker({ format: 'YYYY-MM-DD', modelValue: '2026-09-26' }) const input = inputOf(wrapper) await input.trigger('focus') await stepAt(input, 2, 'ArrowUp') await input.trigger('blur') expect(lastEmitted(wrapper)).toBe('2027-09-26') wrapper.unmount() }) it('Alt+ArrowDown 打开面板并把焦点交给日历网格', async () => { const wrapper = mountPicker({ format: 'YYYY-MM-DD', modelValue: '2026-09-26' }) const input = inputOf(wrapper) await input.trigger('focus') await input.trigger('keydown', { key: 'ArrowDown', altKey: true }) await nextTick() expect(wrapper.find('.fms-date-picker').classes()).toContain('is-open') expect(document.body.querySelector('.fms-cal-cell:focus')).not.toBeNull() wrapper.unmount() }) }) describe('DatePicker 与面板 / 清除按钮的协作', () => { it('点输入框打开面板且焦点留在输入框,可以继续打字', async () => { const wrapper = mountPicker({ format: 'YYYY-MM-DD' }) const input = inputOf(wrapper) input.element.focus() await input.trigger('click') await nextTick() expect(wrapper.find('.fms-date-picker').classes()).toContain('is-open') expect(document.activeElement).toBe(input.element) wrapper.unmount() }) it('面板点选日期后输入框显示新值并清空错误态', async () => { const wrapper = mountPicker({ format: 'YYYY-MM-DD', modelValue: '2026-01-01' }) const input = inputOf(wrapper) await input.setValue('abc') await input.trigger('blur') expect(wrapper.find('.fms-date-picker').classes()).toContain('is-invalid') await input.trigger('click') await nextTick() const cell = document.body.querySelector('.fms-cal-cell:not(.is-disabled)') cell.click() await nextTick() await nextTick() const committed = lastEmitted(wrapper) expect(committed).toMatch(/^\d{4}-\d{2}-\d{2}$/) expect(input.element.value).toBe(committed) expect(wrapper.find('.fms-date-picker').classes()).not.toContain('is-invalid') wrapper.unmount() }) it('清除按钮清空值且不触发重复提交', async () => { const wrapper = mountPicker({ format: 'YYYY-MM-DD', modelValue: '2026-09-26' }) await inputOf(wrapper).trigger('click') await nextTick() const clear = wrapper.find('.fms-date-clear') await clear.trigger('click') await nextTick() expect(lastEmitted(wrapper)).toBe('') expect(document.body.querySelector('.popover-content')).toBeNull() wrapper.unmount() }) }) describe('DatePicker 禁用态与多格式', () => { it('禁用态下输入与方向键均不生效,也不进入错误态', async () => { const wrapper = mountPicker({ format: 'YYYY-MM-DD', modelValue: '2026-09-26', disabled: true }) const input = inputOf(wrapper) expect(input.attributes('disabled')).toBeDefined() await input.trigger('keydown', { key: 'ArrowUp' }) await nextTick() expect(pickerOf(wrapper).emitted('update:modelValue')).toBeFalsy() expect(wrapper.find('.fms-date-picker').classes()).not.toContain('is-invalid') wrapper.unmount() }) it('format 数组时任一写法都能提交', async () => { for (const text of ['2026-09-26', '2026年09月26日']) { const wrapper = mountPicker({ format: ['YYYY年MM月DD日', 'YYYY-MM-DD'], valueFormat: 'YYYY-MM-DD', }) const input = inputOf(wrapper) await input.setValue(text) await input.trigger('blur') expect(lastEmitted(wrapper), text).toBe('2026-09-26') wrapper.unmount() } }) })