import getCroppedImg, { checkIsAnimatedImage, createImage, getMimeType, getRadianAngle, rotateSize } from './utils' type ImageLoadEventType = 'load' | 'error' class MockImageElement { static nextEvent: ImageLoadEventType = 'load' width = 320 height = 160 crossOriginValue = '' srcValue = '' private listeners: Record = {} addEventListener(type: string, listener: EventListenerOrEventListenerObject) { const eventListener = typeof listener === 'function' ? listener : listener.handleEvent.bind(listener) if (!this.listeners[type]) this.listeners[type] = [] this.listeners[type].push(eventListener) } setAttribute(name: string, value: string) { if (name === 'crossOrigin') this.crossOriginValue = value } set src(value: string) { this.srcValue = value queueMicrotask(() => { const event = new Event(MockImageElement.nextEvent) for (const listener of this.listeners[MockImageElement.nextEvent] ?? []) listener(event) }) } get src() { return this.srcValue } } type CanvasMock = { element: HTMLCanvasElement getContextMock: ReturnType toBlobMock: ReturnType } const createCanvasMock = (context: CanvasRenderingContext2D | null, blob: Blob | null = new Blob(['ok'])): CanvasMock => { const getContextMock = vi.fn(() => context) const toBlobMock = vi.fn((callback: BlobCallback) => callback(blob)) return { element: { width: 0, height: 0, getContext: getContextMock, toBlob: toBlobMock, } as unknown as HTMLCanvasElement, getContextMock, toBlobMock, } } const createCanvasContextMock = (): CanvasRenderingContext2D => ({ translate: vi.fn(), rotate: vi.fn(), scale: vi.fn(), drawImage: vi.fn(), }) as unknown as CanvasRenderingContext2D describe('utils', () => { const originalCreateElement = document.createElement.bind(document) let originalImage: typeof Image beforeEach(() => { vi.clearAllMocks() originalImage = globalThis.Image MockImageElement.nextEvent = 'load' }) afterEach(() => { globalThis.Image = originalImage vi.restoreAllMocks() }) const mockCanvasCreation = (canvases: HTMLCanvasElement[]) => { vi.spyOn(document, 'createElement').mockImplementation((...args: Parameters) => { if (args[0] === 'canvas') { const nextCanvas = canvases.shift() if (!nextCanvas) throw new Error('Unexpected canvas creation') return nextCanvas as ReturnType } return originalCreateElement(...args) }) } describe('createImage', () => { it('should resolve image when load event fires', async () => { globalThis.Image = MockImageElement as unknown as typeof Image const image = await createImage('https://example.com/image.png') const mockImage = image as unknown as MockImageElement expect(mockImage.crossOriginValue).toBe('anonymous') expect(mockImage.src).toBe('https://example.com/image.png') }) it('should reject when error event fires', async () => { globalThis.Image = MockImageElement as unknown as typeof Image MockImageElement.nextEvent = 'error' await expect(createImage('https://example.com/broken.png')).rejects.toBeInstanceOf(Event) }) }) describe('getMimeType', () => { it('should return image/png for .png files', () => { expect(getMimeType('photo.png')).toBe('image/png') }) it('should return image/jpeg for .jpg files', () => { expect(getMimeType('photo.jpg')).toBe('image/jpeg') }) it('should return image/jpeg for .jpeg files', () => { expect(getMimeType('photo.jpeg')).toBe('image/jpeg') }) it('should return image/gif for .gif files', () => { expect(getMimeType('animation.gif')).toBe('image/gif') }) it('should return image/webp for .webp files', () => { expect(getMimeType('photo.webp')).toBe('image/webp') }) it('should return image/jpeg as default for unknown extensions', () => { expect(getMimeType('file.bmp')).toBe('image/jpeg') }) it('should return image/jpeg for files with no extension', () => { expect(getMimeType('file')).toBe('image/jpeg') }) it('should handle uppercase extensions via toLowerCase', () => { expect(getMimeType('photo.PNG')).toBe('image/png') }) }) describe('getRadianAngle', () => { it('should return 0 for 0 degrees', () => { expect(getRadianAngle(0)).toBe(0) }) it('should return PI/2 for 90 degrees', () => { expect(getRadianAngle(90)).toBeCloseTo(Math.PI / 2) }) it('should return PI for 180 degrees', () => { expect(getRadianAngle(180)).toBeCloseTo(Math.PI) }) it('should return 2*PI for 360 degrees', () => { expect(getRadianAngle(360)).toBeCloseTo(2 * Math.PI) }) it('should handle negative angles', () => { expect(getRadianAngle(-90)).toBeCloseTo(-Math.PI / 2) }) }) describe('rotateSize', () => { it('should return same dimensions for 0 degree rotation', () => { const result = rotateSize(100, 200, 0) expect(result.width).toBeCloseTo(100) expect(result.height).toBeCloseTo(200) }) it('should swap dimensions for 90 degree rotation', () => { const result = rotateSize(100, 200, 90) expect(result.width).toBeCloseTo(200) expect(result.height).toBeCloseTo(100) }) it('should return same dimensions for 180 degree rotation', () => { const result = rotateSize(100, 200, 180) expect(result.width).toBeCloseTo(100) expect(result.height).toBeCloseTo(200) }) it('should handle square dimensions', () => { const result = rotateSize(100, 100, 45) // 45° rotation of a square produces a larger bounding box const expected = Math.abs(Math.cos(Math.PI / 4) * 100) + Math.abs(Math.sin(Math.PI / 4) * 100) expect(result.width).toBeCloseTo(expected) expect(result.height).toBeCloseTo(expected) }) }) describe('getCroppedImg', () => { it('should return a blob when canvas operations succeed', async () => { globalThis.Image = MockImageElement as unknown as typeof Image const sourceContext = createCanvasContextMock() const croppedContext = createCanvasContextMock() const sourceCanvas = createCanvasMock(sourceContext) const expectedBlob = new Blob(['cropped'], { type: 'image/webp' }) const croppedCanvas = createCanvasMock(croppedContext, expectedBlob) mockCanvasCreation([sourceCanvas.element, croppedCanvas.element]) const result = await getCroppedImg( 'https://example.com/image.webp', { x: 10, y: 20, width: 50, height: 40 }, 'avatar.webp', 90, { horizontal: true, vertical: false }, ) expect(result).toBe(expectedBlob) expect(croppedCanvas.toBlobMock).toHaveBeenCalledWith(expect.any(Function), 'image/webp') expect(sourceContext.translate).toHaveBeenCalled() expect(sourceContext.rotate).toHaveBeenCalled() expect(sourceContext.scale).toHaveBeenCalledWith(-1, 1) expect(croppedContext.drawImage).toHaveBeenCalled() }) it('should apply vertical flip when vertical option is true', async () => { globalThis.Image = MockImageElement as unknown as typeof Image const sourceContext = createCanvasContextMock() const croppedContext = createCanvasContextMock() const sourceCanvas = createCanvasMock(sourceContext) const croppedCanvas = createCanvasMock(croppedContext) mockCanvasCreation([sourceCanvas.element, croppedCanvas.element]) await getCroppedImg( 'https://example.com/image.png', { x: 0, y: 0, width: 20, height: 20 }, 'avatar.png', 0, { horizontal: false, vertical: true }, ) expect(sourceContext.scale).toHaveBeenCalledWith(1, -1) }) it('should throw when source canvas context is unavailable', async () => { globalThis.Image = MockImageElement as unknown as typeof Image const sourceCanvas = createCanvasMock(null) mockCanvasCreation([sourceCanvas.element]) await expect( getCroppedImg('https://example.com/image.png', { x: 0, y: 0, width: 10, height: 10 }, 'avatar.png'), ).rejects.toThrow('Could not create a canvas context') }) it('should throw when cropped canvas context is unavailable', async () => { globalThis.Image = MockImageElement as unknown as typeof Image const sourceCanvas = createCanvasMock(createCanvasContextMock()) const croppedCanvas = createCanvasMock(null) mockCanvasCreation([sourceCanvas.element, croppedCanvas.element]) await expect( getCroppedImg('https://example.com/image.png', { x: 0, y: 0, width: 10, height: 10 }, 'avatar.png'), ).rejects.toThrow('Could not create a canvas context') }) it('should reject when blob creation fails', async () => { globalThis.Image = MockImageElement as unknown as typeof Image const sourceCanvas = createCanvasMock(createCanvasContextMock()) const croppedCanvas = createCanvasMock(createCanvasContextMock(), null) mockCanvasCreation([sourceCanvas.element, croppedCanvas.element]) await expect( getCroppedImg('https://example.com/image.jpg', { x: 0, y: 0, width: 10, height: 10 }, 'avatar.jpg'), ).rejects.toThrow('Could not create a blob') }) }) describe('checkIsAnimatedImage', () => { let originalFileReader: typeof FileReader beforeEach(() => { originalFileReader = globalThis.FileReader }) afterEach(() => { globalThis.FileReader = originalFileReader }) it('should return true for .gif files', async () => { const gifFile = new File([new Uint8Array([0x47, 0x49, 0x46])], 'animation.gif', { type: 'image/gif' }) const result = await checkIsAnimatedImage(gifFile) expect(result).toBe(true) }) it('should return false for non-gif, non-webp files', async () => { const pngFile = new File([new Uint8Array([0x89, 0x50, 0x4E, 0x47])], 'image.png', { type: 'image/png' }) const result = await checkIsAnimatedImage(pngFile) expect(result).toBe(false) }) it('should return true for animated WebP files with ANIM chunk', async () => { // Build a minimal WebP header with ANIM chunk // RIFF....WEBP....ANIM const bytes = new Uint8Array(20) // RIFF signature bytes[0] = 0x52 // R bytes[1] = 0x49 // I bytes[2] = 0x46 // F bytes[3] = 0x46 // F // WEBP signature bytes[8] = 0x57 // W bytes[9] = 0x45 // E bytes[10] = 0x42 // B bytes[11] = 0x50 // P // ANIM chunk at offset 12 bytes[12] = 0x41 // A bytes[13] = 0x4E // N bytes[14] = 0x49 // I bytes[15] = 0x4D // M const webpFile = new File([bytes], 'animated.webp', { type: 'image/webp' }) const result = await checkIsAnimatedImage(webpFile) expect(result).toBe(true) }) it('should return false for static WebP files without ANIM chunk', async () => { const bytes = new Uint8Array(20) // RIFF signature bytes[0] = 0x52 bytes[1] = 0x49 bytes[2] = 0x46 bytes[3] = 0x46 // WEBP signature bytes[8] = 0x57 bytes[9] = 0x45 bytes[10] = 0x42 bytes[11] = 0x50 // No ANIM chunk const webpFile = new File([bytes], 'static.webp', { type: 'image/webp' }) const result = await checkIsAnimatedImage(webpFile) expect(result).toBe(false) }) it('should reject when FileReader encounters an error', async () => { const file = new File([], 'test.png', { type: 'image/png' }) globalThis.FileReader = class { onerror: ((error: ProgressEvent) => void) | null = null onload: ((event: ProgressEvent) => void) | null = null readAsArrayBuffer(_blob: Blob) { const errorEvent = new ProgressEvent('error') as ProgressEvent setTimeout(() => { this.onerror?.(errorEvent) }, 0) } } as unknown as typeof FileReader await expect(checkIsAnimatedImage(file)).rejects.toBeInstanceOf(ProgressEvent) }) }) })