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
}) => (
),
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 ?

: 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('审计日志')
})
})