20260911173133

This commit is contained in:
oneao committed 2026-09-11 17:31:34 +08:00
1 parent 3838ed74b1
commit f3ad727f1c
60 files changed
+4971 -6621

No files matched your search

@@ -0,0 +1,88 @@
/**
* FmsModuleEditModal 冒烟测试(V2:编辑配置来自 s_module_schema.edit JSON)。
* 验证:打开弹窗后按 data-code 加载字段与 edit JSON,渲染分组与字段,无配置错误。
*/
import { mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
import { nextTick } from 'vue'
vi.mock('@/services/api', () => ({
loadDataApi: vi.fn(async (viewName) => {
if (viewName === 's_module') {
return {
data: [
{
b_id: 's_i18n_type',
b_name: '语言类型',
b_module_type: 'data',
b_canuse: 1,
b_view_table: 's_i18n_type',
b_save_table: 's_i18n_type',
b_key_field: 'b_id',
},
],
}
}
if (viewName === 's_field') {
return {
data: [
{ b_field: 'b_id', b_name: '语言编码', b_type: 'input', b_canuse: 1, b_i18n: null },
{ b_field: 'b_name', b_name: '语言名称', b_type: 'input', b_canuse: 1, b_i18n: null },
],
}
}
if (viewName === 's_module_schema') {
return {
data: [
{
b_module_id: 's_i18n_type',
b_schema_type: 'edit',
b_schema_json: JSON.stringify({
schemaVersion: 1,
children: [
{
type: 'group',
title: '基础信息',
children: [
{ field: 'b_id', span: 24, required: true },
{ field: 'b_name', span: 24 },
],
},
],
}),
},
],
}
}
return { data: [] }
}),
saveObjectApi: vi.fn(async () => ({})),
nextIdApi: vi.fn(async () => ({ data: '1' })),
loadDataBySqlApi: vi.fn(async () => ({ data: [] })),
}))
import FmsModuleEditModal from '@/components/fms-module-edit/FmsModuleEditModal.vue'
beforeEach(() => {
setActivePinia(createPinia())
})
describe('FmsModuleEditModal 冒烟', () => {
it('打开后按 data-code 渲染 edit JSON 的分组与字段', async () => {
const wrapper = mount(FmsModuleEditModal, {
props: { open: false, dataCode: 's_i18n_type', record: null },
attachTo: document.body,
})
await wrapper.setProps({ open: true })
await new Promise((r) => setTimeout(r, 40))
await nextTick()
expect(document.querySelector('.fme-error')).toBeNull()
const text = document.body.textContent || ''
expect(text).toContain('基础信息')
expect(text).toContain('语言编码')
expect(text).toContain('语言名称')
wrapper.unmount()
})
})
@@ -0,0 +1,104 @@
/**
* FmsModuleListPage 冒烟测试(V2:元数据来自 s_field + s_module_schema JSON)。
* 验证:按模块编码加载字段与 view/query 界面配置后,能正常渲染数据列、无配置错误。
*/
import { mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
import { nextTick } from 'vue'
import { registerFeature, useAreaSelection } from 'stk-table-vue'
registerFeature(useAreaSelection)
vi.mock('vue-router', () => ({
useRoute: () => ({ path: '/module/i18n-type', meta: {} }),
}))
vi.mock('@/services/api', () => ({
loadDataApi: vi.fn(async (viewName) => {
if (viewName === 's_module') {
return {
data: [
{
b_id: 's_i18n_type',
b_module_type: 'data',
b_canuse: 1,
b_view_table: 's_i18n_type',
b_save_table: 's_i18n_type',
b_key_field: 'b_id',
},
],
}
}
if (viewName === 's_field') {
return {
data: [
{ b_field: 'b_id', b_name: '语言编码', b_type: 'input', b_canuse: 1, b_i18n: null },
{ b_field: 'b_name', b_name: '语言名称', b_type: 'input', b_canuse: 1, b_i18n: null },
],
}
}
if (viewName === 's_module_schema') {
return {
data: [
{
b_module_id: 's_i18n_type',
b_schema_type: 'view',
b_schema_json: JSON.stringify({
schemaVersion: 1,
columns: [
{ field: 'b_id', width: 120 },
{ field: 'b_name', width: 180 },
],
}),
},
{
b_module_id: 's_i18n_type',
b_schema_type: 'query',
b_schema_json: JSON.stringify({
schemaVersion: 1,
quick: ['b_name'],
groups: [{ logic: 'and', conditions: [{ field: 'b_name', operator: 'like' }] }],
}),
},
],
}
}
return { data: [] }
}),
pageDataApi: vi.fn(async () => ({
data: { rows: [{ b_id: 'zh-CN', b_name: '简体中文' }], total: 1 },
})),
saveObjectApi: vi.fn(async () => ({})),
nextIdApi: vi.fn(async () => ({ data: '1' })),
loadDataBySqlApi: vi.fn(async () => ({ data: [] })),
}))
import FmsModuleListPage from '@/components/fms-module-list/FmsModuleListPage.vue'
beforeEach(() => {
setActivePinia(createPinia())
Object.defineProperty(HTMLElement.prototype, 'clientWidth', {
configurable: true,
get() {
return 5000
},
})
})
describe('FmsModuleListPage 冒烟', () => {
it('按 data-code 加载字段与界面配置后渲染列,无配置错误', async () => {
const wrapper = mount(FmsModuleListPage, {
props: { dataCode: 's_i18n_type' },
attachTo: document.body,
})
await new Promise((r) => setTimeout(r, 60))
await nextTick()
expect(wrapper.find('.module-config-error').exists()).toBe(false)
const text = wrapper.text()
expect(text).toContain('语言编码')
expect(text).toContain('语言名称')
wrapper.unmount()
})
})
@@ -1,11 +1,23 @@
/**
* 复现用:新建空数据模块后 FormEditPanel 首次挂载(全部空数组)。
* 若存在响应式自触发循环,Vue 会抛 "Maximum recursive updates exceeded"。
* 表单设计面板(ModuleEditPanel → FormDesignEditor)挂载回归(V2:edit JSON 节点模型)。
* 覆盖:空配置挂载、已有配置切换为空草稿(新建模块序列)均不触发递归更新。
*/
import { mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import { beforeAll, describe, expect, it, vi } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
import ModuleFormEditPanel from '@/views/module/module-management/components/form-edit/FormEditPanel.vue'
import { registerFeature, useAreaSelection } from 'stk-table-vue'
import ModuleEditPanel from '@/views/module/module-management/ModuleEditPanel.vue'
registerFeature(useAreaSelection)
beforeAll(() => {
Object.defineProperty(HTMLElement.prototype, 'clientWidth', {
configurable: true,
get() {
return 5000
},
})
})
vi.mock('@/services/api', () => ({
loadDataApi: vi.fn(async () => ({ data: [] })),
@@ -31,63 +43,54 @@ const StubDraggable = {
`,
}
function mountEmptyPanel() {
return mount(ModuleFormEditPanel, {
const fields = [
{ b_field: 'name', b_name: '姓名', b_type: 'input' },
{ b_field: 'age', b_name: '年龄', b_type: 'number' },
]
const filledSchema = {
schemaVersion: 1,
children: [
{
type: 'group',
title: '基本信息',
children: [
{ field: 'name', span: 24, required: true },
{ field: 'age', span: 24 },
],
},
],
}
function mountPanel(modelValue) {
return mount(ModuleEditPanel, {
props: {
moduleId: 'm1',
editConfig: [],
listConfig: [],
fields: [],
groups: [],
editMode: 'form',
loading: false,
modelValue,
fields,
active: true,
loading: false,
},
global: { stubs: { draggable: StubDraggable } },
})
}
const fields = [
{ b_id: '1', b_name: '姓名', b_field: 'name', b_type: 'input' },
{ b_id: '2', b_name: '年龄', b_field: 'age', b_type: 'number' },
]
const groups = [
{ b_id: 'g1', b_module_id: 'm1', b_parent_id: null, b_title: '基本信息', b_xh: 10, b_canuse: 1 },
]
const editConfig = [
{ b_id: 'e1', b_module_id: 'm1', b_field_id: '1', b_group_id: 'g1', b_visible: 1, b_required: 0, b_readonly: 0, b_disabled: 0, b_colspan: 24, b_height: null, b_placeholder: null, b_xh: 10 },
{ b_id: 'e2', b_module_id: 'm1', b_field_id: '2', b_group_id: null, b_visible: 1, b_required: 0, b_readonly: 0, b_disabled: 0, b_colspan: 24, b_height: null, b_placeholder: null, b_xh: 20 },
]
describe('FormEditPanel 空模块挂载', () => {
it('从空挂载不触发递归更新', async () => {
describe('ModuleEditPanel 挂载', () => {
it('空配置挂载不触发递归更新', async () => {
setActivePinia(createPinia())
const wrapper = mountEmptyPanel()
const wrapper = mountPanel({ schemaVersion: 1, children: [] })
await wrapper.vm.$nextTick()
await wrapper.vm.$nextTick()
expect(wrapper.exists()).toBe(true)
wrapper.unmount()
})
it('已有数据模块切到空草稿(新建模块序列)不触发递归更新', async () => {
it('已有配置切换为空草稿(新建模块序列)不触发递归更新', async () => {
setActivePinia(createPinia())
const wrapper = mount(ModuleFormEditPanel, {
props: {
moduleId: 'm1',
editConfig: editConfig.map((r) => ({ ...r })),
listConfig: [],
fields,
groups: groups.map((g) => ({ ...g })),
editMode: 'form',
loading: false,
active: true,
},
global: { stubs: { draggable: StubDraggable } },
})
const wrapper = mountPanel(filledSchema)
await wrapper.vm.$nextTick()
await wrapper.vm.$nextTick()
// 模拟 addModule:drafts 整体替换为空草稿,props 全部变为空数组
await wrapper.setProps({ editConfig: [], fields: [], groups: [] })
await wrapper.setProps({ modelValue: { schemaVersion: 1, children: [] }, fields: [] })
await wrapper.vm.$nextTick()
await new Promise((r) => setTimeout(r, 30))
expect(wrapper.exists()).toBe(true)
@@ -1,21 +1,23 @@
/**
* 字段定义面板参与勾选接线测试:
* - 虚拟字段 __form/__list/__query 由配置草稿物化(内置 checkbox 的勾选态)
* - change 事件按列路由:参与列 → updateXxxConfig(携带 toggle 后的配置),普通列 → update(字段)
* - update 事件剥离 __ 虚拟字段,不污染字段草稿
* 字段定义面板参与勾选接线测试(V2:界面配置为 s_module_schema JSON):
* - 虚拟字段 __form/__list/__query 由 view/edit/query JSON 物化(内置 checkbox 的勾选态)
* - change 事件按列路由:参与列 → 对应配置 JSON(update:viewSchema/editSchema/querySchema),
* 普通列 → 字段模型(update:modelValue)
* - 字段派生虚拟字段在提交前由 stripInternalKeys 剥离,不污染保存
*/
import { mount } from '@vue/test-utils'
import { beforeAll, describe, expect, it, vi } from 'vitest'
import { beforeAll, describe, expect, it } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
import { nextTick } from 'vue'
import { registerFeature, useAreaSelection } from 'stk-table-vue'
import ModuleFieldsPanel from '@/views/module/module-management/components/ModuleFieldsPanel.vue'
import ModuleFieldsPanel from '@/views/module/module-management/ModuleFieldsPanel.vue'
import { stripInternalKeys } from '@/utils/dataChanges'
// 与 main.js 一致:注册 stk-table 的按需 feature
registerFeature(useAreaSelection)
// jsdom 无布局,容器 clientWidth 为 0 时 StkTable 走默认宽度并启用虚拟列裁剪,
// 只渲染首列——patch 成宽容器让全部列(含参与勾选列)真实渲染
// 只渲染首列——patch 成宽容器让全部列真实渲染
beforeAll(() => {
Object.defineProperty(HTMLElement.prototype, 'clientWidth', {
configurable: true,
@@ -25,45 +27,30 @@ beforeAll(() => {
})
})
vi.mock('@/services/api', () => ({
loadDataApi: vi.fn(),
saveObjectApi: vi.fn(),
nextIdApi: vi.fn(),
describeApi: vi.fn(),
}))
function makeFields() {
return [
{ b_field: 'amount', b_name: '金额', b_type: 'money', b_canuse: 1, b_i18n: null },
{ b_field: 'remark', b_name: '备注', b_type: 'textarea', b_canuse: 1, b_i18n: null },
]
}
const fields = [
{ b_id: '10', b_field: 'amount', b_name: '金额', b_type: 'money', b_canuse: 1, b_i18n: null },
{ b_id: '11', b_field: 'remark', b_name: '备注', b_type: 'textarea', b_canuse: 1, b_i18n: null },
]
const listConfig = [
{ b_id: 'v1', b_field_id: '10', b_visible: 1 },
{ b_id: 'v2', b_field_id: '11', b_visible: 0 }, // 大文本默认隐藏
]
const editConfig = [{ b_id: 'e1', b_field_id: '10', b_visible: 1 }]
const queryConfig = [] // 均未生成查询行
// 行内 checkbox 顺序 = 参与三列 + 启用列(普通编辑列不渲染 checkbox)
const FORM = 0
const LIST = 1
const QUERY = 2
function mountPanel(props = {}) {
return mount(ModuleFieldsPanel, {
function mountPanel() {
const localFields = makeFields()
const wrapper = mount(ModuleFieldsPanel, {
props: {
moduleId: 'm1',
moduleCode: 'demo',
fields: fields.map((f) => ({ ...f })),
editConfig: editConfig.map((r) => ({ ...r })),
listConfig: listConfig.map((r) => ({ ...r })),
queryConfig: queryConfig.map((r) => ({ ...r })),
moduleType: 'data',
modelValue: localFields,
// amount 已参与表单与列表;查询未配置
editSchema: { schemaVersion: 1, children: [{ field: 'amount', span: 24 }] },
viewSchema: { schemaVersion: 1, columns: [{ field: 'amount', width: 110 }] },
querySchema: { schemaVersion: 1, quick: [], groups: [] },
i18n: [],
module: { b_id: 'demo' },
loading: false,
...props,
},
attachTo: document.body,
})
return { wrapper, localFields }
}
async function settle() {
@@ -71,57 +58,64 @@ async function settle() {
await new Promise((r) => setTimeout(r, 100))
}
function rowCheckboxes(wrapper, rowIndex) {
return wrapper.findAll('.stk-tbody-main tr')[rowIndex].findAll('input[type="checkbox"]')
}
describe('ModuleFieldsPanel 参与勾选', () => {
it('虚拟字段按配置草稿物化:amount 表单/列表参与、查询未配置', async () => {
it('虚拟字段按配置 JSON 物化:amount 表单/列表参与、查询未配置', async () => {
setActivePinia(createPinia())
const wrapper = mountPanel()
const { wrapper, localFields } = mountPanel()
await settle()
const boxes = rowCheckboxes(wrapper, 0)
expect(boxes.length).toBe(4) // __form / __list / __query / b_canuse
expect(boxes[FORM].element.checked).toBe(true)
expect(boxes[LIST].element.checked).toBe(true)
expect(boxes[QUERY].element.checked).toBe(false)
expect(localFields[0].__form).toBe(1)
expect(localFields[0].__list).toBe(1)
expect(localFields[0].__query).toBe(0)
expect(localFields[1].__form).toBe(0)
wrapper.unmount()
})
it('勾选查询列 → emit updateQueryConfig,生成默认查询行(b_canuse=1)', async () => {
it('勾选查询列 → emit update:querySchema,生成默认查询条件', async () => {
setActivePinia(createPinia())
const wrapper = mountPanel()
await settle()
await rowCheckboxes(wrapper, 0)[QUERY].setValue(true)
const events = wrapper.emitted('updateQueryConfig')
expect(events).toHaveLength(1)
const rows = events[0][0]
expect(rows).toHaveLength(1)
expect(rows[0].b_field_id).toBe('10')
expect(rows[0].b_canuse).toBe(1)
expect(rows[0].b_component).toBe('number')
wrapper.unmount()
})
it('普通列 change → emit update,且剥离 __ 虚拟字段', async () => {
setActivePinia(createPinia())
const wrapper = mountPanel()
const { wrapper, localFields } = mountPanel()
await settle()
const table = wrapper.findComponent({ name: 'FmsTable' })
table.vm.$emit(
'change',
{ ...fields[0], __form: 1, __list: 1, __query: 0 },
{ dataIndex: 'b_name' },
'新名称',
)
table.vm.$emit('change', localFields[0], { dataIndex: '__query' })
await nextTick()
const events = wrapper.emitted('update')
expect(events).toBeTruthy()
const events = wrapper.emitted('update:querySchema')
expect(events?.length).toBeGreaterThan(0)
const schema = events.at(-1)[0]
const condition = schema.groups[0].conditions[0]
expect(condition.field).toBe('amount')
expect(condition.operator).toBe('eq')
expect(condition.component).toBe('number')
wrapper.unmount()
})
it('取消列表参与 → emit update:viewSchema 移除该字段节点', async () => {
setActivePinia(createPinia())
const { wrapper, localFields } = mountPanel()
await settle()
const table = wrapper.findComponent({ name: 'FmsTable' })
table.vm.$emit('change', localFields[0], { dataIndex: '__list' })
await nextTick()
const events = wrapper.emitted('update:viewSchema')
expect(events?.length).toBeGreaterThan(0)
const schema = events.at(-1)[0]
expect((schema.columns || []).map((n) => n.field)).not.toContain('amount')
wrapper.unmount()
})
it('普通列 change → emit update:modelValue,派生虚拟字段可由 stripInternalKeys 剥离', async () => {
setActivePinia(createPinia())
const { wrapper, localFields } = mountPanel()
await settle()
const table = wrapper.findComponent({ name: 'FmsTable' })
table.vm.$emit('change', localFields[0], { dataIndex: 'b_name' })
await nextTick()
const events = wrapper.emitted('update:modelValue')
expect(events?.length).toBeGreaterThan(0)
const emitted = events.at(-1)[0]
expect(emitted[0].b_id).toBe('10')
expect('__form' in emitted[0]).toBe(false)
expect('__list' in emitted[0]).toBe(false)
expect('__query' in emitted[0]).toBe(false)
expect(emitted[0].b_field).toBe('amount')
const stripped = stripInternalKeys(emitted[0])
expect('__form' in stripped).toBe(false)
expect('__list' in stripped).toBe(false)
expect('__query' in stripped).toBe(false)
wrapper.unmount()
})
})
@@ -1,5 +1,5 @@
/**
* i18n 统一维护纯函数测试:
* i18n 统一维护纯函数测试(V2:(b_key, b_locale) 复合键):
* 覆盖 index.vue 上移后的受管键收集、扁平行→透视、reconcile(补齐/跟名/失效清理)与保存 diff,
* 守护「ModuleI18nPanel 受控化」后数据编排逻辑不回退。
*/
@@ -17,10 +17,10 @@ const languages = [
]
const defaultLocale = 'zh-CN'
// V2:b_id 即模块编码
function demoModule(over = {}) {
return {
b_id: '9',
b_code: 'demo',
b_id: 'demo',
b_module_type: 'data',
b_name: '演示模块',
b_i18n: null,
@@ -28,12 +28,16 @@ function demoModule(over = {}) {
}
}
function demoField(over = {}) {
return { b_field: 'name', 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: [],
fields: [demoField()],
schemas: [],
})
expect(items.map((i) => i.key)).toEqual(['module.demo', 'field.demo.name'])
expect(items[0].id).toBe('module:module.demo')
@@ -42,11 +46,33 @@ describe('collectManagedI18nItems 受管键收集', () => {
expect(items[1].defaultValue).toBe('姓名')
})
it('收集界面配置分组标题的受管键', () => {
const items = collectManagedI18nItems({
module: demoModule(),
fields: [],
schemas: [
{
schemaVersion: 1,
children: [
{
type: 'group',
title: '基础信息',
i18n: 'group.demo.basic',
children: [],
},
],
},
],
})
expect(items.map((i) => i.key)).toContain('group.demo.basic')
expect(items.find((i) => i.key === 'group.demo.basic').defaultValue).toBe('基础信息')
})
it('模块编码为空时不产出受管键(新建未填编码)', () => {
const items = collectManagedI18nItems({
module: demoModule({ b_code: '' }),
fields: [{ b_id: '1', b_field: 'name', b_name: '姓名', b_i18n: null }],
groups: [],
module: demoModule({ b_id: '' }),
fields: [demoField()],
schemas: [],
})
expect(items).toEqual([])
})
@@ -54,12 +80,12 @@ describe('collectManagedI18nItems 受管键收集', () => {
describe('buildI18nPivot / reconcileI18nRows', () => {
it('扁平行转透视后 reconcile 补齐受管行并跟随默认语言', () => {
const flat = [{ b_id: '100', b_key: 'field.demo.name', b_locale: 'en-US', b_value: 'Name' }]
const flat = [{ 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: [],
fields: [demoField()],
schemas: [],
})
const rows = reconcileI18nRows(pivot, items, { defaultLocale, languages })
@@ -74,20 +100,18 @@ describe('buildI18nPivot / reconcileI18nRows', () => {
})
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' }]
const flat = [{ b_key: 'field.demo.name', b_locale: 'en-US', b_value: 'Name' }]
let rows = reconcileI18nRows(
buildI18nPivot(flat),
collectManagedI18nItems({ module: demoModule(), fields, groups: [] }),
collectManagedI18nItems({ module: demoModule(), fields: [demoField()], schemas: [] }),
{ defaultLocale, languages },
)
// 改名后再次 reconcile
rows = reconcileI18nRows(
rows,
collectManagedI18nItems({
module: demoModule({ b_name: '演示模块V2' }),
fields: [{ ...fields[0], b_name: '姓名V2' }],
groups: [],
fields: [demoField({ b_name: '姓名V2' })],
schemas: [],
}),
{ defaultLocale, languages },
)
@@ -98,8 +122,11 @@ describe('buildI18nPivot / reconcileI18nRows', () => {
})
it('字段删除后:无翻译的自动补齐行被清理,带翻译的保留为普通行', () => {
const field = { b_id: '1', b_field: 'name', b_name: '姓名', b_i18n: null }
const items = collectManagedI18nItems({ module: demoModule(), fields: [field], groups: [] })
const items = collectManagedI18nItems({
module: demoModule(),
fields: [demoField()],
schemas: [],
})
const rows = reconcileI18nRows(buildI18nPivot([]), items, { defaultLocale, languages })
expect(rows.some((r) => r._managedId === 'field:field.demo.name')).toBe(true)
@@ -111,14 +138,14 @@ describe('buildI18nPivot / reconcileI18nRows', () => {
describe('buildI18nChangeRequest 保存 diff', () => {
const originalFlat = [
{ b_id: '100', b_key: 'field.demo.name', b_locale: 'en-US', b_value: 'Name' },
{ 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: [],
fields: [demoField()],
schemas: [],
})
return reconcileI18nRows(buildI18nPivot(originalFlat), items, { defaultLocale, languages })
}
@@ -127,40 +154,43 @@ describe('buildI18nChangeRequest 保存 diff', () => {
const req = buildI18nChangeRequest({
draftRows: draftRows(),
originalFlat,
moduleId: '9',
languages,
})
expect(req).not.toBeNull()
expect(req.key_field).toBe('b_key,b_locale')
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
// en-US 未变化 → 不产生 update
expect(req.updates || []).toHaveLength(0)
})
it('修改语言值只产生对应 cell 的 update', () => {
it('修改语言值只产生对应 cell 的 update(按 b_key + b_locale 定位)', () => {
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)
expect(
req.updates.some(
(r) => r.b_key === 'field.demo.name' && r.b_locale === 'en-US' && r.b_value === 'NewName',
),
).toBe(true)
// 复合主键定位,不再依赖 b_id
expect((req.updates || []).every((r) => r.b_id === undefined)).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: '姓名' },
{ b_key: 'module.demo', b_locale: 'zh-CN', b_value: '演示模块' },
{ 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: [],
fields: [demoField()],
schemas: [],
})
const rows = reconcileI18nRows(buildI18nPivot(originalAll), items, {
defaultLocale,
@@ -169,7 +199,6 @@ describe('buildI18nChangeRequest 保存 diff', () => {
const req = buildI18nChangeRequest({
draftRows: rows,
originalFlat: originalAll,
moduleId: '9',
languages,
})
expect(req).toBeNull()
@@ -1,32 +1,40 @@
import { mount } from '@vue/test-utils'
import { nextTick } from 'vue'
import { describe, expect, it, vi, beforeEach } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
import ModuleListConfigPanel from '@/views/module/module-management/components/ModuleListConfigPanel.vue'
import ModuleListConfigPanel from '@/views/module/module-management/ModuleListConfigPanel.vue'
/**
* 列表配置面板与子表设计共用 useStructureEditing / ModuleStructureTree / TablePreview,
* 拖拽语义与 module-table-edit-panel-dnd.spec 同源;本文件聚焦:
* 1) 共用语义在列表侧(s_field_view 行)同样成立;
* 2) 列表侧 features 差异(无分组启停菜单、列节点无删除按钮);
* 3) 分组删除经 update-groups(groups, removedGroupIds) 上抛级联清理。
* 列表配置面板(V2:view JSON 节点模型)结构编辑测试。
* 面板对外数据模型为 s_module_schema.b_schema_json({ schemaVersion, columns: [...] }),
* 列节点为 { field, ... },分组节点为 { type: 'group', title, children: [...] }。
* 编辑器内部经 schemaModel 适配复用结构编辑内核;外部只观察 update:modelValue 的 JSON。
*/
const fields = [
{ b_id: '1', b_name: '姓名', b_field: 'name', b_type: 'input', b_i18n: 'field.sys.name' },
{ b_id: '2', b_name: '年龄', b_field: 'age', b_type: 'number', b_i18n: 'field.sys.age' },
{ b_id: '3', b_name: '状态', b_field: 'status', b_type: 'select', b_i18n: 'field.sys.status' },
{ b_id: '4', b_name: '备注', b_field: 'remark', b_type: 'textarea', b_i18n: 'field.sys.remark' },
]
const groups = [
{ b_id: 'g1', b_module_id: 'm1', b_parent_id: null, b_title: '基本信息', b_xh: 10, b_canuse: 1 },
{ b_id: 'g2', b_module_id: 'm1', b_parent_id: null, b_title: '扩展信息', b_xh: 20, b_canuse: 1 },
]
const listConfig = [
{ b_id: 'v1', b_module_id: 'm1', b_field_id: '1', b_group_id: null, b_i18n: null, b_visible: 1, b_width: 120, b_xh: 10 },
{ b_id: 'v2', b_module_id: 'm1', b_field_id: '2', b_group_id: 'g1', b_i18n: null, b_visible: 1, b_width: 100, b_xh: 20 },
{ b_id: 'v3', b_module_id: 'm1', b_field_id: '3', b_group_id: 'g1', b_i18n: null, b_visible: 1, b_width: 160, b_xh: 30 },
{ b_field: 'name', b_name: '姓名', b_type: 'input' },
{ b_field: 'age', b_name: '年龄', b_type: 'number' },
{ b_field: 'status', b_name: '状态', b_type: 'select' },
{ b_field: 'remark', b_name: '备注', b_type: 'textarea' },
]
function baseSchema() {
return {
schemaVersion: 1,
columns: [
{ field: 'name', width: 120 },
{
type: 'group',
title: '基本信息',
children: [
{ field: 'age', width: 100 },
{ field: 'status', width: 160 },
],
},
],
}
}
const StubDraggable = {
name: 'draggable',
props: {
@@ -64,10 +72,8 @@ function mountPanel(extraProps = {}) {
return mount(ModuleListConfigPanel, {
props: {
moduleId: 'm1',
moduleCode: 'sys_user',
listConfig: listConfig.map((r) => ({ ...r })),
fields,
groups: groups.map((g) => ({ ...g })),
modelValue: baseSchema(),
...extraProps,
},
global: { stubs: { draggable: StubDraggable } },
@@ -88,7 +94,6 @@ function fireMouse(el, type, { clientX = 0, clientY = 20, button = 0 } = {}) {
el.dispatchEvent(ev)
}
/* VTU trigger 会把 dragover/drop 构造为 DragEvent(坐标只有 getter),手工注入 */
function fireDrag(el, type, { clientX = 10, clientY = 20, dataTransfer } = {}) {
const ev = new Event(type, { bubbles: true, cancelable: true })
Object.defineProperty(ev, 'clientX', { value: clientX })
@@ -118,13 +123,21 @@ function endMouseDrag(wrapper) {
wrapper.findComponent({ name: 'draggable' }).vm.$emit('end')
}
function lastUpdate(wrapper) {
const updates = wrapper.emitted('update')
function lastSchema(wrapper) {
const updates = wrapper.emitted('update:modelValue')
expect(updates?.length).toBeGreaterThan(0)
return updates.at(-1)[0]
}
describe('ModuleListConfigPanel 结构编辑(与子表设计共用内核)', () => {
function groupNode(schema, title) {
return (schema.columns || []).find((n) => n.type === 'group' && n.title === title)
}
function topLevelFields(schema) {
return (schema.columns || []).filter((n) => n.type !== 'group').map((n) => n.field)
}
describe('ModuleListConfigPanel 结构编辑(view JSON 节点模型)', () => {
beforeEach(() => {
setActivePinia(createPinia())
document.elementFromPoint = () => hitElement
@@ -142,150 +155,107 @@ describe('ModuleListConfigPanel 结构编辑(与子表设计共用内核)',
Element.prototype.scrollIntoView = vi.fn()
})
it('列拖到分组节点(inside):移入该分组直属列末尾', () => {
it('列拖到分组节点(inside):移入该分组子节点末尾', async () => {
const wrapper = mountPanel()
// 姓名(v1,未分组) 拖到分组「扩展信息」(g2) 行中部
startMouseDrag(wrapper, '姓名')
moveMouseTo(wrapper, '扩展信息', { clientY: 20 })
moveMouseTo(wrapper, '基本信息', { clientY: 20 })
endMouseDrag(wrapper)
await nextTick()
const rows = lastUpdate(wrapper)
expect(rows.find((r) => r.b_field_id === '1').b_group_id).toBe('g2')
expect(rows.map((r) => r.b_field_id)).toEqual(['2', '3', '1'])
const schema = lastSchema(wrapper)
const group = groupNode(schema, '基本信息')
expect(group.children.map((n) => n.field)).toEqual(['age', 'status', 'name'])
expect(topLevelFields(schema)).toEqual([])
})
it('列拖到「未分组」收纳节点:移出分组', () => {
it('列拖到「未分组」收纳节点:移出分组回到顶层', async () => {
const wrapper = mountPanel()
// 年龄(v2,属 g1) 拖到「未分组」
startMouseDrag(wrapper, '年龄')
moveMouseTo(wrapper, '未分组', { clientY: 20 })
endMouseDrag(wrapper)
await nextTick()
const rows = lastUpdate(wrapper)
expect(rows.find((r) => r.b_field_id === '2').b_group_id).toBeNull()
expect(rows.map((r) => r.b_field_id)).toEqual(['1', '2', '3'])
const schema = lastSchema(wrapper)
expect(groupNode(schema, '基本信息').children.map((n) => n.field)).toEqual(['status'])
expect(topLevelFields(schema).sort()).toEqual(['age', 'name'])
})
it('分组拖到分组带上(inside):嵌套(b_parent_id 指向目标)', () => {
const wrapper = mountPanel()
// 分组「扩展信息」(g2) 拖到分组「基本信息」(g1) 行中部
it('分组拖到分组带上(inside):嵌套为子分组', async () => {
const wrapper = mountPanel({
modelValue: {
schemaVersion: 1,
columns: [
{
type: 'group',
title: '基本信息',
children: [{ field: 'name' }],
},
{
type: 'group',
title: '扩展信息',
children: [{ field: 'remark' }],
},
],
},
})
startMouseDrag(wrapper, '扩展信息')
moveMouseTo(wrapper, '基本信息', { clientY: 20 })
endMouseDrag(wrapper)
await nextTick()
const groupUpdates = wrapper.emitted('updateGroups')
expect(groupUpdates?.length).toBeGreaterThan(0)
const nextGroups = groupUpdates.at(-1)[0]
expect(nextGroups.find((g) => g.b_id === 'g2').b_parent_id).toBe('g1')
const schema = lastSchema(wrapper)
const outer = groupNode(schema, '基本信息')
const nested = outer.children.find((n) => n.type === 'group')
expect(nested.title).toBe('扩展信息')
})
it('删除分组:update-groups 带 removedGroupIds,组内行归未分组', () => {
it('删除分组:分组节点消失,组内字段回到顶层', async () => {
const wrapper = mountPanel()
// 树上「基本信息」(g1) 节点常驻删除按钮
const row = wrapper.findAll('.mst-node').find((w) => w.text().includes('基本信息'))
expect(row).toBeTruthy()
const delBtn = row.findAll('button').find((b) => b.attributes('title') === '删除')
expect(delBtn).toBeTruthy()
delBtn.trigger('click')
const groupUpdates = wrapper.emitted('updateGroups')
expect(groupUpdates?.length).toBeGreaterThan(0)
const [nextGroups, removedIds] = groupUpdates.at(-1)
expect(nextGroups.map((g) => g.b_id)).toEqual(['g2'])
expect(removedIds).toEqual(['g1'])
// g1 下的行(年龄/状态)归未分组并重排
const rows = lastUpdate(wrapper)
expect(rows.every((r) => r.b_group_id == null)).toBe(true)
expect(rows.map((r) => r.b_xh)).toEqual([10, 20, 30])
})
it('列表侧 features:分组右键无「启停」,列节点有删除按钮(同表格设计)', async () => {
const wrapper = mountPanel()
// 列节点(姓名)渲染删除按钮
const colRow = wrapper.findAll('.mst-node').find((w) => w.text().includes('姓名'))
expect(colRow).toBeTruthy()
expect(colRow.findAll('button').some((b) => b.attributes('title') === '删除')).toBe(true)
// 分组节点也有删除按钮
const groupRow = wrapper.findAll('.mst-node').find((w) => w.text().includes('基本信息'))
expect(groupRow.findAll('button').some((b) => b.attributes('title') === '删除')).toBe(true)
})
it('树中列节点删除按钮:删除 = 隐藏(b_visible=0),行保留', async () => {
const wrapper = mountPanel()
const colRow = wrapper.findAll('.mst-node').find((w) => w.text().includes('姓名'))
const delBtn = colRow.findAll('button').find((b) => b.attributes('title') === '删除')
expect(delBtn).toBeTruthy()
await delBtn.trigger('click')
const rows = lastUpdate(wrapper)
// 行不删(启用字段始终有配置行),仅置隐藏;预览该列置灰带「隐藏」标记
expect(rows.map((r) => r.b_field_id)).toEqual(['1', '2', '3'])
expect(rows.find((r) => r.b_field_id === '1').b_visible).toBe(0)
const schema = lastSchema(wrapper)
expect(groupNode(schema, '基本信息')).toBeUndefined()
expect(topLevelFields(schema).sort()).toEqual(['age', 'name', 'status'])
})
it('预览表渲染多级表头:band 行含分组标题,列头含字段名', () => {
const wrapper = mountPanel()
const bands = wrapper.findAll('th.mm-tdesign__band')
expect(bands.map((th) => th.text())).toContain('基本信息')
// 未分组列(姓名)表头贯穿 band 行
const ungroupedCell = wrapper
.findAll('th.mm-tdesign__cell--ungrouped')
.find((th) => th.attributes('data-field-id') === '1')
expect(ungroupedCell).toBeTruthy()
expect(ungroupedCell.text()).toContain('姓名')
})
it('字段库字段点击加入:未加入字段点击后生成配置行并选中', async () => {
it('字段库点击未加入字段:追加为顶层列节点', async () => {
const wrapper = mountPanel()
const libItem = wrapper
.findAll('.mm-fdesign__lib-item')
.find((w) => w.attributes('data-field-id') === '4')
.find((w) => w.attributes('data-field-id') === 'remark')
expect(libItem).toBeTruthy()
await libItem.trigger('click')
const rows = lastUpdate(wrapper)
// 新列归未分组区(未分组在前),备注为 textarea:默认隐藏、列宽 160
expect(rows.map((r) => r.b_field_id)).toEqual(['1', '4', '2', '3'])
expect(rows.find((r) => r.b_field_id === '4').b_group_id).toBeNull()
expect(rows.find((r) => r.b_field_id === '4').b_visible).toBe(0)
expect(rows.find((r) => r.b_field_id === '4').b_width).toBe(160)
const schema = lastSchema(wrapper)
expect(topLevelFields(schema)).toContain('remark')
})
it('字段库字段拖入分组带(inside):加入该分组直属列末尾', () => {
it('字段库字段拖入分组带(inside):加入该分组子节点末尾', async () => {
const wrapper = mountPanel()
const libItem = wrapper
.findAll('.mm-fdesign__lib-item')
.find((w) => w.attributes('data-field-id') === '4')
expect(libItem).toBeTruthy()
.find((w) => w.attributes('data-field-id') === 'remark')
const dt = new FakeDataTransfer()
fireDrag(libItem.element, 'dragstart', { dataTransfer: dt })
fireDrag(treeRow(wrapper, '基本信息'), 'dragover', { dataTransfer: dt, clientY: 20 })
fireDrag(treeRow(wrapper, '基本信息'), 'drop', { dataTransfer: dt })
await nextTick()
const rows = lastUpdate(wrapper)
expect(rows.map((r) => r.b_field_id)).toEqual(['1', '2', '3', '4'])
expect(rows.find((r) => r.b_field_id === '4').b_group_id).toBe('g1')
const schema = lastSchema(wrapper)
expect(groupNode(schema, '基本信息').children.map((n) => n.field)).toEqual([
'age',
'status',
'remark',
])
})
it('属性面板:选中列显示列属性(无状态开关),删除列 = 隐藏', async () => {
it('预览表渲染多级表头:band 行含分组标题', () => {
const wrapper = mountPanel()
// 树中点击列节点 → Inspector 显示列属性
const treeRow = wrapper.findAll('.mst-node').find((w) => w.text().includes('姓名'))
expect(treeRow).toBeTruthy()
await treeRow.trigger('click')
const inspector = wrapper.find('.mm-fdesign__inspector')
expect(inspector.exists()).toBe(true)
expect(inspector.text()).toContain('姓名')
// 列表侧无状态开关(显示/必填等是子表编辑语义)
expect(inspector.findAll('.mm-fdesign__status-chip')).toHaveLength(0)
// 删除列按钮:点击隐藏该列(行保留)
const delBtn = inspector.findAll('button').find((b) => b.text().includes('删除列'))
expect(delBtn).toBeTruthy()
await delBtn.trigger('click')
const rows = lastUpdate(wrapper)
expect(rows.map((r) => r.b_field_id)).toEqual(['1', '2', '3'])
expect(rows.find((r) => r.b_field_id === '1').b_visible).toBe(0)
const bands = wrapper.findAll('th.mm-tdesign__band')
expect(bands.map((th) => th.text())).toContain('基本信息')
})
})
@@ -1,443 +1,121 @@
import { mount } from '@vue/test-utils'
import { nextTick } from 'vue'
import { describe, expect, it, vi, beforeEach } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
import ModuleTableEditPanel from '@/views/module/module-management/components/table-edit/TableEditPanel.vue'
/**
* 专用结构树组件 ModuleStructureTree 的状态驱动拖拽,在结构树内完成全部落点
* 语义计算后通过事件交给面板 commit;本文件聚焦这些语义事件的正确性。
* 界面配置 JSON 节点模型适配层测试(schemaModel)。
* 覆盖 view / edit 配置对象与编辑器行/分组草稿的互转(含分组嵌套与未知属性保留),
* 以及字段参与(增删/可见性)的节点操作。
*/
class FakeDataTransfer {
constructor() {
this.store = new Map()
this.types = []
this.effectAllowed = 'none'
this.dropEffect = 'none'
}
setData(type, value) {
if (!this.types.includes(type)) this.types.push(type)
this.store.set(type, String(value))
}
getData(type) {
return this.store.has(type) ? this.store.get(type) : ''
}
}
import { describe, expect, it } from 'vitest'
import {
schemaToEditor,
editorToSchema,
addSchemaField,
removeSchemaField,
hasSchemaField,
setSchemaFieldVisible,
hasQueryCondition,
addQueryCondition,
removeQueryCondition,
defaultQueryCondition,
} from '@/views/module/module-management/components/design-editor/common/schemaModel'
const fields = [
{ b_id: '1', b_name: '姓名', b_field: 'name', b_type: 'input' },
{ b_id: '2', b_name: '年龄', b_field: 'age', b_type: 'number' },
{ b_id: '3', b_name: '状态', b_field: 'status', b_type: 'select' },
{ b_id: '4', b_name: '备注', b_field: 'remark', b_type: 'textarea' },
{ b_id: '5', b_name: '金额', b_field: 'amount', b_type: 'money' },
]
const groups = [
{ b_id: 'g1', b_module_id: 'm1', b_parent_id: null, b_title: '基本信息', b_xh: 10, b_canuse: 1 },
{ b_id: 'g2', b_module_id: 'm1', b_parent_id: null, b_title: '扩展信息', b_xh: 20, b_canuse: 1 },
]
const editConfig = [
{ b_id: 'e1', b_module_id: 'm1', b_field_id: '1', b_group_id: null, b_visible: 1, b_required: 0, b_readonly: 0, b_disabled: 0, b_width: 120, b_xh: 10 },
{ b_id: 'e2', b_module_id: 'm1', b_field_id: '2', b_group_id: 'g1', b_visible: 1, b_required: 0, b_readonly: 0, b_disabled: 0, b_width: 100, b_xh: 20 },
{ b_id: 'e3', b_module_id: 'm1', b_field_id: '3', b_group_id: 'g1', b_visible: 1, b_required: 0, b_readonly: 0, b_disabled: 0, b_width: 160, b_xh: 30 },
{ b_id: 'e4', b_module_id: 'm1', b_field_id: '4', b_group_id: 'g2', b_visible: 1, b_required: 0, b_readonly: 0, b_disabled: 0, b_width: 220, b_xh: 40 },
]
describe('schemaModel:view/edit 配置 ↔ 编辑器草稿', () => {
it('view:分组嵌套解析为 b_group_id / b_parent_id,回写保留分组结构', () => {
const schema = {
schemaVersion: 1,
columns: [
{ field: 'name', width: 120 },
{
type: 'group',
title: '基本信息',
children: [
{ field: 'age', width: 100 },
{
type: 'group',
title: '扩展',
children: [{ field: 'remark', width: 200 }],
},
],
},
],
}
const { rows, groups } = schemaToEditor('view', schema, 'm1')
expect(rows.map((r) => r.b_field)).toEqual(['name', 'age', 'remark'])
// name 未分组;age 属顶级分组;remark 属子分组
expect(rows[0].b_group_id).toBeNull()
const outer = groups.find((g) => g.b_title === '基本信息')
const inner = groups.find((g) => g.b_title === '扩展')
expect(rows[1].b_group_id).toBe(outer.b_id)
expect(rows[2].b_group_id).toBe(inner.b_id)
expect(inner.b_parent_id).toBe(outer.b_id)
function mountPanel() {
return mount(ModuleTableEditPanel, {
props: {
moduleId: 'm1',
editConfig: editConfig.map((r) => ({ ...r })),
listConfig: [],
queryConfig: [],
fields,
groups: groups.map((g) => ({ ...g })),
editMode: 'table',
},
global: { stubs: { draggable: StubDraggable } },
})
}
// 按标题文本定位树节点行(分组树整树展开);专用组件节点行类名 .mst-node
function treeRow(wrapper, title) {
const row = wrapper.findAll('.mst-node').find((w) => w.text().includes(title))
expect(row, `未找到树节点:${title}`).toBeTruthy()
return row.element
}
/* VTU 的 trigger 会把 dragover/drop 构造为 DragEvent(clientY 只有 getter),
自定义坐标会被静默丢弃;手工构造事件并用 defineProperty 注入坐标。 */
function fireDrag(el, type, { clientX = 10, clientY = 20, dataTransfer } = {}) {
const ev = new Event(type, { bubbles: true, cancelable: true })
Object.defineProperty(ev, 'clientX', { value: clientX })
Object.defineProperty(ev, 'clientY', { value: clientY })
Object.defineProperty(ev, 'dataTransfer', { value: dataTransfer })
el.dispatchEvent(ev)
}
// 同理 pointerdown/pointermove 映射为 MouseEvent(clientX 只读),手工注入坐标
function firePointer(el, type, { clientX = 0, pointerId = 1 } = {}) {
const ev = new Event(type, { bubbles: true, cancelable: true })
Object.defineProperty(ev, 'clientX', { value: clientX })
Object.defineProperty(ev, 'pointerId', { value: pointerId })
el.dispatchEvent(ev)
}
/* ---- 内部拖拽改为 vuedraggable(SortableJS force-fallback)驱动,与主表设计器一致 ----
* jsdom 不实现 elementFromPoint(命中检测),测试里 mock 它返回预先指定的目标行元素;
* onSortWindowMove 据此 closest('.mst-node') 定位落点。
* vuedraggable 组件用 StubDraggable 替换,测试通过 emit start/end 来模拟拖拽生命周期。 */
const StubDraggable = {
name: 'draggable',
props: {
list: { type: Array, default: () => [] },
itemKey: { type: String, default: undefined },
},
emits: ['start', 'end'],
template: `
<div class="stub-draggable">
<template v-for="el in list" :key="el[itemKey || 'key']">
<slot name="item" :element="el" />
</template>
<slot name="footer" />
</div>
`,
}
let hitElement = null
function fireMouse(el, type, { clientX = 0, clientY = 20, button = 0 } = {}) {
const ev = new Event(type, { bubbles: true, cancelable: true })
Object.defineProperty(ev, 'clientX', { value: clientX })
Object.defineProperty(ev, 'clientY', { value: clientY })
Object.defineProperty(ev, 'button', { value: button })
el.dispatchEvent(ev)
}
// 在源节点 grip 上开始拖拽(emit start 到 draggable 组件)
function startMouseDrag(wrapper, srcTitle) {
const row = treeRow(wrapper, srcTitle)
const nodeKey = row.getAttribute('data-key')
expect(nodeKey, `节点缺少 data-key:${srcTitle}`).toBeTruthy()
wrapper.findComponent({ name: 'draggable' }).vm.$emit('start', {
item: { dataset: { key: nodeKey } },
})
}
// 移动鼠标到目标节点行(clientY 决定落点 before/inside/after)
function moveMouseTo(wrapper, targetTitle, { clientX = 0, clientY = 20 } = {}) {
const row = treeRow(wrapper, targetTitle)
hitElement = row.querySelector('.mst-node__label') || row
fireMouse(window, 'mousemove', { clientX, clientY })
}
// 松手提交(emit end 到 draggable 组件)
function endMouseDrag(wrapper) {
wrapper.findComponent({ name: 'draggable' }).vm.$emit('end')
}
function lastUpdate(wrapper) {
const updates = wrapper.emitted('update')
expect(updates?.length).toBeGreaterThan(0)
return updates.at(-1)[0]
}
describe('ModuleTableEditPanel 分组树拖拽语义', () => {
beforeEach(() => {
// 面板(数据视图)内嵌 FmsTable,其主题读取 app store
setActivePinia(createPinia())
// jsdom 无命中检测,mock elementFromPoint 返回测试指定的目标行元素
document.elementFromPoint = () => hitElement
// 行高 40:clientY 5 → before,20 → inside,35 → after
Element.prototype.getBoundingClientRect = () => ({
top: 0,
bottom: 40,
left: 0,
right: 200,
width: 200,
height: 40,
x: 0,
y: 0,
toJSON: () => ({}),
})
Element.prototype.scrollIntoView = vi.fn()
const back = editorToSchema('view', rows, groups, schema)
const outerNode = back.columns.find((n) => n.type === 'group')
expect(outerNode.title).toBe('基本信息')
expect(outerNode.children.map((n) => n.field ?? n.title)).toEqual(['age', '扩展'])
// 列宽等已知属性保留
expect(back.columns.find((n) => n.field === 'name').width).toBe(120)
})
it('列拖到列上(中部 inside)直接换位:插到目标列后并沿用其分组,不做入组嵌套', () => {
const wrapper = mountPanel()
// 年龄(c:2,属 g1) 拖到 姓名(c:1,未分组) 行中部
startMouseDrag(wrapper, '年龄')
moveMouseTo(wrapper, '姓名', { clientY: 20 })
endMouseDrag(wrapper)
it('edit:span/required/readonly 与行属性互转,默认值不额外写出', () => {
const schema = {
schemaVersion: 1,
children: [
{ field: 'name', span: 12, required: true },
{ field: 'age' },
],
}
const { rows } = schemaToEditor('edit', schema, 'm1')
expect(rows[0].b_colspan).toBe(12)
expect(rows[0].b_required).toBe(1)
expect(rows[1].b_colspan).toBe(24)
const rows = lastUpdate(wrapper)
expect(rows.map((r) => r.b_field_id)).toEqual(['1', '2', '3', '4'])
expect(rows.map((r) => r.b_xh)).toEqual([10, 20, 30, 40])
// 年龄跟随目标列分组(未分组),而不是嵌套进任何分组
expect(rows.find((r) => r.b_field_id === '2').b_group_id).toBeNull()
const back = editorToSchema('edit', rows, [], schema)
expect(back.children[0]).toMatchObject({ field: 'name', span: 12, required: true })
// span=24 且原节点未显式给出 → 不写出 span
expect(back.children[1].span).toBeUndefined()
})
it('列拖到列上(上缘 before)换到目标列前,跨组移动时沿用目标列分组', () => {
const wrapper = mountPanel()
// 状态(c:3,属 g1) 拖到 备注(c:4,属 g2) 行上缘
startMouseDrag(wrapper, '状态')
moveMouseTo(wrapper, '备注', { clientY: 5 })
endMouseDrag(wrapper)
const rows = lastUpdate(wrapper)
expect(rows.map((r) => r.b_field_id)).toEqual(['1', '2', '3', '4'])
expect(rows.find((r) => r.b_field_id === '3').b_group_id).toBe('g2')
})
it('列拖到分组节点(中部 inside):移入该分组直属列末尾', () => {
const wrapper = mountPanel()
// 姓名(c:1,未分组) 拖到分组「扩展信息」(g2) 行中部 → 加入 g2 直属列末尾(备注后)
startMouseDrag(wrapper, '姓名')
moveMouseTo(wrapper, '扩展信息', { clientY: 20 })
endMouseDrag(wrapper)
const rows = lastUpdate(wrapper)
expect(rows.find((r) => r.b_field_id === '1').b_group_id).toBe('g2')
// g2 列:备注 → 姓名;整体前序:年龄/状态(g1) → 备注/姓名(g2)
expect(rows.map((r) => r.b_field_id)).toEqual(['2', '3', '4', '1'])
})
it('列拖到「未分组」收纳节点:移出分组并追加到未分组区', () => {
const wrapper = mountPanel()
// 年龄(c:2,属 g1) 拖到「未分组」节点 → b_group_id 置空,追加到未分组区末尾(姓名后)
startMouseDrag(wrapper, '年龄')
moveMouseTo(wrapper, '未分组', { clientY: 20 })
endMouseDrag(wrapper)
const rows = lastUpdate(wrapper)
expect(rows.find((r) => r.b_field_id === '2').b_group_id).toBeNull()
// 未分组:姓名 → 年龄;其后 g1(状态)、g2(备注)
expect(rows.map((r) => r.b_field_id)).toEqual(['1', '2', '3', '4'])
})
it('字段库字段拖入分组带(inside):加入该分组直属列末尾', () => {
const wrapper = mountPanel()
// 字段库「金额」(未加入) 拖到分组「基本信息」行中部
const libItem = wrapper
.findAll('.mm-fdesign__lib-item')
.find((w) => w.attributes('data-field-id') === '5')
expect(libItem).toBeTruthy()
const dt = new FakeDataTransfer()
fireDrag(libItem.element, 'dragstart', { dataTransfer: dt })
fireDrag(treeRow(wrapper, '基本信息'), 'dragover', { dataTransfer: dt, clientY: 20 })
fireDrag(treeRow(wrapper, '基本信息'), 'drop', { dataTransfer: dt })
const rows = lastUpdate(wrapper)
expect(rows.map((r) => r.b_field_id)).toEqual(['1', '2', '3', '5', '4'])
expect(rows.find((r) => r.b_field_id === '5').b_group_id).toBe('g1')
expect(rows.map((r) => r.b_xh)).toEqual([10, 20, 30, 40, 50])
})
it('字段库已加入字段拖到分组带:跨组移动(复用列拖拽语义)', () => {
const wrapper = mountPanel()
// 字段库「备注」(已加入 g2) 拖到分组「基本信息」行中部
const libItem = wrapper
.findAll('.mm-fdesign__lib-item')
.find((w) => w.attributes('data-field-id') === '4')
const dt = new FakeDataTransfer()
fireDrag(libItem.element, 'dragstart', { dataTransfer: dt })
fireDrag(treeRow(wrapper, '基本信息'), 'dragover', { dataTransfer: dt, clientY: 20 })
fireDrag(treeRow(wrapper, '基本信息'), 'drop', { dataTransfer: dt })
const rows = lastUpdate(wrapper)
expect(rows.map((r) => r.b_field_id)).toEqual(['1', '2', '3', '4'])
expect(rows.find((r) => r.b_field_id === '4').b_group_id).toBe('g1')
})
it('分组拖到列上(inside)直接换位:变为该列所属分组的后置兄弟,不嵌套', () => {
const wrapper = mountPanel()
// 分组「基本信息」(g1) 拖到 备注(c:4,属 g2) 行中部 → g1 移到 g2 之后(同级换位)
startMouseDrag(wrapper, '基本信息')
moveMouseTo(wrapper, '备注', { clientY: 20 })
endMouseDrag(wrapper)
// 分组重排:g2 在前(10)、g1 在后(20),g1 仍是顶级分组
const groupUpdates = wrapper.emitted('updateGroups')
expect(groupUpdates?.length).toBeGreaterThan(0)
const nextGroups = groupUpdates.at(-1)[0]
const g1 = nextGroups.find((g) => g.b_id === 'g1')
const g2 = nextGroups.find((g) => g.b_id === 'g2')
expect(g1.b_parent_id).toBeNull()
expect(g1.b_xh).toBe(20)
expect(g2.b_xh).toBe(10)
// 列序跟随分组树前序:姓名(未分组) → 备注(g2) → 年龄/状态(g1)
const rows = lastUpdate(wrapper)
expect(rows.map((r) => r.b_field_id)).toEqual(['1', '4', '2', '3'])
})
it('分组拖到分组带上(inside)仍为嵌套语义', () => {
const wrapper = mountPanel()
// 分组「扩展信息」(g2) 拖到分组「基本信息」(g1) 行中部 → 成为 g1 子分组
startMouseDrag(wrapper, '扩展信息')
moveMouseTo(wrapper, '基本信息', { clientY: 20 })
endMouseDrag(wrapper)
const groupUpdates = wrapper.emitted('updateGroups')
const nextGroups = groupUpdates.at(-1)[0]
expect(nextGroups.find((g) => g.b_id === 'g2').b_parent_id).toBe('g1')
})
it('预览表列头右缘拖拽仅调列宽,松手才提交一次', () => {
const wrapper = mountPanel()
const resizer = wrapper
.findAll('th.mm-tdesign__cell')
.find((th) => th.attributes('data-field-id') === '2')
.find('.mm-tdesign__resizer')
expect(resizer).toBeTruthy()
firePointer(resizer.element, 'pointerdown', { clientX: 200 })
firePointer(resizer.element, 'pointermove', { clientX: 230 })
// 拖拽过程中不提交
expect(wrapper.emitted('update')).toBeUndefined()
firePointer(resizer.element, 'pointerup')
const rows = lastUpdate(wrapper)
// 年龄初始宽 100,右移 30px → 130
expect(rows.find((r) => r.b_field_id === '2').b_width).toBe(130)
})
it('预览表列头拖拽:列落列换位并沿用目标列分组', () => {
const wrapper = mountPanel()
const cell = (fid) =>
wrapper
.findAll('th.mm-tdesign__cell')
.find((th) => th.attributes('data-field-id') === fid)
const src = cell('2') // 年龄(g1)
const target = cell('4') // 备注(g2)
expect(src).toBeTruthy()
expect(target).toBeTruthy()
const dt = new FakeDataTransfer()
// 拖到目标列左半区(before)
fireDrag(src.element, 'dragstart', { dataTransfer: dt, clientX: 10, clientY: 20 })
fireDrag(target.element, 'dragover', { dataTransfer: dt, clientX: 5, clientY: 20 })
fireDrag(target.element, 'drop', { dataTransfer: dt, clientX: 5, clientY: 20 })
const rows = lastUpdate(wrapper)
// 年龄移到备注前并进入 g2;顺序:姓名(未分组) → 状态(g1) → 年龄/备注(g2)
expect(rows.find((r) => r.b_field_id === '2').b_group_id).toBe('g2')
expect(rows.map((r) => r.b_field_id)).toEqual(['1', '3', '2', '4'])
})
it('预览表列头拖拽:拖到分组带移入该分组直属列末尾', () => {
const wrapper = mountPanel()
const src = wrapper
.findAll('th.mm-tdesign__cell')
.find((th) => th.attributes('data-field-id') === '1') // 姓名(未分组)
const band = wrapper
.findAll('th.mm-tdesign__band')
.find((th) => th.attributes('data-band-id') === 'g2')
expect(src).toBeTruthy()
expect(band).toBeTruthy()
const dt = new FakeDataTransfer()
fireDrag(src.element, 'dragstart', { dataTransfer: dt, clientX: 10, clientY: 20 })
fireDrag(band.element, 'dragover', { dataTransfer: dt, clientX: 50, clientY: 20 })
fireDrag(band.element, 'drop', { dataTransfer: dt, clientX: 50, clientY: 20 })
const rows = lastUpdate(wrapper)
expect(rows.find((r) => r.b_field_id === '1').b_group_id).toBe('g2')
expect(rows.map((r) => r.b_field_id)).toEqual(['2', '3', '4', '1'])
})
it('预览表:全部分组时,拖到「未分组」落点把列拖出分组', async () => {
// 4 列全部在 g1(无未分组列)
const rows = [
{ b_id: 'e1', b_module_id: 'm1', b_field_id: '1', b_group_id: 'g1', b_visible: 1, b_required: 0, b_readonly: 0, b_disabled: 0, b_width: 120, b_xh: 10 },
{ b_id: 'e2', b_module_id: 'm1', b_field_id: '2', b_group_id: 'g1', b_visible: 1, b_required: 0, b_readonly: 0, b_disabled: 0, b_width: 120, b_xh: 20 },
{ b_id: 'e3', b_module_id: 'm1', b_field_id: '3', b_group_id: 'g1', b_visible: 1, b_required: 0, b_readonly: 0, b_disabled: 0, b_width: 120, b_xh: 30 },
{ b_id: 'e4', b_module_id: 'm1', b_field_id: '4', b_group_id: 'g1', b_visible: 1, b_required: 0, b_readonly: 0, b_disabled: 0, b_width: 120, b_xh: 40 },
]
const wrapper = mount(ModuleTableEditPanel, {
props: {
moduleId: 'm1',
editConfig: rows.map((r) => ({ ...r })),
listConfig: [],
queryConfig: [],
fields,
groups: [groups[0]].map((g) => ({ ...g })),
editMode: 'table',
},
global: { stubs: { draggable: StubDraggable } },
})
const src = wrapper
.findAll('th.mm-tdesign__cell')
.find((th) => th.attributes('data-field-id') === '1')
expect(src).toBeTruthy()
// 无未分组列 → 拖拽开始后才出现「未分组」落点
expect(wrapper.find('.mm-tdesign__ungrouped-drop').exists()).toBe(false)
const dt = new FakeDataTransfer()
fireDrag(src.element, 'dragstart', { dataTransfer: dt, clientX: 10, clientY: 20 })
await nextTick()
const dropZone = wrapper.find('.mm-tdesign__ungrouped-drop')
expect(dropZone.exists()).toBe(true)
fireDrag(dropZone.element, 'dragover', { dataTransfer: dt, clientX: 10, clientY: 20 })
fireDrag(dropZone.element, 'drop', { dataTransfer: dt, clientX: 10, clientY: 20 })
const rowsOut = lastUpdate(wrapper)
expect(rowsOut.find((r) => r.b_field_id === '1').b_group_id).toBeNull()
expect(rowsOut.map((r) => r.b_field_id)).toEqual(['1', '2', '3', '4'])
})
it('悬停亮指示,松手才提交列拖拽', () => {
// 悬停阶段仅亮落点指示,mouseup 时提交一次变更,不再重复
const wrapper = mountPanel()
// 年龄拖到姓名上(after)
startMouseDrag(wrapper, '年龄')
moveMouseTo(wrapper, '姓名', { clientY: 20 })
// mousemove 不触发换位:update 未发出
expect(wrapper.emitted('update')).toBeUndefined()
// mouseup 提交一次
endMouseDrag(wrapper)
expect(wrapper.emitted('update')?.length).toBe(1)
})
it('预览表头:未分组列头在 band 行贯穿整段高度,不留空白', () => {
// 4 列:2 列分组(g1),2 列未分组
const rows = [
{ b_id: 'e1', b_module_id: 'm1', b_field_id: '1', b_group_id: null, b_visible: 1, b_required: 0, b_readonly: 0, b_disabled: 0, b_width: 120, b_xh: 10 },
{ b_id: 'e2', b_module_id: 'm1', b_field_id: '2', b_group_id: null, b_visible: 1, b_required: 0, b_readonly: 0, b_disabled: 0, b_width: 120, b_xh: 20 },
{ b_id: 'e3', b_module_id: 'm1', b_field_id: '3', b_group_id: 'g1', b_visible: 1, b_required: 0, b_readonly: 0, b_disabled: 0, b_width: 120, b_xh: 30 },
{ b_id: 'e4', b_module_id: 'm1', b_field_id: '4', b_group_id: 'g1', b_visible: 1, b_required: 0, b_readonly: 0, b_disabled: 0, b_width: 120, b_xh: 40 },
]
const wrapper = mount(ModuleTableEditPanel, {
props: {
moduleId: 'm1',
editConfig: rows.map((r) => ({ ...r })),
listConfig: [],
queryConfig: [],
fields,
// 仅一个分组 g1(基本信息),避免空分组 g2 产生额外占位列
groups: [groups[0]].map((g) => ({ ...g })),
editMode: 'table',
},
global: { stubs: { draggable: StubDraggable } },
it('未知节点扩展属性在往返中保留', () => {
const schema = {
schemaVersion: 1,
columns: [{ field: 'name', width: 120, fixed: 'left', format: 'money' }],
}
const { rows, groups } = schemaToEditor('view', schema, 'm1')
const back = editorToSchema('view', rows, groups, schema)
expect(back.columns[0]).toMatchObject({ field: 'name', fixed: 'left', format: 'money' })
})
})
describe('schemaModel:字段参与节点操作', () => {
it('view:追加 / 移除字段节点', () => {
let schema = { schemaVersion: 1, columns: [] }
expect(hasSchemaField('view', schema, 'name')).toBe(false)
schema = addSchemaField('view', schema, { b_field: 'name', b_type: 'input' })
expect(hasSchemaField('view', schema, 'name')).toBe(true)
expect(schema.columns[0].width).toBe(120)
schema = removeSchemaField('view', schema, 'name')
expect(hasSchemaField('view', schema, 'name')).toBe(false)
})
it('edit:切换 visible 保留节点', () => {
let schema = { schemaVersion: 1, 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(hasSchemaField('edit', schema, 'name')).toBe(true)
expect(hasSchemaField('edit', schema, 'name', { requireVisible: true })).toBe(false)
})
it('query:条件增删与默认组件', () => {
let schema = { schemaVersion: 1, quick: [], groups: [] }
expect(hasQueryCondition(schema, 'amount')).toBe(false)
expect(defaultQueryCondition({ b_field: 'amount', b_type: 'money' })).toMatchObject({
component: 'number',
operator: 'eq',
})
// 未分组列头在 band 行渲染:2 个 cell,rowspan 贯穿全部表头行(band 行 + 列头行)
const ungroupedCells = wrapper.findAll('th.mm-tdesign__cell--ungrouped')
expect(ungroupedCells).toHaveLength(2)
ungroupedCells.forEach((th) => expect(th.attributes('rowspan')).toBe('2'))
expect(ungroupedCells[0].attributes('data-field-id')).toBe('1')
expect(ungroupedCells[1].attributes('data-field-id')).toBe('2')
// 未分组列头不是空带:渲染了字段名
expect(ungroupedCells[0].text()).toContain('姓名')
expect(ungroupedCells[1].text()).toContain('年龄')
// 分组带正常渲染
const bandCells = wrapper.findAll('th.mm-tdesign__band')
expect(bandCells).toHaveLength(1)
expect(bandCells[0].text()).toContain('基本信息')
// 列头行只渲染分组列(未分组列已在 band 行)
// 注意:只查预览表(.mm-tdesign__table),数据视图的 FmsTable 经 v-show 仍驻留 DOM
const theadRows = wrapper.findAll('.mm-tdesign__table thead tr')
const headerRow = theadRows.at(-1)
const headerCells = headerRow.findAll('th.mm-tdesign__cell')
expect(headerCells.map((th) => th.attributes('data-field-id'))).toEqual(['3', '4'])
// 总列数不变(colgroup 仍为 4 列)
expect(wrapper.findAll('colgroup col')).toHaveLength(4)
schema = addQueryCondition(schema, { b_field: 'amount', b_type: 'money' })
expect(hasQueryCondition(schema, 'amount')).toBe(true)
expect(schema.groups[0].conditions[0].field).toBe('amount')
schema = removeQueryCondition(schema, 'amount')
expect(hasQueryCondition(schema, 'amount')).toBe(false)
})
})
@@ -0,0 +1,157 @@
/**
* 界面配置 JSON → 运行时渲染配置 适配层测试(fms-module-common/schemaRender)。
* 覆盖 view→列表列、edit→分组/编辑行、query→查询条件,以及栏位设置回写 JSON。
*/
import { describe, expect, it } from 'vitest'
import {
buildListConfig,
buildEditRender,
buildQueryRender,
orderQueryConditions,
applyColumnSettings,
} from '@/components/fms-module-common/schemaRender'
import { parseOptions } from '@/components/fms-module-common/fieldUtils'
describe('buildListConfig(view JSON → 列表列)', () => {
it('分组/嵌套分组按文档顺序展开为扁平列,保留宽度与可见性', () => {
const view = {
schemaVersion: 1,
columns: [
{ field: 'name', width: 120 },
{
type: 'group',
title: '基本信息',
children: [
{ field: 'age', width: 100 },
{ type: 'group', title: '深层', children: [{ field: 'remark', width: 200 }] },
],
},
{ field: 'hidden', visible: false },
],
}
const rows = buildListConfig(view)
expect(rows.map((r) => r.b_field)).toEqual(['name', 'age', 'remark', 'hidden'])
expect(rows.map((r) => r.b_xh)).toEqual([10, 20, 30, 40])
expect(rows[0]).toMatchObject({ b_field: 'name', b_width: 120, b_visible: 1 })
expect(rows[3].b_visible).toBe(0)
})
it('无配置时返回空数组', () => {
expect(buildListConfig({ schemaVersion: 1, columns: [] })).toEqual([])
expect(buildListConfig(null)).toEqual([])
})
})
describe('buildEditRender(edit JSON → 分组 + 编辑行)', () => {
it('还原分组与行属性,嵌套分组生成独立分组', () => {
const edit = {
schemaVersion: 1,
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 }] },
],
},
],
}
const { groups, rows } = buildEditRender(edit)
expect(rows.map((r) => r.b_field)).toEqual(['code', 'name', 'remark'])
const outer = groups.find((g) => g.b_title === '基础')
const inner = groups.find((g) => g.b_title === '扩展')
expect(outer).toBeTruthy()
expect(inner.b_parent_id).toBe(outer.b_id)
expect(rows[0]).toMatchObject({ b_group_id: null, b_required: 1, b_colspan: 24 })
expect(rows[1]).toMatchObject({
b_group_id: outer.b_id,
b_colspan: 12,
b_readonly: 1,
b_default_value: 'X',
})
expect(rows[2].b_group_id).toBe(inner.b_id)
})
})
describe('buildQueryRender / orderQueryConditions(query JSON → 查询条件)', () => {
const query = {
schemaVersion: 1,
quick: ['b_status'],
groups: [
{
logic: 'and',
conditions: [
{ field: 'b_no', operator: 'like' },
{ field: 'b_status', operator: 'eq', component: 'select', defaultValue: '1' },
],
},
],
}
it('展开条件并透出 quick', () => {
const { quick, conditions } = buildQueryRender(query)
expect(quick).toEqual(['b_status'])
expect(conditions.map((c) => c.field)).toEqual(['b_no', 'b_status'])
expect(conditions[1]).toMatchObject({ operator: 'eq', component: 'select', defaultValue: '1' })
})
it('quick 字段优先排序', () => {
const { quick, conditions } = buildQueryRender(query)
const ordered = orderQueryConditions(conditions, quick)
expect(ordered.map((c) => c.field)).toEqual(['b_status', 'b_no'])
})
})
describe('applyColumnSettings(栏位设置回写 JSON)', () => {
it('同层按设置顺序重排并更新宽度,保留分组结构', () => {
const view = {
schemaVersion: 1,
columns: [
{ field: 'a', width: 100 },
{ type: 'group', title: 'G', children: [{ field: 'b', width: 110 }, { field: 'c', width: 120 }] },
{ field: 'd', width: 130 },
],
}
const settings = [
{ b_field: 'd', b_width: 300 },
{ b_field: 'b', b_width: 200 },
{ b_field: 'a', b_width: 150 },
{ b_field: 'c', b_width: 120 },
]
const next = applyColumnSettings('view', view, settings)
expect(next.columns[0].field).toBe('d')
expect(next.columns[0].width).toBe(300)
expect(next.columns[1].type).toBe('group')
expect(next.columns[1].children.map((n) => n.field)).toEqual(['b', 'c'])
expect(next.columns[1].children[0].width).toBe(200)
expect(next.columns[2].field).toBe('a')
expect(next.columns[2].width).toBe(150)
})
})
describe('parseOptions(选项来源)', () => {
it('读取界面配置节点的 options 数组', () => {
const { options } = parseOptions({ options: [{ value: '1', label: '是' }, { value: '0', label: '否' }] })
expect(options.map((o) => o.value)).toEqual(['1', '0'])
})
it('读取 { options: [] } 形态并解析 optionConfig', () => {
const { options, optionConfig } = parseOptions({
options: { precision: 3, options: [{ value: 1, label: '一' }] },
})
expect(optionConfig.precision).toBe(3)
expect(options[0]).toMatchObject({ value: 1, label: '一' })
})
it('兼容旧字段行 b_options JSON 字符串', () => {
const { options } = parseOptions({ b_options: JSON.stringify(['A', 'B']) })
expect(options.map((o) => o.value)).toEqual(['A', 'B'])
})
it('无选项来源时返回空', () => {
expect(parseOptions({ b_type: 'input' })).toEqual({ optionConfig: {}, options: [] })
expect(parseOptions(null)).toEqual({ optionConfig: {}, options: [] })
})
})