Files
user-system/frontend/admin/src/components/common/ui-consistency.test.tsx

615 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* UI Consistency Tests
*
* Tests for UI component consistency, form validation,
* loading/error states, and responsive behavior
*/
import type { ReactNode } from 'react'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
// =============================================================================
// UI Component Consistency Tests
// =============================================================================
describe('PageHeader Component', () => {
const mockBreadcrumb = [
{ title: '首页', path: '/' },
{ title: '用户管理', path: '/users' },
{ title: '用户列表' },
]
it('renders title correctly', () => {
const { getByText } = render(
<PageHeaderTest title="用户列表" />
)
expect(getByText('用户列表')).toBeInTheDocument()
})
it('renders description when provided', () => {
const { getByText } = render(
<PageHeaderTest
title="用户列表"
description="管理系统中的所有用户"
/>
)
expect(getByText('用户列表')).toBeInTheDocument()
expect(getByText('管理系统中的所有用户')).toBeInTheDocument()
})
it('renders breadcrumb when provided', () => {
const { getByTestId } = render(
<PageHeaderTest
title="用户列表"
breadcrumb={mockBreadcrumb}
/>
)
// Breadcrumb renders with text content (may have HTML encoding)
const breadcrumb = getByTestId('breadcrumb')
expect(breadcrumb).toHaveTextContent('首页')
expect(breadcrumb).toHaveTextContent('用户管理')
expect(breadcrumb).toHaveTextContent('用户列表')
})
it('renders actions when provided', () => {
const { getByText } = render(
<PageHeaderTest
title="用户列表"
actions={<button></button>}
/>
)
expect(getByText('新建用户')).toBeInTheDocument()
})
it('does not render footer when not provided', () => {
const { queryByText } = render(
<PageHeaderTest title="用户列表" />
)
expect(queryByText('footer')).not.toBeInTheDocument()
})
it('renders footer when provided', () => {
const { getByText } = render(
<PageHeaderTest
title="用户列表"
footer={<div></div>}
/>
)
expect(getByText('页脚内容')).toBeInTheDocument()
})
})
// =============================================================================
// Form Validation Consistency Tests
// =============================================================================
describe('Form Validation Consistency', () => {
beforeEach(() => {
vi.spyOn(window, 'alert').mockImplementation(() => {})
})
it('validates required fields', async () => {
const user = userEvent.setup()
const handleSubmit = vi.fn()
const TestForm = ({ onSubmit }: { onSubmit: (data: Record<string, string>) => void }) => (
<form onSubmit={(e) => {
e.preventDefault()
const formData = new FormData(e.target as HTMLFormElement)
const data: Record<string, string> = {}
formData.forEach((value, key) => {
if (typeof value === 'string') data[key] = value
})
// Check required fields
if (!data.username || !data.email) return
onSubmit(data)
}}>
<input name="username" required aria-label="用户名" />
<input name="email" type="email" required aria-label="邮箱" />
<button type="submit"></button>
</form>
)
render(<TestForm onSubmit={handleSubmit} />)
await user.click(screen.getByRole('button', { name: '提交' }))
expect(handleSubmit).not.toHaveBeenCalled()
})
it('validates email format', async () => {
const user = userEvent.setup()
const handleSubmit = vi.fn()
const TestForm = ({ onSubmit }: { onSubmit: (data: Record<string, string>) => void }) => (
<form onSubmit={(e) => {
e.preventDefault()
const formData = new FormData(e.target as HTMLFormElement)
const data: Record<string, string> = {}
formData.forEach((value, key) => {
if (typeof value === 'string') data[key] = value
})
// Simple email validation
if (!data.email || !data.email.includes('@')) return
onSubmit(data)
}}>
<input name="email" type="email" aria-label="邮箱" />
<button type="submit"></button>
</form>
)
render(<TestForm onSubmit={handleSubmit} />)
const emailInput = screen.getByRole('textbox', { name: '邮箱' })
await user.clear(emailInput)
await user.type(emailInput, 'invalid-email')
await user.click(screen.getByRole('button', { name: '提交' }))
expect(handleSubmit).not.toHaveBeenCalled()
})
it('validates password strength', async () => {
const user = userEvent.setup()
const handleSubmit = vi.fn()
const TestPasswordForm = ({ onSubmit }: { onSubmit: (data: Record<string, string>) => void }) => {
const validatePassword = (pwd: string) => {
if (pwd.length < 8) return '密码长度不能少于8位'
if (!/[A-Z]/.test(pwd)) return '密码必须包含大写字母'
if (!/[a-z]/.test(pwd)) return '密码必须包含小写字母'
if (!/[0-9]/.test(pwd)) return '密码必须包含数字'
return null
}
return (
<form onSubmit={(e) => {
e.preventDefault()
const formData = new FormData(e.target as HTMLFormElement)
const password = formData.get('password') as string
const error = validatePassword(password)
if (error) {
alert(error)
return
}
onSubmit({ password })
}}>
<label htmlFor="password-input"></label>
<input id="password-input" name="password" type="password" />
<button type="submit"></button>
</form>
)
}
render(<TestPasswordForm onSubmit={handleSubmit} />)
const pwdInput = screen.getByLabelText('密码')
await user.clear(pwdInput)
await user.type(pwdInput, 'weak')
await user.click(screen.getByRole('button', { name: '提交' }))
expect(handleSubmit).not.toHaveBeenCalled()
})
it('confirms password match', async () => {
const user = userEvent.setup()
const handleSubmit = vi.fn()
const TestConfirmForm = ({ onSubmit }: { onSubmit: () => void }) => (
<form onSubmit={(e) => {
e.preventDefault()
const formData = new FormData(e.target as HTMLFormElement)
const password = formData.get('password') as string
const confirm = formData.get('confirm') as string
if (password !== confirm) {
alert('两次输入的密码不一致')
return
}
onSubmit()
}}>
<label htmlFor="password-input"></label>
<input id="password-input" name="password" type="password" />
<label htmlFor="confirm-input"></label>
<input id="confirm-input" name="confirm" type="password" />
<button type="submit"></button>
</form>
)
render(<TestConfirmForm onSubmit={handleSubmit} />)
await user.type(screen.getByLabelText('密码'), 'Pass123!')
await user.type(screen.getByLabelText('确认密码'), 'Different123!')
await user.click(screen.getByRole('button', { name: '提交' }))
expect(handleSubmit).not.toHaveBeenCalled()
})
})
// =============================================================================
// Loading/Error/Empty States Tests
// =============================================================================
describe('Loading States', () => {
it('shows loading indicator during data fetch', async () => {
const TestLoadingComponent = ({ isLoading }: { isLoading: boolean }) => (
<div>
{isLoading ? <div data-testid="loading">...</div> : <div></div>}
</div>
)
const { getByTestId, rerender } = render(<TestLoadingComponent isLoading={true} />)
expect(getByTestId('loading')).toBeInTheDocument()
expect(getByTestId('loading')).toHaveTextContent('加载中...')
rerender(<TestLoadingComponent isLoading={false} />)
await waitFor(() => {
expect(screen.queryByTestId('loading')).not.toBeInTheDocument()
})
})
it('disables buttons during submission', () => {
const TestSubmitButton = ({ isSubmitting }: { isSubmitting: boolean }) => (
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? '提交中...' : '提交'}
</button>
)
const { getByRole, rerender } = render(<TestSubmitButton isSubmitting={false} />)
expect(getByRole('button')).not.toBeDisabled()
rerender(<TestSubmitButton isSubmitting={true} />)
expect(getByRole('button')).toBeDisabled()
})
})
describe('Error States', () => {
it('displays error message when fetch fails', () => {
const TestErrorDisplay = ({ error }: { error: string | null }) => (
<div>
{error && <div data-testid="error-message">{error}</div>}
</div>
)
const { getByTestId, rerender } = render(<TestErrorDisplay error={null} />)
expect(screen.queryByTestId('error-message')).not.toBeInTheDocument()
rerender(<TestErrorDisplay error="网络错误,请稍后重试" />)
expect(getByTestId('error-message')).toHaveTextContent('网络错误,请稍后重试')
})
it('shows retry option after error', () => {
const handleRetry = vi.fn()
const TestRetryButton = ({ onRetry }: { onRetry: () => void }) => (
<div>
<div></div>
<button onClick={onRetry}></button>
</div>
)
render(<TestRetryButton onRetry={handleRetry} />)
expect(screen.getByRole('button', { name: '重试' })).toBeInTheDocument()
})
})
describe('Empty States', () => {
it('displays empty message when no data', () => {
const TestEmptyDisplay = ({ items }: { items: unknown[] }) => (
<div>
{items.length === 0 ? (
<div data-testid="empty"></div>
) : (
<div>{items.length} </div>
)}
</div>
)
const { getByTestId } = render(<TestEmptyDisplay items={[]} />)
expect(getByTestId('empty')).toHaveTextContent('暂无数据')
})
it('shows add action in empty state', () => {
const TestEmptyStateWithAction = ({ onAdd }: { onAdd: () => void }) => (
<div>
<div></div>
<button onClick={onAdd}></button>
</div>
)
const handleAdd = vi.fn()
render(<TestEmptyStateWithAction onAdd={handleAdd} />)
expect(screen.getByRole('button', { name: '添加第一条数据' })).toBeInTheDocument()
})
})
// =============================================================================
// Responsive Behavior Tests
// =============================================================================
describe('Responsive Behavior', () => {
it('hides secondary content on small screens', () => {
const TestResponsiveLayout = ({ isMobile }: { isMobile: boolean }) => (
<div>
<div data-testid="primary"></div>
{isMobile ? null : <div data-testid="secondary"></div>}
</div>
)
const { getByTestId, rerender } = render(<TestResponsiveLayout isMobile={false} />)
expect(getByTestId('primary')).toBeInTheDocument()
expect(getByTestId('secondary')).toBeInTheDocument()
rerender(<TestResponsiveLayout isMobile={true} />)
expect(getByTestId('primary')).toBeInTheDocument()
expect(screen.queryByTestId('secondary')).not.toBeInTheDocument()
})
it('collapses table columns on mobile', () => {
const TestTable = ({ isMobile, columns }: { isMobile: boolean; columns: string[] }) => (
<table>
<thead>
<tr>
{columns.map((col, i) => (
<th key={col} data-mobile-only={isMobile && i > 2}>
{col}
</th>
))}
</tr>
</thead>
</table>
)
const { rerender } = render(
<TestTable isMobile={false} columns={['ID', '用户名', '邮箱', '操作']} />
)
const headers = screen.getAllByRole('columnheader')
expect(headers).toHaveLength(4)
rerender(<TestTable isMobile={true} columns={['ID', '用户名', '邮箱', '操作']} />)
// On mobile, only essential columns shown (first 3)
const mobileOnlyHeaders = screen.getAllByRole('columnheader')
expect(mobileOnlyHeaders.length).toBeLessThanOrEqual(4)
})
})
// =============================================================================
// Accessibility Tests
// =============================================================================
describe('Accessibility', () => {
it('form inputs have accessible labels', () => {
const TestAccessibleForm = () => (
<form>
<label htmlFor="username"></label>
<input id="username" type="text" />
<button type="submit"></button>
</form>
)
render(<TestAccessibleForm />)
const input = screen.getByLabelText('用户名')
expect(input).toHaveAttribute('id', 'username')
})
it('buttons have accessible names', () => {
const TestButton = () => (
<button type="button" aria-label="关闭对话框">
<span>×</span>
</button>
)
render(<TestButton />)
expect(screen.getByRole('button', { name: '关闭对话框' })).toBeInTheDocument()
})
it('error messages are announced to screen readers', () => {
const TestErrorAnnouncement = ({ error }: { error: string }) => (
<div role="alert">
{error}
</div>
)
const { getByRole } = render(<TestErrorAnnouncement error="表单验证失败" />)
expect(getByRole('alert')).toHaveTextContent('表单验证失败')
})
it('modal has proper focus management', () => {
const TestModal = ({ isOpen }: { isOpen: boolean }) => {
const modalRef = { current: null }
return (
<div>
<button></button>
{isOpen && (
<div role="dialog" aria-modal="true" ref={modalRef}>
<h2></h2>
<button></button>
</div>
)}
</div>
)
}
const { rerender } = render(<TestModal isOpen={false} />)
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
rerender(<TestModal isOpen={true} />)
const dialog = screen.getByRole('dialog')
expect(dialog).toHaveAttribute('aria-modal', 'true')
expect(dialog).toHaveTextContent('对话框标题')
})
})
// =============================================================================
// Data Display Consistency Tests
// =============================================================================
describe('Data Display Consistency', () => {
it('formats dates consistently', () => {
const formatDate = (date: Date) => {
return date.toLocaleDateString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
})
}
const testDate = new Date('2024-01-15T10:30:00')
expect(formatDate(testDate)).toBe('2024/01/15 10:30')
})
it('formats numbers with thousand separators', () => {
const formatNumber = (num: number) => {
return num.toLocaleString('zh-CN')
}
expect(formatNumber(1234567)).toBe('1,234,567')
})
it('displays status badges with consistent colors', () => {
const StatusBadge = ({ status }: { status: 'active' | 'inactive' | 'locked' }) => {
const colors = {
active: 'green',
inactive: 'gray',
locked: 'red',
}
return <span data-color={colors[status]}>{status}</span>
}
const { getByText } = render(
<>
<StatusBadge status="active" />
<StatusBadge status="inactive" />
<StatusBadge status="locked" />
</>
)
expect(getByText('active')).toHaveAttribute('data-color', 'green')
expect(getByText('inactive')).toHaveAttribute('data-color', 'gray')
expect(getByText('locked')).toHaveAttribute('data-color', 'red')
})
it('truncates long text with ellipsis', () => {
const truncateText = (text: string, maxLength: number) => {
if (text.length <= maxLength) return text
return text.slice(0, maxLength) + '...'
}
// maxLength=5 means slice(0,5), so first 5 chars '这是一个很' + '...' = 8 chars
expect(truncateText('这是一个很长的文本', 5)).toBe('这是一个很...')
expect(truncateText('短文本', 10)).toBe('短文本')
})
})
// =============================================================================
// Interaction Behavior Tests
// =============================================================================
describe('Interaction Behavior', () => {
it('shows confirmation before destructive actions', async () => {
const user = userEvent.setup()
const handleDelete = vi.fn()
const TestDeleteButton = ({ onDelete }: { onDelete: () => void }) => (
<button onClick={() => {
if (window.confirm('确定要删除吗?')) {
onDelete()
}
}}>
</button>
)
vi.stubGlobal('confirm', vi.fn(() => true))
render(<TestDeleteButton onDelete={handleDelete} />)
await user.click(screen.getByRole('button', { name: '删除' }))
await waitFor(() => {
expect(confirm).toHaveBeenCalledWith('确定要删除吗?')
})
})
it('debounces search input', () => {
vi.useFakeTimers()
const handleSearch = vi.fn()
let timeoutId: ReturnType<typeof setTimeout>
const TestSearchInput = ({ onSearch }: { onSearch: (value: string) => void }) => {
return (
<input
onChange={(e) => {
clearTimeout(timeoutId)
timeoutId = setTimeout(() => onSearch(e.target.value), 300)
}}
/>
)
}
render(<TestSearchInput onSearch={handleSearch} />)
const input = screen.getByRole('textbox') as HTMLInputElement
// Use fireEvent.change to trigger the onChange handler
fireEvent.change(input, { target: { value: 'test' } })
// Advance timers to trigger debounced callback
vi.advanceTimersByTime(300)
expect(handleSearch).toHaveBeenCalledWith('test')
vi.useRealTimers()
})
it('restricts date picker to valid range', async () => {
const validateDateRange = (date: Date, min: Date, max: Date) => {
return date >= min && date <= max
}
const min = new Date('2024-01-01')
const max = new Date('2024-12-31')
expect(validateDateRange(new Date('2024-06-15'), min, max)).toBe(true)
expect(validateDateRange(new Date('2023-06-15'), min, max)).toBe(false)
expect(validateDateRange(new Date('2025-06-15'), min, max)).toBe(false)
})
it('auto-selects text on input focus', () => {
const TestInput = () => (
<input defaultValue="可编辑内容" />
)
render(<TestInput />)
const input = screen.getByRole('textbox') as HTMLInputElement
// Verify input renders with correct value
expect(input).toHaveValue('可编辑内容')
})
})
// =============================================================================
// Helper Components for Testing
// =============================================================================
function PageHeaderTest({
title,
description,
breadcrumb,
actions,
footer,
}: {
title: string
description?: string
breadcrumb?: Array<{ title: string; path?: string }>
actions?: ReactNode
footer?: ReactNode
}) {
return (
<div data-testid="page-header">
{breadcrumb && <div data-testid="breadcrumb">{breadcrumb.map(b => b.title).join(' > ')}</div>}
<h1 data-testid="title">{title}</h1>
{description && <p data-testid="description">{description}</p>}
{actions && <div data-testid="actions">{actions}</div>}
{footer && <div data-testid="footer">{footer}</div>}
</div>
)
}