20260902173133

This commit is contained in:
oneao committed 2026-09-02 17:31:35 +08:00
1 parent 6b9cda5424
commit db7813a2d2
16 files changed
+1350 -675

No files matched your search

@@ -151,32 +151,38 @@ describe('fms-table 虚拟滚动验证', () => {
wrapper.unmount()
})
// 列宽拖拽回归:库 useColResize 在 mouseUp 时「原地改写」传入的列对象 width 并
// emit update:columns(同一批对象的新数组)。未绑定 v-model:columns 的父组件
// 不会回灌新数组,displayColumns 必须自行换新数组引用,库才能重算出新列宽。
// 复现方式:拿到库实际收到的 displayColumns,原地改第 1 列宽度后以同批对象
// emit update:columns,断言表头渲染出新宽度。
it('拖拽列宽后(未绑定 v-model:columns),新列宽立即生效不丢失', async () => {
const columns = makeColumns()
// 列宽/列序拖拽依赖 v-model:columns 回写:未绑定的父组件(如 dashboard 单向 :columns)
// 拖了松手就回弹,因此未绑定时直接关闭这两个功能(不渲染把手),绑定时保持开启。
it('未绑定 v-model:columns 时关闭列宽拖拽与列头拖拽(无处回写)', async () => {
const wrapper = mount(FmsTable, {
props: { columns, dataSource: makeRows(20), rowKey: 'id', seq: false },
props: { columns: makeColumns(), dataSource: makeRows(20), rowKey: 'id', seq: false },
attachTo: document.body,
})
await nextTick()
await new Promise((r) => setTimeout(r, 100))
const stkTable = wrapper.findComponent(StkTable)
const displayColumns = stkTable.props('columns')
const target = displayColumns.find((c) => c.dataIndex === 'c1')
target.width = '333px' // 模拟 useColResize mouseUp 的原地改写
expect(stkTable.props('colResizable')).toBe(false)
expect(stkTable.props('headerDrag')).toBe(false)
wrapper.unmount()
})
await stkTable.vm.$emit('update:columns', displayColumns.slice())
it('绑定 v-model:columns 时列宽拖拽与列头拖拽保持开启', async () => {
const wrapper = mount(FmsTable, {
props: {
columns: makeColumns(),
dataSource: makeRows(20),
rowKey: 'id',
seq: false,
// 模拟 v-model:columns:提供 update:columns 监听器
'onUpdate:columns': () => {},
},
attachTo: document.body,
})
await nextTick()
await new Promise((r) => setTimeout(r, 100))
// 表头 th 宽度经 --cw CSS 变量渲染;virtualX 模式下取自 __W__(dealColumns 重算产物)
const th = wrapper.findAll('thead th').find((el) => el.attributes('data-col-key') === 'c1')
expect(th.attributes('style')).toContain('333px')
const stkTable = wrapper.findComponent(StkTable)
expect(stkTable.props('colResizable')).toBe(true)
expect(stkTable.props('headerDrag')).toBeTruthy()
wrapper.unmount()
})
})
@@ -0,0 +1,178 @@
/**
* 回归用例:页面 keep-alive 缓存(曾因包装组件读「当前路由」导致缓存全灭)。
* 用轻量桩组件替换三个页面(统计挂载/激活/卸载、持有自身状态),其余
* (布局、页签、store、路由)全部真实,覆盖:
* 1. 切走再切回:不重新挂载、状态保留(keep-alive 生效)
* 2. 缓存页的内部状态在其它路由切换后仍保留(包装组件不得依赖当前路由)
* 3. 双击页签刷新:页面组件重新挂载、状态归零(缓存槽位不变)
*/
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { flushPromises, mount } from '@vue/test-utils'
import { onActivated, onDeactivated, onUnmounted, ref } from 'vue'
// —— 桩组件共享的计数器(vi.hoisted 保证 mock 工厂可用)——
const counters = vi.hoisted(() => ({
ModuleManagement: { mounts: 0, activated: 0, deactivated: 0, unmounted: 0 },
MenuManagement: { mounts: 0, activated: 0, deactivated: 0, unmounted: 0 },
Dashboard: { mounts: 0, activated: 0, deactivated: 0, unmounted: 0 },
}))
function makeStub(label) {
return {
name: label,
template: `<div data-page="${label}">{{ label }}#{{ clicks }}<button data-inc @click="clicks++">+</button></div>`,
setup() {
const c = counters[label]
// 页面自身状态:模拟树选中、面板展开等 —— 切走再切回应保留
const clicks = ref(0)
c.mounts++
onActivated(() => c.activated++)
onDeactivated(() => c.deactivated++)
onUnmounted(() => c.unmounted++)
return { clicks }
},
}
}
vi.mock('@/views/dashboard/index.vue', () => ({ default: makeStub('Dashboard') }))
vi.mock('@/views/system/module-management/index.vue', () => ({
default: makeStub('ModuleManagement'),
}))
vi.mock('@/views/system/menu-management/index.vue', () => ({
default: makeStub('MenuManagement'),
}))
vi.mock('@/views/detail/DetailPage.vue', () => ({
// 详情页桩:显示自身 id prop,验证包装组件从自身 fullPath(而非当前路由)取参
default: {
name: 'DetailPageStub',
props: ['id'],
template: '<div data-page="Detail">{{ id }}</div>',
},
}))
vi.mock('@/services/api', () => ({
loadDataApi: vi.fn(() => Promise.resolve({ data: [], code: 0, success: true })),
describeApi: vi.fn(() => Promise.resolve({ data: [], code: 0, success: true })),
saveObjectApi: vi.fn(() => Promise.resolve({ code: 0, success: true })),
nextIdApi: vi.fn(() => Promise.resolve({ data: 1, code: 0, success: true })),
}))
import App from '@/App.vue'
import router from '@/router'
import { createTestPinia } from '../helpers/app.js'
import { useAuthStore } from '@/stores/auth'
import { useAppStore } from '@/stores/app'
beforeEach(async () => {
localStorage.clear()
sessionStorage.clear()
Object.values(counters).forEach((c) => {
c.mounts = 0
c.activated = 0
c.deactivated = 0
c.unmounted = 0
})
createTestPinia()
useAuthStore().setSession({ token: 't', user: { id: 1, account: 'u', name: 'U' } }, 'ORG')
await router.push('/dashboard')
await router.isReady()
})
async function mountApp() {
const wrapper = mount(App, { global: { plugins: [router] }, attachTo: document.body })
await flushPromises()
return wrapper
}
describe('页面 keep-alive 缓存', () => {
it('模块管理 ↔ 菜单管理 ↔ 工作台 来回切换:不重新挂载、状态保留', async () => {
const wrapper = await mountApp()
expect(counters.Dashboard.mounts).toBe(1)
// 进入模块管理,点两下按钮制造页面状态
await router.push('/system/module-management')
await flushPromises()
expect(counters.ModuleManagement.mounts).toBe(1)
const inc = () => wrapper.find('[data-page="ModuleManagement"] [data-inc]').trigger('click')
await inc()
await inc()
expect(wrapper.find('[data-page="ModuleManagement"]').text()).toContain('#2')
// 切到菜单管理再切回:不重新挂载,clicks 仍为 2
await router.push('/system/menu-management')
await flushPromises()
expect(counters.ModuleManagement.unmounted).toBe(0)
expect(counters.ModuleManagement.deactivated).toBe(1)
await router.push('/system/module-management')
await flushPromises()
expect(counters.ModuleManagement.mounts).toBe(1)
expect(counters.ModuleManagement.unmounted).toBe(0)
expect(wrapper.find('[data-page="ModuleManagement"]').text()).toContain('#2')
// 再绕经工作台切回:缓存依旧(包装组件不得依赖当前路由)
await router.push('/dashboard')
await flushPromises()
await router.push('/system/menu-management')
await flushPromises()
await router.push('/system/module-management')
await flushPromises()
expect(counters.ModuleManagement.mounts).toBe(1)
expect(wrapper.find('[data-page="ModuleManagement"]').text()).toContain('#2')
// 关闭页签后缓存销毁:重新进入应重新挂载
const appStore = useAppStore()
appStore.removeVisitedTab('/system/module-management')
await router.push('/dashboard')
await flushPromises()
await router.push('/system/module-management')
await flushPromises()
expect(counters.ModuleManagement.mounts).toBe(2)
expect(wrapper.find('[data-page="ModuleManagement"]').text()).toContain('#0')
wrapper.unmount()
})
it('详情页:按参数独立缓存,切走再切回 id 不串、互不影响', async () => {
const wrapper = await mountApp()
await router.push('/detail/1')
await flushPromises()
expect(wrapper.find('[data-page="Detail"]').text()).toBe('1')
// /detail/2 与 /detail/1 是两个独立缓存实例
await router.push('/detail/2')
await flushPromises()
expect(wrapper.find('[data-page="Detail"]').text()).toBe('2')
await router.push('/detail/1')
await flushPromises()
expect(wrapper.find('[data-page="Detail"]').text()).toBe('1')
// 携带 query 时 id 仍正确(query 变化会产生独立缓存槽,但 id 不能被 query 污染)
await router.push('/detail/1?tab=2')
await flushPromises()
expect(wrapper.find('[data-page="Detail"]').text()).toBe('1')
wrapper.unmount()
})
it('双击页签刷新:页面组件重新挂载、状态归零(缓存槽位不变)', async () => {
const wrapper = await mountApp()
await router.push('/system/module-management')
await flushPromises()
await wrapper.find('[data-page="ModuleManagement"] [data-inc]').trigger('click')
expect(wrapper.find('[data-page="ModuleManagement"]').text()).toContain('#1')
const appStore = useAppStore()
appStore.refreshRoute('/system/module-management')
await flushPromises()
expect(counters.ModuleManagement.mounts).toBe(2)
expect(counters.ModuleManagement.unmounted).toBe(1)
expect(wrapper.find('[data-page="ModuleManagement"]').text()).toContain('#0')
wrapper.unmount()
})
})
@@ -0,0 +1,77 @@
/**
* 回归用例:菜单管理 - 新建节点(未保存)→ 切到旧节点 → 切回新节点。
* 曾因 loadMenuConfig 用临时 id 查库返回空 → drafts = { menu: null }(真值)→
* 模板以 null menu 渲染 → render 抛错 → 在布局 <Transition> 下崩溃
* (表现为 "Component inside <Transition> renders non-element root node" +
* "Cannot set properties of null (setting '__vnode')")。
* 修复:watch(menuSelectedId) 对未落库节点不走 loadMenuConfig,从树节点重建草稿。
*/
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { flushPromises } from '@vue/test-utils'
import MenuManagement from '@/views/system/menu-management/index.vue'
import { mountPage } from '../helpers/app.js'
vi.mock('@/services/api', () => ({
loadDataApi: vi.fn(),
saveObjectApi: vi.fn(() => Promise.resolve({ code: 0, success: true })),
nextIdApi: vi.fn(() => Promise.resolve({ data: 9001, code: 0, success: true })),
describeApi: vi.fn(() => Promise.resolve({ data: [], code: 0, success: true })),
}))
vi.mock('@/components/ui/message/message-manager.js', () => {
const m = { success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() }
return { Message: m, default: m }
})
import { loadDataApi } from '@/services/api'
const savedMenu = {
b_id: '1',
b_code: 'home',
b_name: '首页',
b_menu_type: 'page',
b_parent_id: null,
b_route: '/dashboard',
b_xh: 10,
b_canuse: 1,
}
beforeEach(() => {
loadDataApi.mockReset()
loadDataApi.mockImplementation((view, where) => {
if (view === 's_menu' && where === '1 = 1') return Promise.resolve({ data: [savedMenu] })
if (view === 's_menu' && where === 'b_id = 1') return Promise.resolve({ data: [savedMenu] })
if (view === 's_menu') return Promise.resolve({ data: [] }) // 含 b_id = 9001(临时 id):查无此行
return Promise.resolve({ data: [] })
})
})
describe('菜单管理:未保存新节点来回切换', () => {
it('切回未保存的新节点时,从树节点重建草稿,不查库、渲染不报错', async () => {
const { wrapper } = await mountPage(MenuManagement)
await flushPromises()
// 1. 新建节点(未保存)
await wrapper.vm.addMenu(null, 'page')
await flushPromises()
expect(String(wrapper.vm.menuSelectedId)).toBe('9001')
expect(wrapper.vm.drafts?.menu?.b_menu_type).toBe('page')
// 2. 切到旧节点(已保存)
wrapper.vm.menuSelectedId = '1'
await flushPromises()
expect(wrapper.vm.drafts?.menu?.b_id).toBe('1')
// 3. 切回新节点:不应对临时 id 发起查库请求
loadDataApi.mockClear()
wrapper.vm.menuSelectedId = '9001'
await flushPromises()
await flushPromises()
expect(loadDataApi).not.toHaveBeenCalled()
expect(wrapper.vm.drafts?.menu).not.toBeNull()
expect(String(wrapper.vm.drafts?.menu?.b_id)).toBe('9001')
expect(wrapper.vm.drafts?.menu?.b_menu_type).toBe('page')
expect(String(wrapper.vm.loadedMenuId)).toBe('9001')
})
})