import type { ReactNode } from 'react' import { act, render, screen, waitFor, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { MemoryRouter, Route, Routes } from 'react-router-dom' import { AuthContext, type AuthContextValue } from '@/app/providers/auth-context' import { AdminLayout } from './AdminLayout' import styles from './AdminLayout.module.css' const logoutMock = vi.fn(async () => {}) function flattenChildren(children: ReactNode): string { if (children === null || children === undefined || typeof children === 'boolean') { return '' } if (typeof children === 'string' || typeof children === 'number') { return String(children) } if (Array.isArray(children)) { return children.map(flattenChildren).join(' ').trim() } if (typeof children === 'object' && 'props' in children) { return flattenChildren((children as { props?: { children?: ReactNode } }).props?.children) } return '' } vi.mock('antd', async () => { const React = await import('react') type MenuItem = { key?: string label?: ReactNode children?: MenuItem[] type?: string onClick?: () => void } const Layout = Object.assign( ({ children, className, }: { children?: ReactNode className?: string }) => (
{children}
), { Sider: ({ children, className, }: { children?: ReactNode className?: string }) => ( ), Header: ({ children, className, }: { children?: ReactNode className?: string }) => (
{children}
), Content: ({ children, className, }: { children?: ReactNode className?: string }) => (
{children}
), }, ) function Menu({ items = [], onClick, selectedKeys = [], defaultOpenKeys = [], }: { items?: MenuItem[] onClick?: (info: { key: string }) => void selectedKeys?: string[] defaultOpenKeys?: string[] }) { const [openKeys, setOpenKeys] = React.useState((defaultOpenKeys ?? []).map(String)) React.useEffect(() => { setOpenKeys((defaultOpenKeys ?? []).map(String)) }, [defaultOpenKeys]) const renderItem = (item: MenuItem): ReactNode => { if (item.type === 'divider') { return
} const key = String(item.key ?? flattenChildren(item.label)) const label = flattenChildren(item.label) const hasChildren = Boolean(item.children?.length) return (
{hasChildren && openKeys.includes(key) ? item.children?.map(renderItem) : null}
) } return (
{items.map((item) => renderItem(item as MenuItem))}
) } function Dropdown({ children, menu, }: { children?: ReactNode menu?: { items?: MenuItem[] } }) { const [open, setOpen] = React.useState(false) return (
{open ? (
{menu?.items?.map((item, index) => { if (!item || item.type === 'divider') { return
} const key = String(item.key ?? index) return ( ) })}
) : null}
) } return { Avatar: ({ src, style, icon, size, }: { src?: string | null style?: { backgroundColor?: string } icon?: ReactNode size?: number }) => (
{src ? avatar : icon}
), Button: ({ children, icon, onClick, htmlType, ...props }: { children?: ReactNode icon?: ReactNode onClick?: () => void htmlType?: 'button' | 'submit' | 'reset' [key: string]: unknown }) => ( ), Drawer: ({ open, title, children, onClose, }: { open?: boolean title?: ReactNode children?: ReactNode onClose?: () => void }) => ( open ? (
{title}
{children}
) : null ), Dropdown, Layout, Menu, Spin: ({ tip, size, children, }: { tip?: ReactNode size?: string children?: ReactNode }) => (
{children}
), } }) vi.mock('@ant-design/icons', () => ({ ApiOutlined: () => api-icon, DashboardOutlined: () => dashboard-icon, FileTextOutlined: () => file-text-icon, LogoutOutlined: () => logout-icon, MenuFoldOutlined: () => menu-fold-icon, MenuOutlined: () => menu-icon, MenuUnfoldOutlined: () => menu-unfold-icon, SafetyOutlined: () => safety-icon, SettingOutlined: () => setting-icon, UserOutlined: () => user-icon, })) const baseAuthContextValue: AuthContextValue = { user: { id: 1, username: 'admin', email: 'admin@example.com', phone: '13800138000', nickname: 'admin-nickname', avatar: '', status: 1, }, roles: [], isAdmin: true, isAuthenticated: true, isLoading: false, onLoginSuccess: async () => {}, logout: () => logoutMock(), refreshUser: async () => {}, } function setWindowWidth(width: number) { Object.defineProperty(window, 'innerWidth', { configurable: true, writable: true, value: width, }) } function renderAdminLayout( authContextValue: Partial = {}, initialEntry: string = '/profile/security', layoutChildren?: ReactNode, ) { const value: AuthContextValue = { ...baseAuthContextValue, ...authContextValue, } return render( {layoutChildren}}> Dashboard Page} /> Users Page} /> Roles Page} /> Permissions Page} /> Login Logs Page} /> Operation Logs Page} /> Webhooks Page} /> Import Export Page} /> Profile Page} /> Security Page} /> , ) } describe('AdminLayout', () => { beforeEach(() => { logoutMock.mockClear() setWindowWidth(1280) }) afterEach(() => { setWindowWidth(1280) vi.restoreAllMocks() }) it('shows a loading state while the session is restoring', () => { renderAdminLayout({ isLoading: true }) expect(screen.getByTestId('spin')).toHaveAttribute('data-tip') expect(screen.queryByText('Security Page')).not.toBeInTheDocument() }) it('renders desktop admin navigation, breadcrumbs, collapse state, dropdown actions, and mobile drawer navigation', async () => { const user = userEvent.setup() const { container } = renderAdminLayout({ isAdmin: true }, '/profile/security') expect(container.querySelector(`.${styles.logo}`)).toHaveTextContent('用户管理系统') expect(container.querySelector(`.${styles.userName}`)).toHaveTextContent('admin-nickname') expect(screen.getAllByTestId('menu')[0]).toHaveAttribute('data-open-keys', 'profile') expect(screen.getByText('Security Page')).toBeInTheDocument() const breadcrumbLink = container.querySelector(`.${styles.breadcrumbLink}`) expect(breadcrumbLink).not.toBeNull() await user.click(breadcrumbLink as HTMLElement) await waitFor(() => expect(screen.getByText('Profile Page')).toBeInTheDocument()) await user.click(screen.getByTestId('menu-item-access-control')) await user.click(screen.getByTestId('menu-item-/users')) await waitFor(() => expect(screen.getByText('Users Page')).toBeInTheDocument()) await user.click(screen.getByTestId('dropdown-trigger')) await user.click(screen.getByTestId('dropdown-item-security')) await waitFor(() => expect(screen.getByText('Security Page')).toBeInTheDocument()) await user.click(screen.getByTestId('dropdown-trigger')) await user.click(screen.getByTestId('dropdown-item-profile')) await waitFor(() => expect(screen.getByText('Profile Page')).toBeInTheDocument()) await user.click(screen.getByTestId('dropdown-trigger')) await user.click(screen.getByTestId('dropdown-item-logout')) await waitFor(() => expect(logoutMock).toHaveBeenCalledTimes(1)) const collapseButton = screen.getByText('menu-fold-icon').closest('button') expect(collapseButton).not.toBeNull() await user.click(collapseButton as HTMLButtonElement) expect(container.querySelector(`.${styles.logo}`)).toHaveTextContent('UMS') expect(screen.getAllByTestId('menu')[0]).toHaveAttribute('data-open-keys', '') expect(screen.getByText('menu-unfold-icon')).toBeInTheDocument() await act(async () => { setWindowWidth(375) window.dispatchEvent(new Event('resize')) }) await waitFor(() => expect(screen.getByRole('button', { name: 'menu-icon' })).toBeInTheDocument()) await user.click(screen.getByRole('button', { name: 'menu-icon' })) const drawer = screen.getByTestId('drawer') expect(within(drawer).getByTestId('drawer-title')).toHaveTextContent('UMS') await user.click(within(drawer).getByTestId('menu-item-/dashboard')) await waitFor(() => expect(screen.getByText('Dashboard Page')).toBeInTheDocument()) expect(screen.queryByTestId('drawer')).not.toBeInTheDocument() }) it('renders the reduced mobile menu for non-admin users and uses avatar and username fallbacks correctly', async () => { const user = userEvent.setup() setWindowWidth(375) const { container } = renderAdminLayout( { isAdmin: false, user: { id: 2, username: 'operator-name', email: 'operator@example.com', phone: '', nickname: '', avatar: 'https://example.com/avatar.png', status: 1, }, }, '/profile', ) expect(screen.queryByTestId('menu-item-access-control')).not.toBeInTheDocument() expect(screen.queryByTestId('menu-item-logs')).not.toBeInTheDocument() expect(screen.getAllByTestId('menu')[0]).toHaveAttribute('data-open-keys', 'profile') expect(screen.getByTestId('avatar')).toHaveAttribute('data-src', 'https://example.com/avatar.png') expect(screen.getByTestId('avatar')).toHaveAttribute('data-background', '') expect(container.querySelector(`.${styles.userName}`)).toHaveTextContent('operator-name') await user.click(screen.getByRole('button', { name: 'menu-icon' })) const drawer = screen.getByTestId('drawer') await user.click(within(drawer).getByTestId('menu-item-/webhooks')) await waitFor(() => expect(screen.getByText('Webhooks Page')).toBeInTheDocument()) expect(screen.queryByTestId('drawer')).not.toBeInTheDocument() }) it('opens the logs group for audit routes and prefers explicit children over the outlet while keeping the default user fallback', async () => { const { container } = renderAdminLayout( { user: null, }, '/logs/login',
Injected Layout Content
, ) expect(screen.getByText('Injected Layout Content')).toBeInTheDocument() expect(screen.queryByText('Login Logs Page')).not.toBeInTheDocument() expect(container.querySelector(`.${styles.userName}`)?.textContent?.trim().length).toBeGreaterThan(0) expect(screen.getAllByTestId('menu')[0]).toHaveAttribute('data-selected-keys', '/logs/login') expect(container.querySelector(`.${styles.breadcrumb}`)).toHaveTextContent('审计日志') }) })