// @vitest-environment jsdom import { afterEach, describe, expect, test, vi } from 'vitest'; import { PUZZLE_REFERENCE_IMAGE_MAX_DATA_URL_LENGTH, cropPuzzleReferenceImageDataUrl, isPuzzleReferenceImageSquare, readPuzzleReferenceImageForUpload, readPuzzleReferenceImageAsDataUrl, } from './puzzleReferenceImage'; afterEach(() => { vi.unstubAllGlobals(); vi.restoreAllMocks(); }); function stubFileReader(dataUrl: string) { class MockFileReader { result: string | null = null; error: Error | null = null; onload: null | (() => void) = null; onerror: null | (() => void) = null; readAsDataURL() { this.result = dataUrl; this.onload?.(); } } vi.stubGlobal('FileReader', MockFileReader as unknown as typeof FileReader); } function stubImage(width = 4096, height = 3072) { class MockImage { onload: null | (() => void) = null; onerror: null | (() => void) = null; naturalWidth = width; naturalHeight = height; width = width; height = height; set src(_value: string) { this.onload?.(); } } vi.stubGlobal('Image', MockImage as unknown as typeof Image); } function stubCanvas(dataUrls: string[]) { const drawImage = vi.fn(); const toDataURL = vi .fn() .mockImplementation( () => dataUrls.shift() ?? 'data:image/jpeg;base64,small', ); const originalCreateElement = document.createElement.bind(document); vi.spyOn(document, 'createElement').mockImplementation((tagName) => { if (tagName !== 'canvas') { return originalCreateElement(tagName); } return { width: 0, height: 0, getContext: () => ({ drawImage, fillRect: vi.fn(), fillStyle: '', imageSmoothingEnabled: false, imageSmoothingQuality: 'low', }), toDataURL, } as unknown as HTMLCanvasElement; }); return { drawImage, toDataURL }; } describe('readPuzzleReferenceImageAsDataUrl', () => { test('compresses large puzzle reference images before JSON upload', async () => { stubFileReader(`data:image/png;base64,${'A'.repeat(3 * 1024 * 1024)}`); stubImage(); const { drawImage, toDataURL } = stubCanvas([ `data:image/jpeg;base64,${'B'.repeat(1200)}`, `data:image/jpeg;base64,${'C'.repeat(1000)}`, `data:image/jpeg;base64,${'D'.repeat(1400)}`, ]); const file = new File(['x'.repeat(2 * 1024 * 1024)], 'reference.png', { type: 'image/png', }); const dataUrl = await readPuzzleReferenceImageAsDataUrl(file); expect(dataUrl).toBe(`data:image/jpeg;base64,${'C'.repeat(1000)}`); expect(drawImage).toHaveBeenCalledWith(expect.anything(), 0, 0, 1536, 1152); expect(toDataURL).toHaveBeenCalledWith('image/jpeg', 0.84); expect(toDataURL).toHaveBeenCalledWith('image/jpeg', 0.76); expect(toDataURL).toHaveBeenCalledWith('image/jpeg', 0.68); }); test('rejects reference images that still exceed the upload budget', async () => { stubFileReader( `data:image/png;base64,${'A'.repeat(PUZZLE_REFERENCE_IMAGE_MAX_DATA_URL_LENGTH + 1)}`, ); stubImage(); stubCanvas([ `data:image/jpeg;base64,${'B'.repeat(PUZZLE_REFERENCE_IMAGE_MAX_DATA_URL_LENGTH + 1)}`, `data:image/jpeg;base64,${'C'.repeat(PUZZLE_REFERENCE_IMAGE_MAX_DATA_URL_LENGTH + 2)}`, `data:image/jpeg;base64,${'D'.repeat(PUZZLE_REFERENCE_IMAGE_MAX_DATA_URL_LENGTH + 3)}`, ]); const file = new File(['x'.repeat(2 * 1024 * 1024)], 'reference.png', { type: 'image/png', }); await expect(readPuzzleReferenceImageAsDataUrl(file)).rejects.toThrow( '参考图过大,请换一张尺寸更小的图片。', ); }); }); describe('puzzle reference image square crop helpers', () => { test('reports square upload dimensions without opening crop flow', async () => { const sourceDataUrl = 'data:image/png;base64,square'; stubFileReader(sourceDataUrl); stubImage(512, 512); const file = new File(['x'], 'square.png', { type: 'image/png' }); const result = await readPuzzleReferenceImageForUpload(file); expect(result).toEqual({ dataUrl: sourceDataUrl, width: 512, height: 512, }); expect(isPuzzleReferenceImageSquare(result)).toBe(true); }); test('crops non-square uploads to a centered square data URL', async () => { const sourceDataUrl = 'data:image/png;base64,wide'; const croppedDataUrl = 'data:image/jpeg;base64,cropped'; stubImage(800, 600); const { drawImage, toDataURL } = stubCanvas([ `data:image/jpeg;base64,${'A'.repeat(30)}`, croppedDataUrl, `data:image/jpeg;base64,${'B'.repeat(40)}`, ]); const dataUrl = await cropPuzzleReferenceImageDataUrl({ source: sourceDataUrl, cropX: 100, cropY: 0, cropSize: 600, }); expect(dataUrl).toBe(croppedDataUrl); expect(drawImage).toHaveBeenCalledWith( expect.anything(), 100, 0, 600, 600, 0, 0, 600, 600, ); expect(toDataURL).toHaveBeenCalledWith('image/jpeg', 0.88); }); });