import { useLayoutEffect } from 'react'; import { act, renderHook, waitFor } from '@testing-library/react'; import { describe, expect, it, vi } from 'vitest'; import { useComposerAttachments } from '@/pages/Chat/use-composer-attachments'; import type { ComposerAttachmentDependencies, } from '@/pages/Chat/composer-attachments'; import type { ProcessedImageAttachment, } from '@/lib/image-attachment-compression'; function createDeferred() { let resolve!: (value: T) => void; let reject!: (reason?: unknown) => void; const promise = new Promise((promiseResolve, promiseReject) => { resolve = promiseResolve; reject = promiseReject; }); return { promise, reject, resolve }; } function createDependencies(): ComposerAttachmentDependencies { let nextId = 0; let nextUrl = 0; return { objectUrls: { createObjectURL: vi.fn(() => `blob:hook-${++nextUrl}`), revokeObjectURL: vi.fn(), }, idFactory: vi.fn(() => `attachment-${++nextId}`), }; } function unchanged(file: File): ProcessedImageAttachment { return { status: 'unchanged', original: { blob: file, mimeType: file.type, bytes: file.size, width: 1280, height: 720, }, defaultVariant: 'original', }; } describe('useComposerAttachments', () => { it('runs at most one image processing job at a time', async () => { const dependencies = createDependencies(); const first = createDeferred(); const second = createDeferred(); const pending = [first, second]; let active = 0; let maximumActive = 0; const processImage = vi.fn((file: File, signal: AbortSignal) => { expect(signal.aborted).toBe(false); active += 1; maximumActive = Math.max(maximumActive, active); return pending[processImage.mock.calls.length - 1].promise.finally(() => { active -= 1; }); }); const { result } = renderHook(() => useComposerAttachments({ selectedSessionId: 'ses_1', dependencies, processImage, })); const firstFile = new File(['first'], 'first.png', { type: 'image/png' }); const secondFile = new File(['second'], 'second.png', { type: 'image/png' }); act(() => result.current.addFiles([firstFile, secondFile])); expect(processImage).toHaveBeenCalledTimes(1); expect(maximumActive).toBe(1); await act(async () => { first.resolve(unchanged(firstFile)); await first.promise; }); await waitFor(() => expect(processImage).toHaveBeenCalledTimes(2)); expect(maximumActive).toBe(1); await act(async () => { second.resolve(unchanged(secondFile)); await second.promise; }); }); it('removes a queued image without ever starting its processing job', async () => { const dependencies = createDependencies(); const first = createDeferred(); const processImage = vi.fn(() => first.promise); const { result } = renderHook(() => useComposerAttachments({ selectedSessionId: 'ses_1', dependencies, processImage, })); const firstFile = new File(['first'], 'first.png', { type: 'image/png' }); const secondFile = new File(['second'], 'second.png', { type: 'image/png' }); act(() => result.current.addFiles([firstFile, secondFile])); const queuedId = result.current.attachments[1].id; act(() => result.current.removeAttachment(queuedId)); await act(async () => { first.resolve(unchanged(firstFile)); await first.promise; }); expect(processImage).toHaveBeenCalledTimes(1); expect(processImage).toHaveBeenCalledWith(firstFile, expect.any(AbortSignal)); }); it('aborts an active image on removal and ignores its late compressed result', async () => { const dependencies = createDependencies(); const deferred = createDeferred(); let signal: AbortSignal | undefined; const processImage = vi.fn((_file: File, nextSignal: AbortSignal) => { signal = nextSignal; return deferred.promise; }); const { result } = renderHook(() => useComposerAttachments({ selectedSessionId: 'ses_1', dependencies, processImage, })); const file = new File(['original'], 'large.png', { type: 'image/png' }); act(() => result.current.addFiles([file])); const id = result.current.attachments[0].id; act(() => result.current.removeAttachment(id)); expect(signal?.aborted).toBe(true); await act(async () => { deferred.resolve({ status: 'compressed', original: { blob: file, mimeType: 'image/png', bytes: file.size, width: 3000, height: 2000, }, compressed: { blob: new Blob(['compressed'], { type: 'image/png' }), mimeType: 'image/png', bytes: 10, width: 1773, height: 1182, }, defaultVariant: 'compressed', }); await deferred.promise; }); expect(result.current.attachments).toEqual([]); expect(dependencies.objectUrls.createObjectURL).toHaveBeenCalledTimes(1); }); it('aborts active work and drops queued work on a session switch', async () => { const dependencies = createDependencies(); const active = createDeferred(); let signal: AbortSignal | undefined; const processImage = vi.fn((_file: File, nextSignal: AbortSignal) => { signal = nextSignal; return active.promise; }); const { result, rerender } = renderHook( ({ sessionId }) => useComposerAttachments({ selectedSessionId: sessionId, dependencies, processImage, }), { initialProps: { sessionId: 'ses_1' } }, ); const firstFile = new File(['first'], 'first.png', { type: 'image/png' }); act(() => result.current.addFiles([ firstFile, new File(['second'], 'second.png', { type: 'image/png' }), ])); rerender({ sessionId: 'ses_2' }); expect(signal?.aborted).toBe(true); await act(async () => { active.resolve(unchanged(firstFile)); await active.promise; }); expect(processImage).toHaveBeenCalledTimes(1); expect(result.current.attachments).toEqual([]); }); it('deduplicates before starting a job and revokes the rejected preview', () => { const dependencies = createDependencies(); const processImage = vi.fn(async (file: File) => unchanged(file)); const { result } = renderHook(() => useComposerAttachments({ selectedSessionId: 'ses_1', dependencies, processImage, })); const file = new File(['same'], 'same.png', { type: 'image/png' }); act(() => result.current.addFiles([file, file])); expect(result.current.attachments).toHaveLength(1); expect(processImage).toHaveBeenCalledTimes(1); expect(dependencies.objectUrls.revokeObjectURL).toHaveBeenCalledTimes(1); expect(dependencies.objectUrls.revokeObjectURL).toHaveBeenCalledWith('blob:hook-2'); }); it('discards a late job before creating a compressed preview', async () => { const dependencies = createDependencies(); const deferred = createDeferred(); const processImage = vi.fn(() => deferred.promise); const { result } = renderHook(() => useComposerAttachments({ selectedSessionId: 'ses_1', dependencies, processImage, })); const file = new File(['original'], 'large.png', { type: 'image/png' }); act(() => result.current.addFiles([file])); const id = result.current.attachments[0].id; act(() => result.current.removeAttachment(id)); expect(dependencies.objectUrls.revokeObjectURL).toHaveBeenCalledWith('blob:hook-1'); await act(async () => { deferred.resolve({ status: 'compressed', original: { blob: file, mimeType: 'image/png', bytes: file.size, width: 3000, height: 2000, }, compressed: { blob: new Blob(['compressed'], { type: 'image/png' }), mimeType: 'image/png', bytes: 10, width: 1773, height: 1182, }, defaultVariant: 'compressed', }); await deferred.promise; }); expect(result.current.attachments).toEqual([]); expect(dependencies.objectUrls.createObjectURL).toHaveBeenCalledTimes(1); }); it('fails open to the original when an active processing job rejects', async () => { const dependencies = createDependencies(); const processImage = vi.fn(async () => { throw new Error('decode failed at a sensitive local path'); }); const { result } = renderHook(() => useComposerAttachments({ selectedSessionId: 'ses_1', dependencies, processImage, })); act(() => result.current.addFiles([ new File(['original'], 'large.png', { type: 'image/png' }), ])); await waitFor(() => { expect(result.current.attachments[0]).toMatchObject({ selectedVariant: 'original', status: 'failed', warning: '图片压缩失败,发送时将使用原图。', }); }); expect(result.current.attachments[0].compressed).toBeUndefined(); expect(dependencies.objectUrls.createObjectURL).toHaveBeenCalledTimes(1); }); it('handles a late rejected job after removal without restoring state', async () => { const dependencies = createDependencies(); const deferred = createDeferred(); const processImage = vi.fn(() => deferred.promise); const { result } = renderHook(() => useComposerAttachments({ selectedSessionId: 'ses_1', dependencies, processImage, })); act(() => result.current.addFiles([ new File(['original'], 'large.png', { type: 'image/png' }), ])); const id = result.current.attachments[0].id; act(() => result.current.removeAttachment(id)); await act(async () => { deferred.reject(new Error('late decode failure')); await deferred.promise.catch(() => undefined); }); expect(result.current.attachments).toEqual([]); expect(dependencies.objectUrls.createObjectURL).toHaveBeenCalledTimes(1); expect(dependencies.objectUrls.revokeObjectURL) .toHaveBeenCalledWith('blob:hook-1'); }); it('clears and revokes on a normal session switch', () => { const dependencies = createDependencies(); const { result, rerender } = renderHook( ({ sessionId }) => useComposerAttachments({ selectedSessionId: sessionId, dependencies, }), { initialProps: { sessionId: 'ses_1' } }, ); act(() => result.current.addFiles([ new File(['gif'], 'animation.gif', { type: 'image/gif' }), ])); rerender({ sessionId: 'ses_2' }); expect(result.current.attachments).toEqual([]); expect(dependencies.objectUrls.revokeObjectURL).toHaveBeenCalledWith('blob:hook-1'); }); it('clears before paint and stages transfers from the current session', () => { const dependencies = createDependencies(); const layoutTransferSources: string[] = []; const { result, rerender } = renderHook( ({ sessionId }) => { const composer = useComposerAttachments({ selectedSessionId: sessionId, dependencies, }); useLayoutEffect(() => { if (sessionId !== 'ses_2') return; const transfer = composer.stageSessionTransfer('ses_probe'); layoutTransferSources.push(transfer.fromSessionId); composer.cancelSessionTransfer(transfer); }, [composer, sessionId]); return composer; }, { initialProps: { sessionId: 'ses_1' } }, ); act(() => result.current.addFiles([ new File(['gif'], 'animation.gif', { type: 'image/gif' }), ])); rerender({ sessionId: 'ses_2' }); expect(result.current.attachments).toEqual([]); expect(layoutTransferSources[0]).toBe('ses_2'); expect(dependencies.objectUrls.revokeObjectURL) .toHaveBeenCalledWith('blob:hook-1'); }); it('retains URLs for exactly one staged fork transfer then clears next time', () => { const dependencies = createDependencies(); const { result, rerender } = renderHook( ({ sessionId }) => useComposerAttachments({ selectedSessionId: sessionId, dependencies, }), { initialProps: { sessionId: 'ses_old' } }, ); act(() => result.current.addFiles([ new File(['gif'], 'animation.gif', { type: 'image/gif' }), ])); act(() => { result.current.stageSessionTransfer('ses_fork'); }); rerender({ sessionId: 'ses_fork' }); expect(result.current.attachments).toHaveLength(1); expect(result.current.attachments[0].original.previewUrl).toBe('blob:hook-1'); expect(dependencies.objectUrls.revokeObjectURL).not.toHaveBeenCalled(); rerender({ sessionId: 'ses_other' }); expect(result.current.attachments).toEqual([]); expect(dependencies.objectUrls.revokeObjectURL).toHaveBeenCalledWith('blob:hook-1'); }); it('waits for processing and serializes the latest selected variant by captured ID', async () => { const dependencies = createDependencies(); const deferred = createDeferred(); const processImage = vi.fn(() => deferred.promise); const { result } = renderHook(() => useComposerAttachments({ selectedSessionId: 'ses_1', dependencies, processImage, })); const file = new File(['original'], 'large.png', { type: 'image/png' }); act(() => result.current.addFiles([file])); const ids = result.current.captureAttachmentIds(); let serialized: Awaited> | undefined; const serializePromise = act(async () => { serialized = await result.current.serializeByIds(ids); }); expect(serialized).toBeUndefined(); deferred.resolve(unchanged(file)); await serializePromise; expect(serialized).toEqual([ expect.objectContaining({ fileName: 'large.png', mimeType: 'image/png', preview: 'data:image/png;base64,b3JpZ2luYWw=', }), ]); }); it('serializes selected image attachments sequentially', async () => { const dependencies = createDependencies(); const firstGate = createDeferred(); const secondGate = createDeferred(); const started: string[] = []; let active = 0; let maximumActive = 0; const installReadGate = ( file: File, label: string, gate: ReturnType>, ) => { const read = vi.fn(async () => { started.push(label); active += 1; maximumActive = Math.max(maximumActive, active); try { return await gate.promise; } finally { active -= 1; } }); vi.spyOn(file, 'arrayBuffer').mockImplementation(read); const chunk = new Blob([label], { type: file.type }); vi.spyOn(chunk, 'arrayBuffer').mockImplementation(read); vi.spyOn(file, 'slice').mockReturnValue(chunk); }; const firstFile = new File(['first'], 'first.gif', { type: 'image/gif' }); const secondFile = new File(['second'], 'second.gif', { type: 'image/gif' }); installReadGate( firstFile, 'first', firstGate, ); installReadGate( secondFile, 'second', secondGate, ); const { result } = renderHook(() => useComposerAttachments({ selectedSessionId: 'ses_1', dependencies, })); act(() => result.current.addFiles([firstFile, secondFile])); const ids = result.current.captureAttachmentIds(); const serialization = result.current.serializeByIds(ids); await waitFor(() => expect(started).toEqual(['first'])); expect(maximumActive).toBe(1); firstGate.resolve(new TextEncoder().encode('first').buffer); await waitFor(() => expect(started).toEqual(['first', 'second'])); expect(maximumActive).toBe(1); secondGate.resolve(new TextEncoder().encode('second').buffer); await expect(serialization).resolves.toHaveLength(2); expect(maximumActive).toBe(1); }); it('merges undo records through the restoration entry and reports unsafe records', () => { const dependencies = createDependencies(); const processImage = vi.fn(async (file: File) => unchanged(file)); const { result } = renderHook(() => useComposerAttachments({ selectedSessionId: 'ses_1', dependencies, processImage, })); let outcome!: ReturnType; act(() => { outcome = result.current.addRestoredAttachments([ { meta: { fileName: 'restored.png', mimeType: 'image/png', fileSize: 8, preview: null, }, imageDataUrl: 'data:image/png;base64,cmVzdG9yZWQ=', }, { meta: { fileName: 'remote.png', mimeType: 'image/png', fileSize: 8, preview: 'https://example.com/remote.png', }, }, ]); }); expect(outcome.addedIds).toHaveLength(1); expect(outcome.warnings).toEqual([ '无法恢复远程图片附件;未发起网络请求。', ]); expect(result.current.attachments).toHaveLength(1); }); it('revokes current URLs on unmount', async () => { const dependencies = createDependencies(); const { result, unmount } = renderHook(() => useComposerAttachments({ selectedSessionId: 'ses_1', dependencies, })); act(() => result.current.addFiles([ new File(['gif'], 'animation.gif', { type: 'image/gif' }), ])); unmount(); await waitFor(() => { expect(dependencies.objectUrls.revokeObjectURL) .toHaveBeenCalledWith('blob:hook-1'); }); }); });