Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ab3b19418b | |||
| b656c78d7e | |||
| 7fd3819586 | |||
| 595eb59726 | |||
| 70b799aa2d | |||
| 58af2af37b | |||
| 359326c6da |
+10
-3
@@ -1,6 +1,13 @@
|
||||
# pre-push: 严格检查,阻塞有问题的代码推送到远程
|
||||
# 类型检查
|
||||
# 类型检查(全项目,因为类型错误可能跨文件传播)
|
||||
npx tsc --noEmit
|
||||
|
||||
# ESLint 严格检查(不允许 warning)
|
||||
npx eslint . --max-warnings=0
|
||||
# ESLint 严格检查(仅检查本次推送的变更文件,不阻塞不相关的旧代码)
|
||||
# 新分支无 upstream 时,回退到与 origin/main 对比
|
||||
MERGE_BASE=$(git merge-base HEAD @{upstream} 2>/dev/null || git merge-base HEAD origin/main 2>/dev/null)
|
||||
if [ -n "$MERGE_BASE" ]; then
|
||||
CHANGED_FILES=$(git diff --name-only --diff-filter=d "$MERGE_BASE" HEAD -- '*.ts' '*.tsx' '*.js' '*.jsx' '*.mjs')
|
||||
if [ -n "$CHANGED_FILES" ]; then
|
||||
echo "$CHANGED_FILES" | xargs npx eslint --max-warnings=0
|
||||
fi
|
||||
fi
|
||||
|
||||
@@ -53,6 +53,7 @@ export const CopyButton: React.FC<CopyButtonProps> = ({
|
||||
type="button"
|
||||
onClick={handleCopy}
|
||||
title={tooltip ?? t('buttons.copy')}
|
||||
aria-label={tooltip ?? t('buttons.copy')}
|
||||
className={cn(
|
||||
buttonVariants({ variant, size }),
|
||||
copied &&
|
||||
@@ -62,7 +63,7 @@ export const CopyButton: React.FC<CopyButtonProps> = ({
|
||||
{...props}
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="h-[1.2em] w-[1.2em] animate-in fade-in zoom-in-75 duration-200" />
|
||||
<Check className="h-[1.2em] w-[1.2em]" />
|
||||
) : (
|
||||
<Copy className="h-[1.2em] w-[1.2em]" />
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { act, render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
// unmock the globally-mocked component so we test the real implementation
|
||||
vi.unmock('@/components/CopyButton');
|
||||
|
||||
vi.mock('@/utils/clipboard', () => ({
|
||||
copyTextToClipboard: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('sonner', () => ({
|
||||
toast: {
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
import { CopyButton } from '@/components/CopyButton';
|
||||
import { copyTextToClipboard } from '@/utils/clipboard';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
const mockedCopy = vi.mocked(copyTextToClipboard);
|
||||
const mockedToast = vi.mocked(toast);
|
||||
|
||||
describe('CopyButton', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('复制成功时调用 copyTextToClipboard 并传入正确 text', async () => {
|
||||
mockedCopy.mockResolvedValue(true);
|
||||
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
|
||||
|
||||
render(<CopyButton text="hello world" />);
|
||||
|
||||
await user.click(screen.getByRole('button'));
|
||||
|
||||
expect(mockedCopy).toHaveBeenCalledWith('hello world');
|
||||
expect(mockedToast.success).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('复制成功后图标切换为 Check,1.5 秒后恢复', async () => {
|
||||
mockedCopy.mockResolvedValue(true);
|
||||
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
|
||||
|
||||
render(<CopyButton text="test" />);
|
||||
|
||||
// 点击后复制成功,按钮获得 emerald 样式(说明切到了 Check 状态)
|
||||
await user.click(screen.getByRole('button'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button').className).toContain('text-emerald');
|
||||
});
|
||||
|
||||
// 1.5 秒后样式恢复
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(1500);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button').className).not.toContain('text-emerald');
|
||||
});
|
||||
});
|
||||
|
||||
it('复制空文本时弹出 error toast', async () => {
|
||||
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
|
||||
|
||||
render(<CopyButton text="" />);
|
||||
|
||||
await user.click(screen.getByRole('button'));
|
||||
|
||||
expect(mockedCopy).not.toHaveBeenCalled();
|
||||
expect(mockedToast.error).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('复制失败时弹出 error toast', async () => {
|
||||
mockedCopy.mockResolvedValue(false);
|
||||
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
|
||||
|
||||
render(<CopyButton text="something" />);
|
||||
|
||||
await user.click(screen.getByRole('button'));
|
||||
|
||||
expect(mockedCopy).toHaveBeenCalledWith('something');
|
||||
expect(mockedToast.error).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// ==================== 新增测试 ====================
|
||||
|
||||
it('初始渲染时显示 Copy 图标且无 emerald 样式', () => {
|
||||
render(<CopyButton text="initial" />);
|
||||
|
||||
const button = screen.getByRole('button');
|
||||
expect(button.className).not.toContain('text-emerald');
|
||||
// 通过 aria-label 确认按钮存在,图标由 lucide 渲染为 svg
|
||||
expect(button).toHaveAttribute('aria-label');
|
||||
});
|
||||
|
||||
it('自定义 tooltip 会覆盖默认 title 和 aria-label', () => {
|
||||
render(<CopyButton text="tooltip-test" tooltip="自定义提示" />);
|
||||
|
||||
const button = screen.getByRole('button');
|
||||
expect(button).toHaveAttribute('title', '自定义提示');
|
||||
expect(button).toHaveAttribute('aria-label', '自定义提示');
|
||||
});
|
||||
|
||||
it('className 被正确透传到按钮', () => {
|
||||
render(<CopyButton text="class-test" className="my-custom-class" />);
|
||||
|
||||
const button = screen.getByRole('button');
|
||||
expect(button.className).toContain('my-custom-class');
|
||||
});
|
||||
|
||||
it('点击事件阻止冒泡', async () => {
|
||||
mockedCopy.mockResolvedValue(true);
|
||||
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
|
||||
const parentClick = vi.fn();
|
||||
|
||||
render(
|
||||
<div onClick={parentClick}>
|
||||
<CopyButton text="stop-propagation" />
|
||||
</div>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole('button'));
|
||||
|
||||
expect(mockedCopy).toHaveBeenCalled();
|
||||
expect(parentClick).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('组件卸载时清除定时器,不触发状态更新警告', async () => {
|
||||
mockedCopy.mockResolvedValue(true);
|
||||
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
|
||||
|
||||
const { unmount } = render(<CopyButton text="unmount-test" />);
|
||||
|
||||
await user.click(screen.getByRole('button'));
|
||||
|
||||
// 在 1.5 秒超时到期前卸载组件
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(500);
|
||||
});
|
||||
|
||||
// 卸载不应抛出 "Can't perform a React state update on an unmounted component" 警告
|
||||
expect(() => unmount()).not.toThrow();
|
||||
|
||||
// 前进剩余时间,确认没有异常
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(2000);
|
||||
});
|
||||
});
|
||||
|
||||
it('快速连续点击不会创建多个重叠定时器', async () => {
|
||||
mockedCopy.mockResolvedValue(true);
|
||||
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
|
||||
|
||||
render(<CopyButton text="rapid-click" />);
|
||||
|
||||
const button = screen.getByRole('button');
|
||||
|
||||
// 快速点击 3 次
|
||||
await user.click(button);
|
||||
await user.click(button);
|
||||
await user.click(button);
|
||||
|
||||
// copyTextToClipboard 应该被调用 3 次(每次点击都执行)
|
||||
expect(mockedCopy).toHaveBeenCalledTimes(3);
|
||||
|
||||
// 但 setTimeout 相关的 clearTimeout + setTimeout 组合应正常工作
|
||||
// advance 1.5 秒后,copied 状态应恢复为 false
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(1500);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(button.className).not.toContain('text-emerald');
|
||||
});
|
||||
});
|
||||
|
||||
it('其他 button props 通过 ...props 透传', () => {
|
||||
render(<CopyButton text="props-test" data-testid="copy-btn" disabled id="copy-button-id" />);
|
||||
|
||||
const button = screen.getByRole('button');
|
||||
expect(button).toHaveAttribute('data-testid', 'copy-btn');
|
||||
expect(button).toBeDisabled();
|
||||
expect(button).toHaveAttribute('id', 'copy-button-id');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Toaster as Sonner } from 'sonner';
|
||||
import { useThemeMode } from '@/providers/ThemeModeProvider';
|
||||
|
||||
type ToasterProps = React.ComponentProps<typeof Sonner>;
|
||||
|
||||
export function Toaster(props: ToasterProps) {
|
||||
const { resolvedMode } = useThemeMode();
|
||||
|
||||
return (
|
||||
<Sonner
|
||||
theme={resolvedMode}
|
||||
className="toaster group"
|
||||
position="bottom-center"
|
||||
toastOptions={{
|
||||
classNames: {
|
||||
toast:
|
||||
'group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg',
|
||||
description: 'group-[.toast]:text-muted-foreground',
|
||||
actionButton: 'group-[.toast]:bg-primary group-[.toast]:text-primary-foreground',
|
||||
cancelButton: 'group-[.toast]:bg-muted group-[.toast]:text-muted-foreground',
|
||||
},
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Image as ImageIcon, Trash2, Upload } from 'lucide-react';
|
||||
import TextInputArea from '@/components/TextInputArea';
|
||||
import { useI18n } from '@/utils/chromeI18n';
|
||||
import CopyButton from '@/components/CopyButton';
|
||||
import { CopyButton } from '@/components/CopyButton';
|
||||
import DecodeResultPaper from '@/components/DecodeResultPaper';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { downloadBlob, formatFileSize } from '@/utils/base64Converter';
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import TextInputArea from '@/components/TextInputArea';
|
||||
import { useI18n } from '@/utils/chromeI18n';
|
||||
import CopyButton from '@/components/CopyButton';
|
||||
import { CopyButton } from '@/components/CopyButton';
|
||||
import { base64ToText, textToBase64 } from '@/utils/base64Converter';
|
||||
import SwitchButtonGroup from '@/components/SwitchButtonGroup';
|
||||
import { useContextMenuData } from '@/utils/useContextMenuData';
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { useI18n } from '@/utils/chromeI18n';
|
||||
import { formatBytes } from '@/utils/format';
|
||||
import CopyButton from '@/components/CopyButton';
|
||||
import { CopyButton } from '@/components/CopyButton';
|
||||
import TextInputArea from '@/components/TextInputArea';
|
||||
import { validateJson } from '@/utils/jsonFormatter';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
validateJson,
|
||||
} from '@/utils/jsonFormatter';
|
||||
import { formatBytes } from '@/utils/format';
|
||||
import CopyButton from '@/components/CopyButton';
|
||||
import { CopyButton } from '@/components/CopyButton';
|
||||
import SwitchButtonGroup from '@/components/SwitchButtonGroup';
|
||||
import TextInputArea from '@/components/TextInputArea';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import CopyButton from '@/components/CopyButton';
|
||||
import { CopyButton } from '@/components/CopyButton';
|
||||
import { useI18n } from '@/utils/chromeI18n';
|
||||
import { stringifyJson } from '@/utils/jwt';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import TextInputArea from '@/components/TextInputArea';
|
||||
import CopyButton from '@/components/CopyButton';
|
||||
import { CopyButton } from '@/components/CopyButton';
|
||||
import JwtSection from './JwtSection';
|
||||
import { useJwt } from './useJwt';
|
||||
import { useI18n } from '@/utils/chromeI18n';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import QrCodePage from '../index';
|
||||
|
||||
vi.mock('lucide-react', async (importOriginal) => {
|
||||
@@ -102,7 +102,7 @@ describe('QrCodePage', () => {
|
||||
expect(generateButton).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it('点击生成按钮应该切换到预览态', () => {
|
||||
it('点击生成按钮应该切换到预览态', async () => {
|
||||
render(<QrCodePage />);
|
||||
|
||||
// 输入文本
|
||||
@@ -114,14 +114,16 @@ describe('QrCodePage', () => {
|
||||
fireEvent.click(generateButton);
|
||||
|
||||
// 验证切换到预览态
|
||||
expect(screen.getByText('原始文本')).toBeInTheDocument();
|
||||
expect(screen.getByText('编辑')).toBeInTheDocument();
|
||||
expect(screen.queryByText('生成二维码')).not.toBeInTheDocument();
|
||||
// 预览态应该显示二维码
|
||||
expect(screen.getByTestId('qr-code-preview')).toBeInTheDocument();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('原始文本')).toBeInTheDocument();
|
||||
expect(screen.getByText('编辑')).toBeInTheDocument();
|
||||
expect(screen.queryByText('生成二维码')).not.toBeInTheDocument();
|
||||
// 预览态应该显示二维码
|
||||
expect(screen.getByTestId('qr-code-preview')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('预览态应该显示截断的文本', () => {
|
||||
it('预览态应该显示截断的文本', async () => {
|
||||
render(<QrCodePage />);
|
||||
|
||||
// 输入长文本
|
||||
@@ -135,10 +137,12 @@ describe('QrCodePage', () => {
|
||||
|
||||
// 验证显示截断的文本(80字符 + "...")
|
||||
const truncatedText = longText.slice(0, 80) + '...';
|
||||
expect(screen.getByText(truncatedText)).toBeInTheDocument();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(truncatedText)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('预览态应该显示完整的短文本', () => {
|
||||
it('预览态应该显示完整的短文本', async () => {
|
||||
render(<QrCodePage />);
|
||||
|
||||
// 输入短文本
|
||||
@@ -150,10 +154,12 @@ describe('QrCodePage', () => {
|
||||
fireEvent.click(screen.getByText('生成二维码'));
|
||||
|
||||
// 验证显示完整文本
|
||||
expect(screen.getByText(shortText)).toBeInTheDocument();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(shortText)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('点击编辑按钮应该返回输入态', () => {
|
||||
it('点击编辑按钮应该返回输入态', async () => {
|
||||
render(<QrCodePage />);
|
||||
|
||||
// 输入文本并生成
|
||||
@@ -161,6 +167,11 @@ describe('QrCodePage', () => {
|
||||
fireEvent.change(textarea, { target: { value: 'https://example.com' } });
|
||||
fireEvent.click(screen.getByText('生成二维码'));
|
||||
|
||||
// 等待生成完成
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('编辑')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// 点击编辑按钮
|
||||
fireEvent.click(screen.getByText('编辑'));
|
||||
|
||||
@@ -172,7 +183,7 @@ describe('QrCodePage', () => {
|
||||
expect(screen.queryByTestId('qr-code-preview')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('返回编辑态应该保留上次输入的内容', () => {
|
||||
it('返回编辑态应该保留上次输入的内容', async () => {
|
||||
render(<QrCodePage />);
|
||||
|
||||
// 输入文本并生成
|
||||
@@ -180,6 +191,11 @@ describe('QrCodePage', () => {
|
||||
fireEvent.change(textarea, { target: { value: 'https://example.com' } });
|
||||
fireEvent.click(screen.getByText('生成二维码'));
|
||||
|
||||
// 等待生成完成
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('编辑')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// 点击编辑按钮
|
||||
fireEvent.click(screen.getByText('编辑'));
|
||||
|
||||
|
||||
@@ -165,27 +165,31 @@ export function useQrCode(): QrCodeContextValue {
|
||||
return;
|
||||
}
|
||||
|
||||
// 先设置 loading 状态,让 React 渲染加载动画
|
||||
setGeneratorState((prev) => ({ ...prev, generating: true, inputError: '' }));
|
||||
|
||||
const qrCodeDataUrl = generateQrCodeDataUrl(text);
|
||||
// 延迟到下一帧生成,确保 loading 状态先被渲染显示
|
||||
setTimeout(() => {
|
||||
const qrCodeDataUrl = generateQrCodeDataUrl(text);
|
||||
|
||||
if (!qrCodeDataUrl) {
|
||||
setGeneratorState((prev) => ({
|
||||
...prev,
|
||||
generating: false,
|
||||
inputError: t('qrCode:generateError'),
|
||||
}));
|
||||
toast.error(t('qrCode:generateError'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!qrCodeDataUrl) {
|
||||
setGeneratorState((prev) => ({
|
||||
...prev,
|
||||
step: 'preview',
|
||||
savedText: text,
|
||||
qrCodeDataUrl,
|
||||
generating: false,
|
||||
inputError: t('qrCode:generateError'),
|
||||
}));
|
||||
toast.error(t('qrCode:generateError'));
|
||||
return;
|
||||
}
|
||||
|
||||
setGeneratorState((prev) => ({
|
||||
...prev,
|
||||
step: 'preview',
|
||||
savedText: text,
|
||||
qrCodeDataUrl,
|
||||
generating: false,
|
||||
}));
|
||||
}, 0);
|
||||
}, [generatorState.textToEncode, t]);
|
||||
|
||||
/** 返回编辑态,保留上次输入内容 */
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Clock } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import CopyButton from '@/components/CopyButton';
|
||||
import { CopyButton } from '@/components/CopyButton';
|
||||
import type { UnitType } from './constants';
|
||||
import { msToUnit } from './constants';
|
||||
import { useI18n } from '@/utils/chromeI18n';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import CopyButton from '@/components/CopyButton';
|
||||
import { CopyButton } from '@/components/CopyButton';
|
||||
import { useI18n } from '@/utils/chromeI18n';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from 'react';
|
||||
import { ThemeModeProvider } from './ThemeModeProvider';
|
||||
import { RouterProvider } from './RouterProvider';
|
||||
import { Toaster } from '@/components/ui/sonner';
|
||||
|
||||
interface AppRootProps {
|
||||
children: React.ReactNode;
|
||||
@@ -11,6 +12,7 @@ export default function AppRoot({ children }: AppRootProps) {
|
||||
<React.StrictMode>
|
||||
<ThemeModeProvider>
|
||||
<RouterProvider>{children}</RouterProvider>
|
||||
<Toaster />
|
||||
</ThemeModeProvider>
|
||||
</React.StrictMode>
|
||||
);
|
||||
|
||||
@@ -26,12 +26,8 @@ export function useI18n(namespace?: string | string[]) {
|
||||
const namespaces = Array.isArray(namespace) ? namespace : namespace ? [namespace] : [];
|
||||
|
||||
const t = (key: string, options?: Record<string, unknown>): string => {
|
||||
let msgId = key;
|
||||
|
||||
// 处理 namespace:key 格式(兼容原 i18next 用法)
|
||||
if (key.includes(':')) {
|
||||
msgId = key.replace(':', '_').replace(/\./g, '_');
|
||||
}
|
||||
// 统一将分隔符转换为下划线,兼容 'namespace:key.path' 和 'key.path' 两种写法
|
||||
const msgId = key.replace(':', '_').replace(/\./g, '_');
|
||||
|
||||
// 先尝试直接查找 key
|
||||
let message = getMessage(msgId);
|
||||
|
||||
@@ -103,8 +103,8 @@ export function parseContextMenuClick(
|
||||
): ParseResult {
|
||||
const featureKey = getMenuPageType(menuItemId);
|
||||
|
||||
// 处理图片 URL(右键点击图片时)
|
||||
if (info.srcUrl) {
|
||||
// 处理图片 URL(仅 qrCode-image 菜单项,右键点击图片时)
|
||||
if (menuItemId === 'qrCode-image' && info.srcUrl) {
|
||||
return {
|
||||
success: true,
|
||||
data: { featureKey, payload: info.srcUrl },
|
||||
|
||||
Reference in New Issue
Block a user