/** * 缺省页:404(未知路径)/ 403(无权限)/ 500(渲染错误边界)/ 离线(断网覆盖层)。 * 挂载真实 App(布局、路由、store 全真,页面用桩替换),覆盖: * 1. 未知路径渲染 404 页并保留原地址(独立布局,未登录也可达) * 2. 无权限访问模块页由守卫跳到 /403 并在布局内渲染(保留侧栏 / 页签导航) * 3. 直接访问 /500 在布局内渲染 * 4. 断网事件显示离线覆盖层,恢复后移除;覆盖层期间原页面不卸载 * 5. ErrorBoundary 捕获子树渲染错误时就地渲染 500 页,不出白屏 */ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' import { flushPromises, mount } from '@vue/test-utils' import { nextTick } from 'vue' vi.mock('@/views/dashboard/index.vue', () => ({ default: { name: 'Dashboard', template: '
DB
' }, })) vi.mock('@/views/module/module-management/index.vue', () => ({ default: { name: 'ModuleManagement', template: '
MM
' }, })) vi.mock('@/views/detail/DetailPage.vue', () => ({ default: { name: 'DetailPageStub', props: ['id'], template: '
' }, })) 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 ErrorBoundary from '@/views/fallback/ErrorBoundary.vue' import router from '@/router' import { createTestPinia } from '../helpers/app.js' import { useAuthStore } from '@/stores/auth' beforeEach(async () => { localStorage.clear() sessionStorage.clear() createTestPinia() // 普通账号(非 g3soft):用于验证「无权限访问模块中心 → 403」 useAuthStore().setSession({ token: 't', user: { id: 1, account: 'user1', name: 'U' } }, 'ORG') await router.push('/dashboard') await router.isReady() }) afterEach(() => { // 离线用例会把 navigator.onLine 改写为 false,恢复默认值避免串扰 Object.defineProperty(window.navigator, 'onLine', { value: true, configurable: true }) }) async function mountApp() { const wrapper = mount(App, { global: { plugins: [router] }, attachTo: document.body }) await flushPromises() return wrapper } describe('缺省页', () => { it('未知路径:渲染 404 页并保留原地址', async () => { const wrapper = await mountApp() await router.push('/no-such-page') await flushPromises() // 不重定向:地址保持原样,用户能看到自己输错的是什么 expect(router.currentRoute.value.path).toBe('/no-such-page') expect(wrapper.find('.fms-fallback').exists()).toBe(true) expect(wrapper.text()).toContain('页面不存在') // 独立布局:不挂 DefaultLayout(未知路径与登录态无关,未登录也能看) expect(wrapper.find('.fms-topbar').exists()).toBe(false) wrapper.unmount() }) it('无权限访问模块中心:守卫跳 /403,并在布局内渲染(保留导航)', async () => { const wrapper = await mountApp() await router.push('/module/module-management') await flushPromises() expect(router.currentRoute.value.path).toBe('/403') expect(wrapper.find('.fms-fallback').exists()).toBe(true) expect(wrapper.text()).toContain('无权访问') // 缺省页挂在 DefaultLayout 内:顶栏 / 页签栏都在,用户可直接切走 expect(wrapper.find('.fms-topbar').exists()).toBe(true) expect(wrapper.find('.fms-tabs').exists()).toBe(true) wrapper.unmount() }) it('直接访问 /500:在布局内渲染', async () => { const wrapper = await mountApp() await router.push('/500') await flushPromises() expect(wrapper.find('.fms-fallback').exists()).toBe(true) expect(wrapper.text()).toContain('页面出错了') expect(wrapper.find('.fms-topbar').exists()).toBe(true) wrapper.unmount() }) it('断网:显示离线覆盖层,恢复后移除且原页面不卸载', async () => { const wrapper = await mountApp() expect(wrapper.find('[data-page="Dashboard"]').exists()).toBe(true) expect(wrapper.find('.fms-app-offline').exists()).toBe(false) Object.defineProperty(window.navigator, 'onLine', { value: false, configurable: true }) window.dispatchEvent(new Event('offline')) await nextTick() expect(wrapper.find('.fms-app-offline').exists()).toBe(true) expect(wrapper.text()).toContain('网络已断开') // 覆盖层方案:原页面仍在 DOM 中(表单状态 / 滚动位置保留) expect(wrapper.find('[data-page="Dashboard"]').exists()).toBe(true) Object.defineProperty(window.navigator, 'onLine', { value: true, configurable: true }) window.dispatchEvent(new Event('online')) await nextTick() expect(wrapper.find('.fms-app-offline').exists()).toBe(false) wrapper.unmount() }) it('ErrorBoundary:子树渲染错误时就地渲染 500 页,不出白屏', async () => { // 错误是测试故意触发的:静默 Vue / 边界自身的 console.error 输出 const spy = vi.spyOn(console, 'error').mockImplementation(() => {}) const Boom = { name: 'Boom', setup() { throw new Error('boom') }, } const wrapper = mount(ErrorBoundary, { global: { plugins: [router] }, slots: { default: Boom }, }) await flushPromises() expect(wrapper.find('.fms-fallback').exists()).toBe(true) expect(wrapper.text()).toContain('页面出错了') wrapper.unmount() spy.mockRestore() }) })