* fix(ci): fix release workflow artifact upload issue

- Split artifact upload into 3 separate steps (Chrome, Firefox, Source)
- Update download steps to match new artifact names
- Add if-no-files-found: error for better error handling
- Fix Node.js 20 compatibility issue with upload-artifact@v4 glob pattern

* docs: 更新 AGENTS.md,添加测试工具和翻译文件结构说明

* docs: 更新 AGENTS.md,完善 CI 步骤和存储键名格式说明

* fix: remove unnecessary animations from StorageCleaner page

- Remove entrance animations (animate-in, fade-in, zoom-in) from all components
- Remove scale effect (active:scale-[0.99]) from clean button
- Remove transition effects (transition-all, transition-colors) from OptionItem, StorageOptionsGrid, AutoRefreshToggle
- Remove bouncing animation from error icon
- Remove pulsing animation from warning banner
- Keep animate-spin on button loading spinner as functional indicator

* fix: remove unnecessary animations from RightClickRestorer page

* fix: remove all unnecessary animations from all pages

- Remove dead code animations (animate-in, fade-in, slide-in, zoom-in, shake) from tailwindcss-animate plugin (not installed)
- Remove decorative transition effects (transition-all, transition-colors) from all page components
- Remove scale effects (active:scale-95) from buttons
- Remove bounce animations (animate-bounce) from icons
- Keep functional animate-spin on loading spinners as they provide essential loading feedback

Affected pages: Dashboard, Timestamp, Jwt, JsonTools, Base64Converter, HtmlToMarkdown, MarkdownToHtml, QrCode, TextStatistics

* docs: 添加项目文档(copilot-instructions、编码规范、各目录 README)

- 新增 .github/copilot-instructions.md:Copilot 指令文件,涵盖构建命令、CI 流水线、项目架构、关键规范
- 新增 .github/CODING_STANDARDS.md:完整的代码编写规范文档(TypeScript、React、样式、错误处理、命名、测试、Hook、i18n、存储等 12 个章节)
- 新增 11 个目录的 README.md:components、components/ui、config、entrypoints、i18n、lib、pages、providers、public、src、types、utils
- 更新 AGENTS.md:补充页面组件模式说明和 components/ui 目录

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(CopyButton): use shadcn buttonVariants instead of duplicated variant classes

Remove hand-written variantClasses/sizeClasses that duplicated shadcn's
buttonVariants. Now extends ButtonProps and imports buttonVariants from
components/ui/button for consistent styling.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(ErrorBoundary): use shadcn destructive tokens instead of hardcoded red colors

Replace hardcoded red-200/red-50/red-600 with border-destructive,
bg-destructive/5, text-destructive. Use variant='destructive' on
Button. Align error log styling with PageErrorBoundary.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(TextInputArea): replace hardcoded Chinese strings with i18n t() calls

Use existing translation keys for clear success, copy success, and
copy error messages instead of hardcoded Chinese text.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(QrCodePreview): use shadcn Button instead of manual button styles

Replace hand-written button class strings with shadcn Button component
using outline and default variants. Removes ~15 lines of duplicated
Tailwind classes.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* docs(GlobalSnackbar): clarify relationship with sonner toast

Add note explaining when to use GlobalSnackbar (provider context,
severity levels, custom positioning) vs sonner toast (simple one-off
messages).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(ImageUploader): wrap handleClearFile with useCallback

Prevents unnecessary re-renders by memoizing the callback, consistent
with handleFileChange which already uses useCallback.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(SwitchButtonGroup): remove redundant containerPadding conditional

Both branches of the ternary returned 'p-1'. Inline the constant
directly into the className string.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(TopBar): add missing openExtensionPage import

The function was called in handleOpenInTab but never imported from
utils/chromeTabs.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(TopBar): add selectedIndex boundary protection in keyboard nav

Guard against out-of-bounds access when search results change during
keyboard navigation. Check selectedIndex < totalItems before accessing
arrays.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(RouterContainer): remove unnecessary empty-dep useMemo

getEntryPointType() is a simple config getter. Replace useMemo(() => ..., [])
with a direct call — the empty dep array made the memo pointless.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* feat(i18n): add missing translation keys for error boundaries and copy messages

Add keys for errorBoundary, pageErrorBoundary, router, and
messages.copyEmpty in both zh and en locales.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(CopyButton): replace hardcoded Chinese strings with i18n t() calls

Use useTranslation('common') for tooltip, copy empty/success/error
toast messages.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(ErrorBoundary): replace hardcoded Chinese with i18n via withTranslation HOC

Use react-i18next withTranslation HOC for class component to translate
title, description, and refresh button text.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(PageErrorBoundary): replace hardcoded Chinese with i18n via withTranslation HOC

