refactor: 整理项目根目录结构
整理内容: - 删除 60+ 临时测试输出文件 (*.txt) - 移动二进制文件到 bin/ 目录 - 移动 Shell 脚本到 scripts/ 目录 - scripts/dev/: check_gitea.sh, check_sub2api.sh, run_tests.sh - scripts/deploy/: deploy_*.sh, simple_deploy.sh - scripts/ops/: fix_nginx.sh, fix_ssl.sh, install_docker.sh - scripts/test/: test_*.sh, test_*.bat - 移动批处理文件到 scripts/ - 移动 Python 脚本到 tools/ - 清理临时日志文件 保留根目录必要文件: - go.mod, go.sum, go.work - Makefile, docker-compose.yml - .env.example, .gitignore - README.md, AGENTS.md, DEPLOY_GUIDE.md 验证: go build ./... && go test ./... 通过
This commit is contained in:
610
frontend/admin/src/components/common/ui-consistency.test.tsx
Normal file
610
frontend/admin/src/components/common/ui-consistency.test.tsx
Normal file
@@ -0,0 +1,610 @@
|
||||
/**
|
||||
* 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', () => {
|
||||
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()
|
||||
|
||||
const TestSearchInput = ({ onSearch }: { onSearch: (value: string) => void }) => {
|
||||
let timeout: ReturnType<typeof setTimeout>
|
||||
return (
|
||||
<input
|
||||
onChange={(e) => {
|
||||
clearTimeout(timeout)
|
||||
timeout = 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user