Develop (#55)
feat: optimize dashboard/search UX and simplify extension architecture Redesign Dashboard with compact tool grid and recently used tools Improve TopBar search UX with Cmd/Ctrl+K shortcut and better history navigation Reorganize project structure into src/ Migrate i18n from react-i18next to chrome.i18n Remove runtime language switch and settings page Remove HTML/Markdown conversion tools Clean up unused code, dead animations, redundant comments, and imports Improve component consistency with shadcn/ui patterns Replace hardcoded strings/colors with i18n tokens and theme tokens Add comprehensive project documentation and coding standards Fix CI artifact upload workflow and multiple TypeScript/test issues Includes various refactors, UI polish, i18n cleanup, CI improvements, and maintenance updates across the codebase.
This commit is contained in:
@@ -0,0 +1,490 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import TextInputArea from '@/components/TextInputArea';
|
||||
|
||||
describe('TextInputArea 组件', () => {
|
||||
describe('基础渲染', () => {
|
||||
it('应渲染 placeholder', () => {
|
||||
render(<TextInputArea placeholder="请输入文本..." />);
|
||||
expect(screen.getByPlaceholderText('请输入文本...')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('应渲染传入的 value', () => {
|
||||
render(<TextInputArea value="测试内容" onChange={() => {}} />);
|
||||
const textarea = screen.getByRole('textbox');
|
||||
expect(textarea).toHaveValue('测试内容');
|
||||
});
|
||||
|
||||
it('默认显示清空按钮', () => {
|
||||
render(<TextInputArea value="有内容" onChange={() => {}} />);
|
||||
expect(screen.getByRole('button', { name: 'textInputArea.clear' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('无内容时清空按钮应隐藏', () => {
|
||||
render(<TextInputArea value="" onChange={() => {}} />);
|
||||
expect(screen.queryByRole('button', { name: 'textInputArea.clear' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('disabled 时清空按钮应隐藏', () => {
|
||||
render(<TextInputArea value="内容" onChange={() => {}} disabled />);
|
||||
expect(screen.queryByTitle('清空')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('readOnly 时清空按钮应隐藏', () => {
|
||||
render(<TextInputArea value="内容" onChange={() => {}} readOnly />);
|
||||
expect(screen.queryByTitle('清空')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('showClear=false 时不显示清空按钮', () => {
|
||||
render(<TextInputArea value="内容" onChange={() => {}} showClear={false} />);
|
||||
expect(screen.queryByTitle('清空')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('受控模式', () => {
|
||||
it('输入时触发 onChange', () => {
|
||||
const handleChange = vi.fn();
|
||||
render(<TextInputArea value="" onChange={handleChange} />);
|
||||
|
||||
const textarea = screen.getByRole('textbox');
|
||||
fireEvent.change(textarea, { target: { value: '新内容' } });
|
||||
|
||||
expect(handleChange).toHaveBeenCalledWith('新内容');
|
||||
});
|
||||
|
||||
it('清空按钮触发 onChange("")', () => {
|
||||
const handleChange = vi.fn();
|
||||
render(<TextInputArea value="内容" onChange={handleChange} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'textInputArea.clear' }));
|
||||
|
||||
expect(handleChange).toHaveBeenCalledWith('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('非受控模式', () => {
|
||||
it('defaultValue 应显示初始值', () => {
|
||||
render(<TextInputArea defaultValue="初始值" />);
|
||||
expect(screen.getByRole('textbox')).toHaveValue('初始值');
|
||||
});
|
||||
|
||||
it('输入后应更新内部值', () => {
|
||||
render(<TextInputArea defaultValue="" />);
|
||||
|
||||
const textarea = screen.getByRole('textbox');
|
||||
fireEvent.change(textarea, { target: { value: '新内容' } });
|
||||
|
||||
expect(textarea).toHaveValue('新内容');
|
||||
});
|
||||
|
||||
it('清空按钮应清空内容', () => {
|
||||
render(<TextInputArea defaultValue="内容" />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'textInputArea.clear' }));
|
||||
|
||||
expect(screen.getByRole('textbox')).toHaveValue('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('allowCopy 复制功能', () => {
|
||||
it('allowCopy 且有内容时显示复制按钮', () => {
|
||||
render(<TextInputArea value="可复制的内容" onChange={() => {}} allowCopy />);
|
||||
expect(screen.getByRole('button', { name: 'textInputArea.copyContent' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('allowCopy 但无内容时隐藏复制按钮', () => {
|
||||
render(<TextInputArea value="" onChange={() => {}} allowCopy />);
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'textInputArea.copyContent' }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('allowCopy=false 时不显示复制按钮', () => {
|
||||
render(<TextInputArea value="内容" onChange={() => {}} />);
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'textInputArea.copyContent' }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('复制时调用 clipboard writeText', async () => {
|
||||
const user = userEvent.setup();
|
||||
const writeTextSpy = vi.spyOn(navigator.clipboard, 'writeText').mockResolvedValue(undefined);
|
||||
|
||||
render(<TextInputArea value="测试" onChange={() => {}} allowCopy />);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'textInputArea.copyContent' }));
|
||||
|
||||
expect(writeTextSpy).toHaveBeenCalledWith('测试');
|
||||
});
|
||||
|
||||
it('复制失败时调用 clipboard writeText 并捕获错误', async () => {
|
||||
const user = userEvent.setup();
|
||||
const writeTextSpy = vi
|
||||
.spyOn(navigator.clipboard, 'writeText')
|
||||
.mockRejectedValue(new Error('失败'));
|
||||
|
||||
render(<TextInputArea value="测试" onChange={() => {}} allowCopy />);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'textInputArea.copyContent' }));
|
||||
|
||||
expect(writeTextSpy).toHaveBeenCalledWith('测试');
|
||||
});
|
||||
});
|
||||
|
||||
describe('showCount 字符计数', () => {
|
||||
it('显示当前字符数', () => {
|
||||
render(<TextInputArea value="hello" onChange={() => {}} showCount />);
|
||||
expect(screen.getByText('5')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('空内容时显示 0', () => {
|
||||
render(<TextInputArea value="" onChange={() => {}} showCount />);
|
||||
expect(screen.getByText('0')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('设置 maxLength 时显示计数上限', () => {
|
||||
render(<TextInputArea value="ab" onChange={() => {}} showCount maxLength={10} />);
|
||||
expect(screen.getByText('2 / 10')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('maxLength', () => {
|
||||
it('超出 maxLength 的输入应被截断', () => {
|
||||
const handleChange = vi.fn();
|
||||
render(<TextInputArea value="" onChange={handleChange} maxLength={5} />);
|
||||
|
||||
const textarea = screen.getByRole('textbox');
|
||||
fireEvent.change(textarea, { target: { value: '123456' } });
|
||||
|
||||
expect(handleChange).not.toHaveBeenCalledWith('123456');
|
||||
});
|
||||
|
||||
it('未超出 maxLength 的输入应正常触发', () => {
|
||||
const handleChange = vi.fn();
|
||||
render(<TextInputArea value="" onChange={handleChange} maxLength={5} />);
|
||||
|
||||
const textarea = screen.getByRole('textbox');
|
||||
fireEvent.change(textarea, { target: { value: '123' } });
|
||||
|
||||
expect(handleChange).toHaveBeenCalledWith('123');
|
||||
});
|
||||
});
|
||||
|
||||
describe('验证规则', () => {
|
||||
it('onChange 触发时验证失败应设置 error', () => {
|
||||
render(
|
||||
<TextInputArea
|
||||
value=""
|
||||
onChange={() => {}}
|
||||
validateTrigger="onChange"
|
||||
rules={[{ validator: (v) => v.length >= 3, message: '至少3个字符' }]}
|
||||
/>,
|
||||
);
|
||||
|
||||
const textarea = screen.getByRole('textbox');
|
||||
fireEvent.change(textarea, { target: { value: 'ab' } });
|
||||
|
||||
expect(screen.getByText('至少3个字符')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('onBlur 触发时验证失败应设置 error', () => {
|
||||
render(
|
||||
<TextInputArea
|
||||
value="ab"
|
||||
onChange={() => {}}
|
||||
validateTrigger="onBlur"
|
||||
rules={[{ validator: (v) => v.length >= 3, message: '至少3个字符' }]}
|
||||
/>,
|
||||
);
|
||||
|
||||
const textarea = screen.getByRole('textbox');
|
||||
fireEvent.blur(textarea);
|
||||
|
||||
expect(screen.getByText('至少3个字符')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('验证通过不应显示错误', () => {
|
||||
render(
|
||||
<TextInputArea
|
||||
value="abc"
|
||||
onChange={() => {}}
|
||||
validateTrigger="onChange"
|
||||
rules={[{ validator: (v) => v.length >= 3, message: '至少3个字符' }]}
|
||||
/>,
|
||||
);
|
||||
|
||||
const textarea = screen.getByRole('textbox');
|
||||
fireEvent.change(textarea, { target: { value: 'abcd' } });
|
||||
|
||||
expect(screen.queryByText('至少3个字符')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('onAction 触发时验证失败应阻止 action 执行', () => {
|
||||
const handleAction = vi.fn();
|
||||
render(
|
||||
<TextInputArea
|
||||
value="ab"
|
||||
onChange={() => {}}
|
||||
validateTrigger="onAction"
|
||||
rules={[{ validator: (v) => v.length >= 3, message: '至少3个字符' }]}
|
||||
actions={[
|
||||
{
|
||||
key: 'test',
|
||||
label: '执行',
|
||||
onClick: handleAction,
|
||||
},
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText('执行'));
|
||||
|
||||
expect(handleAction).not.toHaveBeenCalled();
|
||||
expect(screen.getByText('至少3个字符')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('操作栏 actions', () => {
|
||||
it('应渲染顶部操作按钮', () => {
|
||||
render(
|
||||
<TextInputArea
|
||||
value="内容"
|
||||
onChange={() => {}}
|
||||
actions={[{ key: 'top-action', label: '顶部操作', onClick: vi.fn() }]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('顶部操作')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('应渲染底部操作按钮', () => {
|
||||
render(
|
||||
<TextInputArea
|
||||
value="内容"
|
||||
onChange={() => {}}
|
||||
actions={[
|
||||
{ key: 'bottom-action', label: '底部操作', position: 'bottom', onClick: vi.fn() },
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('底部操作')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('点击操作按钮触发 onClick', () => {
|
||||
const handleClick = vi.fn();
|
||||
render(
|
||||
<TextInputArea
|
||||
value="内容"
|
||||
onChange={() => {}}
|
||||
actions={[{ key: 'act', label: '操作', onClick: handleClick }]}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText('操作'));
|
||||
|
||||
expect(handleClick).toHaveBeenCalledWith(
|
||||
'内容',
|
||||
expect.objectContaining({
|
||||
clear: expect.any(Function),
|
||||
setError: expect.any(Function),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('disabled 为 true 时按钮应禁用', () => {
|
||||
render(
|
||||
<TextInputArea
|
||||
value=""
|
||||
onChange={() => {}}
|
||||
actions={[{ key: 'act', label: '操作', onClick: vi.fn(), disabled: true }]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('操作')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('disabled 为函数且返回 true 时按钮应禁用', () => {
|
||||
render(
|
||||
<TextInputArea
|
||||
value=""
|
||||
onChange={() => {}}
|
||||
actions={[{ key: 'act', label: '操作', onClick: vi.fn(), disabled: (v) => !v }]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('操作')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('primary 类型按钮应使用 contained 样式', () => {
|
||||
render(
|
||||
<TextInputArea
|
||||
value="内容"
|
||||
onChange={() => {}}
|
||||
actions={[
|
||||
{ key: 'p', label: '主要', type: 'primary', position: 'bottom', onClick: vi.fn() },
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
const btn = screen.getByText('主要');
|
||||
expect(btn).toHaveClass('bg-primary', 'text-primary-foreground');
|
||||
});
|
||||
});
|
||||
|
||||
describe('title', () => {
|
||||
it('应渲染 title', () => {
|
||||
render(<TextInputArea title="输入区域" value="" onChange={() => {}} />);
|
||||
expect(screen.getByText('输入区域')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('不设置 title 时不渲染标题', () => {
|
||||
const { container } = render(<TextInputArea value="" onChange={() => {}} />);
|
||||
expect(container.querySelector('.text-muted-foreground')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('disabled 和 readOnly', () => {
|
||||
it('disabled 时输入框应禁用', () => {
|
||||
render(<TextInputArea value="内容" onChange={() => {}} disabled />);
|
||||
expect(screen.getByRole('textbox')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('readOnly 时输入框应只读', () => {
|
||||
render(<TextInputArea value="内容" onChange={() => {}} readOnly />);
|
||||
expect(screen.getByRole('textbox')).not.toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('autoFocus', () => {
|
||||
it('autoFocus 应自动聚焦', () => {
|
||||
render(<TextInputArea autoFocus value="" onChange={() => {}} />);
|
||||
expect(document.activeElement).toBe(screen.getByRole('textbox'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('复制功能', () => {
|
||||
it('复制成功时调用 clipboard writeText', async () => {
|
||||
const user = userEvent.setup();
|
||||
const writeTextSpy = vi.spyOn(navigator.clipboard, 'writeText').mockResolvedValue(undefined);
|
||||
|
||||
render(<TextInputArea value="测试" onChange={() => {}} allowCopy />);
|
||||
await user.click(screen.getByRole('button', { name: 'textInputArea.copyContent' }));
|
||||
|
||||
expect(writeTextSpy).toHaveBeenCalledWith('测试');
|
||||
});
|
||||
});
|
||||
|
||||
describe('onClear 回调', () => {
|
||||
it('点击清空按钮时应调用 onClear', () => {
|
||||
const handleClear = vi.fn();
|
||||
render(<TextInputArea value="内容" onChange={() => {}} onClear={handleClear} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'textInputArea.clear' }));
|
||||
|
||||
expect(handleClear).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('不传 onClear 时清空按钮应正常工作', () => {
|
||||
render(<TextInputArea defaultValue="内容" />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'textInputArea.clear' }));
|
||||
|
||||
expect(screen.getByRole('textbox')).toHaveValue('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('externalError 外部错误', () => {
|
||||
it('设置 externalError 时应显示错误状态', () => {
|
||||
render(<TextInputArea value="内容" onChange={() => {}} externalError="JSON 格式无效" />);
|
||||
|
||||
expect(screen.getByText('JSON 格式无效')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('externalError 为空时应隐藏错误状态', () => {
|
||||
const { rerender } = render(
|
||||
<TextInputArea value="内容" onChange={() => {}} externalError="错误" />,
|
||||
);
|
||||
|
||||
expect(screen.getByText('错误')).toBeInTheDocument();
|
||||
|
||||
rerender(<TextInputArea value="内容" onChange={() => {}} externalError="" />);
|
||||
|
||||
expect(screen.queryByText('错误')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('externalError 优先级高于内部验证错误', () => {
|
||||
render(
|
||||
<TextInputArea
|
||||
value="ab"
|
||||
onChange={() => {}}
|
||||
externalError="外部错误"
|
||||
validateTrigger="onChange"
|
||||
rules={[{ validator: (v) => v.length >= 3, message: '内部验证错误' }]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('外部错误')).toBeInTheDocument();
|
||||
expect(screen.queryByText('内部验证错误')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('externalError 清除后应显示内部验证错误', () => {
|
||||
const { rerender } = render(
|
||||
<TextInputArea
|
||||
value="ab"
|
||||
onChange={() => {}}
|
||||
externalError="外部错误"
|
||||
validateTrigger="onChange"
|
||||
rules={[{ validator: (v) => v.length >= 3, message: '至少3个字符' }]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('外部错误')).toBeInTheDocument();
|
||||
|
||||
rerender(
|
||||
<TextInputArea
|
||||
value="ab"
|
||||
onChange={() => {}}
|
||||
validateTrigger="onChange"
|
||||
rules={[{ validator: (v) => v.length >= 3, message: '至少3个字符' }]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByText('外部错误')).not.toBeInTheDocument();
|
||||
|
||||
const textarea = screen.getByRole('textbox');
|
||||
fireEvent.change(textarea, { target: { value: 'a' } });
|
||||
|
||||
expect(screen.getByText('至少3个字符')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('autoResize', () => {
|
||||
it('autoResize=true 时设置 minRows/maxRows', () => {
|
||||
const { container } = render(
|
||||
<TextInputArea value="" onChange={() => {}} minRows={3} maxRows={8} />,
|
||||
);
|
||||
|
||||
const textarea = container.querySelector('textarea');
|
||||
expect(textarea).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('autoResize=false 时设置固定 rows', () => {
|
||||
const { container } = render(<TextInputArea value="" onChange={() => {}} minRows={5} />);
|
||||
|
||||
const textarea = container.querySelector('textarea');
|
||||
expect(textarea).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('样式集成', () => {
|
||||
it('应透传 className', () => {
|
||||
const { container } = render(
|
||||
<TextInputArea value="" onChange={() => {}} className="custom-class" />,
|
||||
);
|
||||
|
||||
expect(container.firstChild).toHaveClass('custom-class');
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user