530 lines
18 KiB
TypeScript
530 lines
18 KiB
TypeScript
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<T>() {
|
|
let resolve!: (value: T) => void;
|
|
let reject!: (reason?: unknown) => void;
|
|
const promise = new Promise<T>((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<ProcessedImageAttachment>();
|
|
const second = createDeferred<ProcessedImageAttachment>();
|
|
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<ProcessedImageAttachment>();
|
|
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<ProcessedImageAttachment>();
|
|
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<ProcessedImageAttachment>();
|
|
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<ProcessedImageAttachment>();
|
|
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<ProcessedImageAttachment>();
|
|
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<ProcessedImageAttachment>();
|
|
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<ReturnType<typeof result.current.serializeByIds>> | 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<ArrayBuffer>();
|
|
const secondGate = createDeferred<ArrayBuffer>();
|
|
const started: string[] = [];
|
|
let active = 0;
|
|
let maximumActive = 0;
|
|
const installReadGate = (
|
|
file: File,
|
|
label: string,
|
|
gate: ReturnType<typeof createDeferred<ArrayBuffer>>,
|
|
) => {
|
|
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<typeof result.current.addRestoredAttachments>;
|
|
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');
|
|
});
|
|
});
|
|
});
|