Merge develop into main (#27)

* feat: 添加 contextMenus 权限和消息类型定义

* feat: 实现右键菜单注册与点击分流逻辑

- 新增 utils/contextMenu.ts 封装菜单配置和解析函数
- 在 background.ts 监听 onInstalled 初始化菜单
- 实现 contextMenus.onClicked 点击事件处理
- 分流逻辑:优先发送到侧边栏,否则打开 options 页面
- 添加 contextMenu 单元测试(15个测试用例)
- 更新 vitest.setup.ts 添加 contextMenus mock

* feat: 实现 Content Script 原位轻量提示 UI

- 新增 uiPopover.ts 实现原位弹窗组件(深色主题、自动定位、自动隐藏)
- 新增 contextMenuHandler.ts 处理右键菜单消息
- 时间戳转换:在点击位置显示转换结果
- 文本统计:显示字符/单词/行数/字节统计
- 更新 messageHandler.ts 挂载右键菜单消息监听

* feat: 实现 React 页面层右键菜单数据联动

- 新增 useContextMenuData Hook 处理右键菜单数据传递
- 重构 Jwt/Base64Converter/TextStatistics/QrCode 页面接收右键数据
- RouterProvider 支持从 URL 参数解析右键菜单数据
- 添加 contextMenu/pendingData 存储键到 StorageSchema
- 添加 useContextMenuData 单元测试(12个测试用例)

* test: 添加 background 单元测试与边界情况处理

- 新增 entrypoints/__tests__/background.test.ts (12个测试用例)
- 超长文本截断限制 (>10000字符)
- Base64 内联图片拦截并返回错误提示
- 更新 parseContextMenuClick 返回 ParseResult 格式
- 更新测试匹配新的返回格式
- lint/typecheck/test 全部通过 (579个测试)

* fix: 修复右键菜单跳转到错误页面的问题

- 将 options 页面路径从 '/entrypoints/options/index.html' 修正为 '/options.html'
- WXT 构建后 entrypoints/options/index.html 会输出为根目录的 options.html

* fix: 修复右键菜单跳转目标为 popup 页面

- 将 fallback 页面从 options.html 改为 popup.html
- popup 页面使用 RouterProvider,可以正确处理 URL 参数并跳转到对应功能页面
- options 页面是独立设置页,不支持 URL 参数路由

* fix: 使用 openPopup() 替代 tabs.create() 打开弹窗

- background.ts: 使用 browser.action.openPopup() 打开 popup 弹窗
- 先保存数据到 storage,然后打开 popup
- RouterProvider.tsx: 初始化时检查 storage 中的 pendingData 并跳转到对应页面
- 移除之前错误的 tabs.create() 方式

* fix: 修复右键菜单数据未传递到目标页面的问题

- RouterProvider 不再提前清除 pendingData
- 让目标页面的 useContextMenuData 来消费和清除数据
- 这样确保 TextStatistics 等页面能正确接收选中的文本

* fix: 修复 popup 已打开时右键菜单不生效的问题

- 在 RouterProvider 中添加对 contextMenu/pendingData 的 storage 变化监听
- 当 pendingData 变化时,自动跳转到对应功能页面
- 解决了 openPopup() 只聚焦已有窗口而不触发重新挂载的问题

* feat: 支持右键菜单图片二维码识别

- 在 qrCodeParser.ts 中添加 parseQrCodeFromUrl 函数,支持从图片 URL 解析二维码
- 在 QrCodeToUrlSection 中使用 useContextMenuData 接收右键菜单传递的图片 URL
- 自动下载图片并解析二维码,显示解析结果

* refactor: 暂时移除图片二维码识别功能

- 删除 qrCode-image 右键菜单配置
- 删除 parseQrCodeFromUrl 函数
- 删除 QrCodeToUrlSection 中的 useContextMenuData 相关代码
- 更新相关测试用例

* feat: 支持右键菜单时间戳转换

- 在 useTimestampConverter 中添加 useContextMenuData hook
- 智能识别输入是时间戳还是日期时间字符串
- 自动切换到对应模式并执行转换
- 时间戳自动识别秒/毫秒单位

* fix: 修复右键菜单功能的多个逻辑漏洞

1. 修复时区硬编码问题 - 使用用户选择的时区而非固定 Asia/Shanghai
2. 修复 openPopup() 失败后数据残留 - 失败时清除 storage 中的待处理数据
3. 统一数据过期时间常量 - 导出 CONTEXT_MENU_DATA_EXPIRY_MS 并统一使用
4. 修复 featureKey 类型断言不安全 - 使用映射表处理非常规菜单 ID

* refactor(qrcode): 提取独立组件并添加单元测试

- 创建 pages/QrCode/types.ts 定义状态类型接口
- 提取 QrCodePreview 组件用于二维码预览和操作
- 提取 ImageUploader 组件封装图片上传、拖拽、粘贴逻辑
- 重构 UrlToQrCodeSection 状态提升到父组件
- 重构 QrCodeToUrlSection 通过回调传递解析结果
- 更新主页面 index.tsx 集中管理所有状态
- 为 QrCodePreview 和 ImageUploader 添加单元测试 (23 个用例)

* refactor(qrcode): 引入通用组件并重构双栏布局

- 引入 SwitchButtonGroup 用于模式切换
- 引入 TextInputArea 用于文本输入和结果展示
- 使用 MUI Grid 构建响应式双栏布局
- 实现 generate/parse 模式下的左右面板内容切换
- 修复 iconColor 颜色格式错误,使用 qrCodePageStyles.primaryColor

* refactor(qrcode): 国际化补充、质量保障与错误处理优化

- 补充 i18n 翻译键(generateMode, parseMode, pasteHint 等)
- 创建 useDebounce Hook 实现输入防抖 (200ms)
- 使用 useRef 解决 useEffect 无限循环问题
- 编写 pages/QrCode 单元测试 (7 个用例)
- 优化右键菜单错误处理,改进用户提示
- 运行 603 个测试全部通过

* refactor(qrcode): 组件化重构,采用 Context + Hook 模式

- 创建 QrCodeContext 和 QrCodeProvider 管理共享状态
- 提取 useQrCode Hook 封装所有状态和业务逻辑
- 创建 GeneratePanel 组件处理二维码生成模式
- 创建 ParsePanel 组件处理二维码解析模式
- 简化 index.tsx 为容器组件 (340行 → 47行)
- 删除未使用的旧组件文件 (QrCodeToUrlSection, UrlToQrCodeSection)
- 清理 types.ts 中未使用的类型定义
- 603 个测试全部通过

* fix(qrcode): 固定二维码预览图片尺寸,避免布局抖动

- 设置 QR_PREVIEW_IMAGE 固定尺寸 250x250
- 与 QRious 生成的二维码大小保持一致
- 解决输入内容变化时预览窗口大小跳动问题

* fix(qrcode): 修复切换 tab 后二维码图片失效问题

- 移除 ImageUploader 组件卸载时的预览 URL 释放逻辑
- 在 useQrCode hook 中统一管理预览 URL 生命周期
- 创建新预览 URL 前释放旧的,避免内存泄漏

* fix(qrcode): 固定图片上传区域高度,避免布局抖动

- 将 DROPZONE 的 minHeight: 200 改为 height: 250
- 与二维码预览图片高度保持一致
- 解决上传图片时 div 高度变化问题

* refactor(qrCode): 优化二维码解析功能

- 将'二维码转 URL'改为'二维码转文本'
- 将解析结果的文本预览框的 placeholder 置为空
- 去除手动解析二维码功能,只保留自动解析

* fix(pageHeader): 使用 MUI 主题色替代硬编码颜色,支持暗色模式

* feat(pageHeader): popup 模式下隐藏 PageHeader 组件

* fix(i18n): 统一使用 useLazyTranslation 避免翻译 key 闪烁

* perf: 配置 manualChunks 拆分 vendor chunk,优化打包体积

- 使用 Vite 插件 manualChunksForHtmlOnly 仅对 HTML 多入口构建生效
- 跳过 background/content-script 的 IIFE 构建(不支持 manualChunks)
- 拆分 vendor-react (193KB)、vendor-mui (326KB)、vendor-i18n (55KB)
- 按需加载 vendor-qr (78KB)、vendor-dnd (45KB)、vendor-markdown (41KB)
- PageErrorBoundary chunk 从 454KB 降至 36KB
This commit is contained in:
LingandRX
2026-05-21 01:34:35 +08:00
committed by GitHub
parent 373fc0496c
commit a5c34e5428
52 changed files with 2610 additions and 622 deletions
+179
View File
@@ -0,0 +1,179 @@
import { useCallback, useEffect, useRef } from 'react';
import { Box, IconButton, Typography } from '@mui/material';
import ImageIcon from '@mui/icons-material/Image';
import ClearIcon from '@mui/icons-material/Clear';
import { qrCodePageStyles } from '@/config/pageTheme';
import { useSnackbar } from '@/components/GlobalSnackbar';
import { useLazyTranslation } from '@/utils/useLazyTranslation';
interface ImageUploaderProps {
/** 选中的文件 */
selectedFile: File | null;
/** 文件变更回调 */
onFileChange: (file: File) => void;
/** 清除文件回调 */
onClearFile: () => void;
/** 文件预览 URL */
previewUrl: string;
/** 预览 URL 变更回调 */
onPreviewUrlChange: (url: string) => void;
/** 是否正在拖拽 */
dragging: boolean;
/** 拖拽状态变更回调 */
onDraggingChange: (dragging: boolean) => void;
}
const ImageUploader = ({
selectedFile,
onFileChange,
onClearFile,
previewUrl,
onPreviewUrlChange,
dragging,
onDraggingChange,
}: ImageUploaderProps) => {
const { t } = useLazyTranslation('qrCode');
const { showMessage } = useSnackbar();
const fileInputRef = useRef<HTMLInputElement>(null);
const handleFileChange = useCallback(
(file: File) => {
onFileChange(file);
onPreviewUrlChange(URL.createObjectURL(file));
},
[onFileChange, onPreviewUrlChange],
);
const handleClearFile = () => {
if (previewUrl) {
URL.revokeObjectURL(previewUrl);
}
onClearFile();
showMessage(t('qrCode:imageCleared'), {
severity: 'success',
autoHideDuration: 1000,
});
};
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files.length > 0) {
handleFileChange(e.target.files[0]);
}
};
const handleDragOver = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
onDraggingChange(true);
};
const handleDragLeave = () => {
onDraggingChange(false);
};
const handleDrop = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
onDraggingChange(false);
const droppedFile = e.dataTransfer.files?.[0];
if (droppedFile) {
handleFileChange(droppedFile);
}
};
// 监听粘贴事件
useEffect(() => {
const handlePaste = async (e: ClipboardEvent) => {
const items = e.clipboardData?.items;
if (!items) return;
for (let i = 0; i < items.length; i++) {
if (items[i].type.startsWith('image/')) {
e.preventDefault();
const file = items[i].getAsFile();
if (file) {
try {
handleFileChange(file);
showMessage(t('qrCode:imagePasted'), { severity: 'success', autoHideDuration: 1000 });
} catch (error) {
console.error('处理粘贴图片失败:', error);
showMessage(t('qrCode:imagePasteError'), {
severity: 'error',
autoHideDuration: 3000,
});
}
}
break;
}
}
};
document.addEventListener('paste', handlePaste);
return () => {
document.removeEventListener('paste', handlePaste);
};
}, [showMessage, handleFileChange, t]);
return (
<Box
className="qr-flex-grow"
sx={qrCodePageStyles.DROPZONE(dragging, !!selectedFile)}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
<input
ref={fileInputRef}
type="file"
accept="image/*"
onChange={handleInputChange}
style={{ display: 'none' }}
id="qr-code-upload"
/>
<label
htmlFor="qr-code-upload"
style={{ cursor: 'pointer', textAlign: 'center', width: '100%' }}
>
{selectedFile ? (
<Box sx={qrCodePageStyles.IMAGE_PREVIEW_WRAPPER}>
<Box sx={qrCodePageStyles.IMAGE_PREVIEW_BOX}>
<img
src={previewUrl}
alt="QR Code Preview"
style={qrCodePageStyles.IMAGE_PREVIEW_IMG}
/>
<IconButton
size="small"
onClick={(e) => {
e.stopPropagation();
handleClearFile();
}}
sx={qrCodePageStyles.CLEAR_BUTTON}
>
<ClearIcon fontSize="small" />
</IconButton>
</Box>
<Typography variant="body2" color="text.secondary" sx={{ mt: 2 }}>
{selectedFile.name}
</Typography>
<Typography variant="caption" color="text.secondary">
{t('qrCode:clickToChange')}
</Typography>
</Box>
) : (
<>
<ImageIcon sx={{ fontSize: 48, color: 'text.disabled', mb: 2 }} />
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
{t('qrCode:clickToUpload')}
</Typography>
<Typography variant="caption" color="text.secondary">
{t('qrCode:supportFormats')}
</Typography>
</>
)}
</label>
</Box>
);
};
export default ImageUploader;
+15 -6
View File
@@ -1,5 +1,6 @@
import { alpha, Box, Stack, SxProps, Theme, Typography } from '@mui/material';
import { ReactNode } from 'react';
import { alpha, Box, Stack, SxProps, Theme, Typography, useTheme } from '@mui/material';
import { ReactNode, useMemo } from 'react';
import { getEntryPointType } from '@/config/features';
/**
* PageHeader 组件属性接口
@@ -7,7 +8,7 @@ import { ReactNode } from 'react';
export interface PageHeaderProps {
/** 要显示的图标组件 */
icon: ReactNode;
/** 图标的颜色,默认为 '#1976d2'(蓝色) */
/** 图标的颜色,默认使用主题 primary.main 色 */
iconColor?: string;
/** 主标题文本 */
title: string;
@@ -53,7 +54,7 @@ export interface PageHeaderProps {
*/
export default function PageHeader({
icon,
iconColor = '#1976d2',
iconColor,
title,
subtitle,
badge,
@@ -62,6 +63,14 @@ export default function PageHeader({
subtitleSx,
sx,
}: PageHeaderProps) {
const theme = useTheme();
const resolvedIconColor = iconColor ?? theme.palette.primary.main;
const entryPointType = useMemo(() => getEntryPointType(), []);
if (entryPointType === 'popup') {
return null;
}
return (
<Stack direction="row" spacing={1.5} alignItems="center" sx={{ mb: 2.5, ...sx }}>
{/* 图标容器 */}
@@ -69,8 +78,8 @@ export default function PageHeader({
sx={{
p: 1,
borderRadius: 2.5,
bgcolor: alpha(iconColor, 0.1),
color: iconColor,
bgcolor: alpha(resolvedIconColor, 0.1),
color: resolvedIconColor,
display: 'flex',
...iconSx,
}}
+56
View File
@@ -0,0 +1,56 @@
import { Box, Button, Typography } from '@mui/material';
import DownloadIcon from '@mui/icons-material/Download';
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
import { qrCodePageStyles } from '@/config/pageTheme';
import { useLazyTranslation } from '@/utils/useLazyTranslation';
interface QrCodePreviewProps {
/** 二维码 Data URL */
qrCodeDataUrl: string;
/** 下载回调 */
onDownload: () => void;
/** 复制回调 */
onCopy: () => void;
}
const QrCodePreview = ({ qrCodeDataUrl, onDownload, onCopy }: QrCodePreviewProps) => {
const { t } = useLazyTranslation('qrCode');
if (!qrCodeDataUrl) {
return (
<Box sx={qrCodePageStyles.QR_PREVIEW_CONTAINER}>
<Typography variant="body2" color="text.secondary" sx={qrCodePageStyles.PLACEHOLDER_TEXT}>
{t('qrCode:qrCodeWillShow')}
</Typography>
</Box>
);
}
return (
<Box sx={qrCodePageStyles.QR_PREVIEW_CONTAINER}>
<Box sx={qrCodePageStyles.QR_PREVIEW_INNER}>
<img src={qrCodeDataUrl} alt="QR Code" style={qrCodePageStyles.QR_PREVIEW_IMAGE} />
<Box sx={qrCodePageStyles.QR_PREVIEW_ACTIONS}>
<Button
variant="outlined"
startIcon={<DownloadIcon />}
onClick={onDownload}
sx={qrCodePageStyles.DOWNLOAD_BUTTON}
>
{t('qrCode:downloadButton')}
</Button>
<Button
variant="contained"
startIcon={<ContentCopyIcon />}
onClick={onCopy}
sx={qrCodePageStyles.COPY_BUTTON}
>
{t('qrCode:copyQrButton')}
</Button>
</Box>
</Box>
</Box>
);
};
export default QrCodePreview;
+208
View File
@@ -0,0 +1,208 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { render, screen, fireEvent, act } from '@testing-library/react';
import ImageUploader from '@/components/ImageUploader';
// 模拟 URL API
const mockCreateObjectURL = vi.fn();
const mockRevokeObjectURL = vi.fn();
Object.defineProperty(window.URL, 'createObjectURL', { value: mockCreateObjectURL });
Object.defineProperty(window.URL, 'revokeObjectURL', { value: mockRevokeObjectURL });
// 模拟 showMessage
vi.mock('@/components/GlobalSnackbar', () => ({
useSnackbar: () => ({
showMessage: vi.fn(),
}),
}));
describe('ImageUploader 组件', () => {
const mockOnFileChange = vi.fn();
const mockOnClearFile = vi.fn();
const mockOnPreviewUrlChange = vi.fn();
const mockOnDraggingChange = vi.fn();
const defaultProps = {
selectedFile: null,
onFileChange: mockOnFileChange,
onClearFile: mockOnClearFile,
previewUrl: '',
onPreviewUrlChange: mockOnPreviewUrlChange,
dragging: false,
onDraggingChange: mockOnDraggingChange,
};
beforeEach(() => {
vi.clearAllMocks();
mockCreateObjectURL.mockReturnValue('blob:test-url');
});
afterEach(() => {
vi.restoreAllMocks();
vi.resetAllMocks();
});
describe('渲染测试', () => {
it('当没有选中文件时应显示上传提示', () => {
render(<ImageUploader {...defaultProps} />);
expect(screen.getByText('qrCode:clickToUpload')).toBeInTheDocument();
expect(screen.getByText('qrCode:supportFormats')).toBeInTheDocument();
});
it('当没有选中文件时应显示 ImageIcon', () => {
render(<ImageUploader {...defaultProps} />);
expect(screen.getByTestId('ImageIcon')).toBeInTheDocument();
});
it('当选中文件时应显示文件预览', () => {
const mockFile = new File(['test'], 'test.png', { type: 'image/png' });
render(
<ImageUploader {...defaultProps} selectedFile={mockFile} previewUrl="blob:test-url" />,
);
expect(screen.getByText('test.png')).toBeInTheDocument();
expect(screen.getByText('qrCode:clickToChange')).toBeInTheDocument();
});
it('当选中文件时应显示预览图片', () => {
const mockFile = new File(['test'], 'test.png', { type: 'image/png' });
render(
<ImageUploader {...defaultProps} selectedFile={mockFile} previewUrl="blob:test-url" />,
);
const img = screen.getByAltText('QR Code Preview');
expect(img).toBeInTheDocument();
expect(img).toHaveAttribute('src', 'blob:test-url');
});
it('当选中文件时应显示清除按钮', () => {
const mockFile = new File(['test'], 'test.png', { type: 'image/png' });
render(
<ImageUploader {...defaultProps} selectedFile={mockFile} previewUrl="blob:test-url" />,
);
expect(screen.getByTestId('ClearIcon')).toBeInTheDocument();
});
it('应包含隐藏的文件输入框', () => {
render(<ImageUploader {...defaultProps} />);
const input = document.getElementById('qr-code-upload') as HTMLInputElement;
expect(input).toBeInTheDocument();
expect(input).toHaveAttribute('type', 'file');
expect(input).toHaveAttribute('accept', 'image/*');
});
});
describe('文件选择交互', () => {
it('选择文件时应调用 onFileChange 和 onPreviewUrlChange', async () => {
render(<ImageUploader {...defaultProps} />);
const input = document.getElementById('qr-code-upload') as HTMLInputElement;
const mockFile = new File(['test'], 'test.png', { type: 'image/png' });
await act(async () => {
fireEvent.change(input, { target: { files: [mockFile] } });
});
expect(mockOnFileChange).toHaveBeenCalledWith(mockFile);
expect(mockCreateObjectURL).toHaveBeenCalledWith(mockFile);
expect(mockOnPreviewUrlChange).toHaveBeenCalledWith('blob:test-url');
});
});
describe('拖拽交互', () => {
it('拖拽进入时应调用 onDraggingChange(true)', () => {
const { container } = render(<ImageUploader {...defaultProps} />);
const dropzone = container.firstChild as HTMLElement;
fireEvent.dragOver(dropzone);
expect(mockOnDraggingChange).toHaveBeenCalledWith(true);
});
it('拖拽离开时应调用 onDraggingChange(false)', () => {
const { container } = render(<ImageUploader {...defaultProps} />);
const dropzone = container.firstChild as HTMLElement;
fireEvent.dragLeave(dropzone);
expect(mockOnDraggingChange).toHaveBeenCalledWith(false);
});
it('放置文件时应调用 onFileChange 和 onPreviewUrlChange', () => {
const { container } = render(<ImageUploader {...defaultProps} />);
const dropzone = container.firstChild as HTMLElement;
const mockFile = new File(['test'], 'test.png', { type: 'image/png' });
const dropEvent = new Event('drop', { bubbles: true });
Object.defineProperty(dropEvent, 'dataTransfer', {
value: {
files: [mockFile],
},
});
Object.defineProperty(dropEvent, 'preventDefault', {
value: vi.fn(),
});
fireEvent(dropzone, dropEvent);
expect(mockOnDraggingChange).toHaveBeenCalledWith(false);
expect(mockOnFileChange).toHaveBeenCalledWith(mockFile);
});
});
describe('清除文件功能', () => {
it('点击清除按钮时应调用 onClearFile', () => {
const mockFile = new File(['test'], 'test.png', { type: 'image/png' });
render(
<ImageUploader {...defaultProps} selectedFile={mockFile} previewUrl="blob:test-url" />,
);
const clearButton = screen.getByTestId('ClearIcon').closest('button')!;
fireEvent.click(clearButton);
expect(mockRevokeObjectURL).toHaveBeenCalledWith('blob:test-url');
expect(mockOnClearFile).toHaveBeenCalledTimes(1);
});
it('清除文件时应撤销预览 URL', () => {
const mockFile = new File(['test'], 'test.png', { type: 'image/png' });
render(
<ImageUploader {...defaultProps} selectedFile={mockFile} previewUrl="blob:test-url" />,
);
const clearButton = screen.getByTestId('ClearIcon').closest('button')!;
fireEvent.click(clearButton);
expect(mockRevokeObjectURL).toHaveBeenCalledWith('blob:test-url');
});
});
describe('粘贴功能', () => {
it('监听粘贴事件', () => {
const addEventListenerSpy = vi.spyOn(document, 'addEventListener');
render(<ImageUploader {...defaultProps} />);
expect(addEventListenerSpy).toHaveBeenCalledWith('paste', expect.any(Function));
});
it('组件卸载时应移除粘贴事件监听', () => {
const removeEventListenerSpy = vi.spyOn(document, 'removeEventListener');
const { unmount } = render(<ImageUploader {...defaultProps} />);
unmount();
expect(removeEventListenerSpy).toHaveBeenCalledWith('paste', expect.any(Function));
});
});
describe('样式测试', () => {
it('拖拽状态时应应用拖拽样式', () => {
const { container } = render(<ImageUploader {...defaultProps} dragging={true} />);
const dropzone = container.firstChild as HTMLElement;
expect(dropzone).toBeInTheDocument();
});
it('有文件时应应用有文件样式', () => {
const mockFile = new File(['test'], 'test.png', { type: 'image/png' });
const { container } = render(
<ImageUploader {...defaultProps} selectedFile={mockFile} previewUrl="blob:test-url" />,
);
const dropzone = container.firstChild as HTMLElement;
expect(dropzone).toBeInTheDocument();
});
});
});
+40 -12
View File
@@ -2,8 +2,19 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import AccessTimeIcon from '@mui/icons-material/AccessTime';
import CloseIcon from '@mui/icons-material/Close';
import { render, screen } from '@testing-library/react';
import { ThemeProvider, createTheme } from '@mui/material/styles';
import PageHeader, { type PageHeaderProps } from '@/components/PageHeader';
vi.mock('@/config/features', () => ({
getEntryPointType: vi.fn(() => 'sidepanel'),
}));
const theme = createTheme();
function renderWithTheme(ui: React.ReactElement) {
return render(<ThemeProvider theme={theme}>{ui}</ThemeProvider>);
}
describe('PageHeader 组件系统', () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -22,32 +33,37 @@ describe('PageHeader 组件系统', () => {
describe('PageHeader UI 渲染', () => {
it('应渲染页面标题栏&副标题', () => {
render(<PageHeader {...defaultProps} />);
renderWithTheme(<PageHeader {...defaultProps} />);
expect(screen.getByText('时间戳转换')).toBeInTheDocument();
expect(screen.getByText('Unix 毫秒数转换与格式化')).toBeInTheDocument();
});
it('应渲染图标', () => {
render(<PageHeader {...defaultProps} />);
renderWithTheme(<PageHeader {...defaultProps} />);
expect(screen.getByTestId('AccessTimeIcon')).toBeInTheDocument();
});
it('应渲染自定义图标&图标颜色', () => {
render(<PageHeader {...defaultProps} icon={<CloseIcon />} iconColor="#FF0000" />);
renderWithTheme(<PageHeader {...defaultProps} icon={<CloseIcon />} iconColor="#FF0000" />);
expect(screen.getByTestId('CloseIcon')).toBeInTheDocument();
expect(screen.getByTestId('CloseIcon')).toHaveStyle('color: #FF0000;');
});
it('应默认使用主题 primary 色', () => {
renderWithTheme(<PageHeader {...defaultProps} icon={<CloseIcon />} />);
expect(screen.getByTestId('CloseIcon')).toHaveStyle(`color: ${theme.palette.primary.main};`);
});
it('应渲染 badge 组件', () => {
const badge = <span data-testid="test-badge">New</span>;
render(<PageHeader {...defaultProps} badge={badge} />);
renderWithTheme(<PageHeader {...defaultProps} badge={badge} />);
expect(screen.getByTestId('test-badge')).toBeInTheDocument();
expect(screen.getByText('New')).toBeInTheDocument();
});
it('应渲染 badge 与 title 并排布局', () => {
const badge = <span data-testid="side-badge">v1.0</span>;
render(<PageHeader {...defaultProps} badge={badge} />);
renderWithTheme(<PageHeader {...defaultProps} badge={badge} />);
const title = screen.getByText('时间戳转换');
const badgeEl = screen.getByTestId('side-badge');
expect(title).toBeInTheDocument();
@@ -57,13 +73,15 @@ describe('PageHeader 组件系统', () => {
describe('PageHeader 条件渲染', () => {
it('subtitle 为 undefined 时不应渲染副标题', () => {
const { container } = render(<PageHeader icon={<AccessTimeIcon />} title="仅标题" />);
const { container } = renderWithTheme(
<PageHeader icon={<AccessTimeIcon />} title="仅标题" />,
);
const captionElements = container.querySelectorAll('p');
expect(captionElements.length).toBe(0);
});
it('subtitle 为空字符串时不应渲染副标题', () => {
const { container } = render(
const { container } = renderWithTheme(
<PageHeader icon={<AccessTimeIcon />} title="标题" subtitle="" />,
);
const captionElements = container.querySelectorAll('p');
@@ -71,14 +89,14 @@ describe('PageHeader 组件系统', () => {
});
it('badge 为 undefined 时不应渲染 badge 区域', () => {
render(<PageHeader {...defaultProps} />);
renderWithTheme(<PageHeader {...defaultProps} />);
expect(screen.queryByText('v1.0')).not.toBeInTheDocument();
});
});
describe('PageHeader 样式扩展', () => {
it('iconSx 应作为属性传递给图标容器', () => {
const { container } = render(
const { container } = renderWithTheme(
<PageHeader {...defaultProps} iconSx={{ border: '2px solid red' }} />,
);
const iconContainer = container.querySelector('div');
@@ -86,21 +104,31 @@ describe('PageHeader 组件系统', () => {
});
it('titleSx 应作为属性传递给标题', () => {
render(<PageHeader {...defaultProps} titleSx={{ fontWeight: 'bold' }} />);
renderWithTheme(<PageHeader {...defaultProps} titleSx={{ fontWeight: 'bold' }} />);
const titleEl = screen.getByText('时间戳转换');
expect(titleEl).toBeInTheDocument();
});
it('subtitleSx 应作为属性传递给副标题', () => {
render(<PageHeader {...defaultProps} subtitleSx={{ color: 'red' }} />);
renderWithTheme(<PageHeader {...defaultProps} subtitleSx={{ color: 'red' }} />);
const subtitleEl = screen.getByText('Unix 毫秒数转换与格式化');
expect(subtitleEl).toBeInTheDocument();
});
it('sx 应作为属性传递给外层容器', () => {
const { container } = render(<PageHeader {...defaultProps} sx={{ mt: 3 }} />);
const { container } = renderWithTheme(<PageHeader {...defaultProps} sx={{ mt: 3 }} />);
const outerElement = container.firstChild;
expect(outerElement).toBeTruthy();
});
});
describe('PageHeader 入口点隐藏', () => {
it('popup 模式下应返回 null', async () => {
const { getEntryPointType } = await import('@/config/features');
vi.mocked(getEntryPointType).mockReturnValue('popup');
const { container } = renderWithTheme(<PageHeader {...defaultProps} />);
expect(container.innerHTML).toBe('');
});
});
});
@@ -0,0 +1,92 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import QrCodePreview from '@/components/QrCodePreview';
describe('QrCodePreview 组件', () => {
const mockOnDownload = vi.fn();
const mockOnCopy = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
vi.resetAllMocks();
});
describe('渲染测试', () => {
it('当 qrCodeDataUrl 为空时应显示占位文本', () => {
render(<QrCodePreview qrCodeDataUrl="" onDownload={mockOnDownload} onCopy={mockOnCopy} />);
expect(screen.getByText('qrCode:qrCodeWillShow')).toBeInTheDocument();
});
it('当 qrCodeDataUrl 有值时应显示二维码图片', () => {
const testDataUrl = 'data:image/png;base64,test123';
render(
<QrCodePreview
qrCodeDataUrl={testDataUrl}
onDownload={mockOnDownload}
onCopy={mockOnCopy}
/>,
);
const img = screen.getByAltText('QR Code');
expect(img).toBeInTheDocument();
expect(img).toHaveAttribute('src', testDataUrl);
});
it('当 qrCodeDataUrl 有值时应显示下载按钮', () => {
render(
<QrCodePreview
qrCodeDataUrl="data:image/png;base64,test"
onDownload={mockOnDownload}
onCopy={mockOnCopy}
/>,
);
expect(screen.getByText('qrCode:downloadButton')).toBeInTheDocument();
});
it('当 qrCodeDataUrl 有值时应显示复制按钮', () => {
render(
<QrCodePreview
qrCodeDataUrl="data:image/png;base64,test"
onDownload={mockOnDownload}
onCopy={mockOnCopy}
/>,
);
expect(screen.getByText('qrCode:copyQrButton')).toBeInTheDocument();
});
it('当 qrCodeDataUrl 为空时不应显示操作按钮', () => {
render(<QrCodePreview qrCodeDataUrl="" onDownload={mockOnDownload} onCopy={mockOnCopy} />);
expect(screen.queryByText('qrCode:downloadButton')).not.toBeInTheDocument();
expect(screen.queryByText('qrCode:copyQrButton')).not.toBeInTheDocument();
});
});
describe('交互测试', () => {
it('点击下载按钮时应调用 onDownload 回调', () => {
render(
<QrCodePreview
qrCodeDataUrl="data:image/png;base64,test"
onDownload={mockOnDownload}
onCopy={mockOnCopy}
/>,
);
fireEvent.click(screen.getByText('qrCode:downloadButton'));
expect(mockOnDownload).toHaveBeenCalledTimes(1);
});
it('点击复制按钮时应调用 onCopy 回调', () => {
render(
<QrCodePreview
qrCodeDataUrl="data:image/png;base64,test"
onDownload={mockOnDownload}
onCopy={mockOnCopy}
/>,
);
fireEvent.click(screen.getByText('qrCode:copyQrButton'));
expect(mockOnCopy).toHaveBeenCalledTimes(1);
});
});
});