Files
workspace/code/fms/fms-vue/tests/unit/module-column-smoke.spec.js
T
2026-09-19 21:17:02 +08:00

395 lines
15 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 栏位设置(列宽/顺序)冒烟测试:模拟真实操作链路。
*
* 覆盖:
* 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()
})
})