20260927215912

This commit is contained in:
oneao committed 2026-09-27 21:59:13 +08:00
1 parent 27e49e2e45
commit f22e67fc85
294 files changed
+29247 -3576

No files matched your search

@@ -0,0 +1,349 @@
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: `
<DatePicker
v-model="value"
:format="format"
:value-format="valueFormat"
:disabled="disabled"
/>
`,
},
{ 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()
}
})
})
@@ -0,0 +1,282 @@
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: `
<RangePicker
v-model="value"
:format="format"
:value-format="valueFormat"
:disabled="disabled"
/>
`,
},
{ 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()
})
})