/** * 栏位设置(列宽/顺序)冒烟测试:模拟真实操作链路。 * * 覆盖: * 1. 未改动 → 操作条不出现(不占内容区); * 2. 拖列宽 → 操作条出现,撤销后消失且不落库; * 3. 保存为个人 → 只写 s_user_module_pref(b_schema_type='view'),且只存相对通用的增量; * 4. 保存为通用 → 写 s_module_schema(且只对管理员可见按钮); * 5. 个人层加载 → 合并进列配置,且不得引入通用没有的字段。 */ import { flushPromises, mount } from '@vue/test-utils' import { beforeEach, describe, expect, it, vi } from 'vitest' import { createPinia, setActivePinia } from 'pinia' import { registerFeature, useAreaSelection } from 'stk-table-vue' registerFeature(useAreaSelection) // vi.mock 工厂被提升到 import 之上,工厂内引用的变量必须同样提升 const EMPTY = vi.hoisted(() => Object.freeze({})) vi.mock('vue-router', () => ({ useRoute: () => ({ path: '/module/i18n-type', meta: EMPTY }), })) vi.mock('@/services/exportService', () => ({ exportListApi: vi.fn(async () => 'data.xlsx'), loadTotalsApi: vi.fn(async () => ({ data: EMPTY })), countExportApi: vi.fn(async () => ({ data: EMPTY })), })) /** 通用栏位:b_id 120 / b_name 180。测试按需注入个人层偏好。 */ const GLOBAL_VIEW = { schemaVersion: 1, columns: [ { field: 'b_id', width: 120 }, { field: 'b_name', width: 180 }, ], } let personalColumnPref = null let currentAccount = 'u001' const 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(GLOBAL_VIEW), }, ], } } if (viewName === 's_user_module_pref') { if (!personalColumnPref) return { data: [] } return { data: [ { b_user_id: currentAccount, b_module_id: 's_i18n_type', b_schema_type: 'view', b_schema_json: JSON.stringify(personalColumnPref), }, ], } } return { data: [] } }) const saveObjectApi = vi.fn(async () => ({})) vi.mock('@/services/api', () => ({ loadDataApi: (...args) => loadDataApi(...args), saveObjectApi: (...args) => saveObjectApi(...args), pageDataApi: vi.fn(async () => ({ data: { rows: [{ b_id: 'zh-CN' }], total: 1 } })), nextIdApi: vi.fn(async () => ({ data: '1' })), loadDataBySqlApi: vi.fn(async () => ({ data: [] })), })) vi.mock('@/stores/auth', async () => { const actual = await vi.importActual('@/stores/auth') return { ...actual, useAuthStore: () => ({ userInfo: { account: currentAccount }, loginInfo: { user: { account: currentAccount, b_id: currentAccount } }, }), } }) // 权限 store 决定「保存为通用」按钮是否渲染:默认按非管理员(普通用户)跑 let isG3soft = false vi.mock('@/stores/permissions', () => ({ usePermissionStore: () => ({ get isG3soft() { return isG3soft }, }), })) import FmsModuleListPage from '@/components/fms-module-list/FmsModuleListPage.vue' import FmsModuleTable from '@/components/fms-module-list/FmsModuleTable.vue' import FmsTable from '@/components/fms-table/FmsTable.vue' beforeEach(() => { setActivePinia(createPinia()) loadDataApi.mockClear() saveObjectApi.mockClear() personalColumnPref = null currentAccount = 'u001' isG3soft = false Object.defineProperty(HTMLElement.prototype, 'clientWidth', { configurable: true, get: () => 5000, }) }) async function mountPage() { const wrapper = mount(FmsModuleListPage, { props: { dataCode: 's_i18n_type' }, attachTo: document.body, }) await flushPromises() return wrapper } /** * 模拟用户拖拽列宽:FmsTable 收到后会 emit('update:columns', 渲染列数组), * 其中每项保留 dataIndex/key,只有 width 变化——按这个真实形态构造,别只传 b_field/b_width, * 否则 FmsTable.onColumnsUpdate 按 key/dataIndex 匹配不到原始列,回传结果会变成空数组。 */ async function resizeColumn(wrapper, dataIndex, width) { const table = wrapper.findComponent(FmsModuleTable) const fmsTable = table.findComponent(FmsTable) const rendered = fmsTable .props('columns') .map((column) => (column.dataIndex === dataIndex ? { ...column, width } : { ...column })) fmsTable.vm.$emit('update:columns', rendered) await flushPromises() } /** 操作条内的按钮 */ const barButton = (wrapper, text) => wrapper.findAll('.list-column-bar button').find((button) => button.text().trim() === text) describe('栏位设置冒烟', () => { it('未改动时不出现操作条,也不渲染栏位相关按钮', async () => { const wrapper = await mountPage() expect(wrapper.find('.list-column-bar').exists()).toBe(false) wrapper.unmount() }) it('下拉菜单不再包含栏位相关项(已迁到操作条)', async () => { const wrapper = await mountPage() const menuText = wrapper.find('.list-toolbar__group--tools').text() expect(menuText).not.toContain('恢复栏位设置') expect(menuText).not.toContain('保存栏位设置') wrapper.unmount() }) it('拖列宽后操作条出现;撤销后消失且不落库', async () => { const wrapper = await mountPage() await resizeColumn(wrapper, 'b_name', 320) expect(wrapper.find('.list-column-bar').exists()).toBe(true) expect(wrapper.find('.list-column-bar').text()).toContain('列宽或顺序已调整') await barButton(wrapper, '撤销').trigger('click') await flushPromises() expect(wrapper.find('.list-column-bar').exists()).toBe(false) expect(saveObjectApi).not.toHaveBeenCalled() wrapper.unmount() }) it('普通用户看不到「保存为通用」,只看到个人与撤销', async () => { const wrapper = await mountPage() await resizeColumn(wrapper, 'b_name', 320) const bar = wrapper.find('.list-column-bar') expect(bar.text()).toContain('撤销') expect(bar.text()).toContain('保存为个人') expect(bar.text()).not.toContain('保存为通用') wrapper.unmount() }) it('管理员能看到「保存为通用」', async () => { isG3soft = true const wrapper = await mountPage() await resizeColumn(wrapper, 'b_name', 320) expect(wrapper.find('.list-column-bar').text()).toContain('保存为通用') wrapper.unmount() }) it('保存为个人:只写 s_user_module_pref,且只存改过的字段宽度', async () => { const wrapper = await mountPage() await resizeColumn(wrapper, 'b_name', 320) await barButton(wrapper, '保存为个人').trigger('click') await flushPromises() expect(saveObjectApi).toHaveBeenCalledTimes(1) const requests = saveObjectApi.mock.calls[0][0] expect(requests).toHaveLength(1) const req = requests[0] expect(req.table).toBe('s_user_module_pref') expect(req.key_field).toBe('b_user_id,b_module_id,b_schema_type') const row = req.inserts[0] expect(row.b_user_id).toBe('u001') expect(row.b_module_id).toBe('s_i18n_type') expect(row.b_schema_type).toBe('view') const delta = JSON.parse(row.b_schema_json) // 只有 b_name 改了宽度 → 增量里只应出现它,通用改 b_id 仍能透传 expect(delta.columns).toEqual({ b_name: { width: 320 } }) // 顺序未变 → 不应写 order(否则会把通用顺序固化) expect(delta.order).toBeUndefined() // 保存成功后操作条消失(已成本新基线) expect(wrapper.find('.list-column-bar').exists()).toBe(false) wrapper.unmount() }) it('保存为个人不碰 s_module_schema(不污染通用)', async () => { const wrapper = await mountPage() await resizeColumn(wrapper, 'b_name', 320) await barButton(wrapper, '保存为个人').trigger('click') await flushPromises() const tables = saveObjectApi.mock.calls.flatMap((call) => call[0].map((r) => r.table)) expect(tables).not.toContain('s_module_schema') wrapper.unmount() }) it('保存为通用:写 s_module_schema 并回写宽度', async () => { isG3soft = true const wrapper = await mountPage() await resizeColumn(wrapper, 'b_name', 320) await barButton(wrapper, '保存为通用').trigger('click') await flushPromises() const requests = saveObjectApi.mock.calls.at(-1)[0] const req = requests.find((r) => r.table === 's_module_schema') expect(req).toBeTruthy() expect(req.key_field).toBe('b_module_id,b_schema_type') const patch = req.updates ? req.updates[0] : req.inserts[0] expect(patch.b_schema_type).toBe('view') const schema = JSON.parse(patch.b_schema_json) const nameNode = schema.columns.find((node) => node.field === 'b_name') expect(nameNode.width).toBe(320) expect(wrapper.find('.list-column-bar').exists()).toBe(false) wrapper.unmount() }) it('个人层加载:合并进列配置(宽度生效)', async () => { personalColumnPref = { schemaVersion: 1, columns: { b_name: { width: 260 } } } const wrapper = await mountPage() const table = wrapper.findComponent(FmsModuleTable) const nameConfig = table.props('listConfig').find((item) => item.b_field === 'b_name') expect(nameConfig.b_width).toBe(260) // 已按个人层加载 → 与基线一致 → 不应立即显示「已调整」 expect(wrapper.find('.list-column-bar').exists()).toBe(false) wrapper.unmount() }) it('个人层不得引入通用没有的字段(越权字段被忽略)', async () => { personalColumnPref = { schemaVersion: 1, columns: { b_hack: { width: 999 } }, order: ['b_hack', 'b_id'], } const wrapper = await mountPage() const table = wrapper.findComponent(FmsModuleTable) const fields = table.props('listConfig').map((item) => item.b_field) expect(fields).not.toContain('b_hack') expect(fields).toEqual(['b_id', 'b_name']) wrapper.unmount() }) it('个人层顺序生效,且不误报为「已调整」', async () => { personalColumnPref = { schemaVersion: 1, order: ['b_name', 'b_id'] } const wrapper = await mountPage() const table = wrapper.findComponent(FmsModuleTable) expect(table.props('listConfig').map((item) => item.b_field)).toEqual(['b_name', 'b_id']) expect(wrapper.find('.list-column-bar').exists()).toBe(false) wrapper.unmount() }) it('保留个人设置再次保存时走 updates 而非 inserts(避免主键冲突)', async () => { personalColumnPref = { schemaVersion: 1, columns: { b_name: { width: 260 } } } const wrapper = await mountPage() await resizeColumn(wrapper, 'b_name', 300) await barButton(wrapper, '保存为个人').trigger('click') await flushPromises() const req = saveObjectApi.mock.calls.at(-1)[0].find((r) => r.table === 's_user_module_pref') expect(req.updates).toBeTruthy() expect(req.inserts).toBeUndefined() // 增量是相对通用(180),不是相对个人上一次(260) expect(JSON.parse(req.updates[0].b_schema_json).columns).toEqual({ b_name: { width: 300 } }) wrapper.unmount() }) it('连续改动只累积一次落库,保存后操作条消失', async () => { const wrapper = await mountPage() await resizeColumn(wrapper, 'b_id', 140) await resizeColumn(wrapper, 'b_id', 150) await resizeColumn(wrapper, 'b_name', 260) expect(wrapper.find('.list-column-bar').exists()).toBe(true) await barButton(wrapper, '保存为个人').trigger('click') await flushPromises() expect(saveObjectApi).toHaveBeenCalledTimes(1) const delta = JSON.parse(saveObjectApi.mock.calls[0][0][0].inserts[0].b_schema_json) // 只记最终值(150 / 260),中间态不入增量 expect(delta.columns).toEqual({ b_id: { width: 150 }, b_name: { width: 260 } }) expect(wrapper.find('.list-column-bar').exists()).toBe(false) wrapper.unmount() }) it('保存后再次改动会重新出现操作条(基线已前移)', async () => { const wrapper = await mountPage() await resizeColumn(wrapper, 'b_name', 320) await barButton(wrapper, '保存为个人').trigger('click') await flushPromises() expect(wrapper.find('.list-column-bar').exists()).toBe(false) // 保存后把宽度改回通用值 → 与「新基线 320」不同,仍应提示 await resizeColumn(wrapper, 'b_name', 180) expect(wrapper.find('.list-column-bar').exists()).toBe(true) wrapper.unmount() }) it('改回通用值后保存:清空个人层而非留下空壳覆盖', async () => { personalColumnPref = { schemaVersion: 1, columns: { b_name: { width: 260 } } } const wrapper = await mountPage() // 改回与通用一致(b_name 通用宽度 180) await resizeColumn(wrapper, 'b_name', 180) await barButton(wrapper, '保存为个人').trigger('click') await flushPromises() const req = saveObjectApi.mock.calls.at(-1)[0].find((r) => r.table === 's_user_module_pref') // 无增量可存 → 走删除,不写空 columns expect(req.deletes).toBeTruthy() expect(req.inserts).toBeUndefined() expect(req.updates).toBeUndefined() expect(req.deletes[0]).toMatchObject({ b_user_id: 'u001', b_module_id: 's_i18n_type', b_schema_type: 'view', }) expect(wrapper.find('.list-column-bar').exists()).toBe(false) wrapper.unmount() }) it('已有个人设置但本次改动恰好等于通用时,也走删除清理', async () => { // 个人层只改了顺序,本次把顺序也拖回通用顺序 personalColumnPref = { schemaVersion: 1, order: ['b_name', 'b_id'] } const wrapper = await mountPage() const table = wrapper.findComponent(FmsModuleTable) const fmsTable = table.findComponent(FmsTable) // 拖回通用顺序 b_id, b_name const reordered = fmsTable.props('columns').toReversed() fmsTable.vm.$emit('update:columns', reordered) await flushPromises() expect(wrapper.find('.list-column-bar').exists()).toBe(true) await barButton(wrapper, '保存为个人').trigger('click') await flushPromises() const req = saveObjectApi.mock.calls.at(-1)[0].find((r) => r.table === 's_user_module_pref') expect(req.deletes).toBeTruthy() wrapper.unmount() }) })