/**
* 回归用例:页面 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()
})
})