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:
2026-04-07 18:10:36 +08:00
parent 5dbb530b76
commit 5b6bd93179
152 changed files with 8775 additions and 4084 deletions

View 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>
)
}