Makelore 2.0 initial clean snapshot
This commit is contained in:
529
tests/unit/use-composer-attachments.test.tsx
Normal file
529
tests/unit/use-composer-attachments.test.tsx
Normal file
@@ -0,0 +1,529 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user