import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { lazy, useEffect, useState } from 'react'; import { MemoryRouter, useSearchParams } from 'react-router-dom'; import { isRouteChunkError, PlatformErrorBoundary, RoutePage } from './RouteBoundary'; import { ROUTER_FUTURE } from './routerConfig'; afterEach(() => { cleanup(); vi.restoreAllMocks(); window.sessionStorage.clear(); }); describe('route recovery boundary', () => { it('keeps a recovery screen when the platform shell fails outside page routes', () => { vi.spyOn(console, 'error').mockImplementation(() => undefined); const BrokenShell = () => { throw new Error('shell exploded'); }; render(); expect(screen.getByRole('alert')).toHaveTextContent('平台外壳运行异常'); expect(document.querySelector('.v2-route-error-card.semi-card')).toBeInTheDocument(); expect(screen.getByText('查看技术信息')).toBeInTheDocument(); expect(screen.getByRole('button', { name: /重新加载平台/ })).toHaveClass('semi-button'); expect(screen.getByRole('button', { name: /返回全局监控/ })).toHaveClass('semi-button'); }); it('recognizes deployment-related lazy chunk failures', () => { expect(isRouteChunkError(new TypeError('Failed to fetch dynamically imported module: /assets/Monitor-old.js'))).toBe(true); expect(isRouteChunkError(new Error('Loading chunk 18 failed'))).toBe(true); expect(isRouteChunkError(Object.assign(new Error('Route chunk load timed out after 12000ms: history'), { name: 'RouteChunkLoadTimeoutError' }))).toBe(true); expect(isRouteChunkError(new Error('Cannot read properties of undefined'))).toBe(false); }); it('isolates a page render failure and keeps a visible recovery action', () => { vi.spyOn(console, 'error').mockImplementation(() => undefined); const BrokenPage = () => { throw new Error('render exploded'); }; render(); expect(screen.getByRole('alert')).toHaveTextContent('当前模块暂时无法显示'); expect(document.querySelector('.v2-route-error.is-runtime .v2-route-error-card.semi-card')).toBeInTheDocument(); expect(screen.getByRole('button', { name: /刷新当前页面/ })).toHaveClass('semi-button'); expect(screen.getByRole('button', { name: /返回全局监控/ })).toHaveClass('semi-button'); }); it('shows a deployment recovery page when an automatic chunk reload already ran', () => { vi.spyOn(console, 'error').mockImplementation(() => undefined); window.sessionStorage.setItem('vehicle-platform:route-chunk-reload', JSON.stringify({ routeKey: '/statistics', at: Date.now() })); const StaleChunkPage = () => { throw new TypeError('Failed to fetch dynamically imported module: /assets/Statistics-old.js'); }; render(); expect(screen.getByRole('alert')).toHaveTextContent('检测到页面版本更新'); expect(screen.getByText(/旧标签页仍引用上一版本资源/)).toBeInTheDocument(); expect(screen.getByRole('button', { name: /重试加载模块/ })).toBeInTheDocument(); }); it('recreates a rejected lazy route when the operator retries without a full reload', async () => { vi.spyOn(console, 'error').mockImplementation(() => undefined); window.sessionStorage.setItem('vehicle-platform:route-chunk-reload', JSON.stringify({ routeKey: '/history', at: Date.now() })); const failedPage = lazy(() => Promise.reject(new TypeError('Failed to fetch dynamically imported module: /assets/History-old.js'))); const recreatePage = vi.fn(() => lazy(() => Promise.resolve({ default: () => 历史模块已恢复 }))); render(); expect(await screen.findByRole('alert')).toHaveTextContent('检测到页面版本更新'); fireEvent.click(screen.getByRole('button', { name: /重试加载模块/ })); await waitFor(() => expect(screen.getByText('历史模块已恢复')).toBeInTheDocument()); expect(recreatePage).toHaveBeenCalledTimes(1); expect(screen.queryByRole('alert')).not.toBeInTheDocument(); }); it('explains a bounded route timeout instead of leaving the loading skeleton forever', () => { vi.spyOn(console, 'error').mockImplementation(() => undefined); window.sessionStorage.setItem('vehicle-platform:route-chunk-reload', JSON.stringify({ routeKey: '/history', at: Date.now() })); const TimeoutPage = () => { throw Object.assign(new Error('Route chunk load timed out after 10000ms: history'), { name: 'RouteChunkLoadTimeoutError' }); }; render(); expect(screen.getByRole('alert')).toHaveTextContent('页面资源加载超时'); expect(screen.getByText(/网络暂时不稳定/)).toBeInTheDocument(); expect(screen.getByRole('button', { name: /刷新当前页面/ })).toBeInTheDocument(); }); it('keeps a meaningful content skeleton visible while a route chunk is pending', () => { const PendingPage = lazy(() => new Promise(() => undefined)); const { container } = render(); expect(screen.getByRole('status')).toHaveTextContent('正在加载接入管理'); expect(screen.getByText(/导航与筛选状态已保留/)).toBeInTheDocument(); expect(container.querySelectorAll('.v2-page-skeleton > i')).toHaveLength(4); }); it('keeps page state mounted when only URL search parameters change', () => { let mounts = 0; function FilterablePage() { const [count, setCount] = useState(0); const [, setSearchParams] = useSearchParams(); useEffect(() => { mounts += 1; }, []); return <>本地状态 {count}; } const view = render(
); const content = view.container.querySelector('.v2-content')!; content.scrollTop = 240; fireEvent.click(screen.getByRole('button', { name: '修改本地状态' })); fireEvent.click(screen.getByRole('button', { name: '修改查询参数' })); expect(screen.getByText('本地状态 1')).toBeInTheDocument(); expect(mounts).toBe(1); expect(content.scrollTop).toBe(240); }); it('retries a failed route when its search scope changes without remounting healthy scopes', () => { vi.spyOn(console, 'error').mockImplementation(() => undefined); let healthyMounts = 0; function ScopeSensitivePage() { const [params] = useSearchParams(); if (params.get('scope') === 'broken') throw new Error('invalid route scope'); useEffect(() => { healthyMounts += 1; }, []); return 新筛选范围已恢复; } function RecoveryHarness() { const [, setSearchParams] = useSearchParams(); return <>; } render(); expect(screen.getByRole('alert')).toHaveTextContent('当前模块暂时无法显示'); expect(screen.getByText(/更换筛选条件会自动重试/)).toBeInTheDocument(); fireEvent.click(screen.getByRole('button', { name: '修正筛选条件' })); expect(screen.queryByRole('alert')).not.toBeInTheDocument(); expect(screen.getByText('新筛选范围已恢复')).toBeInTheDocument(); expect(healthyMounts).toBe(1); }); });