feat: 完善代理商端业务逻辑与前后端框架
主要更新: - 更新代理商端文档,明确项目由品牌方分配流程 - 新增Brief配置详情页(已配置)设计稿 - 完善工作台紧急待办中品牌新任务功能 - 整理Pencil设计文件中代理商端页面顺序 - 新增后端FastAPI框架及核心API - 新增前端Next.js页面和组件库 - 添加.gitignore排除构建和缓存文件 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.5
parent
d52509d630
commit
e4959d584f
@@ -0,0 +1,205 @@
|
||||
/**
|
||||
* Button 组件测试
|
||||
* 测试覆盖: variants, sizes, icons, loading, disabled, fullWidth
|
||||
*/
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { Search, ArrowRight } from 'lucide-react';
|
||||
import { Button } from './Button';
|
||||
|
||||
describe('Button', () => {
|
||||
// ==================== 基础渲染测试 ====================
|
||||
describe('基础渲染', () => {
|
||||
it('渲染按钮文本', () => {
|
||||
render(<Button>点击我</Button>);
|
||||
expect(screen.getByRole('button', { name: '点击我' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('默认使用 primary variant 和 md size', () => {
|
||||
render(<Button>默认按钮</Button>);
|
||||
const button = screen.getByRole('button');
|
||||
expect(button).toHaveClass('bg-accent-indigo');
|
||||
expect(button).toHaveClass('px-4', 'py-2.5');
|
||||
});
|
||||
});
|
||||
|
||||
// ==================== Variant 测试 ====================
|
||||
describe('Variant 样式', () => {
|
||||
it('primary variant 应用正确样式', () => {
|
||||
render(<Button variant="primary">Primary</Button>);
|
||||
expect(screen.getByRole('button')).toHaveClass('bg-accent-indigo', 'text-white');
|
||||
});
|
||||
|
||||
it('secondary variant 应用正确样式', () => {
|
||||
render(<Button variant="secondary">Secondary</Button>);
|
||||
expect(screen.getByRole('button')).toHaveClass('bg-bg-elevated', 'text-text-secondary');
|
||||
});
|
||||
|
||||
it('danger variant 应用正确样式', () => {
|
||||
render(<Button variant="danger">Danger</Button>);
|
||||
expect(screen.getByRole('button')).toHaveClass('bg-accent-coral', 'text-white');
|
||||
});
|
||||
|
||||
it('success variant 应用正确样式', () => {
|
||||
render(<Button variant="success">Success</Button>);
|
||||
expect(screen.getByRole('button')).toHaveClass('bg-accent-green', 'text-white');
|
||||
});
|
||||
|
||||
it('ghost variant 应用正确样式', () => {
|
||||
render(<Button variant="ghost">Ghost</Button>);
|
||||
expect(screen.getByRole('button')).toHaveClass('bg-transparent', 'text-text-secondary');
|
||||
});
|
||||
});
|
||||
|
||||
// ==================== Size 测试 ====================
|
||||
describe('Size 样式', () => {
|
||||
it('sm size 应用正确样式', () => {
|
||||
render(<Button size="sm">Small</Button>);
|
||||
expect(screen.getByRole('button')).toHaveClass('px-3', 'py-1.5', 'text-small');
|
||||
});
|
||||
|
||||
it('md size 应用正确样式', () => {
|
||||
render(<Button size="md">Medium</Button>);
|
||||
expect(screen.getByRole('button')).toHaveClass('px-4', 'py-2.5', 'text-body');
|
||||
});
|
||||
|
||||
it('lg size 应用正确样式', () => {
|
||||
render(<Button size="lg">Large</Button>);
|
||||
expect(screen.getByRole('button')).toHaveClass('px-6', 'py-3', 'text-section-title');
|
||||
});
|
||||
});
|
||||
|
||||
// ==================== Icon 测试 ====================
|
||||
describe('Icon 渲染', () => {
|
||||
// 使用 innerHTML 正则匹配验证图标和文本的相对位置
|
||||
// 这种方式对 DOM 结构变化(如添加 wrapper)更健壮
|
||||
|
||||
it('左侧图标正确渲染(图标在文本之前)', () => {
|
||||
render(<Button icon={Search} iconPosition="left">搜索</Button>);
|
||||
const button = screen.getByRole('button');
|
||||
expect(button.querySelector('svg')).toBeInTheDocument();
|
||||
// 验证 SVG 在 "搜索" 文本之前
|
||||
const html = button.innerHTML;
|
||||
const svgPos = html.indexOf('<svg');
|
||||
const textPos = html.indexOf('搜索');
|
||||
expect(svgPos).toBeLessThan(textPos);
|
||||
});
|
||||
|
||||
it('右侧图标正确渲染(图标在文本之后)', () => {
|
||||
render(<Button icon={ArrowRight} iconPosition="right">下一步</Button>);
|
||||
const button = screen.getByRole('button');
|
||||
expect(button.querySelector('svg')).toBeInTheDocument();
|
||||
// 验证 SVG 在 "下一步" 文本之后
|
||||
const html = button.innerHTML;
|
||||
const svgPos = html.indexOf('<svg');
|
||||
const textPos = html.indexOf('下一步');
|
||||
expect(svgPos).toBeGreaterThan(textPos);
|
||||
});
|
||||
|
||||
it('默认图标位置为左侧', () => {
|
||||
render(<Button icon={Search}>搜索</Button>);
|
||||
const button = screen.getByRole('button');
|
||||
expect(button.querySelector('svg')).toBeInTheDocument();
|
||||
// 验证默认情况下 SVG 在 "搜索" 文本之前
|
||||
const html = button.innerHTML;
|
||||
const svgPos = html.indexOf('<svg');
|
||||
const textPos = html.indexOf('搜索');
|
||||
expect(svgPos).toBeLessThan(textPos);
|
||||
});
|
||||
});
|
||||
|
||||
// ==================== Loading 状态测试 ====================
|
||||
describe('Loading 状态', () => {
|
||||
it('loading 状态显示加载动画', () => {
|
||||
render(<Button loading>加载中</Button>);
|
||||
const button = screen.getByRole('button');
|
||||
const spinner = button.querySelector('svg.animate-spin');
|
||||
expect(spinner).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('loading 状态禁用按钮', () => {
|
||||
render(<Button loading>加载中</Button>);
|
||||
expect(screen.getByRole('button')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('loading 状态应用 opacity-50 样式', () => {
|
||||
render(<Button loading>加载中</Button>);
|
||||
expect(screen.getByRole('button')).toHaveClass('opacity-50');
|
||||
});
|
||||
|
||||
it('loading 状态隐藏原有图标', () => {
|
||||
render(<Button loading icon={Search}>搜索</Button>);
|
||||
const button = screen.getByRole('button');
|
||||
// 应该只有 spinner,没有 Search 图标
|
||||
const svgs = button.querySelectorAll('svg');
|
||||
expect(svgs).toHaveLength(1);
|
||||
expect(svgs[0]).toHaveClass('animate-spin');
|
||||
});
|
||||
});
|
||||
|
||||
// ==================== Disabled 状态测试 ====================
|
||||
describe('Disabled 状态', () => {
|
||||
it('disabled 属性禁用按钮', () => {
|
||||
render(<Button disabled>禁用</Button>);
|
||||
expect(screen.getByRole('button')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('disabled 状态应用正确样式', () => {
|
||||
render(<Button disabled>禁用</Button>);
|
||||
expect(screen.getByRole('button')).toHaveClass('opacity-50', 'cursor-not-allowed');
|
||||
});
|
||||
|
||||
it('disabled 状态不触发点击事件', () => {
|
||||
const handleClick = vi.fn();
|
||||
render(<Button disabled onClick={handleClick}>禁用</Button>);
|
||||
fireEvent.click(screen.getByRole('button'));
|
||||
expect(handleClick).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ==================== FullWidth 测试 ====================
|
||||
describe('FullWidth 属性', () => {
|
||||
it('fullWidth 应用 w-full 样式', () => {
|
||||
render(<Button fullWidth>全宽按钮</Button>);
|
||||
expect(screen.getByRole('button')).toHaveClass('w-full');
|
||||
});
|
||||
|
||||
it('非 fullWidth 不应用 w-full 样式', () => {
|
||||
render(<Button>普通按钮</Button>);
|
||||
expect(screen.getByRole('button')).not.toHaveClass('w-full');
|
||||
});
|
||||
});
|
||||
|
||||
// ==================== 事件处理测试 ====================
|
||||
describe('事件处理', () => {
|
||||
it('点击触发 onClick 事件', () => {
|
||||
const handleClick = vi.fn();
|
||||
render(<Button onClick={handleClick}>点击</Button>);
|
||||
fireEvent.click(screen.getByRole('button'));
|
||||
expect(handleClick).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('多次点击触发多次事件', () => {
|
||||
const handleClick = vi.fn();
|
||||
render(<Button onClick={handleClick}>点击</Button>);
|
||||
fireEvent.click(screen.getByRole('button'));
|
||||
fireEvent.click(screen.getByRole('button'));
|
||||
fireEvent.click(screen.getByRole('button'));
|
||||
expect(handleClick).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
});
|
||||
|
||||
// ==================== 自定义属性测试 ====================
|
||||
describe('自定义属性', () => {
|
||||
it('支持自定义 className', () => {
|
||||
render(<Button className="custom-class">自定义</Button>);
|
||||
expect(screen.getByRole('button')).toHaveClass('custom-class');
|
||||
});
|
||||
|
||||
it('支持原生 button 属性', () => {
|
||||
render(<Button type="submit" data-testid="submit-btn">提交</Button>);
|
||||
const button = screen.getByTestId('submit-btn');
|
||||
expect(button).toHaveAttribute('type', 'submit');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,188 @@
|
||||
/**
|
||||
* Card 组件测试
|
||||
* 测试覆盖: Card, CardHeader, CardTitle, CardContent, CardFooter
|
||||
*/
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { Card, CardHeader, CardTitle, CardContent, CardFooter } from './Card';
|
||||
|
||||
describe('Card', () => {
|
||||
// ==================== 基础渲染测试 ====================
|
||||
describe('基础渲染', () => {
|
||||
it('渲染子元素', () => {
|
||||
render(<Card>卡片内容</Card>);
|
||||
expect(screen.getByText('卡片内容')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('默认使用 default variant 和 mobile padding', () => {
|
||||
render(<Card data-testid="card">内容</Card>);
|
||||
const card = screen.getByTestId('card');
|
||||
expect(card).toHaveClass('bg-bg-card', 'rounded-card');
|
||||
});
|
||||
});
|
||||
|
||||
// ==================== Variant 测试 ====================
|
||||
describe('Variant 样式', () => {
|
||||
it('default variant 应用基础样式', () => {
|
||||
render(<Card variant="default">Default</Card>);
|
||||
const card = screen.getByText('Default').closest('div');
|
||||
expect(card).toHaveClass('bg-bg-card');
|
||||
expect(card).not.toHaveClass('shadow-elevated');
|
||||
});
|
||||
|
||||
it('elevated variant 应用阴影样式', () => {
|
||||
render(<Card variant="elevated">Elevated</Card>);
|
||||
const card = screen.getByText('Elevated').closest('div');
|
||||
expect(card).toHaveClass('bg-bg-elevated', 'shadow-elevated');
|
||||
});
|
||||
});
|
||||
|
||||
// ==================== Padding 测试 ====================
|
||||
describe('Padding 样式', () => {
|
||||
it('mobile padding 应用正确样式', () => {
|
||||
render(<Card padding="mobile">Mobile</Card>);
|
||||
const card = screen.getByText('Mobile').closest('div');
|
||||
expect(card).toHaveClass('p-[14px_16px]');
|
||||
});
|
||||
|
||||
it('desktop padding 应用正确样式', () => {
|
||||
render(<Card padding="desktop">Desktop</Card>);
|
||||
const card = screen.getByText('Desktop').closest('div');
|
||||
expect(card).toHaveClass('p-[16px_20px]');
|
||||
});
|
||||
|
||||
it('none padding 应用正确样式', () => {
|
||||
render(<Card padding="none">No Padding</Card>);
|
||||
const card = screen.getByText('No Padding').closest('div');
|
||||
expect(card).toHaveClass('p-0');
|
||||
});
|
||||
});
|
||||
|
||||
// ==================== Hoverable 测试 ====================
|
||||
describe('Hoverable 属性', () => {
|
||||
it('hoverable 应用 hover 样式', () => {
|
||||
render(<Card hoverable>Hoverable</Card>);
|
||||
const card = screen.getByText('Hoverable').closest('div');
|
||||
expect(card).toHaveClass('cursor-pointer', 'transition-all');
|
||||
});
|
||||
|
||||
it('非 hoverable 不应用 hover 样式', () => {
|
||||
render(<Card>Not Hoverable</Card>);
|
||||
const card = screen.getByText('Not Hoverable').closest('div');
|
||||
expect(card).not.toHaveClass('hover:bg-bg-elevated');
|
||||
});
|
||||
});
|
||||
|
||||
// ==================== onClick 测试 ====================
|
||||
describe('onClick 事件', () => {
|
||||
it('点击触发 onClick', () => {
|
||||
const handleClick = vi.fn();
|
||||
render(<Card onClick={handleClick}>Clickable</Card>);
|
||||
fireEvent.click(screen.getByText('Clickable'));
|
||||
expect(handleClick).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('有 onClick 时显示 pointer 样式', () => {
|
||||
const handleClick = vi.fn();
|
||||
render(<Card onClick={handleClick}>Clickable</Card>);
|
||||
const card = screen.getByText('Clickable').closest('div');
|
||||
expect(card).toHaveClass('cursor-pointer');
|
||||
});
|
||||
});
|
||||
|
||||
// ==================== 自定义 className 测试 ====================
|
||||
describe('自定义 className', () => {
|
||||
it('支持自定义 className', () => {
|
||||
render(<Card className="custom-card">Custom</Card>);
|
||||
const card = screen.getByText('Custom').closest('div');
|
||||
expect(card).toHaveClass('custom-card');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('CardHeader', () => {
|
||||
it('渲染子元素', () => {
|
||||
render(<CardHeader>头部内容</CardHeader>);
|
||||
expect(screen.getByText('头部内容')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('应用 flex 布局', () => {
|
||||
render(<CardHeader>Header</CardHeader>);
|
||||
const header = screen.getByText('Header').closest('div');
|
||||
expect(header).toHaveClass('flex', 'items-center', 'justify-between');
|
||||
});
|
||||
|
||||
it('支持自定义 className', () => {
|
||||
render(<CardHeader className="custom-header">Header</CardHeader>);
|
||||
const header = screen.getByText('Header').closest('div');
|
||||
expect(header).toHaveClass('custom-header');
|
||||
});
|
||||
});
|
||||
|
||||
describe('CardTitle', () => {
|
||||
it('渲染为 h3 标签', () => {
|
||||
render(<CardTitle>标题</CardTitle>);
|
||||
expect(screen.getByRole('heading', { level: 3 })).toHaveTextContent('标题');
|
||||
});
|
||||
|
||||
it('应用标题样式', () => {
|
||||
render(<CardTitle>Title</CardTitle>);
|
||||
const title = screen.getByRole('heading');
|
||||
expect(title).toHaveClass('text-section-title', 'text-text-primary', 'font-semibold');
|
||||
});
|
||||
|
||||
it('支持自定义 className', () => {
|
||||
render(<CardTitle className="custom-title">Title</CardTitle>);
|
||||
expect(screen.getByRole('heading')).toHaveClass('custom-title');
|
||||
});
|
||||
});
|
||||
|
||||
describe('CardContent', () => {
|
||||
it('渲染子元素', () => {
|
||||
render(<CardContent>内容区域</CardContent>);
|
||||
expect(screen.getByText('内容区域')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('支持自定义 className', () => {
|
||||
render(<CardContent className="custom-content">Content</CardContent>);
|
||||
const content = screen.getByText('Content').closest('div');
|
||||
expect(content).toHaveClass('custom-content');
|
||||
});
|
||||
});
|
||||
|
||||
describe('CardFooter', () => {
|
||||
it('渲染子元素', () => {
|
||||
render(<CardFooter>页脚内容</CardFooter>);
|
||||
expect(screen.getByText('页脚内容')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('应用边框和间距样式', () => {
|
||||
render(<CardFooter>Footer</CardFooter>);
|
||||
const footer = screen.getByText('Footer').closest('div');
|
||||
expect(footer).toHaveClass('mt-4', 'pt-4', 'border-t', 'border-border-subtle');
|
||||
});
|
||||
|
||||
it('支持自定义 className', () => {
|
||||
render(<CardFooter className="custom-footer">Footer</CardFooter>);
|
||||
const footer = screen.getByText('Footer').closest('div');
|
||||
expect(footer).toHaveClass('custom-footer');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Card 组合使用', () => {
|
||||
it('完整卡片结构渲染正确', () => {
|
||||
render(
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>卡片标题</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>卡片内容</CardContent>
|
||||
<CardFooter>卡片页脚</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
|
||||
expect(screen.getByRole('heading', { name: '卡片标题' })).toBeInTheDocument();
|
||||
expect(screen.getByText('卡片内容')).toBeInTheDocument();
|
||||
expect(screen.getByText('卡片页脚')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -4,12 +4,10 @@
|
||||
*/
|
||||
import React from 'react';
|
||||
|
||||
export interface CardProps {
|
||||
export interface CardProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
variant?: 'default' | 'elevated';
|
||||
padding?: 'mobile' | 'desktop' | 'none';
|
||||
onClick?: () => void;
|
||||
hoverable?: boolean;
|
||||
}
|
||||
|
||||
@@ -24,8 +22,9 @@ export const Card: React.FC<CardProps> = ({
|
||||
className = '',
|
||||
variant = 'default',
|
||||
padding = 'mobile',
|
||||
onClick,
|
||||
hoverable = false,
|
||||
onClick,
|
||||
...props
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
@@ -38,6 +37,7 @@ export const Card: React.FC<CardProps> = ({
|
||||
${className}
|
||||
`}
|
||||
onClick={onClick}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
/**
|
||||
* Input 组件测试
|
||||
* 测试覆盖: Input, SearchInput, PasswordInput
|
||||
*/
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { useRef } from 'react';
|
||||
import { Mail, Lock } from 'lucide-react';
|
||||
import { Input, SearchInput, PasswordInput } from './Input';
|
||||
|
||||
describe('Input', () => {
|
||||
// ==================== 基础渲染测试 ====================
|
||||
describe('基础渲染', () => {
|
||||
it('渲染输入框', () => {
|
||||
render(<Input placeholder="请输入" />);
|
||||
expect(screen.getByPlaceholderText('请输入')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('默认全宽', () => {
|
||||
render(<Input data-testid="input" />);
|
||||
const wrapper = screen.getByTestId('input').closest('div')?.parentElement;
|
||||
expect(wrapper).toHaveClass('w-full');
|
||||
});
|
||||
});
|
||||
|
||||
// ==================== Label 测试 ====================
|
||||
describe('Label', () => {
|
||||
it('渲染 label', () => {
|
||||
render(<Input label="邮箱" />);
|
||||
expect(screen.getByText('邮箱')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('label 使用正确样式', () => {
|
||||
render(<Input label="用户名" />);
|
||||
const label = screen.getByText('用户名');
|
||||
expect(label).toHaveClass('text-caption', 'text-text-secondary');
|
||||
});
|
||||
});
|
||||
|
||||
// ==================== Error 测试 ====================
|
||||
describe('Error 状态', () => {
|
||||
it('显示错误信息', () => {
|
||||
render(<Input error="邮箱格式不正确" />);
|
||||
expect(screen.getByText('邮箱格式不正确')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('错误信息使用红色', () => {
|
||||
render(<Input error="错误" />);
|
||||
const errorText = screen.getByText('错误');
|
||||
expect(errorText).toHaveClass('text-accent-coral');
|
||||
});
|
||||
|
||||
it('错误状态输入框边框变红', () => {
|
||||
render(<Input error="错误" data-testid="input" />);
|
||||
const input = screen.getByTestId('input');
|
||||
expect(input).toHaveClass('border-accent-coral');
|
||||
});
|
||||
});
|
||||
|
||||
// ==================== Hint 测试 ====================
|
||||
describe('Hint 提示', () => {
|
||||
it('显示提示信息', () => {
|
||||
render(<Input hint="请输入有效邮箱" />);
|
||||
expect(screen.getByText('请输入有效邮箱')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('有 error 时不显示 hint', () => {
|
||||
render(<Input hint="提示" error="错误" />);
|
||||
expect(screen.queryByText('提示')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('错误')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ==================== Icon 测试 ====================
|
||||
describe('Icon 渲染', () => {
|
||||
it('渲染左侧图标', () => {
|
||||
render(<Input leftIcon={Mail} data-testid="input" />);
|
||||
const wrapper = screen.getByTestId('input').closest('div');
|
||||
expect(wrapper?.querySelector('svg')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('左侧图标增加左内边距', () => {
|
||||
render(<Input leftIcon={Mail} data-testid="input" />);
|
||||
expect(screen.getByTestId('input')).toHaveClass('pl-10');
|
||||
});
|
||||
|
||||
it('渲染右侧图标', () => {
|
||||
render(<Input rightIcon={Lock} data-testid="input" />);
|
||||
const wrapper = screen.getByTestId('input').closest('div');
|
||||
expect(wrapper?.querySelector('button')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('右侧图标增加右内边距', () => {
|
||||
render(<Input rightIcon={Lock} data-testid="input" />);
|
||||
expect(screen.getByTestId('input')).toHaveClass('pr-10');
|
||||
});
|
||||
|
||||
it('点击右侧图标触发回调', () => {
|
||||
const handleClick = vi.fn();
|
||||
render(<Input rightIcon={Lock} onRightIconClick={handleClick} />);
|
||||
fireEvent.click(screen.getByRole('button'));
|
||||
expect(handleClick).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ==================== Disabled 测试 ====================
|
||||
describe('Disabled 状态', () => {
|
||||
it('disabled 禁用输入框', () => {
|
||||
render(<Input disabled data-testid="input" />);
|
||||
const input = screen.getByTestId('input');
|
||||
// 验证 disabled 属性
|
||||
expect(input).toBeDisabled();
|
||||
// 验证 disabled 时不可编辑
|
||||
expect(input).toHaveAttribute('disabled');
|
||||
});
|
||||
|
||||
it('非 disabled 输入框可编辑', () => {
|
||||
render(<Input data-testid="input" />);
|
||||
const input = screen.getByTestId('input');
|
||||
expect(input).not.toBeDisabled();
|
||||
expect(input).not.toHaveAttribute('disabled');
|
||||
});
|
||||
});
|
||||
|
||||
// ==================== FullWidth 测试 ====================
|
||||
describe('FullWidth 属性', () => {
|
||||
it('fullWidth=false 不应用全宽', () => {
|
||||
render(<Input fullWidth={false} data-testid="input" />);
|
||||
const wrapper = screen.getByTestId('input').closest('div')?.parentElement;
|
||||
expect(wrapper).not.toHaveClass('w-full');
|
||||
});
|
||||
});
|
||||
|
||||
// ==================== ForwardRef 测试 ====================
|
||||
describe('ForwardRef', () => {
|
||||
it('正确转发 ref', () => {
|
||||
const TestComponent = () => {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
return (
|
||||
<>
|
||||
<Input ref={inputRef} data-testid="input" />
|
||||
<button onClick={() => inputRef.current?.focus()}>Focus</button>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
render(<TestComponent />);
|
||||
fireEvent.click(screen.getByText('Focus'));
|
||||
expect(screen.getByTestId('input')).toHaveFocus();
|
||||
});
|
||||
});
|
||||
|
||||
// ==================== 事件处理测试 ====================
|
||||
describe('事件处理', () => {
|
||||
it('onChange 事件正常触发', () => {
|
||||
const handleChange = vi.fn();
|
||||
render(<Input onChange={handleChange} data-testid="input" />);
|
||||
fireEvent.change(screen.getByTestId('input'), { target: { value: 'test' } });
|
||||
expect(handleChange).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('输入值正确更新', () => {
|
||||
render(<Input data-testid="input" />);
|
||||
const input = screen.getByTestId('input');
|
||||
fireEvent.change(input, { target: { value: 'hello' } });
|
||||
expect(input).toHaveValue('hello');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('SearchInput', () => {
|
||||
it('渲染搜索图标', () => {
|
||||
render(<SearchInput data-testid="search" />);
|
||||
const wrapper = screen.getByTestId('search').closest('div');
|
||||
expect(wrapper?.querySelector('svg')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('默认 placeholder 为搜索...', () => {
|
||||
render(<SearchInput />);
|
||||
expect(screen.getByPlaceholderText('搜索...')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('支持自定义 placeholder', () => {
|
||||
render(<SearchInput placeholder="搜索用户" />);
|
||||
expect(screen.getByPlaceholderText('搜索用户')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('正确转发 ref', () => {
|
||||
const TestComponent = () => {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
return (
|
||||
<>
|
||||
<SearchInput ref={inputRef} data-testid="search" />
|
||||
<button onClick={() => inputRef.current?.focus()}>Focus</button>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
render(<TestComponent />);
|
||||
fireEvent.click(screen.getByText('Focus'));
|
||||
expect(screen.getByTestId('search')).toHaveFocus();
|
||||
});
|
||||
});
|
||||
|
||||
describe('PasswordInput', () => {
|
||||
it('默认隐藏密码', () => {
|
||||
render(<PasswordInput data-testid="password" />);
|
||||
expect(screen.getByTestId('password')).toHaveAttribute('type', 'password');
|
||||
});
|
||||
|
||||
it('点击图标切换密码可见性', () => {
|
||||
render(<PasswordInput data-testid="password" />);
|
||||
const input = screen.getByTestId('password');
|
||||
const toggleButton = screen.getByRole('button');
|
||||
|
||||
expect(input).toHaveAttribute('type', 'password');
|
||||
|
||||
fireEvent.click(toggleButton);
|
||||
expect(input).toHaveAttribute('type', 'text');
|
||||
|
||||
fireEvent.click(toggleButton);
|
||||
expect(input).toHaveAttribute('type', 'password');
|
||||
});
|
||||
|
||||
it('正确转发 ref', () => {
|
||||
const TestComponent = () => {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
return (
|
||||
<>
|
||||
<PasswordInput ref={inputRef} data-testid="password" />
|
||||
<button onClick={() => inputRef.current?.focus()}>Focus</button>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
render(<TestComponent />);
|
||||
fireEvent.click(screen.getByText('Focus'));
|
||||
expect(screen.getByTestId('password')).toHaveFocus();
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,8 @@
|
||||
* Input 输入框组件
|
||||
* 设计稿参考: UIDesignSpec.md
|
||||
*/
|
||||
import React, { forwardRef } from 'react';
|
||||
'use client';
|
||||
import React, { forwardRef, useId } from 'react';
|
||||
import { LucideIcon, Search, Eye, EyeOff } from 'lucide-react';
|
||||
|
||||
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||
@@ -25,12 +26,15 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(({
|
||||
fullWidth = true,
|
||||
className = '',
|
||||
disabled,
|
||||
id,
|
||||
...props
|
||||
}, ref) => {
|
||||
const inputId = id ?? useId();
|
||||
|
||||
return (
|
||||
<div className={`${fullWidth ? 'w-full' : ''}`}>
|
||||
{label && (
|
||||
<label className="block mb-1.5 text-caption text-text-secondary">
|
||||
<label htmlFor={inputId} className="block mb-1.5 text-caption text-text-secondary">
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
@@ -43,6 +47,7 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(({
|
||||
)}
|
||||
<input
|
||||
ref={ref}
|
||||
id={inputId}
|
||||
className={`
|
||||
w-full bg-bg-elevated text-text-primary
|
||||
border border-border-subtle rounded-btn
|
||||
|
||||
@@ -0,0 +1,416 @@
|
||||
/**
|
||||
* Modal 组件测试
|
||||
* 测试覆盖: Modal, ConfirmModal, 副作用(ESC、overflow)
|
||||
*/
|
||||
import { render, screen, fireEvent, act } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { Modal, ConfirmModal } from './Modal';
|
||||
|
||||
describe('Modal', () => {
|
||||
const mockOnClose = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
mockOnClose.mockClear();
|
||||
document.body.style.overflow = '';
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// 确保副作用被清理
|
||||
document.body.style.overflow = '';
|
||||
});
|
||||
|
||||
// ==================== 基础渲染测试 ====================
|
||||
describe('基础渲染', () => {
|
||||
it('isOpen=true 时渲染内容', () => {
|
||||
render(
|
||||
<Modal isOpen={true} onClose={mockOnClose}>
|
||||
<p>模态框内容</p>
|
||||
</Modal>
|
||||
);
|
||||
expect(screen.getByText('模态框内容')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('isOpen=false 时不渲染', () => {
|
||||
render(
|
||||
<Modal isOpen={false} onClose={mockOnClose}>
|
||||
<p>模态框内容</p>
|
||||
</Modal>
|
||||
);
|
||||
expect(screen.queryByText('模态框内容')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('渲染标题', () => {
|
||||
render(
|
||||
<Modal isOpen={true} onClose={mockOnClose} title="弹窗标题">
|
||||
内容
|
||||
</Modal>
|
||||
);
|
||||
expect(screen.getByText('弹窗标题')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('渲染页脚', () => {
|
||||
render(
|
||||
<Modal isOpen={true} onClose={mockOnClose} footer={<button>确定</button>}>
|
||||
内容
|
||||
</Modal>
|
||||
);
|
||||
expect(screen.getByText('确定')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ==================== 关闭按钮测试 ====================
|
||||
describe('关闭按钮', () => {
|
||||
it('默认显示关闭按钮', () => {
|
||||
render(
|
||||
<Modal isOpen={true} onClose={mockOnClose} title="标题">
|
||||
内容
|
||||
</Modal>
|
||||
);
|
||||
// 使用 aria-label 精确选择关闭按钮
|
||||
expect(screen.getByLabelText('关闭')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('点击关闭按钮触发 onClose', () => {
|
||||
render(
|
||||
<Modal isOpen={true} onClose={mockOnClose} title="标题">
|
||||
内容
|
||||
</Modal>
|
||||
);
|
||||
// 使用 aria-label 精确选择关闭按钮
|
||||
const closeButton = screen.getByLabelText('关闭');
|
||||
fireEvent.click(closeButton);
|
||||
expect(mockOnClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('showCloseButton=false 隐藏关闭按钮', () => {
|
||||
render(
|
||||
<Modal isOpen={true} onClose={mockOnClose} title="标题" showCloseButton={false}>
|
||||
内容
|
||||
</Modal>
|
||||
);
|
||||
// 关闭按钮不存在
|
||||
expect(screen.queryByLabelText('关闭')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ==================== 遮罩点击测试 ====================
|
||||
describe('遮罩点击', () => {
|
||||
it('点击遮罩默认关闭', () => {
|
||||
render(
|
||||
<Modal isOpen={true} onClose={mockOnClose}>
|
||||
内容
|
||||
</Modal>
|
||||
);
|
||||
const overlay = document.querySelector('.bg-black\\/60');
|
||||
fireEvent.click(overlay!);
|
||||
expect(mockOnClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('closeOnOverlay=false 禁用遮罩点击关闭', () => {
|
||||
render(
|
||||
<Modal isOpen={true} onClose={mockOnClose} closeOnOverlay={false}>
|
||||
内容
|
||||
</Modal>
|
||||
);
|
||||
const overlay = document.querySelector('.bg-black\\/60');
|
||||
fireEvent.click(overlay!);
|
||||
expect(mockOnClose).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ==================== ESC 键测试 ====================
|
||||
describe('ESC 键关闭', () => {
|
||||
it('按 ESC 键默认关闭', async () => {
|
||||
render(
|
||||
<Modal isOpen={true} onClose={mockOnClose}>
|
||||
内容
|
||||
</Modal>
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.keyDown(document, { key: 'Escape' });
|
||||
});
|
||||
|
||||
expect(mockOnClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('closeOnEsc=false 禁用 ESC 关闭', async () => {
|
||||
render(
|
||||
<Modal isOpen={true} onClose={mockOnClose} closeOnEsc={false}>
|
||||
内容
|
||||
</Modal>
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.keyDown(document, { key: 'Escape' });
|
||||
});
|
||||
|
||||
expect(mockOnClose).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('其他按键不触发关闭', async () => {
|
||||
render(
|
||||
<Modal isOpen={true} onClose={mockOnClose}>
|
||||
内容
|
||||
</Modal>
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.keyDown(document, { key: 'Enter' });
|
||||
});
|
||||
|
||||
expect(mockOnClose).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ==================== Body Overflow 副作用测试 ====================
|
||||
describe('Body overflow 副作用', () => {
|
||||
it('打开时锁定 body 滚动', () => {
|
||||
render(
|
||||
<Modal isOpen={true} onClose={mockOnClose}>
|
||||
内容
|
||||
</Modal>
|
||||
);
|
||||
expect(document.body.style.overflow).toBe('hidden');
|
||||
});
|
||||
|
||||
it('关闭时解锁 body 滚动', () => {
|
||||
const { rerender } = render(
|
||||
<Modal isOpen={true} onClose={mockOnClose}>
|
||||
内容
|
||||
</Modal>
|
||||
);
|
||||
expect(document.body.style.overflow).toBe('hidden');
|
||||
|
||||
rerender(
|
||||
<Modal isOpen={false} onClose={mockOnClose}>
|
||||
内容
|
||||
</Modal>
|
||||
);
|
||||
expect(document.body.style.overflow).toBe('');
|
||||
});
|
||||
|
||||
it('卸载时清理 overflow', () => {
|
||||
const { unmount } = render(
|
||||
<Modal isOpen={true} onClose={mockOnClose}>
|
||||
内容
|
||||
</Modal>
|
||||
);
|
||||
expect(document.body.style.overflow).toBe('hidden');
|
||||
|
||||
unmount();
|
||||
expect(document.body.style.overflow).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
// ==================== Size 测试 ====================
|
||||
describe('Size 样式', () => {
|
||||
it('默认 md size', () => {
|
||||
const { container } = render(
|
||||
<Modal isOpen={true} onClose={mockOnClose}>
|
||||
内容
|
||||
</Modal>
|
||||
);
|
||||
expect(container.querySelector('.max-w-md')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('sm size', () => {
|
||||
const { container } = render(
|
||||
<Modal isOpen={true} onClose={mockOnClose} size="sm">
|
||||
内容
|
||||
</Modal>
|
||||
);
|
||||
expect(container.querySelector('.max-w-sm')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('lg size', () => {
|
||||
const { container } = render(
|
||||
<Modal isOpen={true} onClose={mockOnClose} size="lg">
|
||||
内容
|
||||
</Modal>
|
||||
);
|
||||
expect(container.querySelector('.max-w-lg')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('xl size', () => {
|
||||
const { container } = render(
|
||||
<Modal isOpen={true} onClose={mockOnClose} size="xl">
|
||||
内容
|
||||
</Modal>
|
||||
);
|
||||
expect(container.querySelector('.max-w-xl')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ==================== ClassName 测试 ====================
|
||||
describe('ClassName', () => {
|
||||
it('支持自定义 className', () => {
|
||||
const { container } = render(
|
||||
<Modal isOpen={true} onClose={mockOnClose} className="custom-modal">
|
||||
内容
|
||||
</Modal>
|
||||
);
|
||||
expect(container.querySelector('.custom-modal')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('ConfirmModal', () => {
|
||||
const mockOnClose = vi.fn();
|
||||
const mockOnConfirm = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
mockOnClose.mockClear();
|
||||
mockOnConfirm.mockClear();
|
||||
});
|
||||
|
||||
// ==================== 基础渲染测试 ====================
|
||||
describe('基础渲染', () => {
|
||||
it('渲染标题和消息', () => {
|
||||
render(
|
||||
<ConfirmModal
|
||||
isOpen={true}
|
||||
onClose={mockOnClose}
|
||||
onConfirm={mockOnConfirm}
|
||||
title="确认删除"
|
||||
message="确定要删除吗?"
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText('确认删除')).toBeInTheDocument();
|
||||
expect(screen.getByText('确定要删除吗?')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('渲染确认和取消按钮', () => {
|
||||
render(
|
||||
<ConfirmModal
|
||||
isOpen={true}
|
||||
onClose={mockOnClose}
|
||||
onConfirm={mockOnConfirm}
|
||||
title="操作确认"
|
||||
message="消息"
|
||||
confirmText="确定"
|
||||
/>
|
||||
);
|
||||
// Modal 有关闭按钮(X),ConfirmModal 有确认和取消按钮,共 3 个
|
||||
const buttons = screen.getAllByRole('button');
|
||||
expect(buttons.length).toBeGreaterThanOrEqual(2);
|
||||
expect(screen.getByText('确定')).toBeInTheDocument();
|
||||
expect(screen.getByText('取消')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ==================== 按钮文本自定义测试 ====================
|
||||
describe('按钮文本自定义', () => {
|
||||
it('支持自定义确认按钮文本', () => {
|
||||
render(
|
||||
<ConfirmModal
|
||||
isOpen={true}
|
||||
onClose={mockOnClose}
|
||||
onConfirm={mockOnConfirm}
|
||||
title="确认"
|
||||
message="消息"
|
||||
confirmText="删除"
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText('删除')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('支持自定义取消按钮文本', () => {
|
||||
render(
|
||||
<ConfirmModal
|
||||
isOpen={true}
|
||||
onClose={mockOnClose}
|
||||
onConfirm={mockOnConfirm}
|
||||
title="确认"
|
||||
message="消息"
|
||||
cancelText="返回"
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText('返回')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ==================== 事件处理测试 ====================
|
||||
describe('事件处理', () => {
|
||||
it('点击确认按钮触发 onConfirm', () => {
|
||||
render(
|
||||
<ConfirmModal
|
||||
isOpen={true}
|
||||
onClose={mockOnClose}
|
||||
onConfirm={mockOnConfirm}
|
||||
title="操作确认"
|
||||
message="消息"
|
||||
confirmText="确定"
|
||||
/>
|
||||
);
|
||||
fireEvent.click(screen.getByText('确定'));
|
||||
expect(mockOnConfirm).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('点击取消按钮触发 onClose', () => {
|
||||
render(
|
||||
<ConfirmModal
|
||||
isOpen={true}
|
||||
onClose={mockOnClose}
|
||||
onConfirm={mockOnConfirm}
|
||||
title="操作确认"
|
||||
message="消息"
|
||||
/>
|
||||
);
|
||||
fireEvent.click(screen.getByText('取消'));
|
||||
expect(mockOnClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ==================== Variant 测试 ====================
|
||||
describe('Variant 样式', () => {
|
||||
it('danger variant 使用红色确认按钮', () => {
|
||||
render(
|
||||
<ConfirmModal
|
||||
isOpen={true}
|
||||
onClose={mockOnClose}
|
||||
onConfirm={mockOnConfirm}
|
||||
title="确认删除"
|
||||
message="消息"
|
||||
variant="danger"
|
||||
/>
|
||||
);
|
||||
const confirmButton = screen.getByText('确认').closest('button');
|
||||
expect(confirmButton).toHaveClass('bg-accent-coral');
|
||||
});
|
||||
});
|
||||
|
||||
// ==================== Loading 测试 ====================
|
||||
describe('Loading 状态', () => {
|
||||
it('loading 时确认按钮显示加载状态', () => {
|
||||
render(
|
||||
<ConfirmModal
|
||||
isOpen={true}
|
||||
onClose={mockOnClose}
|
||||
onConfirm={mockOnConfirm}
|
||||
title="操作确认"
|
||||
message="消息"
|
||||
confirmText="确定"
|
||||
loading={true}
|
||||
/>
|
||||
);
|
||||
const confirmButton = screen.getByText('确定').closest('button');
|
||||
expect(confirmButton).toBeDisabled();
|
||||
});
|
||||
|
||||
it('loading 时取消按钮也被禁用', () => {
|
||||
render(
|
||||
<ConfirmModal
|
||||
isOpen={true}
|
||||
onClose={mockOnClose}
|
||||
onConfirm={mockOnConfirm}
|
||||
title="操作确认"
|
||||
message="消息"
|
||||
loading={true}
|
||||
/>
|
||||
);
|
||||
const cancelButton = screen.getByText('取消').closest('button');
|
||||
expect(cancelButton).toBeDisabled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,8 @@
|
||||
* Modal 弹窗组件
|
||||
* 设计稿参考: UIDesignSpec.md
|
||||
*/
|
||||
import React, { useEffect, useCallback } from 'react';
|
||||
'use client';
|
||||
import React, { useEffect, useCallback, useRef } from 'react';
|
||||
import { X } from 'lucide-react';
|
||||
import { Button } from './Button';
|
||||
|
||||
@@ -39,6 +40,8 @@ export const Modal: React.FC<ModalProps> = ({
|
||||
showCloseButton = true,
|
||||
className = '',
|
||||
}) => {
|
||||
const previousOverflowRef = useRef<string>('');
|
||||
|
||||
// Handle ESC key
|
||||
const handleKeyDown = useCallback((e: KeyboardEvent) => {
|
||||
if (closeOnEsc && e.key === 'Escape') {
|
||||
@@ -47,13 +50,15 @@ export const Modal: React.FC<ModalProps> = ({
|
||||
}, [closeOnEsc, onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
document.body.style.overflow = 'hidden';
|
||||
}
|
||||
if (!isOpen) return;
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
previousOverflowRef.current = document.body.style.overflow;
|
||||
document.body.style.overflow = 'hidden';
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
document.body.style.overflow = '';
|
||||
document.body.style.overflow = previousOverflowRef.current || '';
|
||||
};
|
||||
}, [isOpen, handleKeyDown]);
|
||||
|
||||
@@ -89,6 +94,8 @@ export const Modal: React.FC<ModalProps> = ({
|
||||
{showCloseButton && (
|
||||
<button
|
||||
onClick={onClose}
|
||||
type="button"
|
||||
aria-label="关闭"
|
||||
className="p-1 text-text-tertiary hover:text-text-primary transition-colors"
|
||||
>
|
||||
<X size={20} />
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
/**
|
||||
* ProgressBar 组件测试
|
||||
* 测试覆盖: ProgressBar, CircularProgress
|
||||
*/
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { ProgressBar, CircularProgress } from './ProgressBar';
|
||||
|
||||
describe('ProgressBar', () => {
|
||||
// ==================== 基础渲染测试 ====================
|
||||
describe('基础渲染', () => {
|
||||
it('渲染进度条', () => {
|
||||
const { container } = render(<ProgressBar value={50} />);
|
||||
expect(container.querySelector('.bg-bg-elevated')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('正确计算进度百分比', () => {
|
||||
const { container } = render(<ProgressBar value={75} />);
|
||||
const progressFill = container.querySelector('.bg-accent-indigo');
|
||||
expect(progressFill).toHaveStyle({ width: '75%' });
|
||||
});
|
||||
});
|
||||
|
||||
// ==================== Value 边界测试 ====================
|
||||
describe('Value 边界值', () => {
|
||||
it('0% 进度', () => {
|
||||
const { container } = render(<ProgressBar value={0} />);
|
||||
const progressFill = container.querySelector('.bg-accent-indigo');
|
||||
expect(progressFill).toHaveStyle({ width: '0%' });
|
||||
});
|
||||
|
||||
it('100% 进度', () => {
|
||||
const { container } = render(<ProgressBar value={100} />);
|
||||
const progressFill = container.querySelector('.bg-accent-indigo');
|
||||
expect(progressFill).toHaveStyle({ width: '100%' });
|
||||
});
|
||||
|
||||
it('超过 100% 限制为 100%', () => {
|
||||
const { container } = render(<ProgressBar value={150} />);
|
||||
const progressFill = container.querySelector('.bg-accent-indigo');
|
||||
expect(progressFill).toHaveStyle({ width: '100%' });
|
||||
});
|
||||
|
||||
it('负值限制为 0%', () => {
|
||||
const { container } = render(<ProgressBar value={-10} />);
|
||||
const progressFill = container.querySelector('.bg-accent-indigo');
|
||||
expect(progressFill).toHaveStyle({ width: '0%' });
|
||||
});
|
||||
});
|
||||
|
||||
// ==================== Max 测试 ====================
|
||||
describe('Max 属性', () => {
|
||||
it('自定义 max 值计算正确', () => {
|
||||
const { container } = render(<ProgressBar value={25} max={50} />);
|
||||
const progressFill = container.querySelector('.bg-accent-indigo');
|
||||
expect(progressFill).toHaveStyle({ width: '50%' }); // 25/50 = 50%
|
||||
});
|
||||
|
||||
it('默认 max 为 100', () => {
|
||||
const { container } = render(<ProgressBar value={30} />);
|
||||
const progressFill = container.querySelector('.bg-accent-indigo');
|
||||
expect(progressFill).toHaveStyle({ width: '30%' });
|
||||
});
|
||||
});
|
||||
|
||||
// ==================== ShowLabel 测试 ====================
|
||||
describe('ShowLabel 属性', () => {
|
||||
it('showLabel=true 显示标签', () => {
|
||||
render(<ProgressBar value={50} showLabel />);
|
||||
expect(screen.getByText('进度')).toBeInTheDocument();
|
||||
expect(screen.getByText('50%')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('showLabel=false 不显示标签(默认)', () => {
|
||||
render(<ProgressBar value={50} />);
|
||||
expect(screen.queryByText('进度')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('标签显示四舍五入的百分比', () => {
|
||||
render(<ProgressBar value={33.7} showLabel />);
|
||||
expect(screen.getByText('34%')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ==================== Size 测试 ====================
|
||||
describe('Size 样式', () => {
|
||||
it('sm size', () => {
|
||||
const { container } = render(<ProgressBar value={50} size="sm" />);
|
||||
expect(container.querySelector('.h-1')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('md size(默认)', () => {
|
||||
const { container } = render(<ProgressBar value={50} size="md" />);
|
||||
expect(container.querySelector('.h-2')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('lg size', () => {
|
||||
const { container } = render(<ProgressBar value={50} size="lg" />);
|
||||
expect(container.querySelector('.h-3')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ==================== Variant 测试 ====================
|
||||
describe('Variant 样式', () => {
|
||||
it('default variant(默认)', () => {
|
||||
const { container } = render(<ProgressBar value={50} />);
|
||||
expect(container.querySelector('.bg-accent-indigo')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('success variant', () => {
|
||||
const { container } = render(<ProgressBar value={50} variant="success" />);
|
||||
expect(container.querySelector('.bg-accent-green')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('warning variant', () => {
|
||||
const { container } = render(<ProgressBar value={50} variant="warning" />);
|
||||
expect(container.querySelector('.bg-accent-amber')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('error variant', () => {
|
||||
const { container } = render(<ProgressBar value={50} variant="error" />);
|
||||
expect(container.querySelector('.bg-accent-coral')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ==================== ClassName 测试 ====================
|
||||
describe('ClassName', () => {
|
||||
it('支持自定义 className', () => {
|
||||
const { container } = render(<ProgressBar value={50} className="custom-progress" />);
|
||||
expect(container.firstChild).toHaveClass('custom-progress');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('CircularProgress', () => {
|
||||
// ==================== 基础渲染测试 ====================
|
||||
describe('基础渲染', () => {
|
||||
it('渲染 SVG 环形进度', () => {
|
||||
const { container } = render(<CircularProgress value={50} />);
|
||||
expect(container.querySelector('svg')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('渲染两个圆(背景和进度)', () => {
|
||||
const { container } = render(<CircularProgress value={50} />);
|
||||
const circles = container.querySelectorAll('circle');
|
||||
expect(circles).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
// ==================== Value 测试 ====================
|
||||
describe('Value 边界值', () => {
|
||||
it('显示正确的百分比', () => {
|
||||
render(<CircularProgress value={75} />);
|
||||
expect(screen.getByText('75%')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('四舍五入百分比', () => {
|
||||
render(<CircularProgress value={33.6} />);
|
||||
expect(screen.getByText('34%')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('超过 100% 限制为 100%', () => {
|
||||
render(<CircularProgress value={150} />);
|
||||
expect(screen.getByText('100%')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('负值限制为 0%', () => {
|
||||
render(<CircularProgress value={-10} />);
|
||||
expect(screen.getByText('0%')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ==================== Size 测试 ====================
|
||||
describe('Size 属性', () => {
|
||||
it('默认 size 为 120', () => {
|
||||
const { container } = render(<CircularProgress value={50} />);
|
||||
const svg = container.querySelector('svg');
|
||||
expect(svg).toHaveAttribute('width', '120');
|
||||
expect(svg).toHaveAttribute('height', '120');
|
||||
});
|
||||
|
||||
it('支持自定义 size', () => {
|
||||
const { container } = render(<CircularProgress value={50} size={80} />);
|
||||
const svg = container.querySelector('svg');
|
||||
expect(svg).toHaveAttribute('width', '80');
|
||||
expect(svg).toHaveAttribute('height', '80');
|
||||
});
|
||||
});
|
||||
|
||||
// ==================== ShowLabel 测试 ====================
|
||||
describe('ShowLabel 属性', () => {
|
||||
it('showLabel=true 显示百分比(默认)', () => {
|
||||
render(<CircularProgress value={50} />);
|
||||
expect(screen.getByText('50%')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('showLabel=false 隐藏百分比', () => {
|
||||
render(<CircularProgress value={50} showLabel={false} />);
|
||||
expect(screen.queryByText('50%')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ==================== Label 测试 ====================
|
||||
describe('Label 属性', () => {
|
||||
it('显示自定义 label', () => {
|
||||
render(<CircularProgress value={50} label="审核中" />);
|
||||
expect(screen.getByText('审核中')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('showLabel=false 时不显示 label', () => {
|
||||
render(<CircularProgress value={50} label="审核中" showLabel={false} />);
|
||||
expect(screen.queryByText('审核中')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ==================== Variant 测试 ====================
|
||||
describe('Variant 样式', () => {
|
||||
it('default variant 使用正确颜色', () => {
|
||||
const { container } = render(<CircularProgress value={50} variant="default" />);
|
||||
const progressCircle = container.querySelectorAll('circle')[1];
|
||||
expect(progressCircle).toHaveAttribute('stroke', '#6366F1');
|
||||
});
|
||||
|
||||
it('success variant 使用正确颜色', () => {
|
||||
const { container } = render(<CircularProgress value={50} variant="success" />);
|
||||
const progressCircle = container.querySelectorAll('circle')[1];
|
||||
expect(progressCircle).toHaveAttribute('stroke', '#32D583');
|
||||
});
|
||||
|
||||
it('warning variant 使用正确颜色', () => {
|
||||
const { container } = render(<CircularProgress value={50} variant="warning" />);
|
||||
const progressCircle = container.querySelectorAll('circle')[1];
|
||||
expect(progressCircle).toHaveAttribute('stroke', '#F59E0B');
|
||||
});
|
||||
|
||||
it('error variant 使用正确颜色', () => {
|
||||
const { container } = render(<CircularProgress value={50} variant="error" />);
|
||||
const progressCircle = container.querySelectorAll('circle')[1];
|
||||
expect(progressCircle).toHaveAttribute('stroke', '#E85A4F');
|
||||
});
|
||||
});
|
||||
|
||||
// ==================== ClassName 测试 ====================
|
||||
describe('ClassName', () => {
|
||||
it('支持自定义 className', () => {
|
||||
const { container } = render(<CircularProgress value={50} className="custom-circular" />);
|
||||
expect(container.firstChild).toHaveClass('custom-circular');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,207 @@
|
||||
/**
|
||||
* ReviewSteps 审核进度条组件
|
||||
* 设计稿参考: pencil-new.pen 达人端任务列表
|
||||
* 显示4个步骤:已提交 → AI审核中 → 代理商审核 → 审核通过
|
||||
*/
|
||||
import React from 'react'
|
||||
import { Check, X, Loader2, Users } from 'lucide-react'
|
||||
|
||||
export type StepStatus = 'done' | 'current' | 'failed' | 'pending'
|
||||
|
||||
export interface ReviewStep {
|
||||
key: string
|
||||
label: string
|
||||
status: StepStatus
|
||||
}
|
||||
|
||||
interface ReviewStepsProps {
|
||||
steps: ReviewStep[]
|
||||
className?: string
|
||||
}
|
||||
|
||||
function StepIcon({ status, isLast }: { status: StepStatus; isLast: boolean }) {
|
||||
const baseClass = 'w-7 h-7 rounded-full flex items-center justify-center'
|
||||
|
||||
switch (status) {
|
||||
case 'done':
|
||||
return (
|
||||
<div className={`${baseClass} bg-accent-green`}>
|
||||
<Check size={14} className="text-white" />
|
||||
</div>
|
||||
)
|
||||
case 'current':
|
||||
return (
|
||||
<div className={`${baseClass} bg-accent-indigo`}>
|
||||
<Loader2 size={14} className="text-white animate-spin" />
|
||||
</div>
|
||||
)
|
||||
case 'failed':
|
||||
return (
|
||||
<div className={`${baseClass} bg-accent-coral`}>
|
||||
<X size={14} className="text-white" />
|
||||
</div>
|
||||
)
|
||||
case 'pending':
|
||||
default:
|
||||
return (
|
||||
<div className={`${baseClass} bg-bg-elevated border-[1.5px] border-border-subtle`}>
|
||||
{isLast ? (
|
||||
<Check size={14} className="text-text-tertiary" />
|
||||
) : (
|
||||
<Users size={14} className="text-text-tertiary" />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function StepLine({ active }: { active: boolean }) {
|
||||
return (
|
||||
<div
|
||||
className={`flex-1 h-0.5 mx-1 ${active ? 'bg-accent-green' : 'bg-border-subtle'}`}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export const ReviewSteps: React.FC<ReviewStepsProps> = ({ steps, className = '' }) => {
|
||||
return (
|
||||
<div className={`flex items-center w-full py-2 ${className}`}>
|
||||
{steps.map((step, index) => {
|
||||
const isLast = index === steps.length - 1
|
||||
const nextStepActive = index < steps.length - 1 &&
|
||||
(steps[index + 1].status === 'done' || steps[index + 1].status === 'current' || steps[index + 1].status === 'failed')
|
||||
|
||||
return (
|
||||
<React.Fragment key={step.key}>
|
||||
<div className="flex flex-col items-center w-[70px]">
|
||||
<StepIcon status={step.status} isLast={isLast} />
|
||||
<span
|
||||
className={`text-[11px] mt-1 font-medium ${
|
||||
step.status === 'done' ? 'text-text-secondary' :
|
||||
step.status === 'current' ? 'text-accent-indigo' :
|
||||
step.status === 'failed' ? 'text-accent-coral' :
|
||||
'text-text-tertiary'
|
||||
}`}
|
||||
>
|
||||
{step.label}
|
||||
</span>
|
||||
</div>
|
||||
{!isLast && <StepLine active={step.status === 'done' || nextStepActive} />}
|
||||
</React.Fragment>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 根据任务状态生成步骤数据 (达人端视角)
|
||||
export function getReviewSteps(taskStatus: string): ReviewStep[] {
|
||||
switch (taskStatus) {
|
||||
case 'pending_upload':
|
||||
return [
|
||||
{ key: 'submitted', label: '已提交', status: 'pending' },
|
||||
{ key: 'ai_review', label: 'AI审核', status: 'pending' },
|
||||
{ key: 'agent_review', label: '代理商审核', status: 'pending' },
|
||||
{ key: 'passed', label: '审核通过', status: 'pending' },
|
||||
]
|
||||
case 'ai_reviewing':
|
||||
return [
|
||||
{ key: 'submitted', label: '已提交', status: 'done' },
|
||||
{ key: 'ai_review', label: 'AI审核中', status: 'current' },
|
||||
{ key: 'agent_review', label: '代理商审核', status: 'pending' },
|
||||
{ key: 'passed', label: '审核通过', status: 'pending' },
|
||||
]
|
||||
case 'agent_reviewing':
|
||||
return [
|
||||
{ key: 'submitted', label: '已提交', status: 'done' },
|
||||
{ key: 'ai_review', label: 'AI审核', status: 'done' },
|
||||
{ key: 'agent_review', label: '代理商审核', status: 'current' },
|
||||
{ key: 'passed', label: '审核通过', status: 'pending' },
|
||||
]
|
||||
case 'need_revision':
|
||||
return [
|
||||
{ key: 'submitted', label: '已提交', status: 'done' },
|
||||
{ key: 'ai_review', label: '需修改', status: 'failed' },
|
||||
{ key: 'agent_review', label: '代理商审核', status: 'pending' },
|
||||
{ key: 'passed', label: '审核通过', status: 'pending' },
|
||||
]
|
||||
case 'passed':
|
||||
return [
|
||||
{ key: 'submitted', label: '已提交', status: 'done' },
|
||||
{ key: 'ai_review', label: 'AI审核', status: 'done' },
|
||||
{ key: 'agent_review', label: '代理商审核', status: 'done' },
|
||||
{ key: 'passed', label: '审核通过', status: 'done' },
|
||||
]
|
||||
case 'rejected':
|
||||
return [
|
||||
{ key: 'submitted', label: '已提交', status: 'done' },
|
||||
{ key: 'ai_review', label: 'AI审核', status: 'done' },
|
||||
{ key: 'agent_review', label: '已驳回', status: 'failed' },
|
||||
{ key: 'passed', label: '审核通过', status: 'pending' },
|
||||
]
|
||||
default:
|
||||
return [
|
||||
{ key: 'submitted', label: '已提交', status: 'pending' },
|
||||
{ key: 'ai_review', label: 'AI审核', status: 'pending' },
|
||||
{ key: 'agent_review', label: '代理商审核', status: 'pending' },
|
||||
{ key: 'passed', label: '审核通过', status: 'pending' },
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
// 代理商/品牌方视角的审核步骤 (包含品牌终审)
|
||||
export function getAgencyReviewSteps(taskStatus: string): ReviewStep[] {
|
||||
switch (taskStatus) {
|
||||
case 'ai_reviewing':
|
||||
return [
|
||||
{ key: 'submitted', label: '已提交', status: 'done' },
|
||||
{ key: 'ai_review', label: 'AI审核', status: 'current' },
|
||||
{ key: 'agent_review', label: '代理商', status: 'pending' },
|
||||
{ key: 'brand_review', label: '品牌终审', status: 'pending' },
|
||||
]
|
||||
case 'agent_reviewing':
|
||||
return [
|
||||
{ key: 'submitted', label: '已提交', status: 'done' },
|
||||
{ key: 'ai_review', label: 'AI审核', status: 'done' },
|
||||
{ key: 'agent_review', label: '代理商', status: 'current' },
|
||||
{ key: 'brand_review', label: '品牌终审', status: 'pending' },
|
||||
]
|
||||
case 'brand_reviewing':
|
||||
return [
|
||||
{ key: 'submitted', label: '已提交', status: 'done' },
|
||||
{ key: 'ai_review', label: 'AI审核', status: 'done' },
|
||||
{ key: 'agent_review', label: '代理商', status: 'done' },
|
||||
{ key: 'brand_review', label: '品牌终审', status: 'current' },
|
||||
]
|
||||
case 'need_revision':
|
||||
return [
|
||||
{ key: 'submitted', label: '已提交', status: 'done' },
|
||||
{ key: 'ai_review', label: '需修改', status: 'failed' },
|
||||
{ key: 'agent_review', label: '代理商', status: 'pending' },
|
||||
{ key: 'brand_review', label: '品牌终审', status: 'pending' },
|
||||
]
|
||||
case 'passed':
|
||||
return [
|
||||
{ key: 'submitted', label: '已提交', status: 'done' },
|
||||
{ key: 'ai_review', label: 'AI审核', status: 'done' },
|
||||
{ key: 'agent_review', label: '代理商', status: 'done' },
|
||||
{ key: 'brand_review', label: '品牌终审', status: 'done' },
|
||||
]
|
||||
case 'rejected':
|
||||
return [
|
||||
{ key: 'submitted', label: '已提交', status: 'done' },
|
||||
{ key: 'ai_review', label: 'AI审核', status: 'done' },
|
||||
{ key: 'agent_review', label: '已驳回', status: 'failed' },
|
||||
{ key: 'brand_review', label: '品牌终审', status: 'pending' },
|
||||
]
|
||||
default:
|
||||
return [
|
||||
{ key: 'submitted', label: '已提交', status: 'done' },
|
||||
{ key: 'ai_review', label: 'AI审核', status: 'done' },
|
||||
{ key: 'agent_review', label: '代理商', status: 'current' },
|
||||
{ key: 'brand_review', label: '品牌终审', status: 'pending' },
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
export default ReviewSteps
|
||||
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* Select 组件测试
|
||||
* 测试覆盖: options, label, error, hint, placeholder, disabled, forwardRef
|
||||
*/
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { useRef } from 'react';
|
||||
import { Select } from './Select';
|
||||
|
||||
const mockOptions = [
|
||||
{ value: 'option1', label: '选项一' },
|
||||
{ value: 'option2', label: '选项二' },
|
||||
{ value: 'option3', label: '选项三', disabled: true },
|
||||
];
|
||||
|
||||
describe('Select', () => {
|
||||
// ==================== 基础渲染测试 ====================
|
||||
describe('基础渲染', () => {
|
||||
it('渲染下拉选择框', () => {
|
||||
render(<Select options={mockOptions} />);
|
||||
expect(screen.getByRole('combobox')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('渲染所有选项', () => {
|
||||
render(<Select options={mockOptions} />);
|
||||
expect(screen.getByText('选项一')).toBeInTheDocument();
|
||||
expect(screen.getByText('选项二')).toBeInTheDocument();
|
||||
expect(screen.getByText('选项三')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('渲染下拉箭头图标', () => {
|
||||
render(<Select options={mockOptions} data-testid="select" />);
|
||||
const wrapper = screen.getByTestId('select').closest('div');
|
||||
expect(wrapper?.querySelector('svg')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ==================== Placeholder 测试 ====================
|
||||
describe('Placeholder', () => {
|
||||
it('显示 placeholder 选项', () => {
|
||||
render(<Select options={mockOptions} placeholder="请选择" />);
|
||||
expect(screen.getByText('请选择')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('placeholder 选项禁用', () => {
|
||||
render(<Select options={mockOptions} placeholder="请选择" />);
|
||||
const placeholderOption = screen.getByText('请选择');
|
||||
expect(placeholderOption).toHaveAttribute('disabled');
|
||||
});
|
||||
});
|
||||
|
||||
// ==================== Label 测试 ====================
|
||||
describe('Label', () => {
|
||||
it('渲染 label', () => {
|
||||
render(<Select options={mockOptions} label="选择类型" />);
|
||||
expect(screen.getByText('选择类型')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('label 使用正确样式', () => {
|
||||
render(<Select options={mockOptions} label="类型" />);
|
||||
const label = screen.getByText('类型');
|
||||
expect(label).toHaveClass('text-caption', 'text-text-secondary');
|
||||
});
|
||||
});
|
||||
|
||||
// ==================== Error 测试 ====================
|
||||
describe('Error 状态', () => {
|
||||
it('显示错误信息', () => {
|
||||
render(<Select options={mockOptions} error="请选择一个选项" />);
|
||||
expect(screen.getByText('请选择一个选项')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('错误信息使用红色', () => {
|
||||
render(<Select options={mockOptions} error="错误" />);
|
||||
expect(screen.getByText('错误')).toHaveClass('text-accent-coral');
|
||||
});
|
||||
|
||||
it('错误状态边框变红', () => {
|
||||
render(<Select options={mockOptions} error="错误" data-testid="select" />);
|
||||
expect(screen.getByTestId('select')).toHaveClass('border-accent-coral');
|
||||
});
|
||||
});
|
||||
|
||||
// ==================== Hint 测试 ====================
|
||||
describe('Hint 提示', () => {
|
||||
it('显示提示信息', () => {
|
||||
render(<Select options={mockOptions} hint="选择您的偏好" />);
|
||||
expect(screen.getByText('选择您的偏好')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('有 error 时不显示 hint', () => {
|
||||
render(<Select options={mockOptions} hint="提示" error="错误" />);
|
||||
expect(screen.queryByText('提示')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('错误')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ==================== Disabled 测试 ====================
|
||||
describe('Disabled 状态', () => {
|
||||
it('disabled 禁用选择框', () => {
|
||||
render(<Select options={mockOptions} disabled data-testid="select" />);
|
||||
expect(screen.getByTestId('select')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('选项可以单独禁用', () => {
|
||||
render(<Select options={mockOptions} />);
|
||||
const disabledOption = screen.getByText('选项三');
|
||||
expect(disabledOption).toHaveAttribute('disabled');
|
||||
});
|
||||
});
|
||||
|
||||
// ==================== FullWidth 测试 ====================
|
||||
describe('FullWidth 属性', () => {
|
||||
it('默认全宽', () => {
|
||||
render(<Select options={mockOptions} data-testid="select" />);
|
||||
const wrapper = screen.getByTestId('select').closest('div')?.parentElement;
|
||||
expect(wrapper).toHaveClass('w-full');
|
||||
});
|
||||
|
||||
it('fullWidth=false 不应用全宽', () => {
|
||||
render(<Select options={mockOptions} fullWidth={false} data-testid="select" />);
|
||||
const wrapper = screen.getByTestId('select').closest('div')?.parentElement;
|
||||
expect(wrapper).not.toHaveClass('w-full');
|
||||
});
|
||||
});
|
||||
|
||||
// ==================== ForwardRef 测试 ====================
|
||||
describe('ForwardRef', () => {
|
||||
it('正确转发 ref', () => {
|
||||
const TestComponent = () => {
|
||||
const selectRef = useRef<HTMLSelectElement>(null);
|
||||
return (
|
||||
<>
|
||||
<Select ref={selectRef} options={mockOptions} data-testid="select" />
|
||||
<button onClick={() => selectRef.current?.focus()}>Focus</button>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
render(<TestComponent />);
|
||||
fireEvent.click(screen.getByText('Focus'));
|
||||
expect(screen.getByTestId('select')).toHaveFocus();
|
||||
});
|
||||
});
|
||||
|
||||
// ==================== 事件处理测试 ====================
|
||||
describe('事件处理', () => {
|
||||
it('onChange 事件正常触发', () => {
|
||||
const handleChange = vi.fn();
|
||||
render(<Select options={mockOptions} onChange={handleChange} data-testid="select" />);
|
||||
fireEvent.change(screen.getByTestId('select'), { target: { value: 'option2' } });
|
||||
expect(handleChange).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('选择值正确更新', () => {
|
||||
render(<Select options={mockOptions} data-testid="select" />);
|
||||
const select = screen.getByTestId('select');
|
||||
fireEvent.change(select, { target: { value: 'option2' } });
|
||||
expect(select).toHaveValue('option2');
|
||||
});
|
||||
});
|
||||
|
||||
// ==================== 自定义属性测试 ====================
|
||||
describe('自定义属性', () => {
|
||||
it('支持自定义 className', () => {
|
||||
render(<Select options={mockOptions} className="custom-select" data-testid="select" />);
|
||||
expect(screen.getByTestId('select')).toHaveClass('custom-select');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* Tag 组件测试
|
||||
* 测试覆盖: Tag, SuccessTag, PendingTag, WarningTag, ErrorTag
|
||||
*/
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { Star } from 'lucide-react';
|
||||
import { Tag, SuccessTag, PendingTag, WarningTag, ErrorTag } from './Tag';
|
||||
|
||||
describe('Tag', () => {
|
||||
// ==================== 基础渲染测试 ====================
|
||||
describe('基础渲染', () => {
|
||||
it('渲染标签文本', () => {
|
||||
render(<Tag status="success">通过</Tag>);
|
||||
expect(screen.getByText('通过')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('默认显示图标', () => {
|
||||
render(<Tag status="success">成功</Tag>);
|
||||
const tag = screen.getByText('成功').closest('span');
|
||||
expect(tag?.querySelector('svg')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ==================== Status 样式测试 ====================
|
||||
describe('Status 样式', () => {
|
||||
it('success 状态应用绿色样式', () => {
|
||||
render(<Tag status="success">成功</Tag>);
|
||||
const tag = screen.getByText('成功').closest('span');
|
||||
expect(tag).toHaveClass('bg-status-success', 'text-accent-green');
|
||||
});
|
||||
|
||||
it('pending 状态应用蓝色样式', () => {
|
||||
render(<Tag status="pending">待处理</Tag>);
|
||||
const tag = screen.getByText('待处理').closest('span');
|
||||
expect(tag).toHaveClass('bg-status-pending', 'text-accent-indigo');
|
||||
});
|
||||
|
||||
it('warning 状态应用黄色样式', () => {
|
||||
render(<Tag status="warning">警告</Tag>);
|
||||
const tag = screen.getByText('警告').closest('span');
|
||||
expect(tag).toHaveClass('bg-status-warning', 'text-accent-amber');
|
||||
});
|
||||
|
||||
it('error 状态应用红色样式', () => {
|
||||
render(<Tag status="error">错误</Tag>);
|
||||
const tag = screen.getByText('错误').closest('span');
|
||||
expect(tag).toHaveClass('bg-status-error', 'text-accent-coral');
|
||||
});
|
||||
});
|
||||
|
||||
// ==================== Size 测试 ====================
|
||||
describe('Size 样式', () => {
|
||||
it('sm size 应用小尺寸样式', () => {
|
||||
render(<Tag status="success" size="sm">小标签</Tag>);
|
||||
const tag = screen.getByText('小标签').closest('span');
|
||||
expect(tag).toHaveClass('px-1.5', 'py-0.5', 'text-[11px]');
|
||||
});
|
||||
|
||||
it('md size 应用中等尺寸样式(默认)', () => {
|
||||
render(<Tag status="success" size="md">中标签</Tag>);
|
||||
const tag = screen.getByText('中标签').closest('span');
|
||||
expect(tag).toHaveClass('px-2', 'py-1', 'text-small');
|
||||
});
|
||||
|
||||
it('默认使用 md size', () => {
|
||||
render(<Tag status="success">默认</Tag>);
|
||||
const tag = screen.getByText('默认').closest('span');
|
||||
expect(tag).toHaveClass('px-2', 'py-1');
|
||||
});
|
||||
});
|
||||
|
||||
// ==================== Icon 测试 ====================
|
||||
describe('Icon 渲染', () => {
|
||||
it('默认显示状态对应的图标', () => {
|
||||
render(<Tag status="success">成功</Tag>);
|
||||
const tag = screen.getByText('成功').closest('span');
|
||||
expect(tag?.querySelector('svg')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('icon={false} 隐藏图标', () => {
|
||||
render(<Tag status="success" icon={false}>无图标</Tag>);
|
||||
const tag = screen.getByText('无图标').closest('span');
|
||||
expect(tag?.querySelector('svg')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('icon={true} 显示默认图标', () => {
|
||||
render(<Tag status="success" icon={true}>有图标</Tag>);
|
||||
const tag = screen.getByText('有图标').closest('span');
|
||||
expect(tag?.querySelector('svg')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('支持自定义图标', () => {
|
||||
render(<Tag status="success" icon={Star}>自定义</Tag>);
|
||||
const tag = screen.getByText('自定义').closest('span');
|
||||
expect(tag?.querySelector('svg')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ==================== 自定义 className 测试 ====================
|
||||
describe('自定义 className', () => {
|
||||
it('支持自定义 className', () => {
|
||||
render(<Tag status="success" className="custom-tag">自定义</Tag>);
|
||||
const tag = screen.getByText('自定义').closest('span');
|
||||
expect(tag).toHaveClass('custom-tag');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ==================== 预定义标签组件测试 ====================
|
||||
describe('SuccessTag', () => {
|
||||
it('渲染 success 状态', () => {
|
||||
render(<SuccessTag>通过</SuccessTag>);
|
||||
const tag = screen.getByText('通过').closest('span');
|
||||
expect(tag).toHaveClass('bg-status-success', 'text-accent-green');
|
||||
});
|
||||
|
||||
it('支持 size 属性', () => {
|
||||
render(<SuccessTag size="sm">小</SuccessTag>);
|
||||
const tag = screen.getByText('小').closest('span');
|
||||
expect(tag).toHaveClass('px-1.5', 'py-0.5');
|
||||
});
|
||||
});
|
||||
|
||||
describe('PendingTag', () => {
|
||||
it('渲染 pending 状态', () => {
|
||||
render(<PendingTag>处理中</PendingTag>);
|
||||
const tag = screen.getByText('处理中').closest('span');
|
||||
expect(tag).toHaveClass('bg-status-pending', 'text-accent-indigo');
|
||||
});
|
||||
|
||||
it('支持 size 属性', () => {
|
||||
render(<PendingTag size="sm">小</PendingTag>);
|
||||
const tag = screen.getByText('小').closest('span');
|
||||
expect(tag).toHaveClass('px-1.5', 'py-0.5');
|
||||
});
|
||||
});
|
||||
|
||||
describe('WarningTag', () => {
|
||||
it('渲染 warning 状态', () => {
|
||||
render(<WarningTag>注意</WarningTag>);
|
||||
const tag = screen.getByText('注意').closest('span');
|
||||
expect(tag).toHaveClass('bg-status-warning', 'text-accent-amber');
|
||||
});
|
||||
|
||||
it('支持 size 属性', () => {
|
||||
render(<WarningTag size="sm">小</WarningTag>);
|
||||
const tag = screen.getByText('小').closest('span');
|
||||
expect(tag).toHaveClass('px-1.5', 'py-0.5');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ErrorTag', () => {
|
||||
it('渲染 error 状态', () => {
|
||||
render(<ErrorTag>失败</ErrorTag>);
|
||||
const tag = screen.getByText('失败').closest('span');
|
||||
expect(tag).toHaveClass('bg-status-error', 'text-accent-coral');
|
||||
});
|
||||
|
||||
it('支持 size 属性', () => {
|
||||
render(<ErrorTag size="sm">小</ErrorTag>);
|
||||
const tag = screen.getByText('小').closest('span');
|
||||
expect(tag).toHaveClass('px-1.5', 'py-0.5');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user