Files
makelore/tests/unit/use-composer-attachments.test.tsx
2026-07-29 17:22:35 +08:00

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');
});
});
});