import { describe, expect, it, vi } from 'vitest'; import { calculateImageResizeTarget, estimateSelectedImageTokens, getEffectiveImageMimeType, getImageEncodeCandidates, getImageFileNameForMime, IMAGE_MAX_AUTO_PROCESS_SOURCE_BYTES, processImageAttachment, shouldSkipAutomaticImageProcessing, type BrowserImageCodec, type DecodedImage, type RenderedImage, } from '@/lib/image-attachment-compression'; import { browserImageCodec } from '@/lib/browser-image-codec'; function createDeferred() { let resolve!: (value: T | PromiseLike) => void; let reject!: (reason?: unknown) => void; const promise = new Promise((promiseResolve, promiseReject) => { resolve = promiseResolve; reject = promiseReject; }); return { promise, reject, resolve }; } describe('calculateImageResizeTarget', () => { it('keeps a small image unchanged', () => { expect(calculateImageResizeTarget(1280, 720)).toEqual({ width: 1280, height: 720, scale: 1, resized: false, }); }); it('caps a 4K image by the pixel limit', () => { const target = calculateImageResizeTarget(3840, 2160); expect(target).toEqual({ width: 1930, height: 1086, scale: expect.closeTo(Math.sqrt(2_097_152 / (3840 * 2160)), 8), resized: true, }); expect(target.width * target.height).toBeLessThanOrEqual(2_097_152); }); it('caps a very wide image by the long edge and never returns zero', () => { expect(calculateImageResizeTarget(4096, 256)).toMatchObject({ width: 2048, height: 128, resized: true, }); expect(calculateImageResizeTarget(100_000, 1)).toMatchObject({ width: 2048, height: 1, resized: true, }); }); it.each([ [Number.NaN, 10], [Number.POSITIVE_INFINITY, 10], [10, Number.NEGATIVE_INFINITY], [0, 10], [-1, 10], [10, 0], ])('rejects invalid dimensions %s × %s', (width, height) => { expect(() => calculateImageResizeTarget(width, height)) .toThrowError(new RangeError('Image dimensions must be finite positive numbers')); }); }); describe('image MIME and encoding rules', () => { it('infers a missing MIME from the filename', () => { expect(getEffectiveImageMimeType(new File([], 'animation.GIF'))).toBe('image/gif'); expect(getEffectiveImageMimeType(new File([], 'vector.svg'))).toBe('image/svg+xml'); expect(getEffectiveImageMimeType(new File([], 'unknown.bin'))).toBe('application/octet-stream'); }); it.each(['image/gif', 'IMAGE/GIF', 'image/svg+xml'])('skips %s immediately', (mimeType) => { expect(shouldSkipAutomaticImageProcessing(mimeType)).toBe(true); }); it('selects lossless output for PNG and BMP', () => { expect(getImageEncodeCandidates('image/png', true)).toEqual([ { mimeType: 'image/png' }, ]); expect(getImageEncodeCandidates('image/bmp', false)).toEqual([ { mimeType: 'image/png' }, ]); }); it('selects JPEG quality and WebP fallbacks deterministically', () => { expect(getImageEncodeCandidates('image/jpeg', false)).toEqual([ { mimeType: 'image/jpeg', quality: 0.85 }, ]); expect(getImageEncodeCandidates('image/webp', true)).toEqual([ { mimeType: 'image/webp', quality: 0.85 }, { mimeType: 'image/png' }, ]); expect(getImageEncodeCandidates('image/avif', false)).toEqual([ { mimeType: 'image/webp', quality: 0.85 }, { mimeType: 'image/jpeg', quality: 0.85 }, ]); }); it('makes transport filename extensions match the selected MIME', () => { expect(getImageFileNameForMime('capture.bmp', 'image/png')).toBe('capture.png'); expect(getImageFileNameForMime('photo.avif', 'image/webp')).toBe('photo.webp'); expect(getImageFileNameForMime('photo.avif', 'image/jpeg')).toBe('photo.jpg'); expect(getImageFileNameForMime('photo.jpeg', 'image/jpg')).toBe('photo.jpg'); expect(getImageFileNameForMime('.hidden', 'image/png')).toBe('.hidden.png'); expect(getImageFileNameForMime('notes.txt', 'text/plain')).toBe('notes.txt'); }); }); describe('estimateSelectedImageTokens', () => { it('delegates only an imported Qwen Plus model to the shared profile', () => { expect(estimateSelectedImageTokens( 'niancode-user-models/qwen3.7-plus', 3840, 2160, )).toBe(8102); expect(estimateSelectedImageTokens( 'niancode-user-models/qwen3.7-plus', 1930, 1086, )).toBe(2049); }); it('returns null for unverified models and a same-named model on another provider', () => { expect(estimateSelectedImageTokens( 'niancode-user-models/qwen-vl-max', 1930, 1086, )).toBeNull(); expect(estimateSelectedImageTokens( 'openai/qwen3.7-plus', 1930, 1086, )).toBeNull(); expect(estimateSelectedImageTokens('qwen3.7-plus', 1930, 1086)).toBeNull(); expect(estimateSelectedImageTokens( 'niancode-user-models/qwen3.7-plus/extra', 1930, 1086, )).toBeNull(); }); }); function createFakeCodec(options: { frameCount?: number | null; width?: number; height?: number; hasAlpha?: boolean; encoded?: Array; }) { const decoded: DecodedImage = { source: {} as CanvasImageSource, width: options.width ?? 3840, height: options.height ?? 2160, dispose: vi.fn(), }; const rendered: RenderedImage = { canvas: {} as HTMLCanvasElement, hasAlpha: options.hasAlpha ?? false, dispose: vi.fn(), }; const encoded = [...(options.encoded ?? [])]; const codec = { inspectFrameCount: vi.fn(async () => ( Object.prototype.hasOwnProperty.call(options, 'frameCount') ? options.frameCount ?? null : 1 )), decode: vi.fn(async () => decoded), render: vi.fn(() => rendered), encode: vi.fn(async () => encoded.shift() ?? null), } satisfies BrowserImageCodec; return { codec, decoded, rendered }; } describe('processImageAttachment', () => { it('keeps an over-limit encoded source sendable without invoking the codec', async () => { const { codec } = createFakeCodec({}); const file = new File([], 'huge.png', { type: 'image/png' }); Object.defineProperty(file, 'size', { configurable: true, value: IMAGE_MAX_AUTO_PROCESS_SOURCE_BYTES + 1, }); await expect(processImageAttachment(file, codec)).resolves.toMatchObject({ status: 'skipped', original: { blob: file, mimeType: 'image/png', bytes: IMAGE_MAX_AUTO_PROCESS_SOURCE_BYTES + 1, }, defaultVariant: 'original', skipReason: 'source-too-large', warning: expect.any(String), }); expect(codec.inspectFrameCount).not.toHaveBeenCalled(); expect(codec.decode).not.toHaveBeenCalled(); expect(codec.render).not.toHaveBeenCalled(); expect(codec.encode).not.toHaveBeenCalled(); }); it('inspects one frame, honors EXIF orientation, resizes, and uses WebP first', async () => { const compressed = new Blob(['compressed'], { type: 'image/webp' }); const { codec, decoded, rendered } = createFakeCodec({ frameCount: 1, encoded: [compressed], }); const file = new File(['original'], 'photo.avif', { type: 'image/avif' }); const result = await processImageAttachment(file, codec); expect(codec.inspectFrameCount).toHaveBeenCalledWith( file, 'image/avif', undefined, ); expect(codec.decode).toHaveBeenCalledWith(file, { imageOrientation: 'from-image', }, undefined); expect(codec.render).toHaveBeenCalledWith(decoded, { width: 1930, height: 1086, }, undefined); expect(codec.encode).toHaveBeenCalledWith(rendered, { mimeType: 'image/webp', quality: 0.85, }, undefined); expect(result).toMatchObject({ status: 'compressed', defaultVariant: 'compressed', compressed: { blob: compressed, mimeType: 'image/webp', width: 1930, height: 1086, }, }); expect(decoded.dispose).toHaveBeenCalledTimes(1); expect(rendered.dispose).toHaveBeenCalledTimes(1); }); it('falls back from unsupported WebP to PNG for an alpha image', async () => { const png = new Blob(['png'], { type: 'image/png' }); const { codec } = createFakeCodec({ frameCount: 1, hasAlpha: true, encoded: [null, png], }); const result = await processImageAttachment( new File(['original'], 'alpha.avif', { type: 'image/avif' }), codec, ); expect(codec.encode).toHaveBeenNthCalledWith(1, expect.anything(), { mimeType: 'image/webp', quality: 0.85, }, undefined); expect(codec.encode).toHaveBeenNthCalledWith(2, expect.anything(), { mimeType: 'image/png', }, undefined); expect(result).toMatchObject({ status: 'compressed', compressed: { mimeType: 'image/png' }, }); }); it.each([ ['image/png', 'animated.png'], ['image/webp', 'animated.webp'], ['image/avif', 'animated.avif'], ])('skips multi-frame %s without decoding its first frame', async (mimeType, name) => { const { codec } = createFakeCodec({ frameCount: 3 }); const result = await processImageAttachment( new File(['animated'], name, { type: mimeType }), codec, ); expect(result).toMatchObject({ status: 'skipped', skipReason: 'animated', defaultVariant: 'original', warning: '检测到动画图片,已保留原图。', }); expect(codec.decode).not.toHaveBeenCalled(); expect(codec.render).not.toHaveBeenCalled(); expect(codec.encode).not.toHaveBeenCalled(); }); it('skips an animation-capable raster when single-frame status is unknown', async () => { const { codec } = createFakeCodec({ frameCount: null }); const result = await processImageAttachment( new File(['unknown'], 'unknown.webp', { type: 'image/webp' }), codec, ); expect(result).toMatchObject({ status: 'skipped', skipReason: 'static-unverified', warning: '无法确认图片为单帧,已保留原图。', }); expect(codec.decode).not.toHaveBeenCalled(); }); it.each([ ['image/gif', 'animation.gif', 'animated'], ['image/svg+xml', 'vector.svg', 'vector'], ] as const)('skips %s before frame inspection', async (mimeType, name, skipReason) => { const { codec } = createFakeCodec({}); const result = await processImageAttachment( new File(['original'], name, { type: mimeType }), codec, ); expect(result).toMatchObject({ status: 'skipped', skipReason, defaultVariant: 'original', }); expect(codec.inspectFrameCount).not.toHaveBeenCalled(); expect(codec.decode).not.toHaveBeenCalled(); }); it('does not render or encode a small one-frame image', async () => { const { codec } = createFakeCodec({ frameCount: 1, width: 1280, height: 720, }); const result = await processImageAttachment( new File(['small'], 'small.jpg', { type: 'image/jpeg' }), codec, ); expect(result).toMatchObject({ status: 'unchanged', defaultVariant: 'original', original: { width: 1280, height: 720 }, }); expect(codec.render).not.toHaveBeenCalled(); expect(codec.encode).not.toHaveBeenCalled(); }); it('fails open when decode throws', async () => { const { codec } = createFakeCodec({ frameCount: 1 }); codec.decode.mockRejectedValueOnce(new Error('decode failed')); const file = new File(['original'], 'broken.png', { type: 'image/png' }); await expect(processImageAttachment(file, codec)).resolves.toEqual({ status: 'failed', original: { blob: file, mimeType: 'image/png', bytes: file.size, }, defaultVariant: 'original', warning: '图片压缩失败,发送时将使用原图。', }); }); it('fails open when every MIME encoder is unsupported', async () => { const { codec } = createFakeCodec({ frameCount: 1, hasAlpha: false, encoded: [null, null], }); const result = await processImageAttachment( new File(['original'], 'photo.avif', { type: 'image/avif' }), codec, ); expect(result).toMatchObject({ status: 'failed', defaultVariant: 'original', warning: '图片压缩失败,发送时将使用原图。', }); }); it('preserves decoded original dimensions when rendering later fails', async () => { const { codec } = createFakeCodec({ frameCount: 1, width: 3000, height: 2000, }); codec.render.mockImplementationOnce(() => { throw new Error('render failed'); }); const file = new File(['original'], 'photo.png', { type: 'image/png' }); await expect(processImageAttachment(file, codec)).resolves.toMatchObject({ status: 'failed', original: { blob: file, width: 3000, height: 2000, }, defaultVariant: 'original', }); }); it('releases decoded and rendered resources when aborting after render', async () => { const controller = new AbortController(); const { codec, decoded, rendered } = createFakeCodec({ frameCount: 1, width: 3000, height: 2000, }); codec.render.mockImplementationOnce(() => { controller.abort(); return rendered; }); await expect(processImageAttachment( new File(['original'], 'photo.png', { type: 'image/png' }), codec, { signal: controller.signal }, )).rejects.toMatchObject({ name: 'AbortError' }); expect(decoded.dispose).toHaveBeenCalledTimes(1); expect(rendered.dispose).toHaveBeenCalledTimes(1); expect(codec.encode).not.toHaveBeenCalled(); }); it('releases resources when an encode completes after cancellation', async () => { const controller = new AbortController(); const { codec, decoded, rendered } = createFakeCodec({ frameCount: 1, width: 3000, height: 2000, }); codec.encode.mockImplementationOnce(async () => { controller.abort(); return new Blob(['compressed'], { type: 'image/png' }); }); await expect(processImageAttachment( new File(['original'], 'photo.png', { type: 'image/png' }), codec, { signal: controller.signal }, )).rejects.toMatchObject({ name: 'AbortError' }); expect(decoded.dispose).toHaveBeenCalledTimes(1); expect(rendered.dispose).toHaveBeenCalledTimes(1); }); it('preserves a compressed result when rendered cleanup throws', async () => { const compressed = new Blob(['compressed'], { type: 'image/webp' }); const { codec, decoded, rendered } = createFakeCodec({ frameCount: 1, encoded: [compressed], }); rendered.dispose = vi.fn(() => { throw new Error('rendered cleanup failed'); }); decoded.dispose = vi.fn(() => { throw new Error('decoded cleanup failed'); }); await expect(processImageAttachment( new File(['original'], 'photo.avif', { type: 'image/avif' }), codec, )).resolves.toMatchObject({ status: 'compressed', defaultVariant: 'compressed', compressed: { blob: compressed }, }); expect(rendered.dispose).toHaveBeenCalledTimes(1); expect(decoded.dispose).toHaveBeenCalledTimes(1); }); it('preserves an unchanged result when decoded cleanup throws', async () => { const { codec, decoded } = createFakeCodec({ frameCount: 1, width: 1280, height: 720, }); decoded.dispose = vi.fn(() => { throw new Error('decoded cleanup failed'); }); await expect(processImageAttachment( new File(['original'], 'small.jpg', { type: 'image/jpeg' }), codec, )).resolves.toMatchObject({ status: 'unchanged', defaultVariant: 'original', original: { width: 1280, height: 720 }, }); expect(decoded.dispose).toHaveBeenCalledTimes(1); }); }); describe('browserImageCodec', () => { it('passes a Blob stream to ImageDecoder without buffering the whole source', async () => { const stream = {} as ReadableStream; const blob = new Blob(['png'], { type: 'image/png' }); const arrayBuffer = vi.spyOn(blob, 'arrayBuffer'); Object.defineProperty(blob, 'stream', { configurable: true, value: vi.fn(() => stream), }); let decoderData: unknown; class FakeImageDecoder { static isTypeSupported = vi.fn(async () => true); tracks = { ready: Promise.resolve(), selectedTrack: { frameCount: 1 }, }; close = vi.fn(); constructor(init: { data: unknown }) { decoderData = init.data; } } vi.stubGlobal('ImageDecoder', FakeImageDecoder); try { await expect(browserImageCodec.inspectFrameCount( blob, 'image/png', )).resolves.toBe(1); expect(decoderData).toBe(stream); expect(arrayBuffer).not.toHaveBeenCalled(); } finally { vi.unstubAllGlobals(); } }); it('does not create a fallback ImageDecoder after buffering is aborted', async () => { const buffered = createDeferred(); const blob = new Blob(['png'], { type: 'image/png' }); const arrayBuffer = vi.spyOn(blob, 'arrayBuffer') .mockImplementation(() => buffered.promise); Object.defineProperty(blob, 'stream', { configurable: true, value: vi.fn(() => { throw new TypeError('ReadableStream input is unsupported'); }), }); const construct = vi.fn(); class FakeImageDecoder { static isTypeSupported = vi.fn(async () => true); tracks = { ready: Promise.resolve(), selectedTrack: { frameCount: 1 }, }; close = vi.fn(); constructor() { construct(); } } vi.stubGlobal('ImageDecoder', FakeImageDecoder); const controller = new AbortController(); try { const inspected = browserImageCodec.inspectFrameCount( blob, 'image/png', controller.signal, ); await vi.waitFor(() => expect(arrayBuffer).toHaveBeenCalledTimes(1)); controller.abort(); buffered.resolve(new ArrayBuffer(3)); await expect(inspected).rejects.toMatchObject({ name: 'AbortError' }); expect(construct).not.toHaveBeenCalled(); } finally { vi.unstubAllGlobals(); } }); it('closes ImageDecoder and rejects when frame inspection is aborted', async () => { const ready = createDeferred(); const close = vi.fn(); const construct = vi.fn(); class FakeImageDecoder { static isTypeSupported = vi.fn(async () => true); tracks = { ready: ready.promise, selectedTrack: { frameCount: 1 }, }; close = close; constructor() { construct(); } } vi.stubGlobal('ImageDecoder', FakeImageDecoder); const controller = new AbortController(); try { const inspected = browserImageCodec.inspectFrameCount( new Blob(['png'], { type: 'image/png' }), 'image/png', controller.signal, ); await vi.waitFor(() => expect(construct).toHaveBeenCalledTimes(1)); controller.abort(); ready.resolve(); await expect(inspected).rejects.toMatchObject({ name: 'AbortError' }); expect(close).toHaveBeenCalledTimes(1); } finally { vi.unstubAllGlobals(); } }); it('closes a late ImageBitmap when decode is aborted', async () => { const bitmap = { width: 640, height: 480, close: vi.fn(), }; const pendingBitmap = createDeferred(); vi.stubGlobal('createImageBitmap', vi.fn(() => pendingBitmap.promise)); const controller = new AbortController(); try { const decoded = browserImageCodec.decode( new Blob(['image'], { type: 'image/png' }), { imageOrientation: 'from-image' }, controller.signal, ); controller.abort(); pendingBitmap.resolve(bitmap); await expect(decoded).rejects.toMatchObject({ name: 'AbortError' }); expect(bitmap.close).toHaveBeenCalledTimes(1); } finally { vi.unstubAllGlobals(); } }); it('revokes the fallback image URL when decode is aborted', async () => { const createObjectURL = vi.fn(() => 'blob:pending-image'); const revokeObjectURL = vi.fn(); class PendingImage { decoding = ''; naturalWidth = 0; naturalHeight = 0; onload: (() => void) | null = null; onerror: (() => void) | null = null; src = ''; } vi.stubGlobal('URL', { createObjectURL, revokeObjectURL }); vi.stubGlobal('Image', PendingImage); vi.stubGlobal('createImageBitmap', undefined); const controller = new AbortController(); try { const decoded = browserImageCodec.decode( new Blob(['image'], { type: 'image/png' }), { imageOrientation: 'from-image' }, controller.signal, ); controller.abort(); await expect(decoded).rejects.toMatchObject({ name: 'AbortError' }); expect(revokeObjectURL).toHaveBeenCalledTimes(1); expect(revokeObjectURL).toHaveBeenCalledWith('blob:pending-image'); } finally { vi.unstubAllGlobals(); } }); it('rejects a pending canvas encoder when aborted', async () => { let finishEncoding!: BlobCallback; const rendered: RenderedImage = { canvas: { toBlob: vi.fn((callback: BlobCallback) => { finishEncoding = callback; }), } as unknown as HTMLCanvasElement, hasAlpha: false, dispose: vi.fn(), }; const controller = new AbortController(); const encoded = browserImageCodec.encode( rendered, { mimeType: 'image/png' }, controller.signal, ); controller.abort(); finishEncoding(new Blob(['late'], { type: 'image/png' })); await expect(encoded).rejects.toMatchObject({ name: 'AbortError' }); }); it.each(['image/jpeg', 'image/bmp'] as const)( 'marks animation-capable bytes mislabeled as %s as unverified', async (mimeType) => { const staticHeader = mimeType === 'image/jpeg' ? new Uint8Array([0xff, 0xd8, 0xff]) : new Uint8Array([0x42, 0x4d]); const webpHeader = new Uint8Array([ 0x52, 0x49, 0x46, 0x46, 0, 0, 0, 0, 0x57, 0x45, 0x42, 0x50, ]); await expect(browserImageCodec.inspectFrameCount( new Blob([staticHeader], { type: mimeType }), mimeType, )).resolves.toBe(1); await expect(browserImageCodec.inspectFrameCount( new Blob([webpHeader], { type: mimeType }), mimeType, )).resolves.toBeNull(); }, ); it('closes ImageDecoder after reading a verified animated-capable raster', async () => { const close = vi.fn(); class FakeImageDecoder { static isTypeSupported = vi.fn(async () => true); tracks = { ready: Promise.resolve(), selectedTrack: { frameCount: 2 }, }; close = close; } vi.stubGlobal('ImageDecoder', FakeImageDecoder); try { await expect(browserImageCodec.inspectFrameCount( new Blob(['png'], { type: 'image/png' }), 'image/png', )).resolves.toBe(2); expect(FakeImageDecoder.isTypeSupported).toHaveBeenCalledWith('image/png'); expect(close).toHaveBeenCalledTimes(1); } finally { vi.unstubAllGlobals(); } }); it('detects alpha from canvas pixels and releases the canvas allocation', () => { const context = { clearRect: vi.fn(), drawImage: vi.fn(), getImageData: vi.fn(() => ({ data: new Uint8ClampedArray([0, 0, 0, 254]), })), } as unknown as CanvasRenderingContext2D; const canvas = { width: 0, height: 0, getContext: vi.fn(() => context), } as unknown as HTMLCanvasElement; const createElement = vi.spyOn(document, 'createElement').mockReturnValue(canvas); try { const rendered = browserImageCodec.render({ source: {} as CanvasImageSource, width: 2, height: 1, dispose: vi.fn(), }, { width: 2, height: 1 }); expect(createElement).toHaveBeenCalledWith('canvas'); expect(rendered.hasAlpha).toBe(true); expect(canvas.width).toBe(2); rendered.dispose(); expect(canvas.width).toBe(1); expect(canvas.height).toBe(1); } finally { createElement.mockRestore(); } }); it('rejects an encoder result with a MIME that differs from the candidate', async () => { const toBlob = vi.fn((callback: BlobCallback) => { callback(new Blob(['png'], { type: 'image/png' })); }); const rendered: RenderedImage = { canvas: { toBlob } as unknown as HTMLCanvasElement, hasAlpha: false, dispose: vi.fn(), }; await expect(browserImageCodec.encode(rendered, { mimeType: 'image/jpeg', quality: 0.85, })).resolves.toBeNull(); expect(toBlob).toHaveBeenCalledWith( expect.any(Function), 'image/jpeg', 0.85, ); }); it('revokes the fallback image URL exactly once when Image setup throws', async () => { const createObjectURL = vi.fn(() => 'blob:test-image'); const revokeObjectURL = vi.fn(); class ThrowingImage { constructor() { throw new Error('Image setup failed'); } } vi.stubGlobal('URL', { createObjectURL, revokeObjectURL }); vi.stubGlobal('Image', ThrowingImage); vi.stubGlobal('createImageBitmap', undefined); try { await expect(browserImageCodec.decode( new Blob(['image'], { type: 'image/png' }), { imageOrientation: 'from-image' }, )).rejects.toThrow('Image setup failed'); expect(revokeObjectURL).toHaveBeenCalledTimes(1); expect(revokeObjectURL).toHaveBeenCalledWith('blob:test-image'); } finally { vi.unstubAllGlobals(); } }); });