459 lines
14 KiB
TypeScript
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,
|
|
}]);
|
|
});
|
|
});
|