Use react-i18next withTranslation HOC for class component to translate
title, description, and retry button text.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(RouterContainer): replace hardcoded Chinese with i18n t() calls

Use useTranslation('common') for 404 page title and description with
entryPointType interpolation.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(pages): 修复 TypeScript 错误、硬编码字符串,移除死代码

- 修复 LiveClock.tsx CopyButton size 属性类型错误 (small → sm)
- 修复 ToolCard.tsx 紫色 RGB 值拼写错误 (147,51,2 purple → 147,51,232)
- 替换 4 处硬编码中文为 i18n 调用 (StorageCleaner, JsonTools)
- MarkdownToHtml 预览链接色改为 CSS 变量以支持暗黑模式
- 移除 Base64Converter 中已被 Base64ConverterSection 替代的死代码
  (FileMode.tsx, ImageMode.tsx 及其测试文件)
- 更新 README.md 移除对已删除文件的引用

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(CopyButton, LiveClock): 修改按钮大小为 'icon',移除硬编码颜色配置

* refactor(TextInputArea): 复用 CopyButton 组件替换内联复制逻辑

* fix(test): 修复 CopyButton mock 未捕获 clipboard writeText 异常

---------

Co-authored-by: Ubuntu <ubuntu@localhost.localdomain>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
LingandRX
2026-05-28 00:40:54 +08:00
committed by GitHub
parent 42d3fedbaa
commit d4c29a1bf4
48 changed files with 1922 additions and 1113 deletions
-220
View File
@@ -1,220 +0,0 @@
import { Trash2, Upload } from 'lucide-react';
import TextInputArea from '@/components/TextInputArea';
import { useLazyTranslation } from '@/utils/useLazyTranslation';
import CopyButton from '@/components/CopyButton';
import DecodeResultPaper from '@/components/DecodeResultPaper';
import { Button } from '@/components/ui/button';
import { downloadBlob, formatFileSize, MAX_FILE_SIZE } from '@/utils/base64Converter';
import { useStorageState } from '@/utils/useStorageState';
import type { Base64ConvertDirection } from '@/types/storage';
import SwitchButtonGroup from '@/components/SwitchButtonGroup';
import { useBase64Converter } from './useBase64Converter'; // 💡 斩断重复代码
import { cn } from '@/lib/utils';
const isValidDirection = (val: unknown): val is Base64ConvertDirection =>
val === 'encode' || val === 'decode';
export default function FileMode() {
const { t } = useLazyTranslation('base64Converter');
const [direction, setDirection] = useStorageState(
'base64Converter/fileMode/direction',
'encode',
isValidDirection,
);
const {
result,
info,
isLoading,
isDragging,
setIsDragging,
fileInputRef,
encodeError,
decodeInput,
setDecodeInput,
decoded,
decodeError,
decodedFileName,
setCustomFileName,
resetAll,
safeFileSelect,
} = useBase64Converter({ mode: 'file' });
const handleDownload = () => {
if (decoded) downloadBlob(decoded.blob, decodedFileName);
};
return (
<div className="w-full flex flex-col space-y-4">
<div className="flex h-11 items-center px-1.5 bg-secondary/40 rounded-xl border border-border/60 w-fit">
<SwitchButtonGroup
value={direction}
options={[
{ value: 'encode', label: t('encode') },
{ value: 'decode', label: t('decode') },
]}
onChange={(next) => {
if (next && next !== direction) {
resetAll();
setDirection(next);
}
}}
size="small"
/>
</div>
{direction === 'encode' ? (
<div className="flex flex-col space-y-4">
<div
onDragOver={(e) => {
e.preventDefault();
setIsDragging(true);
}}
onDragLeave={() => setIsDragging(false)}
onDrop={(e) => {
e.preventDefault();
setIsDragging(false);
const file = e.dataTransfer.files[0];
if (file) safeFileSelect(file);
}}
onClick={() => fileInputRef.current?.click()}
className={cn(
'flex flex-col items-center justify-center min-h-[190px] border-2 border-dashed rounded-2xl p-8 cursor-pointer',
isDragging
? 'border-primary bg-primary/10'
: info
? 'border-primary/60 bg-primary/5'
: 'border-border bg-muted/40 hover:border-primary/80 hover:bg-muted/70',
)}
>
<input
ref={fileInputRef}
type="file"
hidden
onChange={(e) => {
const file = e.target.files?.[0];
if (file) safeFileSelect(file);
}}
/>
{isLoading ? (
<div className="w-9 h-9 border-3 border-primary/20 border-t-primary rounded-full animate-spin" />
) : info ? (
<div className="flex flex-col items-center gap-1.5 text-center">
<Upload className="w-8 h-8 text-primary" />
<span className="text-sm font-bold text-foreground/90 max-w-[280px] truncate">
{info.name}
</span>
<span className="text-xs text-muted-foreground/80 font-mono tabular-nums">
{formatFileSize(info.size)} · {info.type}
</span>
<span className="text-[11px] font-medium text-primary/80 mt-1">
{t('clickOrDropToReplace')}
</span>
</div>
) : (
<div className="flex flex-col items-center gap-1.5 text-center">
<Upload className="w-8 h-8 text-muted-foreground/60" />
<span className="text-xs font-bold text-foreground/80">
{t('clickOrDropToFile')}
</span>
<span className="text-[10px] font-medium text-muted-foreground/60">
{t('maxFileSize', { max: `${MAX_FILE_SIZE / 1024 / 1024} MB` })}
</span>
</div>
)}
</div>
{encodeError && (
<div
role="alert"
className="p-3.5 bg-destructive/10 border border-destructive/20 rounded-xl text-xs font-semibold text-destructive"
>
{encodeError}
</div>
)}
{result && (
<div className="p-4 rounded-2xl bg-card border border-border shadow-sm flex flex-col space-y-3">
<div className="flex justify-between items-center select-none">
<span className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground/90">
{t('base64Output')}
</span>
<div className="flex gap-2">
<CopyButton
text={result.rawBase64}
tooltip={t('copyRaw')}
className="h-6 px-2 rounded-md border text-[10px] font-bold"
/>
<CopyButton
text={result.output}
tooltip={t('copyDataUri')}
className="h-6 px-2 rounded-md border text-[10px] font-bold"
/>
</div>
</div>
<TextInputArea
readOnly
value={
result.output.length > 2000
? `${result.output.substring(0, 2000)}...`
: result.output
}
showClear={false}
minRows={4}
/>
<div className="flex items-center justify-between font-mono text-[10px] text-muted-foreground/70 select-none pt-1">
<div className="flex gap-4 items-center tabular-nums">
<span>
{t('originalSize')}:{' '}
<span className="font-semibold text-foreground/80">
{formatFileSize(result.originalBytes)}
</span>
</span>
<span className="text-border/60">|</span>
<span>
{t('encodedSize')}:{' '}
<span className="font-semibold text-foreground/80">
{formatFileSize(result.outputBytes)}
</span>
</span>
</div>
<Button
variant="ghost"
size="sm"
onClick={resetAll}
className="h-7 rounded-md text-muted-foreground hover:text-destructive text-[11px] gap-1 px-2"
>
<Trash2 className="w-3.5 h-3.5" />
{t('clear')}
</Button>
</div>
</div>
)}
</div>
) : (
<div className="flex flex-col space-y-4">
<TextInputArea
placeholder={t('decodeBase64Placeholder')}
value={decodeInput}
onChange={setDecodeInput}
externalError={decodeError || undefined}
showClear={true}
allowCopy={true}
minRows={6}
onClear={resetAll}
/>
{decoded && (
<DecodeResultPaper
title={t('decodedFileOutput')}
mimeType={decoded.mimeType}
blobSize={decoded.blob.size}
fileName={decodedFileName}
onFileNameChange={setCustomFileName}
onDownload={handleDownload}
/>
)}
</div>
)}
</div>
);
}
-259
View File
@@ -1,259 +0,0 @@
import { Image as ImageIcon, Trash2 } from 'lucide-react';
import TextInputArea from '@/components/TextInputArea';
import { useLazyTranslation } from '@/utils/useLazyTranslation';
import CopyButton from '@/components/CopyButton';
import DecodeResultPaper from '@/components/DecodeResultPaper';
import { Button } from '@/components/ui/button';
import { downloadBlob, formatFileSize } from '@/utils/base64Converter';
import { useStorageState } from '@/utils/useStorageState';
import type { Base64ConvertDirection } from '@/types/storage';
import SwitchButtonGroup from '@/components/SwitchButtonGroup';
import { useBase64Converter } from './useBase64Converter'; // 💡 引入共享核心
import { cn } from '@/lib/utils';
const isValidDirection = (val: unknown): val is Base64ConvertDirection =>
val === 'encode' || val === 'decode';
export default function ImageMode() {
const { t } = useLazyTranslation('base64Converter');
const [direction, setDirection] = useStorageState(
'base64Converter/imageMode/direction',
'encode',
isValidDirection,
);
// 消费完全托管的核心 Hook,消灭本地多余状态机
const {
result,
info,
isLoading,
isDragging,
setIsDragging,
fileInputRef,
encodeError,
decodeInput,
setDecodeInput,
decoded,
decodeError,
decodedFileName,
setCustomFileName,
resetAll,
safeFileSelect,
} = useBase64Converter({ mode: 'image' });
const handleDirectionChange = (next: Base64ConvertDirection) => {
if (!next || next === direction) return;
resetAll();
setDirection(next);
};
const handleDownload = () => {
if (decoded) downloadBlob(decoded.blob, decodedFileName);
};
return (
<div className="w-full flex flex-col space-y-4">
<div className="flex h-11 items-center px-1.5 bg-secondary/40 rounded-xl border border-border/60 w-fit">
<SwitchButtonGroup
value={direction}
options={[
{ value: 'encode', label: t('encode') },
{ value: 'decode', label: t('decode') },
]}
onChange={handleDirectionChange}
size="small"
/>
</div>
{direction === 'encode' ? (
<div className="flex flex-col space-y-4">
{/* 图片拖拽投递箱终端 */}
<div
onDragOver={(e) => {
e.preventDefault();
setIsDragging(true);
}}
onDragLeave={() => setIsDragging(false)}
onDrop={(e) => {
e.preventDefault();
setIsDragging(false);
const file = e.dataTransfer.files[0];
if (file) safeFileSelect(file);
}}
onClick={() => fileInputRef.current?.click()}
className={cn(
'flex flex-col items-center justify-center min-h-[190px] border-2 border-dashed rounded-2xl p-8 cursor-pointer',
isDragging
? 'border-primary bg-primary/10'
: info
? 'border-primary/60 bg-primary/5'
: 'border-border bg-muted/40 hover:border-primary/80 hover:bg-muted/70',
)}
>
<input
ref={fileInputRef}
type="file"
accept="image/*"
hidden
onChange={(e) => {
const file = e.target.files?.[0];
if (file) safeFileSelect(file);
}}
/>
{isLoading ? (
<div className="w-9 h-9 border-3 border-primary/20 border-t-primary rounded-full animate-spin" />
) : info ? (
<div className="flex flex-col items-center gap-1.5 text-center w-full">
{result && (
<div className="relative p-1 border border-border bg-background rounded-lg shadow-sm mb-1 max-w-[180px] overflow-hidden">
<img
src={result.output}
alt="preview"
className="max-h-32 w-full object-contain rounded"
/>
</div>
)}
<span className="text-sm font-bold text-foreground/90 max-w-[280px] truncate">
{info.name}
</span>
<span className="text-xs text-muted-foreground/80 font-mono tabular-nums">
{formatFileSize(info.size)} · {info.type}
</span>
<span className="text-[11px] font-medium text-primary/80 mt-1">
{t('clickOrDropToReplace')}
</span>
</div>
) : (
<div className="flex flex-col items-center gap-1.5 text-center">
<ImageIcon className="w-8 h-8 text-muted-foreground/60" />
<span className="text-xs font-bold text-foreground/80">
{t('clickOrDropToImage')}
</span>
<span className="text-[10px] font-medium text-muted-foreground/60">
{t('supportedFormats')}
</span>
</div>
)}
</div>
{encodeError && (
<div
role="alert"
className="p-3.5 bg-destructive/10 border border-destructive/20 rounded-xl text-xs font-semibold text-destructive tracking-wide"
>
{encodeError}
</div>
)}
{result && (
<div className="p-4 rounded-2xl bg-card border border-border shadow-sm flex flex-col space-y-3">
<div className="flex justify-between items-center select-none">
<span className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground/90">
{t('base64Output')}
</span>
<div className="flex gap-2">
<CopyButton
text={result.rawBase64}
tooltip={t('copyRaw')}
className="h-6 px-2 rounded-md border text-[10px] font-bold"
/>
<CopyButton
text={result.output}
tooltip={t('copyDataUri')}
className="h-6 px-2 rounded-md border text-[10px] font-bold"
/>
</div>
</div>
<TextInputArea
readOnly
value={
result.output.length > 2000
? `${result.output.substring(0, 2000)}...`
: result.output
}
showClear={false}
minRows={4}
/>
<div className="flex items-center justify-between font-mono text-[10px] text-muted-foreground/70 select-none pt-1">
<div className="flex gap-4 items-center tabular-nums">
<span>
{t('originalSize')}:{' '}
<span className="font-semibold text-foreground/80">
{formatFileSize(result.originalBytes)}
</span>
</span>
<span className="text-border/60">|</span>
<span>
{t('encodedSize')}:{' '}
<span className="font-semibold text-foreground/80">
{formatFileSize(result.outputBytes)}
</span>
</span>
</div>
<Button
variant="ghost"
size="sm"
onClick={resetAll}
className="h-7 rounded-md text-muted-foreground hover:text-destructive text-[11px] gap-1 px-2"
>
<Trash2 className="w-3.5 h-3.5" />
{t('clear')}
</Button>
</div>
</div>
)}
{info && !result && (
<div className="flex justify-end select-none">
<Button
variant="outline"
size="sm"
onClick={resetAll}
className="h-8 rounded-md text-xs gap-1.5 text-destructive hover:text-destructive hover:bg-destructive/5 dark:hover:bg-destructive/10"
>
<Trash2 className="w-3.5 h-3.5" />
{t('clear')}
</Button>
</div>
)}
</div>
) : (
<div className="flex flex-col space-y-4">
<TextInputArea
placeholder={t('decodeBase64Placeholder')}
value={decodeInput}
onChange={setDecodeInput}
externalError={decodeError || undefined}
showClear={true}
allowCopy={true}
minRows={6}
onClear={resetAll}
/>
{decoded && (
<div>
<DecodeResultPaper
title={t('decodedImageOutput')}
mimeType={decoded.mimeType}
blobSize={decoded.blob.size}
fileName={decodedFileName}
onFileNameChange={setCustomFileName}
onDownload={handleDownload}
>
<div className="relative p-1.5 border border-border bg-background dark:bg-muted/10 rounded-xl max-w-[220px] mb-3 overflow-hidden shadow-sm">
<img
src={`data:${decoded.mimeType};base64,${decoded.rawBase64}`}
alt="decoded preview"
className="max-h-40 w-full rounded-lg object-contain bg-[linear-gradient(45deg,#ccc_25%,transparent_25%),linear-gradient(-45deg,#ccc_25%,transparent_25%),linear-gradient(45deg,transparent_75%,#ccc_75%),linear-gradient(-45deg,transparent_75%,#ccc_75%)] bg-[size:10px_10px] bg-[position:0_0,0_5px,5px_-5px,-5px_0] dark:bg-none"
/>
</div>
</DecodeResultPaper>
</div>
)}
</div>
)}
</div>
);
}
@@ -1,235 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
import FileMode from '../FileMode';
// Mock CopyButton
vi.mock('@/components/CopyButton', () => ({
default: ({ text, tooltip }: { text: string; tooltip?: string }) => (
<button data-testid="copy-button" data-tooltip={tooltip}>
{text.slice(0, 20)}
</button>
),
}));
beforeEach(() => {
localStorage.clear();
vi.useFakeTimers({ shouldAdvanceTime: true });
});
afterEach(() => {
vi.useRealTimers();
});
// useStorageState's async loadState may overwrite user toggle if we click before the
// initial chrome.storage read settles. Flush pending microtasks first.
const waitForStorageReady = () => act(() => Promise.resolve());
describe('FileMode', () => {
it('应该渲染文件上传区域', async () => {
render(<FileMode />);
await waitForStorageReady();
expect(screen.getByText('base64Converter:clickOrDropToFile')).toBeInTheDocument();
expect(screen.getByText('base64Converter:maxFileSize')).toBeInTheDocument();
});
it('应该处理有效的文件选择', async () => {
render(<FileMode />);
await waitForStorageReady();
const file = new File(['test content'], 'test.txt', { type: 'text/plain' });
// 文件输入是隐藏的,直接触发 change 事件
const hiddenInput = document.querySelector('input[type="file"]') as HTMLInputElement;
fireEvent.change(hiddenInput, { target: { files: [file] } });
await waitFor(() => {
expect(screen.getByText('test.txt')).toBeInTheDocument();
expect(screen.getByText('base64Converter:base64Output')).toBeInTheDocument();
});
});
it('应该拒绝超出大小限制的文件', async () => {
render(<FileMode />);
await waitForStorageReady();
// 创建一个超过 10MB 的文件
const largeContent = new Uint8Array(11 * 1024 * 1024);
const file = new File([largeContent], 'large.bin', { type: 'application/octet-stream' });
const hiddenInput = document.querySelector('input[type="file"]') as HTMLInputElement;
fireEvent.change(hiddenInput, { target: { files: [file] } });
await waitFor(() => {
expect(screen.getByRole('alert')).toHaveTextContent('base64Converter:fileSizeExceeded');
});
});
it('点击清除按钮应该清空文件状态', async () => {
render(<FileMode />);
await waitForStorageReady();
const file = new File(['test'], 'test.txt', { type: 'text/plain' });
const hiddenInput = document.querySelector('input[type="file"]') as HTMLInputElement;
fireEvent.change(hiddenInput, { target: { files: [file] } });
await waitFor(() => {
expect(screen.getByText('test.txt')).toBeInTheDocument();
});
fireEvent.click(screen.getByText('base64Converter:clear'));
await waitFor(() => {
expect(screen.queryByText('test.txt')).not.toBeInTheDocument();
expect(screen.getByText('base64Converter:clickOrDropToFile')).toBeInTheDocument();
});
});
it('应该显示文件大小和类型信息', async () => {
render(<FileMode />);
await waitForStorageReady();
const file = new File(['test content'], 'test.txt', { type: 'text/plain' });
const hiddenInput = document.querySelector('input[type="file"]') as HTMLInputElement;
fireEvent.change(hiddenInput, { target: { files: [file] } });
await waitFor(() => {
expect(screen.getByText('test.txt')).toBeInTheDocument();
});
// 文件类型显示在 caption 中,格式为 "size · type"
expect(screen.getByText(/test\.txt/)).toBeInTheDocument();
});
it('应该显示原始大小和编码大小', async () => {
render(<FileMode />);
await waitForStorageReady();
const file = new File(['test content'], 'test.txt', { type: 'text/plain' });
const hiddenInput = document.querySelector('input[type="file"]') as HTMLInputElement;
fireEvent.change(hiddenInput, { target: { files: [file] } });
await waitFor(() => {
expect(screen.getByText(/base64Converter:originalSize/)).toBeInTheDocument();
expect(screen.getByText(/base64Converter:encodedSize/)).toBeInTheDocument();
});
});
it('应该提供复制按钮', async () => {
render(<FileMode />);
await waitForStorageReady();
const file = new File(['test'], 'test.txt', { type: 'text/plain' });
const hiddenInput = document.querySelector('input[type="file"]') as HTMLInputElement;
fireEvent.change(hiddenInput, { target: { files: [file] } });
await waitFor(() => {
const copyButtons = screen.getAllByTestId('copy-button');
expect(copyButtons.length).toBeGreaterThanOrEqual(2);
});
});
it('应该渲染 encode/decode 切换按钮', () => {
render(<FileMode />);
expect(screen.getByText('base64Converter:encode')).toBeInTheDocument();
expect(screen.getByText('base64Converter:decode')).toBeInTheDocument();
});
it('切到 decode 应该显示 Base64 输入框', async () => {
render(<FileMode />);
await waitForStorageReady();
fireEvent.click(screen.getByText('base64Converter:decode'));
expect(
await screen.findByPlaceholderText('base64Converter:decodeBase64Placeholder'),
).toBeInTheDocument();
});
it('解码 PDF Base64 后应该显示 application/pdf 与默认文件名 decoded.pdf', async () => {
render(<FileMode />);
await waitForStorageReady();
fireEvent.click(screen.getByText('base64Converter:decode'));
const input = await screen.findByPlaceholderText('base64Converter:decodeBase64Placeholder');
fireEvent.change(input, { target: { value: 'JVBERi0K' } });
act(() => {
vi.advanceTimersByTime(250);
});
await waitFor(() => {
expect(screen.getByText('base64Converter:decodedFileOutput')).toBeInTheDocument();
expect(screen.getByText(/application\/pdf/)).toBeInTheDocument();
expect(screen.getByDisplayValue('decoded.pdf')).toBeInTheDocument();
});
});
it('解码后的文件名应该可编辑', async () => {
render(<FileMode />);
await waitForStorageReady();
fireEvent.click(screen.getByText('base64Converter:decode'));
const input = await screen.findByPlaceholderText('base64Converter:decodeBase64Placeholder');
fireEvent.change(input, { target: { value: 'JVBERi0K' } });
act(() => {
vi.advanceTimersByTime(250);
});
const filenameInput = (await screen.findByDisplayValue('decoded.pdf')) as HTMLInputElement;
fireEvent.change(filenameInput, { target: { value: 'my-report.pdf' } });
expect(filenameInput.value).toBe('my-report.pdf');
});
it('解码后应该显示下载按钮', async () => {
render(<FileMode />);
await waitForStorageReady();
fireEvent.click(screen.getByText('base64Converter:decode'));
const input = await screen.findByPlaceholderText('base64Converter:decodeBase64Placeholder');
fireEvent.change(input, { target: { value: 'JVBERi0K' } });
act(() => {
vi.advanceTimersByTime(250);
});
expect(await screen.findByText('base64Converter:download')).toBeInTheDocument();
});
it('解码非法 Base64 应该显示 invalidBase64 错误', async () => {
render(<FileMode />);
await waitForStorageReady();
fireEvent.click(screen.getByText('base64Converter:decode'));
const input = await screen.findByPlaceholderText('base64Converter:decodeBase64Placeholder');
fireEvent.change(input, { target: { value: '!!!not base64' } });
act(() => {
vi.advanceTimersByTime(250);
});
await waitFor(() => {
expect(screen.getByText('base64Converter:invalidBase64')).toBeInTheDocument();
});
});
it('切换方向时应该清空解码状态', async () => {
render(<FileMode />);
await waitForStorageReady();
fireEvent.click(screen.getByText('base64Converter:decode'));
const input = await screen.findByPlaceholderText('base64Converter:decodeBase64Placeholder');
fireEvent.change(input, { target: { value: 'JVBERi0K' } });
act(() => {
vi.advanceTimersByTime(250);
});
await waitFor(() => {
expect(screen.getByText('base64Converter:decodedFileOutput')).toBeInTheDocument();
});
fireEvent.click(screen.getByText('base64Converter:encode'));
await waitFor(() => {
expect(screen.queryByText('decodedFileOutput')).not.toBeInTheDocument();
});
});
});
@@ -1,182 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
import ImageMode from '../ImageMode';
// Mock CopyButton
vi.mock('@/components/CopyButton', () => ({
default: ({ text, tooltip }: { text: string; tooltip?: string }) => (
<button data-testid="copy-button" data-tooltip={tooltip}>
{text.slice(0, 20)}
</button>
),
}));
beforeEach(() => {
localStorage.clear();
vi.useFakeTimers({ shouldAdvanceTime: true });
});
afterEach(() => {
vi.useRealTimers();
});
const waitForStorageReady = () => act(() => Promise.resolve());
describe('ImageMode', () => {
it('应该渲染图像上传区域', async () => {
render(<ImageMode />);
await waitForStorageReady();
expect(screen.getByText('base64Converter:clickOrDropToImage')).toBeInTheDocument();
expect(screen.getByText('base64Converter:supportedFormats')).toBeInTheDocument();
});
it('应该接受有效的图像文件', async () => {
render(<ImageMode />);
await waitForStorageReady();
const file = new File(['fake-image-data'], 'test.png', { type: 'image/png' });
const hiddenInput = document.querySelector('input[type="file"]') as HTMLInputElement;
fireEvent.change(hiddenInput, { target: { files: [file] } });
await waitFor(() => {
expect(screen.getByText('test.png')).toBeInTheDocument();
expect(screen.getByText('base64Converter:base64Output')).toBeInTheDocument();
});
});
it('应该拒绝非图像文件', async () => {
render(<ImageMode />);
await waitForStorageReady();
const file = new File(['not an image'], 'test.txt', { type: 'text/plain' });
const hiddenInput = document.querySelector('input[type="file"]') as HTMLInputElement;
fireEvent.change(hiddenInput, { target: { files: [file] } });
await waitFor(() => {
expect(screen.getByRole('alert')).toHaveTextContent('base64Converter:unsupportedImageType');
});
});
it('应该拒绝超出大小限制的图像', async () => {
render(<ImageMode />);
await waitForStorageReady();
const largeContent = new Uint8Array(11 * 1024 * 1024);
const file = new File([largeContent], 'large.png', { type: 'image/png' });
const hiddenInput = document.querySelector('input[type="file"]') as HTMLInputElement;
fireEvent.change(hiddenInput, { target: { files: [file] } });
await waitFor(() => {
expect(screen.getByRole('alert')).toHaveTextContent('base64Converter:fileSizeExceeded');
});
});
it('应该通过扩展名识别图像', async () => {
render(<ImageMode />);
await waitForStorageReady();
// 没有 MIME 类型但有正确扩展名
const file = new File(['fake'], 'test.jpg');
const hiddenInput = document.querySelector('input[type="file"]') as HTMLInputElement;
fireEvent.change(hiddenInput, { target: { files: [file] } });
await waitFor(() => {
expect(screen.getByText('test.jpg')).toBeInTheDocument();
});
});
it('点击清除按钮应该清空图像状态', async () => {
render(<ImageMode />);
await waitForStorageReady();
const file = new File(['fake'], 'test.png', { type: 'image/png' });
const hiddenInput = document.querySelector('input[type="file"]') as HTMLInputElement;
fireEvent.change(hiddenInput, { target: { files: [file] } });
await waitFor(() => {
expect(screen.getByText('test.png')).toBeInTheDocument();
});
fireEvent.click(screen.getByText('base64Converter:clear'));
await waitFor(() => {
expect(screen.queryByText('test.png')).not.toBeInTheDocument();
expect(screen.getByText('base64Converter:clickOrDropToImage')).toBeInTheDocument();
});
});
it('应该显示图像预览', async () => {
render(<ImageMode />);
await waitForStorageReady();
const file = new File(['fake-image'], 'test.png', { type: 'image/png' });
const hiddenInput = document.querySelector('input[type="file"]') as HTMLInputElement;
fireEvent.change(hiddenInput, { target: { files: [file] } });
await waitFor(() => {
const img = screen.getByAltText('preview');
expect(img).toBeInTheDocument();
expect(img.tagName.toLowerCase()).toBe('img');
});
});
it('应该渲染 encode/decode 切换按钮', async () => {
render(<ImageMode />);
await waitForStorageReady();
expect(screen.getByText('base64Converter:encode')).toBeInTheDocument();
expect(screen.getByText('base64Converter:decode')).toBeInTheDocument();
});
it('解码 PNG Base64 后应该显示图像预览', async () => {
render(<ImageMode />);
await waitForStorageReady();
fireEvent.click(screen.getByText('base64Converter:decode'));
const input = await screen.findByPlaceholderText('base64Converter:decodeBase64Placeholder');
fireEvent.change(input, { target: { value: 'iVBORw0KGgo=' } });
act(() => {
vi.advanceTimersByTime(250);
});
await waitFor(() => {
expect(screen.getByText('base64Converter:decodedImageOutput')).toBeInTheDocument();
const img = screen.getByAltText('decoded preview');
expect(img).toBeInTheDocument();
expect(img.tagName.toLowerCase()).toBe('img');
});
});
it('解码后默认文件名应该为 decoded.png', async () => {
render(<ImageMode />);
await waitForStorageReady();
fireEvent.click(screen.getByText('base64Converter:decode'));
const input = await screen.findByPlaceholderText('base64Converter:decodeBase64Placeholder');
fireEvent.change(input, { target: { value: 'iVBORw0KGgo=' } });
act(() => {
vi.advanceTimersByTime(250);
});
expect(await screen.findByDisplayValue('decoded.png')).toBeInTheDocument();
});
it('解码非法 Base64 应该显示 invalidBase64 错误', async () => {
render(<ImageMode />);
await waitForStorageReady();
fireEvent.click(screen.getByText('base64Converter:decode'));
const input = await screen.findByPlaceholderText('base64Converter:decodeBase64Placeholder');
fireEvent.change(input, { target: { value: '!!!not base64' } });
act(() => {
vi.advanceTimersByTime(250);
});
await waitFor(() => {
expect(screen.getByText('base64Converter:invalidBase64')).toBeInTheDocument();
});
});
});
@@ -4,6 +4,7 @@ import TextMode from '../TextMode';
// Mock CopyButton
vi.mock('@/components/CopyButton', () => ({
CopyButton: ({ text }: { text: string }) => <button data-testid="copy-button">{text}</button>,
default: ({ text }: { text: string }) => <button data-testid="copy-button">{text}</button>,
}));
@@ -39,7 +40,7 @@ describe('TextMode', () => {
await waitFor(() => {
expect(screen.getByText('base64Converter:base64Output')).toBeInTheDocument();
});
expect(screen.getByTestId('copy-button')).toHaveTextContent('SGVsbG8=');
expect(screen.getByRole('button', { name: 'SGVsbG8=' })).toBeInTheDocument();
});
it('应该解码 Base64 文本', async () => {
@@ -58,7 +59,7 @@ describe('TextMode', () => {
await waitFor(() => {
expect(screen.getByText('base64Converter:textOutput')).toBeInTheDocument();
});
expect(screen.getByTestId('copy-button')).toHaveTextContent('Hello');
expect(screen.getByRole('button', { name: 'Hello' })).toBeInTheDocument();
});
it('应该对无效 Base64 显示错误', async () => {
@@ -91,7 +92,7 @@ describe('TextMode', () => {
});
await waitFor(() => {
expect(screen.getByTestId('copy-button')).toHaveTextContent('SGVsbG8=');
expect(screen.getByRole('button', { name: 'SGVsbG8=' })).toBeInTheDocument();
});
// 切换方向
@@ -99,7 +100,7 @@ describe('TextMode', () => {
// 输出应该被清除
await waitFor(() => {
expect(screen.queryByTestId('copy-button')).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'SGVsbG8=' })).not.toBeInTheDocument();
});
});
@@ -114,13 +115,13 @@ describe('TextMode', () => {
});
await waitFor(() => {
expect(screen.getByTestId('copy-button')).toHaveTextContent('SGVsbG8=');
expect(screen.getByRole('button', { name: 'SGVsbG8=' })).toBeInTheDocument();
});
fireEvent.click(screen.getByRole('button', { name: 'textInputArea.clear' }));
await waitFor(() => {
expect(screen.queryByTestId('copy-button')).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'SGVsbG8=' })).not.toBeInTheDocument();
expect(input).toHaveValue('');
});
});