Files
ttstd-infinite-canvas/web/test/profile-avatar-crop.test.ts
2026-10-13 05:31:30 +08:00

137 lines
7.3 KiB
TypeScript

import { describe, expect, test } from "bun:test";
import { centeredAspectSelection, resizeSelection, translateSelection, type CropGrip } from "../src/lib/canvas/image-crop-selection";
import { cropProfileAvatar, planProfileAvatarCrop } from "../src/services/profile-avatar";
describe("fixed avatar crop geometry", () => {
test("starts with the largest centered square on wide, tall and odd images", () => {
for (const size of [
{ width: 1200, height: 800 },
{ width: 800, height: 1200 },
{ width: 901, height: 600 },
]) {
const crop = centeredAspectSelection(size, 1);
expect(crop.width * size.width).toBeCloseTo(Math.min(size.width, size.height));
expect(crop.height * size.height).toBeCloseTo(Math.min(size.width, size.height));
expect(crop.x + crop.width / 2).toBeCloseTo(0.5);
expect(crop.y + crop.height / 2).toBeCloseTo(0.5);
const plan = planProfileAvatarCrop(size, crop);
expect(plan.source.width).toBe(plan.source.height);
expect(plan.outputSide).toBe(512);
}
});
test("every locked grip and movement preserves physical square pixels", () => {
const size = { width: 1200, height: 600 };
const initial = centeredAspectSelection(size, 1);
for (const x of [-1, 0, 1] as const)
for (const y of [-1, 0, 1] as const) {
if (!x && !y) continue;
const resized = resizeSelection(initial, [x, y] as CropGrip, -0.1, -0.1, true);
const moved = translateSelection(resized, 0.2, -0.3);
expect(moved.width * size.width).toBeCloseTo(moved.height * size.height);
expect(planProfileAvatarCrop(size, moved).source.width).toBe(planProfileAvatarCrop(size, moved).source.height);
}
});
test("square crops on panoramic images can shrink from every grip and remain square", () => {
for (const size of [
{ width: 2000, height: 100 },
{ width: 100, height: 2000 },
]) {
const initial = centeredAspectSelection(size, 1);
for (const x of [-1, 0, 1] as const)
for (const y of [-1, 0, 1] as const) {
if (!x && !y) continue;
const next = resizeSelection(initial, [x, y], (-x * initial.width) / 4, (-y * initial.height) / 4, true);
expect(next.width * size.width).toBeCloseTo(75);
expect(next.height * size.height).toBeCloseTo(75);
expect(next.x).toBeGreaterThanOrEqual(0);
expect(next.y).toBeGreaterThanOrEqual(0);
expect(next.x + next.width).toBeLessThanOrEqual(1);
expect(next.y + next.height).toBeLessThanOrEqual(1);
}
}
});
test("one-pixel keyboard steps shrink panoramic squares and enforce a reachable positive minimum", () => {
for (const size of [
{ width: 2000, height: 100 },
{ width: 100, height: 2000 },
]) {
const initial = centeredAspectSelection(size, 1);
expect(resizeSelection(initial, [1, 0], -1 / size.width, 0, true).width * size.width).toBeCloseTo(99);
expect(resizeSelection(initial, [0, 1], 0, -1 / size.height, true).height * size.height).toBeCloseTo(99);
const smallest = resizeSelection(initial, [1, 1], -10, -10, true);
expect(smallest.width * size.width).toBeCloseTo(6);
expect(smallest.height * size.height).toBeCloseTo(6);
}
});
test("rejects decoded images over 16M pixels, invalid and non-square crops", () => {
expect(() => planProfileAvatarCrop({ width: 4001, height: 4000 }, { x: 0, y: 0, width: 1, height: 1 })).toThrow("1600");
expect(() => planProfileAvatarCrop({ width: 100, height: 100 }, { x: 0, y: 0, width: 1, height: 0.5 })).toThrow("正方形");
expect(() => centeredAspectSelection({ width: 100, height: 0 }, 1)).toThrow();
expect(() => centeredAspectSelection({ width: 100, height: 100 }, 0)).toThrow();
expect(() => planProfileAvatarCrop({ width: 100, height: 100 }, { x: 2, y: 0, width: 1, height: 1 })).toThrow();
expect(planProfileAvatarCrop({ width: 100, height: 100 }, { x: 0.2, y: 0.3, width: 0.4, height: 0.4 })).toEqual({ source: { x: 20, y: 30, width: 40, height: 40 }, outputSide: 40 });
});
});
async function withRasterDOM(width: number, height: number, run: (state: { drawn: unknown[][]; imageSources: string[]; canvas: { width: number; height: number }; failExport: () => void }) => Promise<void>) {
const previousImage = globalThis.Image;
const previousDocument = globalThis.document;
const drawn: unknown[][] = [];
const imageSources: string[] = [];
let exportFails = false;
class TestImage {
naturalWidth = width;
naturalHeight = height;
onload: (() => void) | null = null;
onerror: (() => void) | null = null;
set src(value: string) {
imageSources.push(value);
if (value) queueMicrotask(() => this.onload?.());
}
}
const canvas = { width: 0, height: 0, getContext: () => ({ drawImage: (...values: unknown[]) => drawn.push(values) }), toBlob: (callback: (blob: Blob | null) => void) => callback(exportFails ? null : new Blob(["png"], { type: "image/png" })) };
Object.assign(globalThis, { Image: TestImage, document: { createElement: () => canvas } });
try {
await run({
drawn,
imageSources,
canvas,
failExport: () => {
exportFails = true;
},
});
} finally {
Object.assign(globalThis, { Image: previousImage, document: previousDocument });
}
}
describe("avatar PNG export", () => {
test("draws the chosen source region into a square bounded by 512 and releases buffers", async () => {
await withRasterDOM(1200, 800, async ({ drawn, canvas, imageSources }) => {
const file = await cropProfileAvatar("blob:test", { x: 0.25, y: 0.125, width: 0.5, height: 0.75 }, "../../private.jpg");
expect(drawn[0].slice(1)).toEqual([300, 100, 600, 600, 0, 0, 512, 512]);
expect(file.name).toBe("avatar.png");
expect(file.type).toBe("image/png");
expect(file.size).toBeGreaterThan(0);
expect(canvas.width).toBe(0);
expect(canvas.height).toBe(0);
expect(imageSources.at(-1)).toBe("");
});
});
test("fails explicitly when PNG export fails and still releases image and canvas", async () => {
await withRasterDOM(100, 100, async ({ failExport, canvas, imageSources }) => {
failExport();
await expect(cropProfileAvatar("blob:test", { x: 0, y: 0, width: 1, height: 1 }, "avatar.jpg")).rejects.toThrow("PNG");
expect(canvas.width).toBe(0);
expect(imageSources.at(-1)).toBe("");
});
});
test("rejects pixel bombs before drawing", async () => {
await withRasterDOM(5000, 5000, async ({ drawn, imageSources }) => {
await expect(cropProfileAvatar("blob:test", { x: 0, y: 0, width: 1, height: 1 }, "avatar.jpg")).rejects.toThrow("1600");
expect(drawn).toHaveLength(0);
expect(imageSources.at(-1)).toBe("");
});
});
});