refactor(ImageUploader): 移除预览 URL 相关逻辑并简化粘贴事件处理
- 删除 ImageUploader 组件中的 onPreviewUrlChange 属性及相关逻辑,简化文件处理流程。 - 更新测试用例,确保只调用 onFileChange 方法,移除对预览 URL 的依赖。 - 新增 ParsePanel 组件的测试,确保粘贴功能的正确性和唯一性。
This commit is contained in:
@@ -23,7 +23,6 @@ vi.mock('sonner', () => ({
|
|||||||
describe('ImageUploader 组件', () => {
|
describe('ImageUploader 组件', () => {
|
||||||
const mockOnFileChange = vi.fn();
|
const mockOnFileChange = vi.fn();
|
||||||
const mockOnClearFile = vi.fn();
|
const mockOnClearFile = vi.fn();
|
||||||
const mockOnPreviewUrlChange = vi.fn();
|
|
||||||
const mockOnDraggingChange = vi.fn();
|
const mockOnDraggingChange = vi.fn();
|
||||||
|
|
||||||
const defaultProps = {
|
const defaultProps = {
|
||||||
@@ -31,7 +30,6 @@ describe('ImageUploader 组件', () => {
|
|||||||
onFileChange: mockOnFileChange,
|
onFileChange: mockOnFileChange,
|
||||||
onClearFile: mockOnClearFile,
|
onClearFile: mockOnClearFile,
|
||||||
previewUrl: '',
|
previewUrl: '',
|
||||||
onPreviewUrlChange: mockOnPreviewUrlChange,
|
|
||||||
dragging: false,
|
dragging: false,
|
||||||
onDraggingChange: mockOnDraggingChange,
|
onDraggingChange: mockOnDraggingChange,
|
||||||
};
|
};
|
||||||
@@ -95,7 +93,7 @@ describe('ImageUploader 组件', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('文件选择交互', () => {
|
describe('文件选择交互', () => {
|
||||||
it('选择文件时应调用 onFileChange 和 onPreviewUrlChange', async () => {
|
it('选择文件时应调用 onFileChange', async () => {
|
||||||
render(<ImageUploader {...defaultProps} />);
|
render(<ImageUploader {...defaultProps} />);
|
||||||
const input = document.getElementById('qr-code-upload') as HTMLInputElement;
|
const input = document.getElementById('qr-code-upload') as HTMLInputElement;
|
||||||
const mockFile = new File(['test'], 'test.png', { type: 'image/png' });
|
const mockFile = new File(['test'], 'test.png', { type: 'image/png' });
|
||||||
@@ -105,8 +103,7 @@ describe('ImageUploader 组件', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(mockOnFileChange).toHaveBeenCalledWith(mockFile);
|
expect(mockOnFileChange).toHaveBeenCalledWith(mockFile);
|
||||||
expect(mockCreateObjectURL).toHaveBeenCalledWith(mockFile);
|
expect(mockOnFileChange).toHaveBeenCalledTimes(1);
|
||||||
expect(mockOnPreviewUrlChange).toHaveBeenCalledWith('blob:test-url');
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -127,7 +124,7 @@ describe('ImageUploader 组件', () => {
|
|||||||
expect(mockOnDraggingChange).toHaveBeenCalledWith(false);
|
expect(mockOnDraggingChange).toHaveBeenCalledWith(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('放置文件时应调用 onFileChange 和 onPreviewUrlChange', () => {
|
it('放置文件时应调用 onFileChange', () => {
|
||||||
const { container } = render(<ImageUploader {...defaultProps} />);
|
const { container } = render(<ImageUploader {...defaultProps} />);
|
||||||
const dropzone = container.firstChild as HTMLElement;
|
const dropzone = container.firstChild as HTMLElement;
|
||||||
const mockFile = new File(['test'], 'test.png', { type: 'image/png' });
|
const mockFile = new File(['test'], 'test.png', { type: 'image/png' });
|
||||||
@@ -146,6 +143,7 @@ describe('ImageUploader 组件', () => {
|
|||||||
|
|
||||||
expect(mockOnDraggingChange).toHaveBeenCalledWith(false);
|
expect(mockOnDraggingChange).toHaveBeenCalledWith(false);
|
||||||
expect(mockOnFileChange).toHaveBeenCalledWith(mockFile);
|
expect(mockOnFileChange).toHaveBeenCalledWith(mockFile);
|
||||||
|
expect(mockOnFileChange).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -165,20 +163,12 @@ describe('ImageUploader 组件', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('粘贴功能', () => {
|
describe('粘贴功能', () => {
|
||||||
it('监听粘贴事件', () => {
|
it('不应注册 document 粘贴事件监听(由 ParsePanel 统一处理)', () => {
|
||||||
const addEventListenerSpy = vi.spyOn(document, 'addEventListener');
|
const addEventListenerSpy = vi.spyOn(document, 'addEventListener');
|
||||||
render(<ImageUploader {...defaultProps} />);
|
render(<ImageUploader {...defaultProps} />);
|
||||||
|
|
||||||
expect(addEventListenerSpy).toHaveBeenCalledWith('paste', expect.any(Function));
|
const pasteListeners = addEventListenerSpy.mock.calls.filter(([event]) => event === 'paste');
|
||||||
});
|
expect(pasteListeners).toHaveLength(0);
|
||||||
|
|
||||||
it('组件卸载时应移除粘贴事件监听', () => {
|
|
||||||
const removeEventListenerSpy = vi.spyOn(document, 'removeEventListener');
|
|
||||||
const { unmount } = render(<ImageUploader {...defaultProps} />);
|
|
||||||
|
|
||||||
unmount();
|
|
||||||
|
|
||||||
expect(removeEventListenerSpy).toHaveBeenCalledWith('paste', expect.any(Function));
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,151 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { act, render, screen } from '@testing-library/react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import ParsePanel from '../components/ParsePanel';
|
||||||
|
import type { QrCodeParserState } from '../types';
|
||||||
|
|
||||||
|
const mockHandleFileChange = vi.fn();
|
||||||
|
const mockHandleClearFile = vi.fn();
|
||||||
|
const mockSetParserState = vi.fn();
|
||||||
|
|
||||||
|
let mockParserState: QrCodeParserState = {
|
||||||
|
selectedFile: null,
|
||||||
|
previewUrl: '',
|
||||||
|
dragging: false,
|
||||||
|
decodedResult: '',
|
||||||
|
parsing: false,
|
||||||
|
parseError: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mock('sonner', () => ({
|
||||||
|
toast: {
|
||||||
|
success: vi.fn(),
|
||||||
|
error: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../contexts/QrCodeContext', () => ({
|
||||||
|
useQrCodeContext: () => ({
|
||||||
|
parserState: mockParserState,
|
||||||
|
setParserState: mockSetParserState,
|
||||||
|
handleFileChange: mockHandleFileChange,
|
||||||
|
handleClearFile: mockHandleClearFile,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
function createImagePasteEvent(file: File) {
|
||||||
|
const pasteEvent = new Event('paste', { bubbles: true }) as ClipboardEvent;
|
||||||
|
Object.defineProperty(pasteEvent, 'clipboardData', {
|
||||||
|
value: {
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
type: file.type,
|
||||||
|
getAsFile: () => file,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
getData: () => '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
Object.defineProperty(pasteEvent, 'preventDefault', { value: vi.fn() });
|
||||||
|
return pasteEvent;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createBase64PasteEvent(dataUrl: string) {
|
||||||
|
const pasteEvent = new Event('paste', { bubbles: true }) as ClipboardEvent;
|
||||||
|
Object.defineProperty(pasteEvent, 'clipboardData', {
|
||||||
|
value: {
|
||||||
|
items: [],
|
||||||
|
getData: (type: string) => (type === 'text/plain' ? dataUrl : ''),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
Object.defineProperty(pasteEvent, 'preventDefault', { value: vi.fn() });
|
||||||
|
return pasteEvent;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ParsePanel 组件', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mockParserState = {
|
||||||
|
selectedFile: null,
|
||||||
|
previewUrl: '',
|
||||||
|
dragging: false,
|
||||||
|
decodedResult: '',
|
||||||
|
parsing: false,
|
||||||
|
parseError: '',
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('粘贴功能', () => {
|
||||||
|
it('粘贴图片时应只处理一次', async () => {
|
||||||
|
render(<ParsePanel />);
|
||||||
|
const mockFile = new File(['img'], 'paste.png', { type: 'image/png' });
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
document.dispatchEvent(createImagePasteEvent(mockFile));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockHandleFileChange).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockHandleFileChange).toHaveBeenCalledWith(mockFile);
|
||||||
|
expect(toast.success).toHaveBeenCalledTimes(1);
|
||||||
|
expect(toast.success).toHaveBeenCalledWith('图片粘贴成功,正在解析...');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('与 ImageUploader 同时挂载时 document 上只应有一个 paste 监听', () => {
|
||||||
|
const addEventListenerSpy = vi.spyOn(document, 'addEventListener');
|
||||||
|
render(<ParsePanel />);
|
||||||
|
|
||||||
|
const pasteListeners = addEventListenerSpy.mock.calls.filter(([event]) => event === 'paste');
|
||||||
|
expect(pasteListeners).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('粘贴 Base64 data URI 时应转换为文件并只处理一次', async () => {
|
||||||
|
const dataUrl = 'data:image/png;base64,abcd';
|
||||||
|
const mockBlob = new Blob(['img'], { type: 'image/png' });
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
vi.fn().mockResolvedValue({
|
||||||
|
blob: () => Promise.resolve(mockBlob),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
render(<ParsePanel />);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
document.dispatchEvent(createBase64PasteEvent(dataUrl));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(fetch).toHaveBeenCalledWith(dataUrl);
|
||||||
|
expect(mockHandleFileChange).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockHandleFileChange).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ name: 'pasted-image.png', type: 'image/png' }),
|
||||||
|
);
|
||||||
|
expect(toast.success).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('已上传图片时粘贴仍应只处理一次', async () => {
|
||||||
|
mockParserState = {
|
||||||
|
selectedFile: new File(['existing'], 'existing.png', { type: 'image/png' }),
|
||||||
|
previewUrl: 'blob:existing',
|
||||||
|
dragging: false,
|
||||||
|
decodedResult: 'decoded-text',
|
||||||
|
parsing: false,
|
||||||
|
parseError: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
render(<ParsePanel />);
|
||||||
|
expect(screen.getByText('已上传图片')).toBeInTheDocument();
|
||||||
|
|
||||||
|
const mockFile = new File(['new'], 'new.png', { type: 'image/png' });
|
||||||
|
await act(async () => {
|
||||||
|
document.dispatchEvent(createImagePasteEvent(mockFile));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockHandleFileChange).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockHandleFileChange).toHaveBeenCalledWith(mockFile);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useEffect, useRef } from 'react';
|
import { useCallback, useRef } from 'react';
|
||||||
import { Image, X } from 'lucide-react';
|
import { Image, X } from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@@ -13,8 +13,6 @@ interface ImageUploaderProps {
|
|||||||
onClearFile: () => void;
|
onClearFile: () => void;
|
||||||
/** 文件预览 URL */
|
/** 文件预览 URL */
|
||||||
previewUrl: string;
|
previewUrl: string;
|
||||||
/** 预览 URL 变更回调 */
|
|
||||||
onPreviewUrlChange: (url: string) => void;
|
|
||||||
/** 是否正在拖拽 */
|
/** 是否正在拖拽 */
|
||||||
dragging: boolean;
|
dragging: boolean;
|
||||||
/** 拖拽状态变更回调 */
|
/** 拖拽状态变更回调 */
|
||||||
@@ -26,7 +24,6 @@ const ImageUploader = ({
|
|||||||
onFileChange,
|
onFileChange,
|
||||||
onClearFile,
|
onClearFile,
|
||||||
previewUrl,
|
previewUrl,
|
||||||
onPreviewUrlChange,
|
|
||||||
dragging,
|
dragging,
|
||||||
onDraggingChange,
|
onDraggingChange,
|
||||||
}: ImageUploaderProps) => {
|
}: ImageUploaderProps) => {
|
||||||
@@ -35,9 +32,8 @@ const ImageUploader = ({
|
|||||||
const handleFileChange = useCallback(
|
const handleFileChange = useCallback(
|
||||||
(file: File) => {
|
(file: File) => {
|
||||||
onFileChange(file);
|
onFileChange(file);
|
||||||
onPreviewUrlChange(URL.createObjectURL(file));
|
|
||||||
},
|
},
|
||||||
[onFileChange, onPreviewUrlChange],
|
[onFileChange],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleClearFile = useCallback(() => {
|
const handleClearFile = useCallback(() => {
|
||||||
@@ -72,38 +68,6 @@ const ImageUploader = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 监听粘贴事件
|
|
||||||
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);
|
|
||||||
toast.success('图片粘贴成功,正在解析...');
|
|
||||||
} catch (error) {
|
|
||||||
console.error('处理粘贴图片失败:', error);
|
|
||||||
toast.error('粘贴图片失败,请重试');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
document.addEventListener('paste', handlePaste);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener('paste', handlePaste);
|
|
||||||
};
|
|
||||||
}, [handleFileChange]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`flex flex-col items-center justify-center h-[250px] border-2 border-dashed rounded-xl p-4 cursor-pointer transition-all duration-200 ${
|
className={`flex flex-col items-center justify-center h-[250px] border-2 border-dashed rounded-xl p-4 cursor-pointer transition-all duration-200 ${
|
||||||
|
|||||||
@@ -65,7 +65,6 @@ export default function ParsePanel() {
|
|||||||
onFileChange={handleFileChange}
|
onFileChange={handleFileChange}
|
||||||
onClearFile={handleClearFile}
|
onClearFile={handleClearFile}
|
||||||
previewUrl={parserState.previewUrl}
|
previewUrl={parserState.previewUrl}
|
||||||
onPreviewUrlChange={(url) => setParserState((prev) => ({ ...prev, previewUrl: url }))}
|
|
||||||
dragging={parserState.dragging}
|
dragging={parserState.dragging}
|
||||||
onDraggingChange={(dragging) => setParserState((prev) => ({ ...prev, dragging }))}
|
onDraggingChange={(dragging) => setParserState((prev) => ({ ...prev, dragging }))}
|
||||||
/>
|
/>
|
||||||
|
|||||||
Reference in New Issue
Block a user