171 lines
5.0 KiB
TypeScript
171 lines
5.0 KiB
TypeScript
// @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);
|
|
});
|
|
});
|