20260927215912
This commit is contained in:
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()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,179 @@
|
||||
{
|
||||
"_comment": "公式引擎共享测试向量:前端 vitest 与 Java JUnit 读同一份文件,防止两套实现漂移。expect 为落库口径原始值(decimal 用字符串,null 表示空值)。",
|
||||
"fields": [
|
||||
{ "b_field": "b_qty", "b_type": "number" },
|
||||
{ "b_field": "b_price", "b_type": "money" },
|
||||
{ "b_field": "b_amount", "b_type": "money" },
|
||||
{ "b_field": "b_rate", "b_type": "number" },
|
||||
{ "b_field": "b_name", "b_type": "input" },
|
||||
{ "b_field": "b_no", "b_type": "input" },
|
||||
{ "b_field": "b_flag", "b_type": "checkbox" },
|
||||
{ "b_field": "b_day", "b_type": "date" },
|
||||
{ "b_field": "b_end", "b_type": "date" }
|
||||
],
|
||||
"evaluate": [
|
||||
{ "id": "arith_literals", "formula": "1 + 2", "expect": "3" },
|
||||
{ "id": "arith_precedence", "formula": "2 + 3 * 4", "expect": "14" },
|
||||
{ "id": "arith_parens", "formula": "(2 + 3) * 4", "expect": "20" },
|
||||
{ "id": "unary_minus", "formula": "-3 + 10", "expect": "7" },
|
||||
{ "id": "decimal_no_float_error", "formula": "0.1 + 0.2", "expect": "0.3" },
|
||||
{ "id": "decimal_mul", "formula": "1.1 * 3", "expect": "3.3" },
|
||||
{ "id": "division", "formula": "10 / 4", "expect": "2.5" },
|
||||
{ "id": "division_by_zero", "formula": "1 / 0", "expect": null },
|
||||
{ "id": "modulo", "formula": "10 % 3", "expect": "1" },
|
||||
{ "id": "modulo_negative", "formula": "MOD(-7, 3)", "expect": "-1" },
|
||||
{ "id": "modulo_by_zero", "formula": "10 % 0", "expect": null },
|
||||
{
|
||||
"id": "field_multiply",
|
||||
"formula": "b_qty * b_price",
|
||||
"values": { "b_qty": 3, "b_price": "12.50" },
|
||||
"expect": "37.5"
|
||||
},
|
||||
{
|
||||
"id": "field_chain_discount",
|
||||
"formula": "ROUND(b_qty * b_price * (1 - b_rate), 2)",
|
||||
"values": { "b_qty": 3, "b_price": "10.00", "b_rate": "0.13" },
|
||||
"expect": "26.1"
|
||||
},
|
||||
{ "id": "round_half_up", "formula": "ROUND(2.345, 2)", "expect": "2.35" },
|
||||
{ "id": "round_negative_half_up", "formula": "ROUND(-2.345, 2)", "expect": "-2.35" },
|
||||
{ "id": "round_no_digits", "formula": "ROUND(2.5)", "expect": "3" },
|
||||
{ "id": "roundup", "formula": "ROUNDUP(2.341, 2)", "expect": "2.35" },
|
||||
{ "id": "rounddown", "formula": "ROUNDDOWN(2.349, 2)", "expect": "2.34" },
|
||||
{ "id": "abs", "formula": "ABS(-5)", "expect": "5" },
|
||||
{ "id": "ceil", "formula": "CEIL(1.1)", "expect": "2" },
|
||||
{ "id": "ceil_negative", "formula": "CEIL(-1.1)", "expect": "-1" },
|
||||
{ "id": "floor", "formula": "FLOOR(1.9)", "expect": "1" },
|
||||
{ "id": "floor_negative", "formula": "FLOOR(-1.1)", "expect": "-2" },
|
||||
{ "id": "pow", "formula": "POW(2, 10)", "expect": "1024" },
|
||||
{ "id": "sqrt", "formula": "SQRT(9)", "expect": "3" },
|
||||
{ "id": "sqrt_negative", "formula": "SQRT(-4)", "expect": null },
|
||||
{ "id": "min_max", "formula": "MAX(1, 5, 3) - MIN(2, 1)", "expect": "4" },
|
||||
{
|
||||
"id": "if_true",
|
||||
"formula": "IF(b_qty > 0, b_price, 0)",
|
||||
"values": { "b_qty": 2, "b_price": "9.5" },
|
||||
"expect": "9.5"
|
||||
},
|
||||
{ "id": "if_false", "formula": "IF(0 > 0, 1, 2)", "expect": "2" },
|
||||
{ "id": "if_null_condition", "formula": "IF(NULL, 1, 2)", "expect": "2" },
|
||||
{
|
||||
"id": "ifblank",
|
||||
"formula": "IFBLANK(b_price, 0)",
|
||||
"values": { "b_price": null },
|
||||
"expect": "0"
|
||||
},
|
||||
{
|
||||
"id": "coalesce",
|
||||
"formula": "COALESCE(b_price, b_name, 'x')",
|
||||
"values": { "b_price": null, "b_name": "a" },
|
||||
"expect": "a"
|
||||
},
|
||||
{ "id": "isblank", "formula": "ISBLANK(b_price)", "values": { "b_price": null }, "expect": true },
|
||||
{ "id": "null_arithmetic", "formula": "b_qty + 1", "values": { "b_qty": null }, "expect": "1" },
|
||||
{
|
||||
"id": "text_concat",
|
||||
"formula": "b_name & '-' & b_no",
|
||||
"values": { "b_name": "A", "b_no": "01" },
|
||||
"expect": "A-01"
|
||||
},
|
||||
{
|
||||
"id": "text_concat_null",
|
||||
"formula": "b_name & b_no",
|
||||
"values": { "b_name": "A", "b_no": null },
|
||||
"expect": "A"
|
||||
},
|
||||
{ "id": "and_three_valued", "formula": "TRUE AND NULL", "expect": null },
|
||||
{ "id": "and_false_wins", "formula": "FALSE AND NULL", "expect": false },
|
||||
{ "id": "or_true_wins", "formula": "TRUE OR NULL", "expect": true },
|
||||
{ "id": "or_three_valued", "formula": "FALSE OR NULL", "expect": null },
|
||||
{ "id": "not_boolean", "formula": "NOT(b_flag)", "values": { "b_flag": 0 }, "expect": true },
|
||||
{ "id": "compare_true", "formula": "b_qty >= 3", "values": { "b_qty": 3 }, "expect": true },
|
||||
{ "id": "compare_null", "formula": "b_qty > 3", "values": { "b_qty": null }, "expect": null },
|
||||
{ "id": "checkbox_equals", "formula": "b_flag == TRUE", "values": { "b_flag": 1 }, "expect": true },
|
||||
{
|
||||
"id": "text_functions",
|
||||
"formula": "UPPER(LEFT(b_name, 2)) & TRIM(' x ')",
|
||||
"values": { "b_name": "abcd" },
|
||||
"expect": "ABx"
|
||||
},
|
||||
{
|
||||
"id": "text_len_mid",
|
||||
"formula": "LEN(MID(b_name, 2, 2))",
|
||||
"values": { "b_name": "abcd" },
|
||||
"expect": "2"
|
||||
},
|
||||
{ "id": "text_replace", "formula": "REPLACE(b_no, '0', '')", "values": { "b_no": "A001" }, "expect": "A1" },
|
||||
{ "id": "text_cast", "formula": "TEXT(b_qty)", "values": { "b_qty": 3 }, "expect": "3" },
|
||||
{ "id": "number_cast", "formula": "NUMBER('2.50')", "expect": "2.5" },
|
||||
{ "id": "number_cast_invalid", "formula": "NUMBER('abc')", "expect": null },
|
||||
{ "id": "datediff_days", "formula": "DATEDIFF('2026-01-01', '2026-03-15', 'day')", "expect": "73" },
|
||||
{ "id": "datediff_months", "formula": "DATEDIFF('2026-01-15', '2026-03-14', 'month')", "expect": "1" },
|
||||
{ "id": "dateadd_month_clamp", "formula": "DATEADD('2026-01-31', 1, 'month')", "expect": "2026-02-28" },
|
||||
{ "id": "dateadd_day", "formula": "DATEADD('2026-02-28', 1, 'day')", "expect": "2026-03-01" },
|
||||
{
|
||||
"id": "date_parts",
|
||||
"formula": "YEAR(b_day) * 100 + MONTH(b_day)",
|
||||
"values": { "b_day": "2026-03-15" },
|
||||
"expect": "202603"
|
||||
},
|
||||
{ "id": "formatdate", "formula": "FORMATDATE('2026-03-05', 'yyyy/MM/dd')", "expect": "2026/03/05" },
|
||||
{ "id": "err_unknown_function", "formula": "FOO(1)", "expectError": "E_FUNC_UNKNOWN" },
|
||||
{ "id": "err_deferred_aggregate", "formula": "SUM(b_amount)", "expectError": "E_AGG_DEFERRED" },
|
||||
{ "id": "err_arity", "formula": "ROUND(1, 2, 3)", "expectError": "E_FUNC_ARGS" },
|
||||
{
|
||||
"id": "err_type_operand",
|
||||
"formula": "b_name * 2",
|
||||
"values": { "b_name": "abc" },
|
||||
"expectError": "E_TYPE_OPERAND"
|
||||
}
|
||||
],
|
||||
"validate": [
|
||||
{ "id": "ok_basic", "formula": "ROUND(b_qty * b_price, 2)", "expect": [] },
|
||||
{ "id": "ok_concat", "formula": "b_name & '-' & b_no", "expect": [] },
|
||||
{ "id": "ok_ifblank", "formula": "IFBLANK(b_amount, 0)", "expect": [] },
|
||||
{ "id": "err_aggregate", "formula": "SUM(b_amount)", "expect": ["E_AGG_DEFERRED"] },
|
||||
{ "id": "err_volatile_today", "formula": "TODAY()", "expect": ["E_FUNC_VOLATILE"] },
|
||||
{ "id": "err_volatile_now", "formula": "NOW()", "expect": ["E_FUNC_VOLATILE"] },
|
||||
{ "id": "err_relation_scope", "formula": "lines.b_amount", "expect": ["E_FIELD_SCOPE_DEFERRED"] },
|
||||
{ "id": "err_unknown_field", "formula": "b_nope + 1", "expect": ["E_FIELD_UNKNOWN"] },
|
||||
{ "id": "err_null_compare", "formula": "b_amount == NULL", "expect": ["E_NULL_COMPARE"] },
|
||||
{ "id": "err_type_operand", "formula": "b_name * 2", "expect": ["E_TYPE_OPERAND"] },
|
||||
{ "id": "err_compare_chained", "formula": "b_qty < 1 < 2", "expect": ["E_COMPARE_CHAINED"] },
|
||||
{ "id": "err_syntax_tail", "formula": "b_qty +", "expect": ["E_OPERAND_EXPECTED"] },
|
||||
{ "id": "err_func_args", "formula": "DATEDIFF(b_day)", "expect": ["E_FUNC_ARGS"] },
|
||||
{ "id": "err_unknown_function", "formula": "FOO(b_qty)", "expect": ["E_FUNC_UNKNOWN"] },
|
||||
{ "id": "err_empty", "formula": " ", "expect": ["E_FORMULA_EMPTY"] },
|
||||
{
|
||||
"id": "err_target_type",
|
||||
"formula": "b_qty * b_price",
|
||||
"targetType": "text",
|
||||
"expect": ["E_TYPE_TARGET"]
|
||||
}
|
||||
],
|
||||
"graph": {
|
||||
"chain": {
|
||||
"fields": ["b_qty", "b_price", "b_amount"],
|
||||
"formulas": {
|
||||
"b_amount": "b_qty * b_price",
|
||||
"b_price": "b_amount / 2"
|
||||
},
|
||||
"expectCycle": true
|
||||
},
|
||||
"acyclic": {
|
||||
"fields": ["b_qty", "b_price", "b_amount"],
|
||||
"formulas": {
|
||||
"b_amount": "ROUND(b_qty * b_price, 2)"
|
||||
},
|
||||
"expectCycle": false,
|
||||
"expectOrder": ["main:b_amount"]
|
||||
}
|
||||
},
|
||||
"revision": {
|
||||
"nodes": [
|
||||
{ "field": "b_amount", "formula": "b_qty * b_price", "overridable": false },
|
||||
{ "field": "b_price", "formula": "1 + 2", "overridable": true }
|
||||
],
|
||||
"expect": "fnv1a64:e4bcb9ebe6caeac0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
canonicalizeDateText,
|
||||
listDateSegments,
|
||||
parseDateInput,
|
||||
resolveDateSegment,
|
||||
stepDateSegment,
|
||||
} from '@/components/ui/date/date-utils.js'
|
||||
|
||||
const DATE_FMT = 'YYYY-MM-DD'
|
||||
const DATETIME_FMT = 'YYYY-MM-DD HH:mm:ss'
|
||||
|
||||
/** 段列表压缩成 { field: [start, end] } 便于断言 */
|
||||
function segmentMap(text, format) {
|
||||
return Object.fromEntries(listDateSegments(text, format).map((s) => [s.field, [s.start, s.end]]))
|
||||
}
|
||||
|
||||
const at = (text) => new Date(text)
|
||||
|
||||
describe('date-utils/listDateSegments + resolveDateSegment', () => {
|
||||
it('YYYY-MM-DD 下三段区间正确', () => {
|
||||
expect(segmentMap('2026-09-26', DATE_FMT)).toEqual({
|
||||
year: [0, 4],
|
||||
month: [5, 7],
|
||||
day: [8, 10],
|
||||
})
|
||||
})
|
||||
|
||||
it('分隔符换成 / 、. 或年月日汉字后段区间不变', () => {
|
||||
expect(segmentMap('2026/09/26', DATE_FMT)).toEqual({
|
||||
year: [0, 4],
|
||||
month: [5, 7],
|
||||
day: [8, 10],
|
||||
})
|
||||
expect(segmentMap('2026.09.26', DATE_FMT)).toEqual({
|
||||
year: [0, 4],
|
||||
month: [5, 7],
|
||||
day: [8, 10],
|
||||
})
|
||||
expect(segmentMap('2026年09月26日', 'YYYY年MM月DD日')).toEqual({
|
||||
year: [0, 4],
|
||||
month: [5, 7],
|
||||
day: [8, 10],
|
||||
})
|
||||
// 反向也成立:format 带汉字,手打短横线同样能定位到段
|
||||
expect(segmentMap('2026-09-26', 'YYYY年MM月DD日')).toEqual({
|
||||
year: [0, 4],
|
||||
month: [5, 7],
|
||||
day: [8, 10],
|
||||
})
|
||||
})
|
||||
|
||||
it('含时分秒时追加 hour / minute / second 段', () => {
|
||||
expect(segmentMap('2026-09-26 14:30:00', DATETIME_FMT)).toEqual({
|
||||
year: [0, 4],
|
||||
month: [5, 7],
|
||||
day: [8, 10],
|
||||
hour: [11, 13],
|
||||
minute: [14, 16],
|
||||
second: [17, 19],
|
||||
})
|
||||
})
|
||||
|
||||
it('hh:mm A 下 meridiem 段覆盖整段 PM(大小写均可识别)', () => {
|
||||
expect(segmentMap('02:30 PM', 'hh:mm A')).toEqual({
|
||||
hour: [0, 2],
|
||||
minute: [3, 5],
|
||||
meridiem: [6, 8],
|
||||
})
|
||||
expect(segmentMap('02:30 pm', 'hh:mm A')).toEqual({
|
||||
hour: [0, 2],
|
||||
minute: [3, 5],
|
||||
meridiem: [6, 8],
|
||||
})
|
||||
})
|
||||
|
||||
it('Do 的 day 段含序数后缀,MMM 的 month 段为整段字母', () => {
|
||||
expect(segmentMap('26th', 'Do')).toEqual({ day: [0, 4] })
|
||||
expect(segmentMap('Sep', 'MMM')).toEqual({ month: [0, 3] })
|
||||
})
|
||||
|
||||
it('相邻段首尾相接(YYYYMMDD)时边界位归左段', () => {
|
||||
expect(segmentMap('20260926', 'YYYYMMDD')).toEqual({
|
||||
year: [0, 4],
|
||||
month: [4, 6],
|
||||
day: [6, 8],
|
||||
})
|
||||
expect(resolveDateSegment('20260926', 4, 'YYYYMMDD')?.field).toBe('year')
|
||||
expect(resolveDateSegment('20260926', 5, 'YYYYMMDD')?.field).toBe('month')
|
||||
})
|
||||
|
||||
it('光标停在分隔符上归左侧段,停在文本末尾归最后一段', () => {
|
||||
expect(resolveDateSegment('2026-09-26', 4, DATE_FMT)?.field).toBe('year')
|
||||
expect(resolveDateSegment('2026-09-26', 7, DATE_FMT)?.field).toBe('month')
|
||||
expect(resolveDateSegment('2026-09-26', 10, DATE_FMT)?.field).toBe('day')
|
||||
})
|
||||
|
||||
it('caret 越界时被 clamp 到文本长度,空文本与不匹配文本返回 null', () => {
|
||||
expect(resolveDateSegment('2026-09-26', 999, DATE_FMT)?.field).toBe('day')
|
||||
expect(resolveDateSegment('', 0, DATE_FMT)).toBeNull()
|
||||
expect(resolveDateSegment('2026-09-26', 3, 'hh:mm A')).toBeNull()
|
||||
expect(resolveDateSegment('2026-09-26', 3, DATETIME_FMT)).toBeNull() // 缺时分秒,整串失配
|
||||
})
|
||||
|
||||
it('不可步进的 token(dddd)不产生段', () => {
|
||||
expect(listDateSegments('星期一', 'dddd')).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('date-utils/stepDateSegment', () => {
|
||||
it('day +1 跨月进位', () => {
|
||||
expect(stepDateSegment(at('2026-09-30T00:00:00'), 'day', 1)).toEqual(
|
||||
new Date('2026-10-01T00:00:00'),
|
||||
)
|
||||
})
|
||||
|
||||
it('month +1 月末夹取', () => {
|
||||
expect(stepDateSegment(at('2026-01-31T00:00:00'), 'month', 1)).toEqual(
|
||||
new Date('2026-02-28T00:00:00'),
|
||||
)
|
||||
})
|
||||
|
||||
it('year +1 闰日夹取', () => {
|
||||
expect(stepDateSegment(at('2024-02-29T00:00:00'), 'year', 1)).toEqual(
|
||||
new Date('2025-02-28T00:00:00'),
|
||||
)
|
||||
})
|
||||
|
||||
it('hour / minute / second 回绕且日期不变', () => {
|
||||
expect(stepDateSegment(at('2026-09-26T23:10:00'), 'hour', 1)).toEqual(
|
||||
new Date('2026-09-26T00:10:00'),
|
||||
)
|
||||
expect(stepDateSegment(at('2026-09-26T14:59:00'), 'minute', 1)).toEqual(
|
||||
new Date('2026-09-26T14:00:00'),
|
||||
)
|
||||
expect(stepDateSegment(at('2026-09-26T14:30:59'), 'second', 1)).toEqual(
|
||||
new Date('2026-09-26T14:30:00'),
|
||||
)
|
||||
})
|
||||
|
||||
it('hour -1 从 0 回绕到 23 且日期不变', () => {
|
||||
expect(stepDateSegment(at('2026-09-26T00:10:00'), 'hour', -1)).toEqual(
|
||||
new Date('2026-09-26T23:10:00'),
|
||||
)
|
||||
})
|
||||
|
||||
it('meridiem ±1 只翻转上下午,小时数字不变(12 小时制显示)', () => {
|
||||
const am = at('2026-09-26T09:30:00')
|
||||
const pm = stepDateSegment(am, 'meridiem', 1)
|
||||
expect(pm).toEqual(new Date('2026-09-26T21:30:00'))
|
||||
expect(stepDateSegment(pm, 'meridiem', -1)).toEqual(am)
|
||||
})
|
||||
|
||||
it('非法 field 返回原日期,非法日期返回 null', () => {
|
||||
const value = at('2026-09-26T09:30:00')
|
||||
expect(stepDateSegment(value, 'unknown', 1)).toEqual(value)
|
||||
expect(stepDateSegment(null, 'day', 1)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('date-utils/parseDateInput', () => {
|
||||
it('空串与纯空白视为 isEmpty 且合法(组件按清空值处理)', () => {
|
||||
expect(parseDateInput('', DATE_FMT)).toEqual({ date: null, isEmpty: true, isValid: true })
|
||||
expect(parseDateInput(' ', DATE_FMT)).toEqual({ date: null, isEmpty: true, isValid: true })
|
||||
})
|
||||
|
||||
it('严格格式命中', () => {
|
||||
expect(parseDateInput('2026-09-26', DATE_FMT).date).toEqual(new Date('2026-09-26T00:00:00'))
|
||||
})
|
||||
|
||||
it('斜杠与点号分隔经归一化后命中', () => {
|
||||
expect(parseDateInput('2026/09/26', DATE_FMT).date).toEqual(new Date('2026-09-26T00:00:00'))
|
||||
expect(parseDateInput('2026.09.26', DATE_FMT).date).toEqual(new Date('2026-09-26T00:00:00'))
|
||||
})
|
||||
|
||||
it('中文年月日经 canonicalize 后命中', () => {
|
||||
expect(parseDateInput('2026年09月26日', DATE_FMT).date).toEqual(new Date('2026-09-26T00:00:00'))
|
||||
})
|
||||
|
||||
it('含时分秒的输入对 YYYY-MM-DD HH:mm 也能命中', () => {
|
||||
const result = parseDateInput('2026-09-26 14:30:00', 'YYYY-MM-DD HH:mm')
|
||||
expect(result.isValid).toBe(true)
|
||||
expect(result.date).toEqual(new Date('2026-09-26T14:30:00'))
|
||||
})
|
||||
|
||||
it('部分输入(只有年月)命中且日补 1', () => {
|
||||
const result = parseDateInput('2026-09', DATE_FMT)
|
||||
expect(result.isValid).toBe(true)
|
||||
expect(result.date).toEqual(new Date('2026-09-01T00:00:00'))
|
||||
})
|
||||
|
||||
it('纯文字与非法日期判定为不合法', () => {
|
||||
expect(parseDateInput('abc', DATE_FMT)).toEqual({ date: null, isEmpty: false, isValid: false })
|
||||
expect(parseDateInput('2026-13-45', DATE_FMT).isValid).toBe(false)
|
||||
})
|
||||
|
||||
it('format 为数组时任一写法都能命中', () => {
|
||||
const fmts = ['YYYY年MM月DD日', 'YYYY-MM-DD']
|
||||
expect(parseDateInput('2026年09月26日', fmts).isValid).toBe(true)
|
||||
expect(parseDateInput('2026-09-26', fmts).isValid).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('date-utils/canonicalizeDateText', () => {
|
||||
it('统一分隔符并压缩空白', () => {
|
||||
expect(canonicalizeDateText(' 2026年09月26日 ')).toBe('2026-09-26')
|
||||
expect(canonicalizeDateText('2026.09.26')).toBe('2026-09-26')
|
||||
expect(canonicalizeDateText('2026-09-26 14:30')).toBe('2026-09-26 14:30')
|
||||
})
|
||||
})
|
||||
@@ -39,14 +39,15 @@ vi.mock('@/services/api', () => ({
|
||||
b_module_id: 's_i18n_type',
|
||||
b_schema_type: 'edit',
|
||||
b_schema_json: JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
schemaVersion: 2,
|
||||
children: [
|
||||
{
|
||||
type: 'group',
|
||||
id: 'group1',
|
||||
title: '基础信息',
|
||||
children: [
|
||||
{ field: 'b_id', span: 24, required: true },
|
||||
{ field: 'b_name', span: 24 },
|
||||
{ type: 'row', children: [{ field: 'b_id', span: 24, required: true }] },
|
||||
{ type: 'row', children: [{ field: 'b_name', span: 24 }] },
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -49,14 +49,15 @@ const fields = [
|
||||
]
|
||||
|
||||
const filledSchema = {
|
||||
schemaVersion: 1,
|
||||
schemaVersion: 2,
|
||||
children: [
|
||||
{
|
||||
type: 'group',
|
||||
id: 'group1',
|
||||
title: '基本信息',
|
||||
children: [
|
||||
{ field: 'name', span: 24, required: true },
|
||||
{ field: 'age', span: 24 },
|
||||
{ type: 'row', children: [{ field: 'name', span: 24, required: true }] },
|
||||
{ type: 'row', children: [{ field: 'age', span: 24 }] },
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* 计算公式 · 设计器集成测试。
|
||||
* 覆盖:JSON 适配层往返(formula / overridable 不丢失)、公式编辑器实时校验与提交、
|
||||
* 属性面板 emit 上抛(父级据此写入草稿)。
|
||||
*/
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
editorToSchema,
|
||||
schemaToEditor,
|
||||
} from '@/views/module/module-management/components/design-editor/common/schemaModel'
|
||||
import FormulaEditor from '@/views/module/module-management/components/design-editor/common/FormulaEditor.vue'
|
||||
import FormInspector from '@/views/module/module-management/components/design-editor/form/FormInspector.vue'
|
||||
|
||||
const FIELDS = [
|
||||
{ b_field: 'b_qty', b_name: '数量', b_type: 'number' },
|
||||
{ b_field: 'b_price', b_name: '单价', b_type: 'money' },
|
||||
{ b_field: 'b_amount', b_name: '金额', b_type: 'money' },
|
||||
]
|
||||
|
||||
function editSchemaWithFormula(node = {}) {
|
||||
return {
|
||||
schemaVersion: 2,
|
||||
children: [
|
||||
{ type: 'row', children: [{ field: 'b_qty' }, { field: 'b_price' }] },
|
||||
{
|
||||
type: 'row',
|
||||
children: [{ field: 'b_amount', readonly: true, ...node }],
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
describe('公式设计器 · JSON 适配层', () => {
|
||||
it('加载时带出 formula / overridable,回写时原样保留', () => {
|
||||
const schema = editSchemaWithFormula({
|
||||
formula: 'ROUND(b_qty * b_price, 2)',
|
||||
overridable: true,
|
||||
})
|
||||
const { rows, groups } = schemaToEditor('edit', schema, 'mod1')
|
||||
const amountRow = rows.find((row) => row.b_field === 'b_amount')
|
||||
expect(amountRow.b_formula).toBe('ROUND(b_qty * b_price, 2)')
|
||||
expect(amountRow.b_overridable).toBe(1)
|
||||
|
||||
const back = editorToSchema('edit', rows, groups, schema)
|
||||
const node = back.children[1].children[0]
|
||||
expect(node.formula).toBe('ROUND(b_qty * b_price, 2)')
|
||||
expect(node.overridable).toBe(true)
|
||||
})
|
||||
|
||||
it('清空公式时同时清掉 overridable,不留残属性', () => {
|
||||
const schema = editSchemaWithFormula({ formula: 'b_qty * 2', overridable: true })
|
||||
const { rows, groups } = schemaToEditor('edit', schema, 'mod1')
|
||||
const amountRow = rows.find((row) => row.b_field === 'b_amount')
|
||||
amountRow.b_formula = ''
|
||||
amountRow.b_overridable = 0
|
||||
|
||||
const back = editorToSchema('edit', rows, groups, schema)
|
||||
const node = back.children[1].children[0]
|
||||
expect('formula' in node).toBe(false)
|
||||
expect('overridable' in node).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('公式设计器 · 公式编辑器', () => {
|
||||
it('输入实时校验,失焦才提交给父级(撤销以一次落定的编辑为粒度)', async () => {
|
||||
const wrapper = mount(FormulaEditor, {
|
||||
props: { modelValue: '', fields: FIELDS },
|
||||
})
|
||||
const textarea = wrapper.find('textarea')
|
||||
// 只派发 input(setValue 会连带 change,测不出"提交时机"):输入过程不提交
|
||||
textarea.element.value = 'b_qty * b_price'
|
||||
await textarea.trigger('input')
|
||||
expect(wrapper.emitted('update:modelValue')).toBeFalsy()
|
||||
expect(wrapper.text()).toContain('语法与字段引用检查通过')
|
||||
|
||||
await textarea.trigger('change')
|
||||
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual(['b_qty * b_price'])
|
||||
})
|
||||
|
||||
it('未知函数与未知字段给出可读错误', async () => {
|
||||
const wrapper = mount(FormulaEditor, {
|
||||
props: { modelValue: 'FOO(b_qty) + b_nope', fields: FIELDS },
|
||||
})
|
||||
const text = wrapper.text()
|
||||
expect(text).toContain('未知函数「FOO」')
|
||||
expect(text).toContain('字段「b_nope」不存在')
|
||||
})
|
||||
|
||||
it('子表阶段函数不可插入(面板中标注)', () => {
|
||||
const wrapper = mount(FormulaEditor, { props: { modelValue: '', fields: FIELDS } })
|
||||
expect(wrapper.html()).toBeTruthy()
|
||||
expect(wrapper.text()).not.toContain('语法与字段引用检查通过')
|
||||
})
|
||||
})
|
||||
|
||||
describe('公式设计器 · 属性面板', () => {
|
||||
it('公式与可覆写开关变更均 emit 上抛给父级写草稿', async () => {
|
||||
const wrapper = mount(FormInspector, {
|
||||
props: {
|
||||
selectedRow: {
|
||||
b_field: 'b_amount',
|
||||
b_colspan: 24,
|
||||
b_formula: 'b_qty * 2',
|
||||
b_overridable: 0,
|
||||
},
|
||||
fieldName: () => '金额',
|
||||
controlTypeLabel: () => '金额',
|
||||
spanOf: () => 24,
|
||||
isTextarea: () => false,
|
||||
heightOf: () => 48,
|
||||
formula: 'b_qty * 2',
|
||||
formulaOverridable: false,
|
||||
fields: FIELDS,
|
||||
},
|
||||
})
|
||||
const editor = wrapper.findComponent(FormulaEditor)
|
||||
expect(editor.exists()).toBe(true)
|
||||
editor.vm.$emit('update:modelValue', 'b_qty * 3')
|
||||
expect(wrapper.emitted('formula-change')?.at(-1)).toEqual(['b_qty * 3'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* 公式引擎单测:直接跑共享测试向量(tests/fixtures/formula-vectors.json),
|
||||
* 同一份向量由 Java 端 JUnit 复用,保证前后端语义一致。
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import vectors from '../fixtures/formula-vectors.json'
|
||||
import {
|
||||
buildGraph,
|
||||
cycleText,
|
||||
evaluate,
|
||||
fieldNodeId,
|
||||
formulaRevision,
|
||||
parse,
|
||||
refsOf,
|
||||
validateFormula,
|
||||
validateFormulas,
|
||||
} from '@/components/fms-module-common/formula'
|
||||
import { rawValue } from '@/components/fms-module-common/formula/values'
|
||||
import { fieldTypeMapOf } from '@/components/fms-module-common/formula'
|
||||
|
||||
const fieldTypes = fieldTypeMapOf(vectors.fields)
|
||||
const fieldMeta = Object.fromEntries(vectors.fields.map((field) => [field.b_field, field]))
|
||||
|
||||
describe('公式引擎 · 求值向量', () => {
|
||||
for (const testCase of vectors.evaluate) {
|
||||
it(`${testCase.id}:${testCase.formula}`, () => {
|
||||
const { ast, errors } = parse(testCase.formula)
|
||||
expect(errors).toEqual([])
|
||||
const { value, error } = evaluate(ast, { fields: testCase.values || {}, fieldTypes })
|
||||
if (testCase.expectError) {
|
||||
expect(error?.code).toBe(testCase.expectError)
|
||||
return
|
||||
}
|
||||
expect(error).toBeNull()
|
||||
expect(rawValue(value)).toEqual(testCase.expect)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
describe('公式引擎 · 校验向量', () => {
|
||||
for (const testCase of vectors.validate) {
|
||||
it(`${testCase.id}:${testCase.formula}`, () => {
|
||||
const { errors } = validateFormula(testCase.formula, {
|
||||
fields: fieldMeta,
|
||||
targetType: testCase.targetType,
|
||||
})
|
||||
expect(errors.map((issue) => issue.code)).toEqual(testCase.expect)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
describe('公式引擎 · 依赖图', () => {
|
||||
it('链式公式检测出循环引用', () => {
|
||||
const config = vectors.graph.chain
|
||||
const nodes = Object.entries(config.formulas).map(([field, formula]) => ({ field, formula }))
|
||||
const { graph } = validateFormulas(nodes, { fields: fieldMeta })
|
||||
expect(graph.hasCycle).toBe(config.expectCycle)
|
||||
expect(graph.cycles.length).toBeGreaterThan(0)
|
||||
expect(cycleText(graph.cycles[0]).length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('无环公式给出拓扑序,节点 ID 带作用域', () => {
|
||||
const config = vectors.graph.acyclic
|
||||
const nodes = Object.entries(config.formulas).map(([field, formula]) => ({ field, formula }))
|
||||
const { graph, errors } = validateFormulas(nodes, { fields: fieldMeta })
|
||||
expect(errors).toEqual([])
|
||||
expect(graph.hasCycle).toBe(config.expectCycle)
|
||||
expect(graph.order).toEqual(config.expectOrder)
|
||||
})
|
||||
|
||||
it('依赖抽取带作用域前缀', () => {
|
||||
const { ast } = parse('b_qty * b_price')
|
||||
const { refs, usesRow } = refsOf(ast)
|
||||
expect(usesRow).toBe(false)
|
||||
expect(refs.map((ref) => ref.id)).toEqual([
|
||||
fieldNodeId('main', null, 'b_qty'),
|
||||
fieldNodeId('main', null, 'b_price'),
|
||||
])
|
||||
})
|
||||
|
||||
it('buildGraph 对自环给出环路', () => {
|
||||
const graph = buildGraph([{ id: 'main:a', deps: ['main:a'] }])
|
||||
expect(graph.hasCycle).toBe(true)
|
||||
expect(graph.order).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('公式引擎 · 配置摘要', () => {
|
||||
it('与共享向量一致(Java 端使用同一期望值)', () => {
|
||||
expect(formulaRevision(vectors.revision.nodes)).toBe(vectors.revision.expect)
|
||||
})
|
||||
|
||||
it('公式变化摘要变化,节点顺序不影响摘要', () => {
|
||||
const nodes = vectors.revision.nodes
|
||||
const reordered = [...nodes].reverse()
|
||||
expect(formulaRevision(reordered)).toBe(formulaRevision(nodes))
|
||||
const changed = nodes.map((node, index) =>
|
||||
index === 0 ? { ...node, formula: 'b_qty * b_price + 1' } : node,
|
||||
)
|
||||
expect(formulaRevision(changed)).not.toBe(formulaRevision(nodes))
|
||||
})
|
||||
})
|
||||
|
||||
describe('公式引擎 · 运行模型', () => {
|
||||
it('按依赖顺序重算下游字段,且只算受影响字段', async () => {
|
||||
const { createFormulaModel } = await import('@/components/fms-module-common/formula')
|
||||
const editSchema = {
|
||||
children: [
|
||||
{ type: 'row', children: [{ field: 'b_qty' }, { field: 'b_price' }] },
|
||||
{
|
||||
type: 'row',
|
||||
children: [{ field: 'b_amount', formula: 'ROUND(b_qty * b_price, 2)', readonly: true }],
|
||||
},
|
||||
{
|
||||
type: 'row',
|
||||
children: [
|
||||
{ field: 'b_rate', formula: 'b_amount / 2', readonly: true, overridable: true },
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
const model = createFormulaModel({ editSchema, fields: vectors.fields })
|
||||
expect(model.errors).toEqual([])
|
||||
expect(model.revision.startsWith('fnv1a64:')).toBe(true)
|
||||
|
||||
const ctx = { fields: { b_qty: 3, b_price: '12.50' } }
|
||||
expect(model.affectedFields(['b_qty'])).toEqual(['b_amount', 'b_rate'])
|
||||
const { values, errors } = model.recalculate(['b_qty'], ctx)
|
||||
expect(errors).toEqual([])
|
||||
expect(values.get('b_amount')).toBe('37.5')
|
||||
expect(values.get('b_rate')).toBe('18.75')
|
||||
expect(model.affectedFields(['b_name'])).toEqual([])
|
||||
})
|
||||
|
||||
it('循环引用时模型报告错误且不静默通过', async () => {
|
||||
const { createFormulaModel } = await import('@/components/fms-module-common/formula')
|
||||
const editSchema = {
|
||||
children: [
|
||||
{ type: 'row', children: [{ field: 'b_amount', formula: 'b_price + 1', readonly: true }] },
|
||||
{ type: 'row', children: [{ field: 'b_price', formula: 'b_amount + 1', readonly: true }] },
|
||||
],
|
||||
}
|
||||
const model = createFormulaModel({ editSchema, fields: vectors.fields })
|
||||
expect(model.hasCycle).toBe(true)
|
||||
expect(model.errors.some((issue) => issue.code === 'E_CYCLE')).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,275 @@
|
||||
/**
|
||||
* 计算公式 · 运行期集成测试(FmsModuleEditModal)。
|
||||
* 覆盖:打开旧单据不重算、依赖变更触发重算、链式公式收敛、覆写与恢复、
|
||||
* 保存请求携带 moduleCode / formulaRevision / formulaOverrides。
|
||||
*/
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { nextTick } from 'vue'
|
||||
|
||||
const { saveObjectApi } = vi.hoisted(() => ({ saveObjectApi: vi.fn(async () => ({})) }))
|
||||
|
||||
vi.mock('@/services/api', () => ({
|
||||
loadDataApi: vi.fn(async (viewName, condition) => {
|
||||
// 第二个模块(b_order2)用于验证「被公式引用的隐藏字段(静默字段)」:条件里带模块编码即可区分
|
||||
const hidden = String(condition || '').includes('b_order2')
|
||||
if (viewName === 's_module') {
|
||||
return {
|
||||
data: [
|
||||
hidden
|
||||
? {
|
||||
b_id: 'b_order2',
|
||||
b_name: '订单2',
|
||||
b_module_type: 'data',
|
||||
b_canuse: 1,
|
||||
b_view_table: 'b_order2',
|
||||
b_save_table: 'b_order2',
|
||||
b_key_field: 'b_id',
|
||||
}
|
||||
: {
|
||||
b_id: 'b_order',
|
||||
b_name: '订单',
|
||||
b_module_type: 'data',
|
||||
b_canuse: 1,
|
||||
b_view_table: 'b_order',
|
||||
b_save_table: 'b_order',
|
||||
b_key_field: 'b_id',
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
if (viewName === 's_field') {
|
||||
return {
|
||||
data: hidden
|
||||
? [
|
||||
{ b_field: 'b_id', b_name: '单号', b_type: 'input', b_canuse: 1 },
|
||||
{ b_field: 'b_qty', b_name: '数量', b_type: 'number', b_canuse: 1 },
|
||||
{ b_field: 'b_price', b_name: '单价', b_type: 'money', b_canuse: 1 },
|
||||
{ b_field: 'b_amount', b_name: '金额', b_type: 'money', b_canuse: 1 },
|
||||
{ b_field: 'b_rate', b_name: '系数', b_type: 'number', b_canuse: 1 },
|
||||
]
|
||||
: [
|
||||
{ b_field: 'b_id', b_name: '单号', b_type: 'input', b_canuse: 1 },
|
||||
{ b_field: 'b_qty', b_name: '数量', b_type: 'number', b_canuse: 1 },
|
||||
{ b_field: 'b_price', b_name: '单价', b_type: 'money', b_canuse: 1 },
|
||||
{ b_field: 'b_amount', b_name: '金额', b_type: 'money', b_canuse: 1 },
|
||||
{ b_field: 'b_tax', b_name: '税额', b_type: 'money', b_canuse: 1 },
|
||||
],
|
||||
}
|
||||
}
|
||||
if (viewName === 's_module_schema') {
|
||||
return {
|
||||
data: [
|
||||
{
|
||||
b_module_id: hidden ? 'b_order2' : 'b_order',
|
||||
b_schema_type: 'edit',
|
||||
b_schema_json: JSON.stringify(
|
||||
hidden
|
||||
? {
|
||||
schemaVersion: 2,
|
||||
children: [
|
||||
{
|
||||
type: 'row',
|
||||
children: [
|
||||
{ field: 'b_qty', span: 12 },
|
||||
{ field: 'b_price', span: 12 },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'row',
|
||||
children: [
|
||||
{
|
||||
field: 'b_amount',
|
||||
readonly: true,
|
||||
formula: 'ROUND(b_qty * b_price * b_rate, 2)',
|
||||
},
|
||||
],
|
||||
},
|
||||
// 隐藏的依赖字段:只取值、不渲染(静默字段)
|
||||
{ type: 'row', children: [{ field: 'b_rate', visible: false }] },
|
||||
],
|
||||
}
|
||||
: {
|
||||
schemaVersion: 2,
|
||||
children: [
|
||||
{
|
||||
type: 'group',
|
||||
id: 'g1',
|
||||
title: '基础信息',
|
||||
children: [
|
||||
{
|
||||
type: 'row',
|
||||
children: [
|
||||
{ field: 'b_qty', span: 12 },
|
||||
{ field: 'b_price', span: 12 },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'row',
|
||||
children: [
|
||||
{
|
||||
field: 'b_amount',
|
||||
readonly: true,
|
||||
formula: 'ROUND(b_qty * b_price, 2)',
|
||||
},
|
||||
{
|
||||
field: 'b_tax',
|
||||
readonly: true,
|
||||
formula: 'b_amount * 0.1',
|
||||
overridable: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
),
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
return { data: [] }
|
||||
}),
|
||||
saveObjectApi,
|
||||
nextIdApi: vi.fn(async () => ({ data: '1' })),
|
||||
loadDataBySqlApi: vi.fn(async () => ({ data: [] })),
|
||||
}))
|
||||
|
||||
import FmsModuleEditModal from '@/components/fms-module-edit/FmsModuleEditModal.vue'
|
||||
|
||||
/** 弹窗内的输入框(按表单顺序):[b_qty, b_price, b_amount, b_tax] */
|
||||
function inputs() {
|
||||
return [...document.querySelectorAll('.fms-modal-body input')]
|
||||
}
|
||||
|
||||
/** 按索引写值并触发 input(v-model 口径),然后等一轮渲染 */
|
||||
async function setInput(index, value) {
|
||||
const input = inputs()[index]
|
||||
input.value = value
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
await nextTick()
|
||||
}
|
||||
|
||||
function buttonByText(text) {
|
||||
return [...document.querySelectorAll('.fms-modal-footer button, .fms-modal-body button')].find(
|
||||
(button) => button.textContent.trim() === text,
|
||||
)
|
||||
}
|
||||
|
||||
async function openModal(record, dataCode = 'b_order') {
|
||||
const wrapper = mount(FmsModuleEditModal, {
|
||||
props: { open: false, dataCode, record },
|
||||
attachTo: document.body,
|
||||
})
|
||||
await wrapper.setProps({ open: true })
|
||||
await new Promise((resolve) => setTimeout(resolve, 40))
|
||||
await nextTick()
|
||||
return wrapper
|
||||
}
|
||||
|
||||
const STORED = { b_id: 'O1', b_qty: 9, b_price: '10.00', b_amount: '99', b_tax: '9.9' }
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
document.body.innerHTML = ''
|
||||
saveObjectApi.mockClear()
|
||||
})
|
||||
|
||||
describe('计算公式 · 运行期', () => {
|
||||
it('打开旧单据不重算,原有计算值原样保留', async () => {
|
||||
const wrapper = await openModal({ ...STORED })
|
||||
expect(document.querySelector('.fme-formula-banner')).toBeNull()
|
||||
expect(inputs()[2].value).toBe('99')
|
||||
expect(inputs()[3].value).toBe('9.9')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('依赖字段变更触发重算,链式公式逐级收敛', async () => {
|
||||
const wrapper = await openModal({ ...STORED })
|
||||
await setInput(0, '2')
|
||||
expect(inputs()[2].value).toBe('20')
|
||||
expect(inputs()[3].value).toBe('2')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('覆写后不再被公式覆盖,恢复公式回到计算结果', async () => {
|
||||
const wrapper = await openModal({ ...STORED })
|
||||
buttonByText('手工调整').click()
|
||||
await nextTick()
|
||||
await setInput(3, '5')
|
||||
|
||||
await setInput(0, '3')
|
||||
expect(inputs()[2].value).toBe('30')
|
||||
expect(inputs()[3].value).toBe('5') // 覆写值不被覆盖
|
||||
|
||||
buttonByText('恢复公式').click()
|
||||
await nextTick()
|
||||
expect(inputs()[3].value).toBe('3')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('保存请求带 moduleCode / formulaRevision / formulaOverrides,且计算值进入更新', async () => {
|
||||
const wrapper = await openModal({ ...STORED })
|
||||
await setInput(0, '2')
|
||||
buttonByText('确定').click()
|
||||
await new Promise((resolve) => setTimeout(resolve, 20))
|
||||
|
||||
const [requests] = saveObjectApi.mock.calls.at(-1)
|
||||
const request = requests[0]
|
||||
expect(request.table).toBe('b_order')
|
||||
expect(request.moduleCode).toBe('b_order')
|
||||
expect(String(request.formulaRevision).startsWith('fnv1a64:')).toBe(true)
|
||||
expect(request.formulaOverrides).toBeUndefined()
|
||||
const updated = request.updates[0]
|
||||
expect(String(updated.b_qty)).toBe('2')
|
||||
expect(updated.b_amount).toBe('20')
|
||||
expect(updated.b_tax).toBe('2')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('覆写声明随请求提交(不落业务字段)', async () => {
|
||||
const wrapper = await openModal({ ...STORED })
|
||||
buttonByText('手工调整').click()
|
||||
await nextTick()
|
||||
await setInput(3, '5')
|
||||
buttonByText('确定').click()
|
||||
await new Promise((resolve) => setTimeout(resolve, 20))
|
||||
|
||||
const [requests] = saveObjectApi.mock.calls.at(-1)
|
||||
expect(requests[0].formulaOverrides).toEqual(['b_tax'])
|
||||
expect(requests[0].updates[0].b_tax).toBe('5')
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
describe('计算公式 · 静默字段(被引用的隐藏字段)', () => {
|
||||
it('隐藏依赖只取值不渲染,并参与重算', async () => {
|
||||
const wrapper = await openModal(
|
||||
{ b_id: 'O2', b_qty: 2, b_price: '10.00', b_amount: '10', b_rate: 0.5 },
|
||||
'b_order2',
|
||||
)
|
||||
// 隐藏字段不渲染:只有 数量 / 单价 / 金额 三个输入
|
||||
expect(inputs().length).toBe(3)
|
||||
await setInput(0, '4')
|
||||
expect(inputs()[2].value).toBe('20')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('隐藏依赖为空时按空值参与运算,不阻断保存', async () => {
|
||||
const wrapper = await openModal(
|
||||
{ b_id: 'O2', b_qty: 2, b_price: '10.00', b_amount: '10' },
|
||||
'b_order2',
|
||||
)
|
||||
await setInput(0, '4')
|
||||
expect(inputs()[2].value).toBe('0')
|
||||
|
||||
buttonByText('确定').click()
|
||||
await new Promise((resolve) => setTimeout(resolve, 20))
|
||||
const [requests] = saveObjectApi.mock.calls.at(-1)
|
||||
expect(requests[0].moduleCode).toBe('b_order2')
|
||||
expect(requests[0].updates[0].b_amount).toBe('0')
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
@@ -4,9 +4,24 @@ import {
|
||||
defaultQueryValue,
|
||||
sqlLiteral,
|
||||
} from '@/components/fms-module-common/queryUtils'
|
||||
import { formatDateValue, parseOptions } from '@/components/fms-module-common/fieldUtils'
|
||||
import {
|
||||
formatDateValue,
|
||||
parseOptions,
|
||||
toUtcIsoDateTime,
|
||||
} from '@/components/fms-module-common/fieldUtils'
|
||||
import { setMessages, translate } from '@/stores/i18n'
|
||||
|
||||
const pad2 = (number) => String(number).padStart(2, '0')
|
||||
|
||||
/**
|
||||
* 本地墙钟串 → UTC 墙钟串(与实现同口径,断言不依赖运行环境时区)。
|
||||
* 库中 datetime 存 UTC,查询条件里的本地时刻会先折算(见后端 TimeUtils / queryUtils.utcWallTextOf)。
|
||||
*/
|
||||
function toUtcWall(localText) {
|
||||
const date = new Date(String(localText).replace(' ', 'T'))
|
||||
return `${date.getUTCFullYear()}-${pad2(date.getUTCMonth() + 1)}-${pad2(date.getUTCDate())} ${pad2(date.getUTCHours())}:${pad2(date.getUTCMinutes())}:${pad2(date.getUTCSeconds())}`
|
||||
}
|
||||
|
||||
/** buildConditionSql 需要按字段名查到字段定义,b_type 决定字面量形式(数字裸写 / 文本 N'...') */
|
||||
function fieldMapOf(...fields) {
|
||||
return new Map(fields.map((field) => [field.b_field, field]))
|
||||
@@ -58,17 +73,23 @@ describe('fms-module-list query utilities', () => {
|
||||
).toBe("[status] IN (N'a', N'b')")
|
||||
})
|
||||
|
||||
it('日期时间字面量:自带时分秒按原时刻比较,仅日精度才补当天起 / 当天末', () => {
|
||||
it('日期时间字面量:本地时刻折算成 UTC 后比较,date 字段不折算', () => {
|
||||
const datetimeField = { b_field: 'created', b_type: 'datetime' }
|
||||
const dateField = { b_field: 'created', b_type: 'date' }
|
||||
|
||||
// datetime 值自带时分秒 → 原样保留,结束端也不再被夹到当天末
|
||||
expect(sqlLiteral('2026-01-01 09:30:00', datetimeField)).toBe("N'2026-01-01 09:30:00'")
|
||||
expect(sqlLiteral('2026-01-31 18:00:00', datetimeField, true)).toBe("N'2026-01-31 18:00:00'")
|
||||
// 只有日精度 → 按端点补默认时间
|
||||
expect(sqlLiteral('2026-01-01', datetimeField)).toBe("N'2026-01-01 00:00:00'")
|
||||
expect(sqlLiteral('2026-01-31', datetimeField, true)).toBe("N'2026-01-31 23:59:59'")
|
||||
// date 字段始终截到日,不补时分秒
|
||||
// datetime:库中存 UTC(见后端 TimeUtils),本地端点先折算再拼字面量;
|
||||
// 自带时分秒按原时刻、仅日精度才补当天起 / 当天末,再统一折算
|
||||
expect(sqlLiteral('2026-01-01 09:30:00', datetimeField)).toBe(
|
||||
`N'${toUtcWall('2026-01-01 09:30:00')}'`,
|
||||
)
|
||||
expect(sqlLiteral('2026-01-31 18:00:00', datetimeField, true)).toBe(
|
||||
`N'${toUtcWall('2026-01-31 18:00:00')}'`,
|
||||
)
|
||||
expect(sqlLiteral('2026-01-01', datetimeField)).toBe(`N'${toUtcWall('2026-01-01 00:00:00')}'`)
|
||||
expect(sqlLiteral('2026-01-31', datetimeField, true)).toBe(
|
||||
`N'${toUtcWall('2026-01-31 23:59:59')}'`,
|
||||
)
|
||||
// date 字段是纯日历值(无时区语义):始终截到日,不折算
|
||||
expect(sqlLiteral('2026-01-01 09:30:00', dateField)).toBe("N'2026-01-01'")
|
||||
})
|
||||
|
||||
@@ -89,21 +110,39 @@ describe('fms-module-list query utilities', () => {
|
||||
})
|
||||
|
||||
it('formats ISO dates consistently', () => {
|
||||
expect(formatDateValue('2026-08-01T12:34:56Z')).toMatch(/^2026-08-01/)
|
||||
expect(formatDateValue('2026-08-01', false)).toBe('2026-08-01')
|
||||
expect(formatDateValue(null)).toBe('')
|
||||
})
|
||||
|
||||
/**
|
||||
* 时间契约:后端统一输出「显示格式」的墙钟串(见后端 DbUtils.normalizeJdbcValue,
|
||||
* 与旧项目读取 ResultSet 时格式化的做法一致):
|
||||
* date → "2026-09-15",datetime → "2026-09-15 21:16:47",即拿即显示、不做时区换算。
|
||||
* 展示层对标准输入应为透传;仅非标准输入(数字时间戳等)走 Date 兜底。
|
||||
* 时间契约(见后端 TimeUtils 与 fieldUtils 注释):
|
||||
* 库中 datetime 列存 UTC,接口出口是带 Z 的 ISO 串("2026-09-26T02:00:00Z");
|
||||
* 展示时按浏览器本地时区折算,提交时再由 toUtcIsoDateTime 转回 UTC。
|
||||
* date / time 与无时区标记的墙钟串没有时区语义,原样透传。
|
||||
*/
|
||||
it('passes through display-format strings without timezone math', () => {
|
||||
it('展示:带 Z 的 UTC 串折算成本地,墙钟串与日期串原样透传', () => {
|
||||
// 用往返断言,不依赖运行环境时区
|
||||
const local = '2026-09-15 21:16:47'
|
||||
const utc = toUtcIsoDateTime(local)
|
||||
expect(utc).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/)
|
||||
expect(formatDateValue(utc, true)).toBe(local)
|
||||
expect(formatDateValue(utc, false)).toBe('2026-09-15')
|
||||
|
||||
// 无时区标记的墙钟串(存量数据、本地值):透传
|
||||
expect(formatDateValue('2026-09-15 21:16:47', true)).toBe('2026-09-15 21:16:47')
|
||||
expect(formatDateValue('2026-09-15 21:16:47', false)).toBe('2026-09-15')
|
||||
expect(formatDateValue('2026-09-15', false)).toBe('2026-09-15')
|
||||
// 非日期开头的输入(数字时间戳等)仍走 Date 兜底,按本地时间格式化
|
||||
expect(formatDateValue(1789471007000, true)).toMatch(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/)
|
||||
})
|
||||
|
||||
it('提交:本地时间值转成带 Z 的 UTC 串,已是带标记的串原样返回', () => {
|
||||
expect(toUtcIsoDateTime('2026-09-15 21:16:47')).toMatch(
|
||||
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/,
|
||||
)
|
||||
expect(toUtcIsoDateTime('2026-09-15T13:16:47Z')).toBe('2026-09-15T13:16:47Z')
|
||||
expect(toUtcIsoDateTime('2026-09-15T21:16:47+08:00')).toBe('2026-09-15T21:16:47+08:00')
|
||||
// 本地 → UTC → 展示:往返一致
|
||||
expect(formatDateValue(toUtcIsoDateTime('2026-09-15 21:16:47'), true)).toBe('2026-09-15 21:16:47')
|
||||
})
|
||||
})
|
||||
@@ -60,23 +60,58 @@ describe('schemaModel:view/edit 配置 ↔ 编辑器草稿', () => {
|
||||
expect(back.columns.find((n) => n.field === 'name').width).toBe(120)
|
||||
})
|
||||
|
||||
it('edit:span/required/readonly 与行属性互转,默认值不额外写出', () => {
|
||||
it('edit:行节点 ↔ b_line 草稿互转,span/required 保留、默认值不额外写出', () => {
|
||||
const schema = {
|
||||
schemaVersion: 1,
|
||||
schemaVersion: 2,
|
||||
children: [
|
||||
{ field: 'name', span: 12, required: true },
|
||||
{ field: 'age' },
|
||||
{
|
||||
type: 'row',
|
||||
children: [
|
||||
{ field: 'name', span: 12, required: true },
|
||||
{ field: 'age', span: 12 },
|
||||
],
|
||||
},
|
||||
{ type: 'row', children: [{ field: 'remark' }] },
|
||||
],
|
||||
}
|
||||
const { rows } = schemaToEditor('edit', schema, 'm1')
|
||||
expect(rows.map((r) => r.b_field)).toEqual(['name', 'age', 'remark'])
|
||||
expect(rows[0].b_colspan).toBe(12)
|
||||
expect(rows[0].b_required).toBe(1)
|
||||
expect(rows[1].b_colspan).toBe(24)
|
||||
expect(rows[2].b_colspan).toBe(24)
|
||||
// 同一行共用一个行 id,不同行必须不同(行是硬边界)
|
||||
expect(rows[0].b_line).toBe(rows[1].b_line)
|
||||
expect(rows[2].b_line).not.toBe(rows[0].b_line)
|
||||
|
||||
const back = editorToSchema('edit', rows, [], schema)
|
||||
expect(back.children[0]).toMatchObject({ field: 'name', span: 12, required: true })
|
||||
expect(back.schemaVersion).toBe(2)
|
||||
expect(back.children[0].type).toBe('row')
|
||||
expect(back.children[0].children[0]).toMatchObject({ field: 'name', span: 12, required: true })
|
||||
// span=24 且原节点未显式给出 → 不写出 span
|
||||
expect(back.children[1].span).toBeUndefined()
|
||||
expect(back.children[1].children[0].span).toBeUndefined()
|
||||
})
|
||||
|
||||
it('edit:老结构(平铺 + newline)加载时折成临时行,不写回 newline', () => {
|
||||
const schema = {
|
||||
schemaVersion: 1,
|
||||
children: [
|
||||
{ field: 'a', span: 8 },
|
||||
{ field: 'b', span: 8 },
|
||||
{ field: 'c', span: 8, newline: true },
|
||||
{ field: 'd', span: 24 },
|
||||
],
|
||||
}
|
||||
const { rows } = schemaToEditor('edit', schema, 'm1')
|
||||
// a b 同行(8+8=16);c 显式断行;d 放不下再折行
|
||||
expect(rows[0].b_line).toBe(rows[1].b_line)
|
||||
expect(rows[2].b_line).not.toBe(rows[1].b_line)
|
||||
expect(rows[3].b_line).not.toBe(rows[2].b_line)
|
||||
|
||||
const back = editorToSchema('edit', rows, [], schema)
|
||||
expect(back.children.map((n) => n.type)).toEqual(['row', 'row', 'row'])
|
||||
expect(back.children[0].children.map((n) => n.field)).toEqual(['a', 'b'])
|
||||
expect(back.children[1].children.map((n) => n.field)).toEqual(['c'])
|
||||
expect(JSON.stringify(back)).not.toContain('newline')
|
||||
})
|
||||
|
||||
it('未知节点扩展属性在往返中保留', () => {
|
||||
@@ -123,13 +158,19 @@ describe('schemaModel:字段参与节点操作', () => {
|
||||
expect(hasSchemaField('view', schema, 'name')).toBe(false)
|
||||
})
|
||||
|
||||
it('edit:切换 visible 保留节点', () => {
|
||||
let schema = { schemaVersion: 1, children: [{ field: 'name', span: 24 }] }
|
||||
it('edit:切换 visible 保留行节点与节点属性', () => {
|
||||
let schema = {
|
||||
schemaVersion: 2,
|
||||
children: [{ type: 'row', children: [{ field: 'name', span: 24 }] }],
|
||||
}
|
||||
expect(hasSchemaField('edit', schema, 'name', { requireVisible: true })).toBe(true)
|
||||
schema = setSchemaFieldVisible('edit', schema, 'name', false)
|
||||
expect(schema.children[0].visible).toBe(false)
|
||||
expect(schema.children[0].type).toBe('row')
|
||||
expect(schema.children[0].children[0].visible).toBe(false)
|
||||
expect(hasSchemaField('edit', schema, 'name')).toBe(true)
|
||||
expect(hasSchemaField('edit', schema, 'name', { requireVisible: true })).toBe(false)
|
||||
// 删除字段后空行节点一并消失
|
||||
expect(removeSchemaField('edit', schema, 'name').children).toEqual([])
|
||||
})
|
||||
|
||||
it('query:条件增删与默认组件', () => {
|
||||
|
||||
@@ -24,6 +24,18 @@ const fields = new Map([
|
||||
['status', { b_field: 'status', b_type: 'select' }],
|
||||
])
|
||||
|
||||
const pad2 = (number) => String(number).padStart(2, '0')
|
||||
|
||||
/**
|
||||
* 本地墙钟串 → UTC 墙钟串。
|
||||
* datetime 条件拼 SQL 前会折算成 UTC(库中存 UTC,见后端 TimeUtils),
|
||||
* 断言用同一折算、不依赖运行环境时区。
|
||||
*/
|
||||
function toUtcWall(localText) {
|
||||
const date = new Date(String(localText).replace(' ', 'T'))
|
||||
return `${date.getUTCFullYear()}-${pad2(date.getUTCMonth() + 1)}-${pad2(date.getUTCDate())} ${pad2(date.getUTCHours())}:${pad2(date.getUTCMinutes())}:${pad2(date.getUTCSeconds())}`
|
||||
}
|
||||
|
||||
describe('查询条件 AST 构造', () => {
|
||||
it('按字段类型给出默认操作符:文本 like、日期 between、其余 eq', () => {
|
||||
expect(defaultOperator({ b_type: 'input' })).toBe('like')
|
||||
@@ -62,13 +74,13 @@ describe('查询条件 AST 构造', () => {
|
||||
{ type: 'condition', field: 'created', operator: 'ge', value: ['2026-01-01', ''] },
|
||||
fields,
|
||||
),
|
||||
).toBe("[created] >= N'2026-01-01 00:00:00'")
|
||||
).toBe(`[created] >= N'${toUtcWall('2026-01-01 00:00:00')}'`)
|
||||
expect(
|
||||
astToSql(
|
||||
{ type: 'condition', field: 'created', operator: 'le', value: ['', '2026-01-31'] },
|
||||
fields,
|
||||
),
|
||||
).toBe("[created] <= N'2026-01-31 23:59:59'")
|
||||
).toBe(`[created] <= N'${toUtcWall('2026-01-31 23:59:59')}'`)
|
||||
})
|
||||
|
||||
it('嵌套条件组按各自逻辑加括号,空组不产生条件', () => {
|
||||
@@ -113,19 +125,29 @@ describe('日期预设区间', () => {
|
||||
it('语义值 { preset } 只在组装 AST 时解析,配置不写死日期', () => {
|
||||
expect(isDatePresetValue({ preset: 'today' })).toBe(true)
|
||||
expect(isDatePresetValue(['2026-09-23'])).toBe(false)
|
||||
expect(resolveDatePresetValue({ preset: 'today' })).toEqual(['2026-09-23', '2026-09-23'])
|
||||
expect(resolveDatePresetValue('2026-09-23')).toBe('2026-09-23')
|
||||
// 不写死基准日:按实现同源解析「今天」,避免测试随日期流逝变成时间炸弹。
|
||||
// preset 由组装 AST 的入口(buildViewAst / buildQuickAst / buildFilterStateAst)解析,
|
||||
// astToSql 只认已解析的区间数组,所以这里走页签条件入口。
|
||||
const [start, end] = resolveDatePresetValue({ preset: 'today' })
|
||||
expect(start).toBe(end)
|
||||
expect(start).toMatch(/^\d{4}-\d{2}-\d{2}$/)
|
||||
expect(
|
||||
astToSql(
|
||||
{
|
||||
type: 'condition',
|
||||
field: 'created',
|
||||
operator: 'between',
|
||||
value: { preset: 'today' },
|
||||
},
|
||||
buildViewAst(
|
||||
{
|
||||
code: 'today',
|
||||
conditions: [
|
||||
{ field: 'created', operator: 'between', value: { preset: 'today' } },
|
||||
],
|
||||
},
|
||||
fields,
|
||||
),
|
||||
fields,
|
||||
),
|
||||
).toBe("[created] BETWEEN N'2026-09-23 00:00:00' AND N'2026-09-23 23:59:59'")
|
||||
).toBe(
|
||||
`[created] BETWEEN N'${toUtcWall(`${start} 00:00:00`)}' AND N'${toUtcWall(`${end} 23:59:59`)}'`,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -140,8 +162,9 @@ describe('查询入口 → AST(页签 / 关键词 / 快捷筛选 / 更多筛
|
||||
],
|
||||
}
|
||||
const ast = buildViewAst(view, fields)
|
||||
const [start, end] = resolveDatePresetValue({ preset: 'today' })
|
||||
expect(astToSql(ast, fields)).toBe(
|
||||
"([status] = N'失败' AND [created] BETWEEN N'2026-09-23 00:00:00' AND N'2026-09-23 23:59:59')",
|
||||
`([status] = N'失败' AND [created] BETWEEN N'${toUtcWall(`${start} 00:00:00`)}' AND N'${toUtcWall(`${end} 23:59:59`)}')`,
|
||||
)
|
||||
expect(buildViewAst({ code: 'all' }, fields)).toBeNull()
|
||||
})
|
||||
@@ -188,9 +211,10 @@ describe('查询入口 → AST(页签 / 关键词 / 快捷筛选 / 更多筛
|
||||
status: '',
|
||||
created: { preset: 'this_month' },
|
||||
})
|
||||
const [monthStart, monthEnd] = resolveDatePresetValue({ preset: 'this_month' })
|
||||
expect(ast.children).toHaveLength(2)
|
||||
expect(astToSql(ast, fields)).toBe(
|
||||
"([name] LIKE N'%abc%' AND [created] BETWEEN N'2026-09-01 00:00:00' AND N'2026-09-30 23:59:59')",
|
||||
`([name] LIKE N'%abc%' AND [created] BETWEEN N'${toUtcWall(`${monthStart} 00:00:00`)}' AND N'${toUtcWall(`${monthEnd} 23:59:59`)}')`,
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -41,24 +41,30 @@ describe('buildListConfig(view JSON → 列表列)', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildEditRender(edit JSON → 分组 + 编辑行)', () => {
|
||||
it('还原分组与行属性,嵌套分组生成独立分组', () => {
|
||||
describe('buildEditRender(edit JSON → 分组 + 行 + 编辑行)', () => {
|
||||
it('行节点 → lines,行属性与嵌套分组保留', () => {
|
||||
const edit = {
|
||||
schemaVersion: 1,
|
||||
schemaVersion: 2,
|
||||
children: [
|
||||
{ field: 'code', span: 24, required: true },
|
||||
{ type: 'row', children: [{ field: 'code', span: 24, required: true }] },
|
||||
{
|
||||
type: 'group',
|
||||
title: '基础',
|
||||
children: [
|
||||
{ field: 'name', span: 12, readonly: true, defaultValue: 'X' },
|
||||
{ type: 'group', title: '扩展', children: [{ field: 'remark', span: 24 }] },
|
||||
{
|
||||
type: 'row',
|
||||
children: [
|
||||
{ field: 'name', span: 12, readonly: true, defaultValue: 'X' },
|
||||
{ field: 'age', span: 12 },
|
||||
],
|
||||
},
|
||||
{ type: 'group', title: '扩展', children: [{ type: 'row', children: [{ field: 'remark', span: 24 }] }] },
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
const { groups, rows } = buildEditRender(edit)
|
||||
expect(rows.map((r) => r.b_field)).toEqual(['code', 'name', 'remark'])
|
||||
const { groups, rows, lines } = buildEditRender(edit)
|
||||
expect(rows.map((r) => r.b_field)).toEqual(['code', 'name', 'age', 'remark'])
|
||||
const outer = groups.find((g) => g.b_title === '基础')
|
||||
const inner = groups.find((g) => g.b_title === '扩展')
|
||||
expect(outer).toBeTruthy()
|
||||
@@ -71,6 +77,29 @@ describe('buildEditRender(edit JSON → 分组 + 编辑行)', () => {
|
||||
b_default_value: 'X',
|
||||
})
|
||||
expect(rows[2].b_group_id).toBe(inner.b_id)
|
||||
// 行是显式结构:同一行 b_line 相同、跨行不同,lines 按行聚合
|
||||
expect(rows[1].b_line).toBe(rows[2].b_line)
|
||||
expect(rows[0].b_line).not.toBe(rows[1].b_line)
|
||||
expect(lines.map((l) => l.rows.map((r) => r.b_field))).toEqual([
|
||||
['code'],
|
||||
['name', 'age'],
|
||||
['remark'],
|
||||
])
|
||||
expect(lines.every((l) => l.rows.every((r) => r.b_line === l.key))).toBe(true)
|
||||
})
|
||||
|
||||
it('老结构(平铺 + newline)容错折行,只读不回写', () => {
|
||||
const edit = {
|
||||
schemaVersion: 1,
|
||||
children: [
|
||||
{ field: 'a', span: 8 },
|
||||
{ field: 'b', span: 8 },
|
||||
{ field: 'c', span: 8, newline: true },
|
||||
{ field: 'd', span: 24 },
|
||||
],
|
||||
}
|
||||
const { lines } = buildEditRender(edit)
|
||||
expect(lines.map((l) => l.rows.map((r) => r.b_field))).toEqual([['a', 'b'], ['c'], ['d']])
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -30,7 +30,9 @@ describe('一键整理的布局后处理', () => {
|
||||
],
|
||||
}
|
||||
|
||||
const moved = spreadBranchNodes(graph)
|
||||
const moved = spreadBranchNodes(graph, {
|
||||
defaultExitTargets: defaultExits({ amount_gateway: ['finance_review'] }),
|
||||
})
|
||||
|
||||
expect(moved.has('gm_approve')).toBe(true)
|
||||
// 线在 x=200(金额判断与财务复核同列),旁支被推到线右侧、留白 120
|
||||
@@ -54,7 +56,9 @@ describe('一键整理的布局后处理', () => {
|
||||
],
|
||||
}
|
||||
|
||||
spreadBranchNodes(graph)
|
||||
spreadBranchNodes(graph, {
|
||||
defaultExitTargets: defaultExits({ amount_gateway: ['finance_review'] }),
|
||||
})
|
||||
|
||||
expect(graph.nodes[1].text.x).toBe(graph.nodes[1].x)
|
||||
expect(graph.nodes[0].text.x).toBe(200)
|
||||
@@ -218,4 +222,35 @@ describe('一键整理的布局后处理', () => {
|
||||
|
||||
expect(graph.nodes[0].x).toBe(0)
|
||||
})
|
||||
|
||||
it('条件网关两条都是显式条件、没有兜底时,两条支线左右分开、主干走到汇合点', () => {
|
||||
const graph = {
|
||||
nodes: [
|
||||
node('start', 'fms-start', 0, 0, 52, 52),
|
||||
node('gateway', 'fms-exclusive-gateway', 0, 100, 68, 68),
|
||||
node('left', 'fms-user-task', -200, 200, 160, 48),
|
||||
node('right', 'fms-user-task', 200, 200, 160, 48),
|
||||
node('join', 'fms-user-task', 0, 300, 160, 48),
|
||||
],
|
||||
edges: [
|
||||
edge('e1', 'start', 'gateway'),
|
||||
edge('e2', 'gateway', 'left'),
|
||||
edge('e3', 'gateway', 'right'),
|
||||
edge('e4', 'left', 'join'),
|
||||
edge('e5', 'right', 'join'),
|
||||
],
|
||||
}
|
||||
|
||||
const moved = spreadBranchNodes(graph)
|
||||
|
||||
// 没有默认出口,主干跳过两条支线直接走到汇合点,两条支线都算旁支
|
||||
expect(moved.has('left')).toBe(true)
|
||||
expect(moved.has('right')).toBe(true)
|
||||
expect(graph.nodes[1].x).toBe(0)
|
||||
// 左右各留白 120:86 + (120 - 86)
|
||||
expect(graph.nodes[2].x).toBe(-234)
|
||||
expect(graph.nodes[3].x).toBe(234)
|
||||
// 汇合点在主轴上,不动
|
||||
expect(graph.nodes[4].x).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -576,9 +576,7 @@ describe('模块管理:保存后的未保存判定', () => {
|
||||
})
|
||||
|
||||
describe('模块管理:卡头信息与刷新', () => {
|
||||
const LOCAL_DATETIME = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/
|
||||
|
||||
it('保存主表变更时写入 b_updated_at,且时间戳不进草稿、不会让表单重新变脏', async () => {
|
||||
it('保存主表变更时前端不提交审计列,草稿也不会因此变脏', async () => {
|
||||
const wrapper = await mountManagement()
|
||||
await flushPromises()
|
||||
wrapper.vm.moduleSelectedId = 'demo'
|
||||
@@ -590,17 +588,14 @@ describe('模块管理:卡头信息与刷新', () => {
|
||||
await settleConfigReload(wrapper)
|
||||
|
||||
const moduleReq = savedRequests.find((request) => request.table === 's_module')
|
||||
expect(moduleReq.updates?.[0]).toMatchObject({
|
||||
b_id: 'demo',
|
||||
b_name: '改名后',
|
||||
b_updated_at: expect.stringMatching(LOCAL_DATETIME),
|
||||
})
|
||||
|
||||
// 时间戳若被写进草稿,重载后的基线与草稿就会不一致,这里会判定为仍有未保存改动
|
||||
expect(moduleReq.updates?.[0]).toMatchObject({ b_id: 'demo', b_name: '改名后' })
|
||||
// 审计列(b_updated_at 等)由后端按表列存在性统一维护(核心表结构设计 §1.6)
|
||||
expect(moduleReq.updates?.[0]).not.toHaveProperty('b_updated_at')
|
||||
expect(moduleReq.updates?.[0]).not.toHaveProperty('b_updated_by')
|
||||
expect(wrapper.vm.hasUnsavedChanges()).toBe(false)
|
||||
})
|
||||
|
||||
it('只改子表(多语言)时也刷新模块的最后保存时间', async () => {
|
||||
it('只改子表(多语言)时也触碰主表行,让后端刷新「最后保存时间」', async () => {
|
||||
const wrapper = await mountManagement()
|
||||
await flushPromises()
|
||||
wrapper.vm.moduleSelectedId = 'demo'
|
||||
@@ -612,15 +607,13 @@ describe('模块管理:卡头信息与刷新', () => {
|
||||
await wrapper.vm.handleSave()
|
||||
await settleConfigReload(wrapper)
|
||||
|
||||
// 主表行本身没有变化,仍要补一条只写时间戳的 update,否则卡头时间会停在改主表的时刻
|
||||
// 主表行本身没有变化:补一条只带主键的 update 触碰它,时间戳由后端补
|
||||
const moduleReq = savedRequests.find((request) => request.table === 's_module')
|
||||
expect(moduleReq.updates).toEqual([
|
||||
{ b_id: 'demo', b_updated_at: expect.stringMatching(LOCAL_DATETIME) },
|
||||
])
|
||||
expect(moduleReq.updates).toEqual([{ b_id: 'demo' }])
|
||||
expect(wrapper.vm.hasUnsavedChanges()).toBe(false)
|
||||
})
|
||||
|
||||
it('库里还没有 b_updated_at 列时(迁移未执行)不补时间戳更新', async () => {
|
||||
it('库里还没有 b_updated_at 列时(迁移未执行)不触碰主表行', async () => {
|
||||
delete db.s_module.find((item) => item.b_id === 'demo').b_updated_at
|
||||
|
||||
const wrapper = await mountManagement()
|
||||
@@ -634,7 +627,7 @@ describe('模块管理:卡头信息与刷新', () => {
|
||||
await wrapper.vm.handleSave()
|
||||
await settleConfigReload(wrapper)
|
||||
|
||||
// 只写不存在列的 update 会被后端以「没有可修改的字段」拒绝,整次保存回滚
|
||||
// 只带主键的 update 在后端补不上任何可写列(审计列不存在),会被「没有可修改的字段」拒绝
|
||||
expect(savedRequests.some((request) => request.table === 's_module')).toBe(false)
|
||||
})
|
||||
|
||||
|
||||
Reference in new issue
Block a user