diff --git a/src/pages/QrCode/__tests__/ImageUploader.test.tsx b/src/pages/QrCode/__tests__/ImageUploader.test.tsx index 96c9b49..5d4807a 100644 --- a/src/pages/QrCode/__tests__/ImageUploader.test.tsx +++ b/src/pages/QrCode/__tests__/ImageUploader.test.tsx @@ -23,7 +23,6 @@ vi.mock('sonner', () => ({ describe('ImageUploader 组件', () => { const mockOnFileChange = vi.fn(); const mockOnClearFile = vi.fn(); - const mockOnPreviewUrlChange = vi.fn(); const mockOnDraggingChange = vi.fn(); const defaultProps = { @@ -31,7 +30,6 @@ describe('ImageUploader 组件', () => { onFileChange: mockOnFileChange, onClearFile: mockOnClearFile, previewUrl: '', - onPreviewUrlChange: mockOnPreviewUrlChange, dragging: false, onDraggingChange: mockOnDraggingChange, }; @@ -95,7 +93,7 @@ describe('ImageUploader 组件', () => { }); describe('文件选择交互', () => { - it('选择文件时应调用 onFileChange 和 onPreviewUrlChange', async () => { + it('选择文件时应调用 onFileChange', async () => { render(); const input = document.getElementById('qr-code-upload') as HTMLInputElement; const mockFile = new File(['test'], 'test.png', { type: 'image/png' }); @@ -105,8 +103,7 @@ describe('ImageUploader 组件', () => { }); expect(mockOnFileChange).toHaveBeenCalledWith(mockFile); - expect(mockCreateObjectURL).toHaveBeenCalledWith(mockFile); - expect(mockOnPreviewUrlChange).toHaveBeenCalledWith('blob:test-url'); + expect(mockOnFileChange).toHaveBeenCalledTimes(1); }); }); @@ -127,7 +124,7 @@ describe('ImageUploader 组件', () => { expect(mockOnDraggingChange).toHaveBeenCalledWith(false); }); - it('放置文件时应调用 onFileChange 和 onPreviewUrlChange', () => { + it('放置文件时应调用 onFileChange', () => { const { container } = render(); const dropzone = container.firstChild as HTMLElement; const mockFile = new File(['test'], 'test.png', { type: 'image/png' }); @@ -146,6 +143,7 @@ describe('ImageUploader 组件', () => { expect(mockOnDraggingChange).toHaveBeenCalledWith(false); expect(mockOnFileChange).toHaveBeenCalledWith(mockFile); + expect(mockOnFileChange).toHaveBeenCalledTimes(1); }); }); @@ -165,20 +163,12 @@ describe('ImageUploader 组件', () => { }); describe('粘贴功能', () => { - it('监听粘贴事件', () => { + it('不应注册 document 粘贴事件监听(由 ParsePanel 统一处理)', () => { const addEventListenerSpy = vi.spyOn(document, 'addEventListener'); render(); - expect(addEventListenerSpy).toHaveBeenCalledWith('paste', expect.any(Function)); - }); - - it('组件卸载时应移除粘贴事件监听', () => { - const removeEventListenerSpy = vi.spyOn(document, 'removeEventListener'); - const { unmount } = render(); - - unmount(); - - expect(removeEventListenerSpy).toHaveBeenCalledWith('paste', expect.any(Function)); + const pasteListeners = addEventListenerSpy.mock.calls.filter(([event]) => event === 'paste'); + expect(pasteListeners).toHaveLength(0); }); }); diff --git a/src/pages/QrCode/__tests__/ParsePanel.test.tsx b/src/pages/QrCode/__tests__/ParsePanel.test.tsx new file mode 100644 index 0000000..d724186 --- /dev/null +++ b/src/pages/QrCode/__tests__/ParsePanel.test.tsx @@ -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(); + 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(); + + 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(); + + 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(); + 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); + }); + }); +}); diff --git a/src/pages/QrCode/components/ImageUploader.tsx b/src/pages/QrCode/components/ImageUploader.tsx index 38645d3..01ebd79 100644 --- a/src/pages/QrCode/components/ImageUploader.tsx +++ b/src/pages/QrCode/components/ImageUploader.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef } from 'react'; +import { useCallback, useRef } from 'react'; import { Image, X } from 'lucide-react'; import { toast } from 'sonner'; import { Button } from '@/components/ui/button'; @@ -13,8 +13,6 @@ interface ImageUploaderProps { onClearFile: () => void; /** 文件预览 URL */ previewUrl: string; - /** 预览 URL 变更回调 */ - onPreviewUrlChange: (url: string) => void; /** 是否正在拖拽 */ dragging: boolean; /** 拖拽状态变更回调 */ @@ -26,7 +24,6 @@ const ImageUploader = ({ onFileChange, onClearFile, previewUrl, - onPreviewUrlChange, dragging, onDraggingChange, }: ImageUploaderProps) => { @@ -35,9 +32,8 @@ const ImageUploader = ({ const handleFileChange = useCallback( (file: File) => { onFileChange(file); - onPreviewUrlChange(URL.createObjectURL(file)); }, - [onFileChange, onPreviewUrlChange], + [onFileChange], ); 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 (
setParserState((prev) => ({ ...prev, previewUrl: url }))} dragging={parserState.dragging} onDraggingChange={(dragging) => setParserState((prev) => ({ ...prev, dragging }))} />