Files
readest/apps/readest-app/src/__tests__/utils/image.test.ts
T
Huang Xin 0e516f6e56 chore(test): add unit tests and enforce dash-case naming for test files (#3715)
Add ~290 new unit tests covering utils (lru, diff, css, a11y, walk,
usage, txt-worker), services (EdgeTTSClient, transformers), and
suppress noisy console output across all test files. Rename 27
camelCase test files to dash-case for consistency.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 09:14:00 +02:00

251 lines
7.5 KiB
TypeScript

import { describe, test, expect, beforeEach, afterEach, vi } from 'vitest';
// ── Mock globals for jsdom canvas / Image / fetch ─────────────────────
// jsdom does not implement canvas or Image loading, so we mock them.
interface MockImageInstance {
crossOrigin: string;
src: string;
width: number;
height: number;
onload: ((ev?: Event) => void) | null;
onerror: ((ev?: string | Event) => void) | null;
}
let mockImageInstances: MockImageInstance[] = [];
class MockImage {
crossOrigin = '';
src = '';
width = 200;
height = 300;
onload: ((ev?: Event) => void) | null = null;
onerror: ((ev?: string | Event) => void) | null = null;
constructor() {
mockImageInstances.push(this as MockImageInstance);
// Auto-trigger onload when src is set
Object.defineProperty(this, 'src', {
get: () => this._src,
set: (val: string) => {
this._src = val;
if (val) {
// Schedule onload in a microtask to allow tests to set handlers
Promise.resolve().then(() => {
if (this.onload) {
this.onload(new Event('load'));
}
});
}
},
});
}
private _src = '';
}
// Mock canvas context
interface MockCanvasContext {
imageSmoothingEnabled: boolean;
imageSmoothingQuality: string;
drawImage: ReturnType<typeof vi.fn>;
}
let mockCtx: MockCanvasContext;
const mockToDataURL = vi.fn().mockReturnValue('data:image/jpeg;base64,AABBCC');
const mockToBlob = vi.fn();
function createMockCanvas() {
mockCtx = {
imageSmoothingEnabled: false,
imageSmoothingQuality: '',
drawImage: vi.fn(),
};
return {
width: 0,
height: 0,
getContext: vi.fn().mockReturnValue(mockCtx),
toDataURL: mockToDataURL,
toBlob: mockToBlob,
};
}
// Patch document.createElement for canvas
const originalCreateElement = document.createElement.bind(document);
vi.spyOn(document, 'createElement').mockImplementation((tag: string) => {
if (tag === 'canvas') {
return createMockCanvas() as unknown as HTMLElement;
}
return originalCreateElement(tag);
});
// Patch global Image
vi.stubGlobal('Image', MockImage);
// Patch URL methods
vi.stubGlobal('URL', {
...URL,
createObjectURL: vi.fn().mockReturnValue('blob:http://localhost/fake-blob'),
revokeObjectURL: vi.fn(),
});
// Mock fetch
const mockFetchResponse = {
ok: true,
status: 200,
statusText: 'OK',
blob: vi.fn().mockResolvedValue(new Blob(['fake-image-data'], { type: 'image/jpeg' })),
};
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(mockFetchResponse));
// Import after mocks
import { processDiscordCover, fetchImageAsBase64 } from '@/utils/image';
beforeEach(() => {
vi.clearAllMocks();
mockImageInstances = [];
mockFetchResponse.ok = true;
mockFetchResponse.status = 200;
mockFetchResponse.statusText = 'OK';
mockFetchResponse.blob.mockResolvedValue(new Blob(['fake-image-data'], { type: 'image/jpeg' }));
// Re-apply document.createElement mock (restoreAllMocks clears it)
vi.spyOn(document, 'createElement').mockImplementation((tag: string) => {
if (tag === 'canvas') {
return createMockCanvas() as unknown as HTMLElement;
}
return originalCreateElement(tag);
});
vi.spyOn(console, 'error').mockImplementation(() => {});
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('processDiscordCover', () => {
test('fetches cover and icon images', async () => {
// Setup: toBlob calls the callback with a Blob
mockToBlob.mockImplementation((callback: (blob: Blob | null) => void) => {
callback(new Blob(['jpeg-data'], { type: 'image/jpeg' }));
});
const promise = processDiscordCover(
'https://example.com/cover.jpg',
'https://example.com/icon.png',
);
// Wait for images to load
await vi.dynamicImportSettled();
const result = await promise;
expect(fetch).toHaveBeenCalledTimes(2);
expect(fetch).toHaveBeenCalledWith('https://example.com/cover.jpg');
expect(fetch).toHaveBeenCalledWith('https://example.com/icon.png');
expect(result).toBeInstanceOf(Blob);
});
test('draws cover image centered for portrait aspect ratio', async () => {
mockToBlob.mockImplementation((callback: (blob: Blob | null) => void) => {
callback(new Blob(['jpeg-data'], { type: 'image/jpeg' }));
});
// Create images with portrait dimensions (taller than wide)
const promise = processDiscordCover(
'https://example.com/cover.jpg',
'https://example.com/icon.png',
);
await vi.dynamicImportSettled();
await promise;
// Canvas context should have drawImage called (cover + icon)
expect(mockCtx.drawImage).toHaveBeenCalled();
expect(mockCtx.imageSmoothingEnabled).toBe(true);
expect(mockCtx.imageSmoothingQuality).toBe('high');
});
test('rejects when toBlob returns null', async () => {
mockToBlob.mockImplementation((callback: (blob: Blob | null) => void) => {
callback(null);
});
const promise = processDiscordCover(
'https://example.com/cover.jpg',
'https://example.com/icon.png',
);
await expect(promise).rejects.toThrow('Failed to create blob');
});
test('rejects on fetch failure', async () => {
(fetch as ReturnType<typeof vi.fn>).mockRejectedValueOnce(new Error('Network error'));
await expect(
processDiscordCover('https://example.com/cover.jpg', 'https://example.com/icon.png'),
).rejects.toThrow('Network error');
});
});
describe('fetchImageAsBase64', () => {
test('fetches image and returns base64 string', async () => {
const result = await fetchImageAsBase64('https://example.com/image.jpg');
expect(fetch).toHaveBeenCalledWith('https://example.com/image.jpg');
expect(result).toBe('data:image/jpeg;base64,AABBCC');
});
test('uses default options when none specified', async () => {
await fetchImageAsBase64('https://example.com/image.jpg');
// Default format is image/jpeg, quality 0.85, targetWidth 256
expect(mockToDataURL).toHaveBeenCalledWith('image/jpeg', 0.85);
});
test('uses custom options', async () => {
await fetchImageAsBase64('https://example.com/image.png', {
targetWidth: 128,
format: 'image/png',
quality: 0.5,
});
expect(mockToDataURL).toHaveBeenCalledWith('image/png', 0.5);
});
test('rejects on fetch failure', async () => {
(fetch as ReturnType<typeof vi.fn>).mockRejectedValueOnce(new Error('Network error'));
await expect(fetchImageAsBase64('https://example.com/image.jpg')).rejects.toThrow(
'Network error',
);
});
test('rejects on non-ok response', async () => {
(fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: false,
status: 404,
statusText: 'Not Found',
});
await expect(fetchImageAsBase64('https://example.com/image.jpg')).rejects.toThrow(
'Failed to fetch image: 404 Not Found',
);
});
test('calculates correct dimensions from aspect ratio', async () => {
// Image: 200x300, targetWidth 256 -> newHeight = 256 * (300/200) = 384
await fetchImageAsBase64('https://example.com/image.jpg', { targetWidth: 256 });
// The canvas size should be set appropriately
// We verify drawImage was called with correct dimensions
expect(mockCtx.drawImage).toHaveBeenCalled();
});
test('creates object URL and revokes it', async () => {
await fetchImageAsBase64('https://example.com/image.jpg');
expect(URL.createObjectURL).toHaveBeenCalled();
expect(URL.revokeObjectURL).toHaveBeenCalled();
});
});