import { describe, it, expect, afterEach } from 'vitest' import { mount } from '@vue/test-utils' import { nextTick, ref } from 'vue' import RangePicker from '@/components/ui/date/range-picker.vue' /** 用真 v-model 宿主,才能验证 emit 出的是字符串数组并回流到输入框 */ function mountRange(props = {}) { return mount( { components: { RangePicker }, setup() { const value = ref(props.modelValue ?? null) return { value, format: props.format, valueFormat: props.valueFormat, disabled: Boolean(props.disabled), } }, template: ` `, }, { attachTo: document.body }, ) } function inputs(wrapper) { return wrapper.findAll('.fms-range-input') } /** emit 来自子组件,宿主只负责 v-model 回流 */ function pickerOf(wrapper) { return wrapper.findComponent(RangePicker) } 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('RangePicker 两端手动输入', () => { it('两端输入框都不再 readonly', () => { const wrapper = mountRange({ format: 'YYYY-MM-DD' }) const [start, end] = inputs(wrapper) expect(start.attributes('readonly')).toBeUndefined() expect(end.attributes('readonly')).toBeUndefined() wrapper.unmount() }) it('只编辑开始端并失焦:另一端保持空串', async () => { const wrapper = mountRange({ format: 'YYYY-MM-DD' }) const [start] = inputs(wrapper) await start.setValue('2026-09-26') await start.trigger('blur') expect(lastEmitted(wrapper)).toEqual(['2026-09-26', '']) wrapper.unmount() }) it('只编辑结束端并失焦:开始端保持空串', async () => { const wrapper = mountRange({ format: 'YYYY-MM-DD' }) const [, end] = inputs(wrapper) await end.setValue('2026-12-31') await end.trigger('blur') expect(lastEmitted(wrapper)).toEqual(['', '2026-12-31']) wrapper.unmount() }) it('两端倒序输入时自动排序为有序区间', async () => { const wrapper = mountRange({ format: 'YYYY-MM-DD' }) const [start, end] = inputs(wrapper) await start.setValue('2026-12-31') await start.trigger('blur') await end.setValue('2026-01-01') await end.trigger('blur') expect(lastEmitted(wrapper)).toEqual(['2026-01-01', '2026-12-31']) wrapper.unmount() }) it('开始端非法:不 emit,错误只标在开始端', async () => { const wrapper = mountRange({ format: 'YYYY-MM-DD' }) const [start, end] = inputs(wrapper) await start.setValue('2026-13-45') await start.trigger('blur') expect(pickerOf(wrapper).emitted('update:modelValue')).toBeFalsy() expect(start.element.value).toBe('') expect(start.attributes('aria-invalid')).toBe('true') expect(end.attributes('aria-invalid')).toBeUndefined() expect(wrapper.find('.fms-range-picker').classes()).toContain('is-invalid') wrapper.unmount() }) it('结束端非法:错误只标在结束端', async () => { const wrapper = mountRange({ format: 'YYYY-MM-DD', modelValue: ['2026-01-01', '2026-02-01'] }) const [start, end] = inputs(wrapper) await end.setValue('abc') await end.trigger('blur') expect(pickerOf(wrapper).emitted('update:modelValue')).toBeFalsy() expect(end.element.value).toBe('2026-02-01') expect(end.attributes('aria-invalid')).toBe('true') expect(start.attributes('aria-invalid')).toBeUndefined() wrapper.unmount() }) it('Tab 链路:开始端失焦先提交,结束端聚焦时预填已提交文本', async () => { const wrapper = mountRange({ format: 'YYYY-MM-DD' }) const [start, end] = inputs(wrapper) await start.setValue('2026-09-26') // jsdom 的 trigger('blur') 不带 relatedTarget,这里手动派发以模拟 Tab 到结束端 start.element.dispatchEvent(new FocusEvent('blur', { relatedTarget: end.element })) await nextTick() expect(lastEmitted(wrapper)).toEqual(['2026-09-26', '']) end.element.focus() await end.trigger('focus') expect(end.element.value).toBe('') await end.setValue('2026-12-31') await end.trigger('blur') expect(lastEmitted(wrapper)).toEqual(['2026-09-26', '2026-12-31']) wrapper.unmount() }) it('两端都清空后 emit 空串数组,清除按钮消失', async () => { const wrapper = mountRange({ format: 'YYYY-MM-DD', modelValue: ['2026-09-26', '2026-12-31'] }) const [start, end] = inputs(wrapper) await start.setValue('') await start.trigger('blur') await end.setValue('') await end.trigger('blur') expect(lastEmitted(wrapper)).toEqual(['', '']) await nextTick() expect(wrapper.find('.fms-date-clear').exists()).toBe(false) wrapper.unmount() }) it('Esc 只还原当前聚焦端,另一端保持不变', async () => { const wrapper = mountRange({ format: 'YYYY-MM-DD', modelValue: ['2026-01-01', '2026-12-31'], }) const [start, end] = inputs(wrapper) await start.setValue('2030-05-05') await start.trigger('keydown', { key: 'Escape' }) await nextTick() expect(start.element.value).toBe('2026-01-01') expect(end.element.value).toBe('2026-12-31') expect(pickerOf(wrapper).emitted('update:modelValue')).toBeFalsy() wrapper.unmount() }) it('Enter 提交当前端并关闭面板', async () => { const wrapper = mountRange({ format: 'YYYY-MM-DD' }) const [start] = inputs(wrapper) await start.trigger('click') await nextTick() expect(wrapper.find('.fms-range-picker').classes()).toContain('is-open') await start.setValue('2026-09-26') await start.trigger('keydown', { key: 'Enter' }) await nextTick() expect(lastEmitted(wrapper)).toEqual(['2026-09-26', '']) expect(wrapper.find('.fms-range-picker').classes()).not.toContain('is-open') wrapper.unmount() }) }) describe('RangePicker 两端方向键微调', () => { it('两端各自独立微调:开始端改年、结束端改日', async () => { const wrapper = mountRange({ format: 'YYYY-MM-DD', modelValue: ['2026-01-01', '2026-12-31'], }) const [start, end] = inputs(wrapper) await start.trigger('focus') await stepAt(start, 2, 'ArrowUp') expect(start.element.value).toBe('2027-01-01') expect(end.element.value).toBe('2026-12-31') await end.trigger('focus') await stepAt(end, 9, 'ArrowUp') expect(end.element.value).toBe('2027-01-01') // 12-31 跨年进位 wrapper.unmount() }) it('微调结果失焦后提交,且倒序时自动排序', async () => { const wrapper = mountRange({ format: 'YYYY-MM-DD', modelValue: ['2026-01-01', '2026-12-31'], }) const [start] = inputs(wrapper) await start.trigger('focus') await stepAt(start, 2, 'ArrowUp') // 2027-01-01 超过结束端 await start.trigger('blur') expect(lastEmitted(wrapper)).toEqual(['2026-12-31', '2027-01-01']) wrapper.unmount() }) }) describe('RangePicker datetime 与面板协作', () => { it('datetime 端点提交出秒级字符串', async () => { const wrapper = mountRange({ format: 'YYYY-MM-DD HH:mm', valueFormat: 'YYYY-MM-DD HH:mm:ss', }) const [start, end] = inputs(wrapper) await start.setValue('2026-09-26 14:30') await start.trigger('blur') await end.setValue('2026-09-26 18:00') await end.trigger('blur') expect(lastEmitted(wrapper)).toEqual(['2026-09-26 14:30:00', '2026-09-26 18:00:00']) wrapper.unmount() }) it('回归:面板点选两端后「确定」仍提交字符串数组', async () => { const wrapper = mountRange({ format: 'YYYY-MM-DD' }) const [start] = inputs(wrapper) await start.trigger('click') await nextTick() const cells = document.body.querySelectorAll('.fms-cal-cell:not(.is-disabled)') cells[10].click() await nextTick() cells[20].click() await nextTick() const committed = lastEmitted(wrapper) expect(committed).toHaveLength(2) expect(committed[0]).toMatch(/^\d{4}-\d{2}-\d{2}$/) expect(committed[1]).toMatch(/^\d{4}-\d{2}-\d{2}$/) expect(committed[0] <= committed[1]).toBe(true) wrapper.unmount() }) it('回归:清除按钮清空两端', async () => { const wrapper = mountRange({ format: 'YYYY-MM-DD', modelValue: ['2026-01-01', '2026-12-31'], }) await inputs(wrapper)[0].trigger('click') await nextTick() await wrapper.find('.fms-date-clear').trigger('click') await nextTick() expect(lastEmitted(wrapper)).toEqual(['', '']) wrapper.unmount() }) })