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

459 lines
14 KiB
TypeScript

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,
}]);
});
});