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()
})
})
+179
View File
@@ -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:条件增删与默认组件', () => {
+36 -12
View File
@@ -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)
})