import { describe, expect, it, vi } from 'vitest'; import { applyProcessedImage, buildProjectCommandPartsFromAttachedFiles, composerAttachmentToAttachedFileMeta, consumeComposerSessionTransferToken, createComposerAttachment, createComposerAttachmentFromStoredFile, createComposerSessionTransferToken, disposeComposerAttachment, getComposerAttachmentKey, getSelectedComposerAttachmentVariant, selectComposerAttachmentVariant, type ComposerAttachmentDependencies, } from '@/pages/Chat/composer-attachments'; function createDependencies(): ComposerAttachmentDependencies { let nextUrl = 0; return { objectUrls: { createObjectURL: vi.fn(() => `blob:test-${++nextUrl}`), revokeObjectURL: vi.fn(), }, idFactory: vi.fn(() => 'attachment-1'), }; } describe('composer attachments', () => { it('creates renderer-only state with an original Object URL', () => { const dependencies = createDependencies(); const file = new File(['original'], 'large.png', { type: 'image/png' }); const attachment = createComposerAttachment(file, { dependencies }); expect(attachment).toMatchObject({ id: 'attachment-1', displayName: 'large.png', selectedVariant: 'original', status: 'processing', originalFile: file, original: { blob: file, mimeType: 'image/png', previewUrl: 'blob:test-1', transportFileName: 'large.png', }, }); }); it('keeps the original usable when its preview URL cannot be allocated', async () => { const dependencies = createDependencies(); vi.mocked(dependencies.objectUrls.createObjectURL).mockImplementationOnce(() => { throw new Error('preview allocation exposed a sensitive local path'); }); const file = new File(['original'], 'large.png', { type: 'image/png' }); const attachment = createComposerAttachment(file, { dependencies }); expect(attachment).toMatchObject({ selectedVariant: 'original', status: 'failed', originalFile: file, original: { blob: file, previewUrl: null, }, warning: '无法创建图片预览,发送时将使用原图。', }); expect(attachment.compressed).toBeUndefined(); await expect(composerAttachmentToAttachedFileMeta(attachment)).resolves .toMatchObject({ fileName: 'large.png', fileSize: file.size, }); }); it('applies a compressed result and selects it by default', () => { const dependencies = createDependencies(); const file = new File(['original'], 'photo.avif', { type: 'image/avif' }); const attachment = createComposerAttachment(file, { dependencies }); const compressedBlob = new Blob(['compressed'], { type: 'image/webp' }); const next = applyProcessedImage(attachment, { status: 'compressed', original: { blob: file, mimeType: 'image/avif', bytes: file.size, width: 3840, height: 2160, }, compressed: { blob: compressedBlob, mimeType: 'image/webp', bytes: compressedBlob.size, width: 1930, height: 1086, }, defaultVariant: 'compressed', }, dependencies.objectUrls); expect(next).toMatchObject({ displayName: 'photo.avif', selectedVariant: 'compressed', status: 'compressed', compressed: { previewUrl: 'blob:test-2', transportFileName: 'photo.webp', }, }); expect(getSelectedComposerAttachmentVariant(next)).toBe(next.compressed); }); it('falls back to the original when a compressed preview URL cannot be allocated', () => { const dependencies = createDependencies(); const file = new File(['original'], 'photo.avif', { type: 'image/avif' }); const attachment = createComposerAttachment(file, { dependencies }); vi.mocked(dependencies.objectUrls.createObjectURL).mockImplementationOnce(() => { throw new Error('compressed preview allocation failed'); }); const compressedBlob = new Blob(['compressed'], { type: 'image/webp' }); const next = applyProcessedImage(attachment, { status: 'compressed', original: { blob: file, mimeType: 'image/avif', bytes: file.size, width: 3840, height: 2160, }, compressed: { blob: compressedBlob, mimeType: 'image/webp', bytes: compressedBlob.size, width: 1930, height: 1086, }, defaultVariant: 'compressed', }, dependencies.objectUrls); expect(next).toMatchObject({ selectedVariant: 'original', status: 'failed', original: { previewUrl: 'blob:test-1', width: 3840, height: 2160, }, warning: '无法创建图片预览,发送时将使用原图。', }); expect(next.compressed).toBeUndefined(); expect(getSelectedComposerAttachmentVariant(next)).toBe(next.original); }); it('serializes only the selected variant with a MIME-matching filename', async () => { const dependencies = createDependencies(); const file = new File(['original'], 'capture.bmp', { type: 'image/bmp' }); const attachment = createComposerAttachment(file, { dependencies }); const compressedBlob = new Blob(['compressed'], { type: 'image/png' }); const compressed = applyProcessedImage(attachment, { status: 'compressed', original: { blob: file, mimeType: 'image/bmp', bytes: file.size, width: 3000, height: 2000, }, compressed: { blob: compressedBlob, mimeType: 'image/png', bytes: compressedBlob.size, width: 1773, height: 1182, }, defaultVariant: 'compressed', }, dependencies.objectUrls); await expect(composerAttachmentToAttachedFileMeta(compressed)).resolves.toEqual({ fileName: 'capture.png', mimeType: 'image/png', fileSize: compressedBlob.size, preview: 'data:image/png;base64,Y29tcHJlc3NlZA==', source: 'user-upload', }); const original = selectComposerAttachmentVariant(compressed, 'original'); await expect(composerAttachmentToAttachedFileMeta(original)).resolves.toMatchObject({ fileName: 'capture.bmp', mimeType: 'image/bmp', preview: 'data:image/bmp;base64,b3JpZ2luYWw=', }); }); it('serializes image bytes in bounded Blob slices without a whole-Blob copy', async () => { const dependencies = createDependencies(); const bytes = Uint8Array.from( { length: 100_000 }, (_, index) => index % 251, ); const file = new File([bytes], 'chunked.png', { type: 'image/png' }); const wholeBlobRead = vi.spyOn(file, 'arrayBuffer'); const slice = vi.spyOn(file, 'slice'); const attachment = createComposerAttachment(file, { dependencies }); const serialized = await composerAttachmentToAttachedFileMeta(attachment); expect(wholeBlobRead).not.toHaveBeenCalled(); expect(slice).toHaveBeenCalledTimes(3); for (const [start, end] of slice.mock.calls) { expect(Number(end) - Number(start)).toBeLessThanOrEqual(48 * 1024); } const encoded = serialized.preview?.split(',')[1] ?? ''; expect(Buffer.from(encoded, 'base64')).toEqual(Buffer.from(bytes)); }); it('keeps the AVIF display name while WebP/JPEG transport names follow MIME', async () => { const dependencies = createDependencies(); const file = new File(['original'], 'portrait.avif', { type: 'image/avif' }); const attachment = createComposerAttachment(file, { dependencies }); const jpeg = new Blob(['jpeg'], { type: 'image/jpeg' }); const processed = applyProcessedImage(attachment, { status: 'compressed', original: { blob: file, mimeType: 'image/avif', bytes: file.size, width: 3000, height: 3000, }, compressed: { blob: jpeg, mimeType: 'image/jpeg', bytes: jpeg.size, width: 1448, height: 1448, }, defaultVariant: 'compressed', }, dependencies.objectUrls); expect(processed.displayName).toBe('portrait.avif'); await expect(composerAttachmentToAttachedFileMeta(processed)).resolves.toMatchObject({ fileName: 'portrait.jpg', mimeType: 'image/jpeg', }); }); it('revokes owned preview URLs once when disposed', () => { const dependencies = createDependencies(); const file = new File(['original'], 'large.png', { type: 'image/png' }); const attachment = createComposerAttachment(file, { dependencies }); const compressedBlob = new Blob(['compressed'], { type: 'image/png' }); const processed = applyProcessedImage(attachment, { status: 'compressed', original: { blob: file, mimeType: 'image/png', bytes: file.size, width: 3000, height: 2000, }, compressed: { blob: compressedBlob, mimeType: 'image/png', bytes: compressedBlob.size, width: 1773, height: 1182, }, defaultVariant: 'compressed', }, dependencies.objectUrls); disposeComposerAttachment(processed, dependencies.objectUrls); expect(dependencies.objectUrls.revokeObjectURL).toHaveBeenCalledTimes(2); expect(dependencies.objectUrls.revokeObjectURL).toHaveBeenCalledWith('blob:test-1'); expect(dependencies.objectUrls.revokeObjectURL).toHaveBeenCalledWith('blob:test-2'); }); it('restores a stored Data URL through the normal image model without fetching', () => { const dependencies = createDependencies(); const restored = createComposerAttachmentFromStoredFile({ meta: { fileName: 'restored.png', mimeType: 'image/png', fileSize: 8, preview: null, source: 'message-ref', }, imageDataUrl: 'data:image/png;base64,cmVzdG9yZWQ=', }, { dependencies }); expect(restored.warning).toBeUndefined(); expect(restored.attachment).toMatchObject({ displayName: 'restored.png', source: 'undo-restore', status: 'processing', original: { bytes: 8, mimeType: 'image/png', }, }); }); it('rejects remote image URLs and restores path-only non-images without network access', () => { const dependencies = createDependencies(); expect(createComposerAttachmentFromStoredFile({ meta: { fileName: 'remote.png', mimeType: 'image/png', fileSize: 10, preview: 'https://example.com/remote.png', }, }, { dependencies })).toEqual({ attachment: null, warning: '无法恢复远程图片附件;未发起网络请求。', }); const restoredPath = createComposerAttachmentFromStoredFile({ meta: { fileName: 'brief.md', mimeType: 'text/markdown', fileSize: 100, preview: null, filePath: 'D:/repo/brief.md', }, }, { dependencies }); expect(restoredPath.attachment).toMatchObject({ displayName: 'brief.md', filePath: 'D:/repo/brief.md', source: 'undo-restore', status: 'unchanged', }); }); it('round-trips a finite non-negative stored size for a path-only undo file', async () => { const dependencies = createDependencies(); const restored = createComposerAttachmentFromStoredFile({ meta: { fileName: 'brief.md', mimeType: 'text/markdown', fileSize: 4096, preview: null, filePath: 'D:/repo/brief.md', }, }, { dependencies }); expect(restored.attachment?.original.bytes).toBe(4096); await expect(composerAttachmentToAttachedFileMeta( restored.attachment!, )).resolves.toMatchObject({ fileName: 'brief.md', fileSize: 4096, filePath: 'D:/repo/brief.md', }); }); it('uses stable identity for restored attachment deduplication', () => { const dependencies = createDependencies(); const first = createComposerAttachment( new File([], 'brief.md', { type: 'text/markdown' }), { dependencies, filePath: 'D:/repo/brief.md' }, ); const second = createComposerAttachment( new File([], 'renamed.md', { type: 'text/markdown' }), { dependencies, filePath: 'D:/repo/brief.md' }, ); expect(getComposerAttachmentKey(first)).toBe(getComposerAttachmentKey(second)); }); it('consumes an exact fork transfer token once', () => { const token = createComposerSessionTransferToken( 'ses_old', 'ses_fork', () => 'transfer-1', ); expect(consumeComposerSessionTransferToken( token, 'ses_old', 'ses_fork', )).toEqual({ retainAttachments: true, token: null, }); expect(consumeComposerSessionTransferToken( null, 'ses_fork', 'ses_other', )).toEqual({ retainAttachments: false, token: null, }); expect(consumeComposerSessionTransferToken( token, 'ses_other', 'ses_fork', ).retainAttachments).toBe(false); }); it('builds image file parts and bounded synthetic path context without touching command arguments', () => { const parts = buildProjectCommandPartsFromAttachedFiles([ { fileName: 'selected.webp', mimeType: 'image/webp', fileSize: 4, preview: 'data:image/webp;base64,V0VCUA==', source: 'user-upload', }, { fileName: 'spec.md', mimeType: 'text/markdown', fileSize: 10, preview: null, filePath: 'D:/repo/private/spec.md', source: 'user-upload', }, { fileName: `${'x'.repeat(2_500)}.txt`, mimeType: 'text/plain', fileSize: 0, preview: null, source: 'message-ref', }, ]); expect(parts).toEqual([ { type: 'text', text: expect.stringContaining('请参考以下由我拖入输入框的文件路径'), synthetic: true, }, { type: 'file', mime: 'image/webp', filename: 'selected.webp', url: 'data:image/webp;base64,V0VCUA==', }, ]); expect(parts[0]?.type === 'text' ? parts[0].text.length : 0) .toBeLessThanOrEqual(2_000); expect(JSON.stringify(parts)).not.toContain('staged changes'); }); it('does not turn a remote image URL into a runtime file part', () => { const parts = buildProjectCommandPartsFromAttachedFiles([{ fileName: 'remote.png', mimeType: 'image/png', fileSize: 10, preview: 'https://example.com/remote.png', source: 'message-ref', }]); expect(parts).toEqual([{ type: 'text', text: expect.stringContaining('remote.png'), synthetic: true, }]); }); });