20260903221055
This commit is contained in:
1 parent
3d4e2c7413
commit
0928437c67
46 files changed
+2121
-1643
No files matched your search
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* dataChanges.diffRow(单条记录字段级差异比对)测试:
|
||||
* 语义对齐旧系统 diffData 的单表分支 —— 无基线视为新建(整行 insert),
|
||||
* 修改时 updates 只携带「变化字段 + 主键」,内置 null/''/undefined 等价、
|
||||
* 数字型等价与 _/v_/ignoreFields 过滤;主键默认 'b_id',可自定义。
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { diffRow } from '@/utils/dataChanges'
|
||||
|
||||
describe('diffRow 单条记录', () => {
|
||||
it('无基线时视为新建,返回整行 insert', () => {
|
||||
const changes = diffRow({
|
||||
current: { b_id: '1', b_code: 'demo', b_name: '演示模块' },
|
||||
original: null,
|
||||
})
|
||||
expect(changes.hasChanges).toBe(true)
|
||||
expect(changes.inserts).toEqual([{ b_id: '1', b_code: 'demo', b_name: '演示模块' }])
|
||||
expect(changes.updates).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('与基线完全一致时 hasChanges 为 false(不产生仅主键的空 update)', () => {
|
||||
const original = { b_id: '1', b_code: 'demo', b_name: '演示模块' }
|
||||
const changes = diffRow({ current: { ...original }, original })
|
||||
expect(changes.hasChanges).toBe(false)
|
||||
expect(changes.updates).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('修改只提交变化字段 + 主键', () => {
|
||||
const original = { b_id: '1', b_code: 'demo', b_name: '演示模块', b_xh: 10 }
|
||||
const changes = diffRow({
|
||||
current: { b_id: '1', b_code: 'demo', b_name: '改名后', b_xh: 10 },
|
||||
original,
|
||||
})
|
||||
expect(changes.updates).toEqual([{ b_id: '1', b_name: '改名后' }])
|
||||
})
|
||||
|
||||
it('null/undefined/空串 与数字型(40 vs "40.00")归一后不误报变更', () => {
|
||||
const original = { b_id: '1', b_name: '演示', b_code: null, b_xh: 40 }
|
||||
const changes = diffRow({
|
||||
current: { b_id: '1', b_name: '演示', b_code: '', b_xh: '40.00' },
|
||||
original,
|
||||
})
|
||||
expect(changes.hasChanges).toBe(false)
|
||||
})
|
||||
|
||||
it('跳过 _ 前缀与 v_ 前缀字段,ignoreFields 不参与比较', () => {
|
||||
const original = {
|
||||
b_id: '1',
|
||||
b_name: '演示',
|
||||
_treeKey: 'x',
|
||||
v_customer_name: 'A',
|
||||
b_currentperiod: '2026-01',
|
||||
}
|
||||
const changes = diffRow({
|
||||
current: {
|
||||
b_id: '1',
|
||||
b_name: '演示',
|
||||
_treeKey: 'y', // 内部字段变化不影响
|
||||
v_customer_name: 'B', // 视图字段变化不影响
|
||||
b_currentperiod: '2026-02', // ignoreFields 内,不影响
|
||||
},
|
||||
original,
|
||||
ignoreFields: ['b_currentperiod'],
|
||||
})
|
||||
expect(changes.hasChanges).toBe(false)
|
||||
})
|
||||
|
||||
it('insert 行剔除 _/v_/ignoreFields 前缀字段', () => {
|
||||
const changes = diffRow({
|
||||
current: { b_id: '1', b_name: '新', _ui: true, v_view: 'x', b_xh: 10 },
|
||||
original: null,
|
||||
ignoreFields: ['b_xh'],
|
||||
})
|
||||
expect(changes.inserts).toEqual([{ b_id: '1', b_name: '新' }])
|
||||
})
|
||||
|
||||
it('支持非默认主键(primaryKey)', () => {
|
||||
const original = { gid: '7', b_name: '旧' }
|
||||
const changes = diffRow({
|
||||
current: { gid: '7', b_name: '新' },
|
||||
original,
|
||||
primaryKey: 'gid',
|
||||
})
|
||||
expect(changes.updates).toEqual([{ gid: '7', b_name: '新' }])
|
||||
})
|
||||
})
|
||||
@@ -11,7 +11,9 @@ registerFeature(useAreaSelection)
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(HTMLElement.prototype, 'clientWidth', {
|
||||
configurable: true,
|
||||
get() { return 5000 }
|
||||
get() {
|
||||
return 5000
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
@@ -22,9 +24,17 @@ vi.mock('@/services/api', () => ({
|
||||
describeApi: vi.fn(),
|
||||
}))
|
||||
|
||||
const fields = [
|
||||
{ b_id: '1', b_name: '姓名', b_field: 'name', b_type: 'input', b_canuse: 1 }
|
||||
]
|
||||
const fields = [{ b_id: '1', b_name: '姓名', b_field: 'name', b_type: 'input', b_canuse: 1 }]
|
||||
|
||||
/** 受控 rows 模型:把最后一次 update:rows 回灌到 props,模拟父级 v-model */
|
||||
async function syncRows(wrapper) {
|
||||
const events = wrapper.emitted('update:rows')
|
||||
if (events && events.length) {
|
||||
await wrapper.setProps({ rows: events.at(-1)[0] })
|
||||
}
|
||||
await nextTick()
|
||||
await new Promise((r) => setTimeout(r, 20))
|
||||
}
|
||||
|
||||
describe('module-management 删除(单个无确认弹窗 + 批量)', () => {
|
||||
beforeEach(() => setActivePinia(createPinia()))
|
||||
@@ -63,14 +73,20 @@ describe('module-management 删除(单个无确认弹窗 + 批量)', () => {
|
||||
await settle()
|
||||
}
|
||||
|
||||
async function clickToolbarAdd(wrapper, label) {
|
||||
const btn = wrapper.findAll('button').find((b) => b.text().includes(label))
|
||||
expect(btn, `${label} 按钮应存在`).toBeTruthy()
|
||||
await btn.trigger('click')
|
||||
await settle()
|
||||
}
|
||||
|
||||
it('查询面板:单个删除不弹弹窗且行被移除', async () => {
|
||||
const wrapper = mount(ModuleQueryConfigPanel, {
|
||||
props: { moduleId: 'm1', queryConfig: [], fields, loading: false },
|
||||
attachTo: document.body,
|
||||
})
|
||||
await settle()
|
||||
await wrapper.findAll('button').find((b) => b.text().includes('新增查询条件')).trigger('click')
|
||||
await settle()
|
||||
await clickToolbarAdd(wrapper, '新增查询条件')
|
||||
expect(wrapper.findAll('.stk-tbody-main tr').length).toBe(1)
|
||||
await clickDelete(wrapper)
|
||||
await checkNoModal()
|
||||
@@ -85,8 +101,7 @@ describe('module-management 删除(单个无确认弹窗 + 批量)', () => {
|
||||
})
|
||||
await settle()
|
||||
for (let i = 0; i < 2; i++) {
|
||||
await wrapper.findAll('button').find((b) => b.text().includes('新增查询条件')).trigger('click')
|
||||
await settle()
|
||||
await clickToolbarAdd(wrapper, '新增查询条件')
|
||||
}
|
||||
expect(wrapper.findAll('.stk-tbody-main tr').length).toBe(2)
|
||||
await selectRowCheckbox(wrapper, 0)
|
||||
@@ -97,10 +112,9 @@ describe('module-management 删除(单个无确认弹窗 + 批量)', () => {
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('多语言面板:单个删除不弹弹窗且行被移除', async () => {
|
||||
it('多语言面板:新增/单个删除通过 update:rows 上报且不弹弹窗', async () => {
|
||||
const wrapper = mount(ModuleI18nPanel, {
|
||||
props: {
|
||||
moduleId: 'm1',
|
||||
rows: [],
|
||||
languages: [
|
||||
{ code: 'zh-CN', name: '简体中文', default: true },
|
||||
@@ -111,20 +125,22 @@ describe('module-management 删除(单个无确认弹窗 + 批量)', () => {
|
||||
attachTo: document.body,
|
||||
})
|
||||
await settle()
|
||||
await wrapper.findAll('button').find((b) => b.text().includes('新增多语言')).trigger('click')
|
||||
await settle()
|
||||
const before = wrapper.findAll('.stk-tbody-main tr').length
|
||||
expect(before).toBeGreaterThan(0)
|
||||
await clickToolbarAdd(wrapper, '新增多语言')
|
||||
await syncRows(wrapper)
|
||||
const rowsAfterAdd = wrapper.props('rows')
|
||||
expect(rowsAfterAdd.length).toBe(1)
|
||||
expect(rowsAfterAdd[0]._managedId, '手动新增行不受管,可删除').toBeUndefined()
|
||||
|
||||
await clickDelete(wrapper)
|
||||
await syncRows(wrapper)
|
||||
await checkNoModal()
|
||||
expect(wrapper.findAll('.stk-tbody-main tr').length, '单个删除后行应减少').toBe(before - 1)
|
||||
expect(wrapper.props('rows').length, '单个删除后行应减少').toBe(0)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('多语言面板:批量删除选中多行', async () => {
|
||||
it('多语言面板:批量删除选中多行通过 update:rows 上报且不弹弹窗', async () => {
|
||||
const wrapper = mount(ModuleI18nPanel, {
|
||||
props: {
|
||||
moduleId: 'm1',
|
||||
rows: [],
|
||||
languages: [
|
||||
{ code: 'zh-CN', name: '简体中文', default: true },
|
||||
@@ -135,18 +151,17 @@ describe('module-management 删除(单个无确认弹窗 + 批量)', () => {
|
||||
attachTo: document.body,
|
||||
})
|
||||
await settle()
|
||||
const before = wrapper.findAll('.stk-tbody-main tr').length
|
||||
for (let i = 0; i < 2; i++) {
|
||||
await wrapper.findAll('button').find((b) => b.text().includes('新增多语言')).trigger('click')
|
||||
await settle()
|
||||
await clickToolbarAdd(wrapper, '新增多语言')
|
||||
await syncRows(wrapper)
|
||||
}
|
||||
const total = wrapper.findAll('.stk-tbody-main tr').length
|
||||
expect(total).toBeGreaterThanOrEqual(before + 2)
|
||||
expect(wrapper.props('rows').length).toBe(2)
|
||||
await selectRowCheckbox(wrapper, 0)
|
||||
await selectRowCheckbox(wrapper, 1)
|
||||
await clickToolbarBatch(wrapper)
|
||||
await syncRows(wrapper)
|
||||
await checkNoModal()
|
||||
expect(wrapper.findAll('.stk-tbody-main tr').length, '批量删除后应只剩受管行').toBe(before)
|
||||
expect(wrapper.props('rows').length, '批量删除后应无行').toBe(0)
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,112 +0,0 @@
|
||||
/**
|
||||
* 字段参与开关(fieldParticipation)测试:
|
||||
* - 判定:行存在 + 行内开关(表单/列表 b_visible、查询 b_canuse),b_field_id 与 b_id 类型不一致按字符串比较
|
||||
* - 切换:行为空/存在两路;关 = 置行内开关 0(不删行,保留布局/配置);开 = 行不存在时按默认生成且强制参与
|
||||
* - 不变性:toggle 返回新数组、非目标行保持原引用
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
isFormOn,
|
||||
isListOn,
|
||||
isQueryOn,
|
||||
toggleForm,
|
||||
toggleList,
|
||||
toggleQuery,
|
||||
} from '@/views/module/module-management/composables/fieldParticipation'
|
||||
|
||||
const MODULE_ID = 'm1'
|
||||
|
||||
function makeField(over = {}) {
|
||||
return { b_id: '10', b_field: 'amount', b_name: '金额', b_type: 'money', b_canuse: 1, ...over }
|
||||
}
|
||||
|
||||
describe('参与判定', () => {
|
||||
const field = makeField()
|
||||
|
||||
it('行存在且开关为 1 即参与;行不存在不参与', () => {
|
||||
expect(isFormOn([{ b_field_id: '10', b_visible: 1 }], field)).toBe(true)
|
||||
expect(isFormOn([{ b_field_id: '10', b_visible: 0 }], field)).toBe(false)
|
||||
expect(isFormOn([], field)).toBe(false)
|
||||
expect(isListOn([{ b_field_id: 10, b_visible: 1 }], field)).toBe(true) // 类型不一致按字符串比较
|
||||
expect(isQueryOn([{ b_field_id: '10', b_canuse: 1 }], field)).toBe(true)
|
||||
expect(isQueryOn([{ b_field_id: '10', b_canuse: 0 }], field)).toBe(false)
|
||||
})
|
||||
|
||||
it('空配置安全', () => {
|
||||
expect(isFormOn(undefined, field)).toBe(false)
|
||||
expect(isListOn(null, field)).toBe(false)
|
||||
expect(isQueryOn([], field)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('表单参与切换', () => {
|
||||
it('行存在:切换 b_visible,不删行(保留布局)', () => {
|
||||
const rows = [{ b_id: 'e1', b_field_id: '10', b_visible: 1, b_colspan: 12, b_group_id: 'g1' }]
|
||||
const off = toggleForm(rows, makeField(), MODULE_ID)
|
||||
expect(off).toHaveLength(1)
|
||||
expect(off[0].b_visible).toBe(0)
|
||||
expect(off[0].b_colspan).toBe(12) // 布局属性保留
|
||||
expect(off[0].b_group_id).toBe('g1')
|
||||
const on = toggleForm(off, makeField(), MODULE_ID)
|
||||
expect(on[0].b_visible).toBe(1)
|
||||
})
|
||||
|
||||
it('行不存在:生成默认行追加到末尾(b_visible=1、b_colspan=24、b_xh 为末尾序号)', () => {
|
||||
const rows = [{ b_id: 'e1', b_field_id: '9', b_visible: 1, b_xh: 30 }]
|
||||
const next = toggleForm(rows, makeField(), MODULE_ID)
|
||||
expect(next).toHaveLength(2)
|
||||
const created = next[1]
|
||||
expect(created.b_field_id).toBe('10')
|
||||
expect(created.b_visible).toBe(1)
|
||||
expect(created.b_colspan).toBe(24)
|
||||
expect(created.b_module_id).toBe('m1')
|
||||
expect(created.b_xh).toBe(40) // 末尾追加,不排到表单最前
|
||||
expect(next[0]).toBe(rows[0]) // 非目标行保持原引用
|
||||
})
|
||||
})
|
||||
|
||||
describe('列表参与切换', () => {
|
||||
it('行存在:切换 b_visible(与「删除列=隐藏」语义一致)', () => {
|
||||
const rows = [{ b_id: 'v1', b_field_id: '10', b_visible: 1, b_width: 110 }]
|
||||
const off = toggleList(rows, makeField(), MODULE_ID)
|
||||
expect(off[0].b_visible).toBe(0)
|
||||
expect(off[0].b_width).toBe(110)
|
||||
expect(toggleList(off, makeField(), MODULE_ID)[0].b_visible).toBe(1)
|
||||
})
|
||||
|
||||
it('行不存在:按默认生成且强制显示(含大文本)', () => {
|
||||
const textarea = makeField({ b_type: 'textarea' })
|
||||
const next = toggleList([], textarea, MODULE_ID)
|
||||
expect(next).toHaveLength(1)
|
||||
expect(next[0].b_visible).toBe(1) // 显式意愿覆盖大文本默认不参与
|
||||
expect(next[0].b_xh).toBe(10)
|
||||
})
|
||||
})
|
||||
|
||||
describe('查询参与切换', () => {
|
||||
it('行存在:切换 b_canuse', () => {
|
||||
const rows = [{ b_id: 'q1', b_field_id: '10', b_canuse: 0, b_operator: 'eq' }]
|
||||
const on = toggleQuery(rows, makeField(), MODULE_ID)
|
||||
expect(on[0].b_canuse).toBe(1)
|
||||
expect(on[0].b_operator).toBe('eq') // 既有查询配置保留
|
||||
expect(toggleQuery(on, makeField(), MODULE_ID)[0].b_canuse).toBe(0)
|
||||
})
|
||||
|
||||
it('行不存在:按默认生成(money → number/eq)且强制参与', () => {
|
||||
const next = toggleQuery([], makeField(), MODULE_ID)
|
||||
expect(next).toHaveLength(1)
|
||||
expect(next[0].b_canuse).toBe(1)
|
||||
expect(next[0].b_component).toBe('number')
|
||||
expect(next[0].b_operator).toBe('eq')
|
||||
expect(next[0].b_field_module_id).toBe('m1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('切换的不变性', () => {
|
||||
it('返回新数组,不修改原数组', () => {
|
||||
const rows = [{ b_id: 'e1', b_field_id: '10', b_visible: 1 }]
|
||||
const next = toggleForm(rows, makeField(), MODULE_ID)
|
||||
expect(next).not.toBe(rows)
|
||||
expect(rows[0].b_visible).toBe(1) // 原行未被修改
|
||||
})
|
||||
})
|
||||
@@ -1,138 +0,0 @@
|
||||
/**
|
||||
* 分 section 脏检查测试:守护 hasModuleChanges 的判定语义。
|
||||
* 背景见 docs/module-management-tab-status-plan.md 阶段一:
|
||||
* - 加载时自动补全的默认列表行只写 drafts 不写 originals(useModuleConfig.loadModuleConfig),
|
||||
* 此时视为「脏」是既有行为,分 section 对比必须原样保留,不得“修复”;
|
||||
* - hasModuleChanges 从整树 JSON.stringify 对比改为 dirtySections 派生,
|
||||
* 本 spec 以判定结果等价为验收基准。
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
import { useModuleConfig } from '@/views/module/module-management/composables/useModuleConfig'
|
||||
|
||||
vi.mock('@/services/api', () => ({
|
||||
loadDataApi: vi.fn(),
|
||||
describeApi: vi.fn(),
|
||||
}))
|
||||
vi.mock('@/components/ui', () => ({
|
||||
Message: { success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() },
|
||||
}))
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
/** 完整的 8 段草稿结构(与 loadModuleConfig 构造的 original 同形) */
|
||||
function baseConfig(over = {}) {
|
||||
const config = {
|
||||
module: { b_id: '1', b_code: 'demo', b_name: '演示模块', b_module_type: 'data' },
|
||||
fields: [{ b_id: '10', b_field: 'amount', b_name: '金额', b_canuse: 1 }],
|
||||
groups: [],
|
||||
listConfig: [],
|
||||
editConfig: [],
|
||||
queryConfig: [],
|
||||
autoCode: [],
|
||||
powers: [],
|
||||
}
|
||||
return Object.assign(config, over)
|
||||
}
|
||||
|
||||
function setup({ drafts, originals }) {
|
||||
const draftsRef = ref(drafts)
|
||||
const originalsRef = ref(originals)
|
||||
const deps = {
|
||||
moduleSelectedId: ref('1'),
|
||||
moduleTreeData: ref([]),
|
||||
findModuleNode: () => null,
|
||||
selectedModule: ref({ b_id: '1', b_code: 'demo', b_name: '演示模块' }),
|
||||
busy: ref(false),
|
||||
configLoading: ref(false),
|
||||
configSyncing: ref(false),
|
||||
activeSection: ref('basic'),
|
||||
loadedModuleId: ref('1'),
|
||||
originals: originalsRef,
|
||||
drafts: draftsRef,
|
||||
skipModuleLoad: { value: false },
|
||||
showConfirm: vi.fn(),
|
||||
switchConfirm: ref(null),
|
||||
i18nPanelRef: ref(null),
|
||||
}
|
||||
return { ...useModuleConfig(deps), originalsRef, draftsRef }
|
||||
}
|
||||
|
||||
describe('useModuleConfig 脏检查', () => {
|
||||
it('未修改时 hasModuleChanges 为 false', () => {
|
||||
const originals = baseConfig()
|
||||
const { hasModuleChanges } = setup({ drafts: JSON.parse(JSON.stringify(originals)), originals })
|
||||
expect(hasModuleChanges.value).toBe(false)
|
||||
})
|
||||
|
||||
it('无草稿时 hasModuleChanges 为 false', () => {
|
||||
const { hasModuleChanges } = setup({ drafts: null, originals: null })
|
||||
expect(hasModuleChanges.value).toBe(false)
|
||||
})
|
||||
|
||||
it('任一 section 修改即 hasModuleChanges 为 true', () => {
|
||||
const originals = baseConfig()
|
||||
for (const key of ['module', 'fields', 'groups', 'listConfig', 'editConfig', 'queryConfig', 'autoCode', 'powers']) {
|
||||
const drafts = JSON.parse(JSON.stringify(originals))
|
||||
if (key === 'module') drafts.module.b_name = '改名'
|
||||
else drafts[key] = [...drafts[key], { b_id: 'new' }]
|
||||
const { hasModuleChanges } = setup({ drafts, originals })
|
||||
expect(hasModuleChanges.value, `修改 ${key} 应判定为脏`).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('加载时补全的默认列表行(只写 drafts)视为脏——既有行为', () => {
|
||||
const originals = baseConfig()
|
||||
const drafts = JSON.parse(JSON.stringify(originals))
|
||||
drafts.listConfig = [{ b_id: 'tmp1', b_field_id: '10', b_visible: 1, b_xh: 10 }]
|
||||
const { hasModuleChanges } = setup({ drafts, originals })
|
||||
expect(hasModuleChanges.value).toBe(true)
|
||||
})
|
||||
|
||||
it('查询模块缺失的 section(undefined)两侧一致时不脏,单侧出现即脏', () => {
|
||||
const originals = {
|
||||
module: { b_id: '2', b_code: 'q', b_module_type: 'query' },
|
||||
fields: [],
|
||||
groups: [],
|
||||
listConfig: [],
|
||||
editConfig: undefined,
|
||||
queryConfig: [],
|
||||
autoCode: undefined,
|
||||
powers: [],
|
||||
}
|
||||
const same = setup({ drafts: JSON.parse(JSON.stringify(originals)), originals })
|
||||
expect(same.hasModuleChanges.value).toBe(false)
|
||||
|
||||
const drafts = JSON.parse(JSON.stringify(originals))
|
||||
drafts.editConfig = []
|
||||
const diff = setup({ drafts, originals })
|
||||
expect(diff.hasModuleChanges.value).toBe(true)
|
||||
})
|
||||
|
||||
it('dirtySections 精确指出被修改的 section', () => {
|
||||
const originals = baseConfig()
|
||||
const drafts = JSON.parse(JSON.stringify(originals))
|
||||
drafts.module.b_name = '改名'
|
||||
drafts.queryConfig = [{ b_id: 'q1' }]
|
||||
const { dirtySections } = setup({ drafts, originals })
|
||||
expect(dirtySections.value.has('module')).toBe(true)
|
||||
expect(dirtySections.value.has('queryConfig')).toBe(true)
|
||||
expect(dirtySections.value.has('fields')).toBe(false)
|
||||
expect(dirtySections.value.size).toBe(2)
|
||||
})
|
||||
|
||||
it('保存后整体替换 originals/drafts 为同一份深拷贝,脏状态清空', () => {
|
||||
const originals = baseConfig()
|
||||
const drafts = JSON.parse(JSON.stringify(originals))
|
||||
drafts.module.b_name = '改名'
|
||||
const api = setup({ drafts, originals })
|
||||
expect(api.hasModuleChanges.value).toBe(true)
|
||||
// 模拟 useModuleSave 保存后的整体 cloneData 换引用
|
||||
const saved = JSON.parse(JSON.stringify(drafts))
|
||||
api.originalsRef.value = saved
|
||||
api.draftsRef.value = JSON.parse(JSON.stringify(saved))
|
||||
expect(api.hasModuleChanges.value).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,177 @@
|
||||
/**
|
||||
* i18n 统一维护纯函数测试:
|
||||
* 覆盖 index.vue 上移后的受管键收集、扁平行→透视、reconcile(补齐/跟名/失效清理)与保存 diff,
|
||||
* 守护「ModuleI18nPanel 受控化」后数据编排逻辑不回退。
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
buildI18nChangeRequest,
|
||||
buildI18nPivot,
|
||||
collectManagedI18nItems,
|
||||
reconcileI18nRows,
|
||||
} from '@/views/module/module-management/utils'
|
||||
|
||||
const languages = [
|
||||
{ code: 'zh-CN', name: '简体中文', default: true },
|
||||
{ code: 'en-US', name: 'English', default: false },
|
||||
]
|
||||
const defaultLocale = 'zh-CN'
|
||||
|
||||
function demoModule(over = {}) {
|
||||
return {
|
||||
b_id: '9',
|
||||
b_code: 'demo',
|
||||
b_module_type: 'data',
|
||||
b_name: '演示模块',
|
||||
b_i18n: null,
|
||||
...over,
|
||||
}
|
||||
}
|
||||
|
||||
describe('collectManagedI18nItems 受管键收集', () => {
|
||||
it('收集模块名与字段名的受管键', () => {
|
||||
const items = collectManagedI18nItems({
|
||||
module: demoModule(),
|
||||
fields: [{ b_id: '1', b_field: 'name', b_name: '姓名', b_i18n: null }],
|
||||
groups: [],
|
||||
})
|
||||
expect(items.map((i) => i.key)).toEqual(['module.demo', 'field.demo.name'])
|
||||
expect(items[0].id).toBe('module:module.demo')
|
||||
expect(items[1].id).toBe('field:field.demo.name')
|
||||
expect(items[0].defaultValue).toBe('演示模块')
|
||||
expect(items[1].defaultValue).toBe('姓名')
|
||||
})
|
||||
|
||||
it('模块编码为空时不产出受管键(新建未填编码)', () => {
|
||||
const items = collectManagedI18nItems({
|
||||
module: demoModule({ b_code: '' }),
|
||||
fields: [{ b_id: '1', b_field: 'name', b_name: '姓名', b_i18n: null }],
|
||||
groups: [],
|
||||
})
|
||||
expect(items).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildI18nPivot / reconcileI18nRows', () => {
|
||||
it('扁平行转透视后 reconcile 补齐受管行并跟随默认语言', () => {
|
||||
const flat = [{ b_id: '100', b_key: 'field.demo.name', b_locale: 'en-US', b_value: 'Name' }]
|
||||
const pivot = buildI18nPivot(flat)
|
||||
const items = collectManagedI18nItems({
|
||||
module: demoModule(),
|
||||
fields: [{ b_id: '1', b_field: 'name', b_name: '姓名', b_i18n: null }],
|
||||
groups: [],
|
||||
})
|
||||
const rows = reconcileI18nRows(pivot, items, { defaultLocale, languages })
|
||||
|
||||
const byKey = new Map(rows.map((r) => [r.b_key, r]))
|
||||
expect(byKey.size).toBe(2)
|
||||
// 手工翻译保留
|
||||
expect(byKey.get('field.demo.name')['en-US']).toBe('Name')
|
||||
// 受管默认语言跟随当前名称
|
||||
expect(byKey.get('field.demo.name')['zh-CN']).toBe('姓名')
|
||||
expect(byKey.get('module.demo')['zh-CN']).toBe('演示模块')
|
||||
expect(byKey.get('field.demo.name')._managedId).toBe('field:field.demo.name')
|
||||
})
|
||||
|
||||
it('名称变化后 reconcile 自动跟名,人工翻译不被覆盖', () => {
|
||||
const fields = [{ b_id: '1', b_field: 'name', b_name: '姓名', b_i18n: null }]
|
||||
const flat = [{ b_id: '100', b_key: 'field.demo.name', b_locale: 'en-US', b_value: 'Name' }]
|
||||
let rows = reconcileI18nRows(
|
||||
buildI18nPivot(flat),
|
||||
collectManagedI18nItems({ module: demoModule(), fields, groups: [] }),
|
||||
{ defaultLocale, languages },
|
||||
)
|
||||
// 改名后再次 reconcile
|
||||
rows = reconcileI18nRows(
|
||||
rows,
|
||||
collectManagedI18nItems({
|
||||
module: demoModule({ b_name: '演示模块V2' }),
|
||||
fields: [{ ...fields[0], b_name: '姓名V2' }],
|
||||
groups: [],
|
||||
}),
|
||||
{ defaultLocale, languages },
|
||||
)
|
||||
const byKey = new Map(rows.map((r) => [r.b_key, r]))
|
||||
expect(byKey.get('module.demo')['zh-CN']).toBe('演示模块V2')
|
||||
expect(byKey.get('field.demo.name')['zh-CN']).toBe('姓名V2')
|
||||
expect(byKey.get('field.demo.name')['en-US']).toBe('Name')
|
||||
})
|
||||
|
||||
it('字段删除后:无翻译的自动补齐行被清理,带翻译的保留为普通行', () => {
|
||||
const field = { b_id: '1', b_field: 'name', b_name: '姓名', b_i18n: null }
|
||||
const items = collectManagedI18nItems({ module: demoModule(), fields: [field], groups: [] })
|
||||
const rows = reconcileI18nRows(buildI18nPivot([]), items, { defaultLocale, languages })
|
||||
expect(rows.some((r) => r._managedId === 'field:field.demo.name')).toBe(true)
|
||||
|
||||
// 字段消失:受管行无持久翻译,应被清理
|
||||
const next = reconcileI18nRows(rows, items.slice(0, 1), { defaultLocale, languages })
|
||||
expect(next.some((r) => r.b_key === 'field.demo.name')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildI18nChangeRequest 保存 diff', () => {
|
||||
const originalFlat = [
|
||||
{ b_id: '100', b_key: 'field.demo.name', b_locale: 'en-US', b_value: 'Name' },
|
||||
]
|
||||
|
||||
function draftRows() {
|
||||
const items = collectManagedI18nItems({
|
||||
module: demoModule(),
|
||||
fields: [{ b_id: '1', b_field: 'name', b_name: '姓名', b_i18n: null }],
|
||||
groups: [],
|
||||
})
|
||||
return reconcileI18nRows(buildI18nPivot(originalFlat), items, { defaultLocale, languages })
|
||||
}
|
||||
|
||||
it('首存新增受管默认语言 cell,已存在值不重复插入', () => {
|
||||
const req = buildI18nChangeRequest({
|
||||
draftRows: draftRows(),
|
||||
originalFlat,
|
||||
moduleId: '9',
|
||||
languages,
|
||||
})
|
||||
expect(req).not.toBeNull()
|
||||
const keys = (req.inserts || []).map((r) => `${r.b_key}|${r.b_locale}`).sort()
|
||||
expect(keys).toEqual(['field.demo.name|zh-CN', 'module.demo|zh-CN'])
|
||||
// 新增行 b_id 为负数临时 id(保存编排统一换真实主键)
|
||||
expect((req.inserts || []).every((r) => /^-\d+$/.test(String(r.b_id)))).toBe(true)
|
||||
// en-US 有主键且未变化 → 不产生 update
|
||||
expect(req.updates || []).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('修改语言值只产生对应 cell 的 update', () => {
|
||||
const rows = draftRows()
|
||||
rows.find((r) => r.b_key === 'field.demo.name')['en-US'] = 'NewName'
|
||||
const req = buildI18nChangeRequest({
|
||||
draftRows: rows,
|
||||
originalFlat,
|
||||
moduleId: '9',
|
||||
languages,
|
||||
})
|
||||
expect(req.updates.some((r) => r.b_id === '100' && r.b_value === 'NewName')).toBe(true)
|
||||
})
|
||||
|
||||
it('与基线完全一致时不产生请求', () => {
|
||||
const originalAll = [
|
||||
...originalFlat,
|
||||
{ b_id: '101', b_key: 'module.demo', b_locale: 'zh-CN', b_value: '演示模块' },
|
||||
{ b_id: '102', b_key: 'field.demo.name', b_locale: 'zh-CN', b_value: '姓名' },
|
||||
]
|
||||
const items = collectManagedI18nItems({
|
||||
module: demoModule(),
|
||||
fields: [{ b_id: '1', b_field: 'name', b_name: '姓名', b_i18n: null }],
|
||||
groups: [],
|
||||
})
|
||||
const rows = reconcileI18nRows(buildI18nPivot(originalAll), items, {
|
||||
defaultLocale,
|
||||
languages,
|
||||
})
|
||||
const req = buildI18nChangeRequest({
|
||||
draftRows: rows,
|
||||
originalFlat: originalAll,
|
||||
moduleId: '9',
|
||||
languages,
|
||||
})
|
||||
expect(req).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
formatDateValue,
|
||||
parseOptions,
|
||||
} from '@/components/fms-module-list/moduleListUtils'
|
||||
import { setMessages, translate } from '@/utils/translate'
|
||||
import { setMessages, translate } from '@/stores/i18n'
|
||||
|
||||
function item(over = {}) {
|
||||
return {
|
||||
|
||||
@@ -1,213 +0,0 @@
|
||||
/**
|
||||
* useModuleSave 保存编排测试:守护跨表保存的关键不变量。
|
||||
* 这些顺序约束此前只存在于注释中,被改动时不会有任何报警:
|
||||
* 1. 校验失败时不产生任何保存请求,且定位到对应分节;
|
||||
* 2. s_module 有修改走 updates、新建走 inserts;
|
||||
* 3. 临时 id 先分配真实主键,再跨表重映射 b_field_id(req 行与草稿行同引用);
|
||||
* 4. s_autocode 的 updates 剥离服务端维护的 b_currentperiod / b_currentvalue;
|
||||
* 5. i18n inserts 分配真实主键并回写面板,避免二次保存重复插入。
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
import { useModuleSave } from '@/views/module/module-management/composables/useModuleSave'
|
||||
import { saveObjectApi, nextIdApi } from '@/services/api'
|
||||
import { Message } from '@/components/ui'
|
||||
|
||||
vi.mock('@/services/api', () => ({
|
||||
saveObjectApi: vi.fn(),
|
||||
nextIdApi: vi.fn(),
|
||||
}))
|
||||
vi.mock('@/components/ui', () => ({
|
||||
Message: { success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() },
|
||||
}))
|
||||
|
||||
let idSeq
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
idSeq = 9000
|
||||
nextIdApi.mockImplementation(async (count) => {
|
||||
const ids = []
|
||||
for (let i = 0; i < count; i++) ids.push(String(++idSeq))
|
||||
return { data: ids }
|
||||
})
|
||||
})
|
||||
|
||||
function baseDrafts(over = {}) {
|
||||
const drafts = {
|
||||
// b_i18n 与 buildModuleI18nKey('data', 'demo') 派生值一致:saveModule 每次保存都会物化该键
|
||||
module: { b_id: '1', b_code: 'demo', b_name: '演示模块', b_module_type: 'data', b_i18n: 'module.demo' },
|
||||
fields: [],
|
||||
groups: [],
|
||||
listConfig: [],
|
||||
editConfig: [],
|
||||
queryConfig: [],
|
||||
autoCode: [],
|
||||
powers: [],
|
||||
}
|
||||
return Object.assign(drafts, over)
|
||||
}
|
||||
|
||||
function setup({ drafts, originals, basicPanel = null, autoCodePanel = null, i18nPanel = null }) {
|
||||
const unsavedModuleIds = new Set()
|
||||
const deps = {
|
||||
selectedModule: ref({ b_id: '1', b_code: 'demo', b_name: '演示模块' }),
|
||||
drafts: ref(drafts),
|
||||
originals: ref(originals),
|
||||
loadedModuleId: ref('1'),
|
||||
moduleSelectedId: ref('1'),
|
||||
unsavedModuleIds,
|
||||
configSaving: ref(false),
|
||||
busy: ref(false),
|
||||
activeSection: ref('basic'),
|
||||
moduleTreeData: ref([]),
|
||||
findModuleNode: () => null,
|
||||
basicPanelRef: ref(basicPanel),
|
||||
autoCodePanelRef: ref(autoCodePanel),
|
||||
i18nPanelRef: ref(i18nPanel),
|
||||
getFinalI18nItems: () => [{ key: 'module.demo', managed: true, id: 'module:demo' }],
|
||||
}
|
||||
return { saveModule: useModuleSave(deps).saveModule, unsavedModuleIds, activeSection: deps.activeSection }
|
||||
}
|
||||
|
||||
function reqOf(table) {
|
||||
return saveObjectApi.mock.calls.at(-1)[0].find((req) => req.table === table)
|
||||
}
|
||||
|
||||
describe('useModuleSave 保存编排', () => {
|
||||
it('无变更时不发请求并提示', async () => {
|
||||
const drafts = baseDrafts()
|
||||
const { saveModule } = setup({ drafts, originals: JSON.parse(JSON.stringify(drafts)) })
|
||||
|
||||
const ok = await saveModule()
|
||||
|
||||
expect(ok).toBe(true)
|
||||
expect(saveObjectApi).not.toHaveBeenCalled()
|
||||
expect(Message.info).toHaveBeenCalledWith('没有需要保存的变更')
|
||||
})
|
||||
|
||||
it('基础面板校验失败:不发请求,定位到 basic 分节', async () => {
|
||||
const drafts = baseDrafts()
|
||||
const { saveModule, activeSection } = setup({
|
||||
drafts,
|
||||
originals: JSON.parse(JSON.stringify(drafts)),
|
||||
basicPanel: { validate: async () => ['编码不能为空'] },
|
||||
})
|
||||
|
||||
const ok = await saveModule()
|
||||
|
||||
expect(ok).toBe(false)
|
||||
expect(saveObjectApi).not.toHaveBeenCalled()
|
||||
expect(activeSection.value).toBe('basic')
|
||||
expect(Message.warning).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('s_module 修改走 updates,新建走 inserts', async () => {
|
||||
const drafts = baseDrafts({ module: { b_id: '1', b_code: 'demo', b_name: '改名后' } })
|
||||
const originals = baseDrafts()
|
||||
const { saveModule } = setup({ drafts, originals })
|
||||
await saveModule()
|
||||
expect(reqOf('s_module').updates[0].b_name).toBe('改名后')
|
||||
|
||||
const { saveModule: saveNew, unsavedModuleIds } = setup({
|
||||
drafts: baseDrafts(),
|
||||
originals: baseDrafts(),
|
||||
})
|
||||
unsavedModuleIds.add('1')
|
||||
await saveNew()
|
||||
expect(reqOf('s_module').inserts[0].b_code).toBe('demo')
|
||||
})
|
||||
|
||||
it('临时 id 分配真实主键并跨表重映射 b_field_id', async () => {
|
||||
// 新字段(临时 id -1)+ 引用它的列表列:编辑行同引用,重映射必须同时落到请求与草稿
|
||||
const listRow = { b_id: '100', b_module_id: '1', b_field_id: '-1', b_visible: 1, b_xh: 10 }
|
||||
const drafts = baseDrafts({
|
||||
fields: [{ b_id: '-1', b_module_id: '1', b_field: 'name', b_name: '姓名' }],
|
||||
listConfig: [listRow],
|
||||
})
|
||||
const originals = baseDrafts({
|
||||
listConfig: [{ b_id: '100', b_module_id: '1', b_field_id: '9999', b_visible: 1, b_xh: 10 }],
|
||||
})
|
||||
const { saveModule } = setup({ drafts, originals })
|
||||
|
||||
const ok = await saveModule()
|
||||
|
||||
expect(ok).toBe(true)
|
||||
expect(nextIdApi).toHaveBeenCalled()
|
||||
const fieldReq = reqOf('s_field')
|
||||
expect(fieldReq.inserts[0].b_id).toBe('9001')
|
||||
// 列表列由旧字段改挂新字段:update 行内的临时外键必须已换成真实 id
|
||||
const viewReq = reqOf('s_field_view')
|
||||
expect(viewReq.updates[0].b_field_id).toBe('9001')
|
||||
// 草稿行与请求行同引用:重映射后草稿也持有真实 id
|
||||
expect(drafts.listConfig[0].b_field_id).toBe('9001')
|
||||
})
|
||||
|
||||
it('s_autocode updates 剥离服务端维护的期间/当前值', async () => {
|
||||
const rule = {
|
||||
b_id: '200',
|
||||
b_module_id: '1',
|
||||
b_field_id: '100',
|
||||
b_canuse: 1,
|
||||
b_currentperiod: 2026,
|
||||
b_currentvalue: 5,
|
||||
}
|
||||
const drafts = baseDrafts({
|
||||
autoCode: [rule],
|
||||
module: { b_id: '1', b_code: 'demo', b_name: '演示模块', b_module_type: 'data', b_i18n: 'module.demo', b_savetable: 't_demo' },
|
||||
})
|
||||
const { saveModule } = setup({
|
||||
drafts,
|
||||
// 原件行与草稿行存在差异(当前值不同)才会产生 update,从而走到剥离逻辑
|
||||
originals: baseDrafts({ autoCode: [{ ...rule, b_currentvalue: 4 }] }),
|
||||
})
|
||||
|
||||
await saveModule()
|
||||
|
||||
const update = reqOf('s_autocode').updates[0]
|
||||
expect(update).not.toHaveProperty('b_currentperiod')
|
||||
expect(update).not.toHaveProperty('b_currentvalue')
|
||||
expect(update.b_field_id).toBe('100')
|
||||
})
|
||||
|
||||
it('i18n inserts 分配真实主键并回写面板,事务内提交', async () => {
|
||||
const drafts = baseDrafts({ module: { b_id: '1', b_code: 'demo', b_name: '新名称' } })
|
||||
const originals = baseDrafts()
|
||||
const i18nPanel = {
|
||||
reconcileManagedRows: vi.fn(),
|
||||
buildSaveRequest: vi.fn(() => ({
|
||||
table: 's_i18n',
|
||||
key_field: 'b_id',
|
||||
inserts: [{ b_id: '-9', b_key: 'module.demo', b_locale: 'zh-CN', b_value: '新名称' }],
|
||||
})),
|
||||
applyInsertedIds: vi.fn(),
|
||||
markSaved: vi.fn(),
|
||||
}
|
||||
const { saveModule } = setup({ drafts, originals, i18nPanel })
|
||||
|
||||
const ok = await saveModule()
|
||||
|
||||
expect(ok).toBe(true)
|
||||
// 受管键 reconcile 在构建请求前执行
|
||||
expect(i18nPanel.reconcileManagedRows).toHaveBeenCalledWith([
|
||||
{ key: 'module.demo', managed: true, id: 'module:demo' },
|
||||
])
|
||||
// 临时主键分配后回写面板,避免二次保存重复插入
|
||||
expect(i18nPanel.applyInsertedIds).toHaveBeenCalledWith([
|
||||
expect.objectContaining({ b_id: '9001', b_key: 'module.demo' }),
|
||||
])
|
||||
expect(reqOf('s_i18n').inserts[0].b_id).toBe('9001')
|
||||
expect(i18nPanel.markSaved).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('跨面板校验前置:启用自动编码但未配置保存表则拦截', async () => {
|
||||
const rule = { b_id: '200', b_module_id: '1', b_field_id: '100', b_canuse: 1 }
|
||||
const drafts = baseDrafts({ autoCode: [rule] })
|
||||
const { saveModule } = setup({ drafts, originals: baseDrafts() })
|
||||
|
||||
const ok = await saveModule()
|
||||
|
||||
expect(ok).toBe(false)
|
||||
expect(saveObjectApi).not.toHaveBeenCalled()
|
||||
expect(Message.warning).toHaveBeenCalledWith('启用自动编码前请先配置保存表')
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user