/** * 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( ) expect(getByText('用户列表')).toBeInTheDocument() }) it('renders description when provided', () => { const { getByText } = render( ) expect(getByText('用户列表')).toBeInTheDocument() expect(getByText('管理系统中的所有用户')).toBeInTheDocument() }) it('renders breadcrumb when provided', () => { const { getByTestId } = render( ) // 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( 新建用户} /> ) expect(getByText('新建用户')).toBeInTheDocument() }) it('does not render footer when not provided', () => { const { queryByText } = render( ) expect(queryByText('footer')).not.toBeInTheDocument() }) it('renders footer when provided', () => { const { getByText } = render( 页脚内容} /> ) 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) => void }) => (
{ e.preventDefault() const formData = new FormData(e.target as HTMLFormElement) const data: Record = {} formData.forEach((value, key) => { if (typeof value === 'string') data[key] = value }) // Check required fields if (!data.username || !data.email) return onSubmit(data) }}> ) render() 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) => void }) => (
{ e.preventDefault() const formData = new FormData(e.target as HTMLFormElement) const data: Record = {} formData.forEach((value, key) => { if (typeof value === 'string') data[key] = value }) // Simple email validation if (!data.email || !data.email.includes('@')) return onSubmit(data) }}> ) render() 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) => 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 (
{ 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 }) }}>
) } render() 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 }) => (
{ 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() }}>
) render() 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 }) => (
{isLoading ?
加载中...
:
数据加载完成
}
) const { getByTestId, rerender } = render() expect(getByTestId('loading')).toBeInTheDocument() expect(getByTestId('loading')).toHaveTextContent('加载中...') rerender() await waitFor(() => { expect(screen.queryByTestId('loading')).not.toBeInTheDocument() }) }) it('disables buttons during submission', () => { const TestSubmitButton = ({ isSubmitting }: { isSubmitting: boolean }) => ( ) const { getByRole, rerender } = render() expect(getByRole('button')).not.toBeDisabled() rerender() expect(getByRole('button')).toBeDisabled() }) }) describe('Error States', () => { it('displays error message when fetch fails', () => { const TestErrorDisplay = ({ error }: { error: string | null }) => (
{error &&
{error}
}
) const { getByTestId, rerender } = render() expect(screen.queryByTestId('error-message')).not.toBeInTheDocument() rerender() expect(getByTestId('error-message')).toHaveTextContent('网络错误,请稍后重试') }) it('shows retry option after error', () => { const handleRetry = vi.fn() const TestRetryButton = ({ onRetry }: { onRetry: () => void }) => (
加载失败
) render() expect(screen.getByRole('button', { name: '重试' })).toBeInTheDocument() }) }) describe('Empty States', () => { it('displays empty message when no data', () => { const TestEmptyDisplay = ({ items }: { items: unknown[] }) => (
{items.length === 0 ? (
暂无数据
) : (
{items.length} 条数据
)}
) const { getByTestId } = render() expect(getByTestId('empty')).toHaveTextContent('暂无数据') }) it('shows add action in empty state', () => { const TestEmptyStateWithAction = ({ onAdd }: { onAdd: () => void }) => (
暂无数据
) const handleAdd = vi.fn() render() expect(screen.getByRole('button', { name: '添加第一条数据' })).toBeInTheDocument() }) }) // ============================================================================= // Responsive Behavior Tests // ============================================================================= describe('Responsive Behavior', () => { it('hides secondary content on small screens', () => { const TestResponsiveLayout = ({ isMobile }: { isMobile: boolean }) => (
主要导航
{isMobile ? null :
次要内容
}
) const { getByTestId, rerender } = render() expect(getByTestId('primary')).toBeInTheDocument() expect(getByTestId('secondary')).toBeInTheDocument() rerender() expect(getByTestId('primary')).toBeInTheDocument() expect(screen.queryByTestId('secondary')).not.toBeInTheDocument() }) it('collapses table columns on mobile', () => { const TestTable = ({ isMobile, columns }: { isMobile: boolean; columns: string[] }) => ( {columns.map((col, i) => ( ))}
2}> {col}
) const { rerender } = render( ) const headers = screen.getAllByRole('columnheader') expect(headers).toHaveLength(4) rerender() // 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 = () => (
) render() const input = screen.getByLabelText('用户名') expect(input).toHaveAttribute('id', 'username') }) it('buttons have accessible names', () => { const TestButton = () => ( ) render() expect(screen.getByRole('button', { name: '关闭对话框' })).toBeInTheDocument() }) it('error messages are announced to screen readers', () => { const TestErrorAnnouncement = ({ error }: { error: string }) => (
{error}
) const { getByRole } = render() expect(getByRole('alert')).toHaveTextContent('表单验证失败') }) it('modal has proper focus management', () => { const TestModal = ({ isOpen }: { isOpen: boolean }) => { const modalRef = { current: null } return (
{isOpen && (

对话框标题

)}
) } const { rerender } = render() expect(screen.queryByRole('dialog')).not.toBeInTheDocument() rerender() 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 {status} } const { getByText } = render( <> ) 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 }) => ( ) vi.stubGlobal('confirm', vi.fn(() => true)) render() 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 const TestSearchInput = ({ onSearch }: { onSearch: (value: string) => void }) => { return ( { clearTimeout(timeoutId) timeoutId = setTimeout(() => onSearch(e.target.value), 300) }} /> ) } render() 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 = () => ( ) render() 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 (
{breadcrumb &&
{breadcrumb.map(b => b.title).join(' > ')}
}

{title}

{description &&

{description}

} {actions &&
{actions}
} {footer &&
{footer}
}
) }