615 lines
20 KiB
TypeScript
615 lines
20 KiB
TypeScript
/**
|
||
* 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>
|
||
)
|
||
}
|