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 ./... 通过
2026-04-07 18:10:36 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 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', () => {
|
2026-04-11 23:03:04 +08:00
|
|
|
|
beforeEach(() => {
|
|
|
|
|
|
vi.spyOn(window, 'alert').mockImplementation(() => {})
|
|
|
|
|
|
})
|
|
|
|
|
|
|
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 ./... 通过
2026-04-07 18:10:36 +08:00
|
|
|
|
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()
|
|
|
|
|
|
|
2026-04-10 08:09:48 +08:00
|
|
|
|
let timeoutId: ReturnType<typeof setTimeout>
|
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 ./... 通过
2026-04-07 18:10:36 +08:00
|
|
|
|
const TestSearchInput = ({ onSearch }: { onSearch: (value: string) => void }) => {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<input
|
|
|
|
|
|
onChange={(e) => {
|
2026-04-10 08:09:48 +08:00
|
|
|
|
clearTimeout(timeoutId)
|
|
|
|
|
|
timeoutId = setTimeout(() => onSearch(e.target.value), 300)
|
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 ./... 通过
2026-04-07 18:10:36 +08:00
|
|
|
|
}}
|
|
|
|
|
|
/>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|