mirror of
https://github.com/lobehub/lobehub.git
synced 2026-09-20 04:56:13 +08:00
295 lines
9.5 KiB
TypeScript
295 lines
9.5 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
import compressImage, {
|
|
COMPRESSIBLE_IMAGE_TYPES,
|
|
compressImageFile,
|
|
MAX_IMAGE_BYTES,
|
|
MAX_IMAGE_SIZE,
|
|
} from './compressImage';
|
|
|
|
const getContextSpy = vi.spyOn(global.HTMLCanvasElement.prototype, 'getContext');
|
|
const drawImageSpy = vi.spyOn(CanvasRenderingContext2D.prototype, 'drawImage');
|
|
const toDataURLSpy = vi.spyOn(global.HTMLCanvasElement.prototype, 'toDataURL');
|
|
|
|
beforeEach(() => {
|
|
getContextSpy.mockClear();
|
|
drawImageSpy.mockClear();
|
|
toDataURLSpy.mockClear();
|
|
});
|
|
|
|
describe('compressImage', () => {
|
|
it('should compress image when width exceeds maxSize', () => {
|
|
const img = document.createElement('img');
|
|
img.width = 3000;
|
|
img.height = 2000;
|
|
|
|
const r = compressImage({ img });
|
|
|
|
expect(r).toMatch(/^data:image\/png;base64,/);
|
|
expect(drawImageSpy).toBeCalledWith(img, 0, 0, 3000, 2000, 0, 0, 1920, 1280);
|
|
});
|
|
|
|
it('should compress image when height exceeds maxSize', () => {
|
|
const img = document.createElement('img');
|
|
img.width = 2000;
|
|
img.height = 3000;
|
|
|
|
const r = compressImage({ img });
|
|
|
|
expect(r).toMatch(/^data:image\/png;base64,/);
|
|
expect(drawImageSpy).toBeCalledWith(img, 0, 0, 2000, 3000, 0, 0, 1280, 1920);
|
|
});
|
|
|
|
it('should not compress image when within maxSize', () => {
|
|
const img = document.createElement('img');
|
|
img.width = 1800;
|
|
img.height = 1800;
|
|
|
|
compressImage({ img });
|
|
|
|
expect(drawImageSpy).toBeCalledWith(img, 0, 0, 1800, 1800, 0, 0, 1800, 1800);
|
|
});
|
|
|
|
it('should use specified output type', () => {
|
|
const img = document.createElement('img');
|
|
img.width = 100;
|
|
img.height = 100;
|
|
|
|
const r = compressImage({ img, type: 'image/jpeg' });
|
|
|
|
expect(r).toMatch(/^data:image\/jpeg;base64,/);
|
|
});
|
|
|
|
it('should encode JPEG inputs as JPEG with 0.85 quality (preserve format, lossy)', () => {
|
|
const img = document.createElement('img');
|
|
img.width = 100;
|
|
img.height = 100;
|
|
|
|
compressImage({ img, type: 'image/jpeg' });
|
|
|
|
expect(toDataURLSpy).toHaveBeenCalledWith('image/jpeg', 0.85);
|
|
});
|
|
|
|
it('should encode non-JPEG inputs as PNG without a quality argument (lossless)', () => {
|
|
const img = document.createElement('img');
|
|
img.width = 100;
|
|
img.height = 100;
|
|
|
|
compressImage({ img, type: 'image/png' });
|
|
|
|
expect(toDataURLSpy).toHaveBeenCalledWith('image/png');
|
|
});
|
|
|
|
it('should default to PNG when no type is provided', () => {
|
|
const img = document.createElement('img');
|
|
img.width = 100;
|
|
img.height = 100;
|
|
|
|
compressImage({ img });
|
|
|
|
expect(toDataURLSpy).toHaveBeenCalledWith('image/png');
|
|
});
|
|
|
|
it('should support custom maxSize', () => {
|
|
const img = document.createElement('img');
|
|
img.width = 500;
|
|
img.height = 300;
|
|
|
|
compressImage({ img, maxSize: 400 });
|
|
|
|
expect(drawImageSpy).toBeCalledWith(img, 0, 0, 500, 300, 0, 0, 400, 240);
|
|
});
|
|
});
|
|
|
|
describe('COMPRESSIBLE_IMAGE_TYPES', () => {
|
|
it('should include jpeg, png, webp', () => {
|
|
expect(COMPRESSIBLE_IMAGE_TYPES.has('image/jpeg')).toBe(true);
|
|
expect(COMPRESSIBLE_IMAGE_TYPES.has('image/png')).toBe(true);
|
|
expect(COMPRESSIBLE_IMAGE_TYPES.has('image/webp')).toBe(true);
|
|
});
|
|
|
|
it('should exclude gif and svg', () => {
|
|
expect(COMPRESSIBLE_IMAGE_TYPES.has('image/gif')).toBe(false);
|
|
expect(COMPRESSIBLE_IMAGE_TYPES.has('image/svg+xml')).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('constants', () => {
|
|
it('MAX_IMAGE_SIZE should be 1920', () => {
|
|
expect(MAX_IMAGE_SIZE).toBe(1920);
|
|
});
|
|
|
|
it('MAX_IMAGE_BYTES should be 3MB (binary cap so base64 stays ~4MB, well under 5MB)', () => {
|
|
expect(MAX_IMAGE_BYTES).toBe(3 * 1024 * 1024);
|
|
});
|
|
});
|
|
|
|
describe('compressImageFile', () => {
|
|
const createMockFile = (name: string, type: string, size: number) => {
|
|
const content = new Uint8Array(size);
|
|
return new File([content], name, { type });
|
|
};
|
|
|
|
const mockImageLoad = (width: number, height: number) => {
|
|
const originalImage = global.Image;
|
|
global.Image = class MockImage extends originalImage {
|
|
constructor() {
|
|
super();
|
|
Object.defineProperty(this, 'width', { value: width, writable: false });
|
|
Object.defineProperty(this, 'height', { value: height, writable: false });
|
|
setTimeout(() => this.dispatchEvent(new Event('load')), 0);
|
|
}
|
|
} as any;
|
|
|
|
return () => {
|
|
global.Image = originalImage;
|
|
};
|
|
};
|
|
|
|
it('should skip compression for small images', async () => {
|
|
const file = createMockFile('small.png', 'image/png', 1000);
|
|
|
|
const restoreImage = mockImageLoad(800, 600);
|
|
|
|
const result = await compressImageFile(file);
|
|
|
|
expect(result).toBe(file); // same reference, no compression
|
|
restoreImage();
|
|
});
|
|
|
|
it('should correct MIME type for small images when declared type does not match bytes', async () => {
|
|
const pngBytes = new Uint8Array([
|
|
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44,
|
|
0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x06, 0x00, 0x00, 0x00, 0x1f,
|
|
0x15, 0xc4, 0x89,
|
|
]);
|
|
const file = new File([pngBytes], 'mislabelled.jpg', { type: 'image/jpeg' });
|
|
|
|
const restoreImage = mockImageLoad(800, 600);
|
|
|
|
const result = await compressImageFile(file);
|
|
|
|
expect(result).not.toBe(file);
|
|
expect(result.type).toBe('image/png');
|
|
expect(result.name).toBe('mislabelled.jpg');
|
|
expect([...new Uint8Array(await result.arrayBuffer())]).toEqual([...pngBytes]);
|
|
restoreImage();
|
|
});
|
|
|
|
it('should compress images exceeding max dimensions', async () => {
|
|
const file = createMockFile('large.png', 'image/png', 1000);
|
|
|
|
const originalImage = global.Image;
|
|
global.Image = class MockImage extends originalImage {
|
|
constructor() {
|
|
super();
|
|
Object.defineProperty(this, 'width', { value: 3000, writable: false });
|
|
Object.defineProperty(this, 'height', { value: 2000, writable: false });
|
|
setTimeout(() => this.dispatchEvent(new Event('load')), 0);
|
|
}
|
|
} as any;
|
|
|
|
const result = await compressImageFile(file);
|
|
|
|
expect(result).not.toBe(file);
|
|
expect(result.type).toBe('image/png');
|
|
expect(result.name).toBe('large.png');
|
|
global.Image = originalImage;
|
|
});
|
|
|
|
it('should preserve JPEG type when compressing large JPEG inputs (no PNG re-encoding)', async () => {
|
|
const file = createMockFile('photo.jpg', 'image/jpeg', 1000);
|
|
|
|
const originalImage = global.Image;
|
|
global.Image = class MockImage extends originalImage {
|
|
constructor() {
|
|
super();
|
|
Object.defineProperty(this, 'width', { value: 3000, writable: false });
|
|
Object.defineProperty(this, 'height', { value: 2000, writable: false });
|
|
setTimeout(() => this.dispatchEvent(new Event('load')), 0);
|
|
}
|
|
} as any;
|
|
|
|
const result = await compressImageFile(file);
|
|
|
|
expect(result).not.toBe(file);
|
|
// Output File MIME type must match the source format — previously this
|
|
// was hardcoded to 'image/png', which inflated photographic JPEGs.
|
|
expect(result.type).toBe('image/jpeg');
|
|
expect(result.name).toBe('photo.jpg');
|
|
expect(toDataURLSpy).toHaveBeenCalledWith('image/jpeg', 0.85);
|
|
global.Image = originalImage;
|
|
});
|
|
|
|
it('should preserve WebP inputs as PNG (existing fallback behaviour)', async () => {
|
|
const file = createMockFile('photo.webp', 'image/webp', 1000);
|
|
|
|
const originalImage = global.Image;
|
|
global.Image = class MockImage extends originalImage {
|
|
constructor() {
|
|
super();
|
|
Object.defineProperty(this, 'width', { value: 3000, writable: false });
|
|
Object.defineProperty(this, 'height', { value: 2000, writable: false });
|
|
setTimeout(() => this.dispatchEvent(new Event('load')), 0);
|
|
}
|
|
} as any;
|
|
|
|
const result = await compressImageFile(file);
|
|
|
|
expect(result).not.toBe(file);
|
|
// WebP isn't supported as a canvas output target, so we still fall back
|
|
// to lossless PNG. Documents the deliberate choice.
|
|
expect(result.type).toBe('image/png');
|
|
global.Image = originalImage;
|
|
});
|
|
|
|
it('should compress images exceeding max file size even if dimensions are small', async () => {
|
|
const file = createMockFile('heavy.png', 'image/png', 6 * 1024 * 1024);
|
|
|
|
const originalImage = global.Image;
|
|
global.Image = class MockImage extends originalImage {
|
|
constructor() {
|
|
super();
|
|
Object.defineProperty(this, 'width', { value: 1800, writable: false });
|
|
Object.defineProperty(this, 'height', { value: 1800, writable: false });
|
|
setTimeout(() => this.dispatchEvent(new Event('load')), 0);
|
|
}
|
|
} as any;
|
|
|
|
const result = await compressImageFile(file);
|
|
|
|
expect(result).not.toBe(file);
|
|
expect(result.type).toBe('image/png');
|
|
global.Image = originalImage;
|
|
});
|
|
|
|
it('should resolve original file on load error', async () => {
|
|
const file = createMockFile('broken.png', 'image/png', 1000);
|
|
|
|
const originalImage = global.Image;
|
|
global.Image = class MockImage extends originalImage {
|
|
constructor() {
|
|
super();
|
|
setTimeout(() => this.dispatchEvent(new Event('error')), 0);
|
|
}
|
|
} as any;
|
|
|
|
const result = await compressImageFile(file);
|
|
|
|
expect(result).toBe(file);
|
|
global.Image = originalImage;
|
|
});
|
|
|
|
it('should resolve original file when MIME correction fails after image load', async () => {
|
|
const file = createMockFile('broken-buffer.png', 'image/png', 1000);
|
|
vi.spyOn(file, 'arrayBuffer').mockRejectedValue(new Error('Failed to read file'));
|
|
|
|
const restoreImage = mockImageLoad(800, 600);
|
|
|
|
const result = await compressImageFile(file);
|
|
|
|
expect(result).toBe(file);
|
|
restoreImage();
|
|
});
|
|
});
|