/** * 回归用例:页面 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: `
{{ label }}#{{ clicks }}
`, 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/module/module-management/index.vue', () => ({ default: makeStub('ModuleManagement'), })) vi.mock('@/views/module/menu-management/index.vue', () => ({ default: makeStub('MenuManagement'), })) vi.mock('@/views/detail/DetailPage.vue', () => ({ // 详情页桩:显示自身 id prop,验证包装组件从自身 fullPath(而非当前路由)取参 default: { name: 'DetailPageStub', props: ['id'], template: '
{{ id }}
', }, })) 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() // 模块中心 /module/* 在路由层仅 g3soft 放行,缓存测试用管理员账号避免被守卫拦截 useAuthStore().setSession({ token: 't', user: { id: 1, account: 'g3soft', 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('/module/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('/module/menu-management') await flushPromises() expect(counters.ModuleManagement.unmounted).toBe(0) expect(counters.ModuleManagement.deactivated).toBe(1) await router.push('/module/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('/module/menu-management') await flushPromises() await router.push('/module/module-management') await flushPromises() expect(counters.ModuleManagement.mounts).toBe(1) expect(wrapper.find('[data-page="ModuleManagement"]').text()).toContain('#2') // 关闭页签后缓存销毁:重新进入应重新挂载 const appStore = useAppStore() appStore.removeVisitedTab('/module/module-management') await router.push('/dashboard') await flushPromises() await router.push('/module/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('/module/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('/module/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() }) })