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, 1024, 768);
|
||
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(
|
||
'参考图过大,请压缩后再上传(当前 10.0MB,最多 6MB)。',
|
||
);
|
||
});
|
||
});
|
||
|
||
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);
|
||
});
|
||
});
|