// @vitest-environment jsdom // // The crop frame: it sits on the picture, not on the box's bars; the arrows move it and, on a focused // corner, resize it, and the player underneath never sees those arrows; a drag moves it in picture // fractions; and the layer lets every press outside the frame through. jsdom lays nothing out, so the // box's size is stubbed. import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; import { cleanup, fireEvent, render, screen } from "@testing-library/react"; import { useState } from "react"; vi.mock("react-i18next", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, useTranslation: () => ({ t: (k: string) => k }) }; }); import { CropFrame } from "./CropFrame"; import type { Frac } from "../../lib/cropGeometry"; afterEach(cleanup); beforeAll(() => { class PointerEventStub extends MouseEvent { pointerId: number; constructor(type: string, init: PointerEventInit = {}) { super(type, init); this.pointerId = init.pointerId ?? 1; } } globalThis.PointerEvent ??= PointerEventStub as unknown as typeof PointerEvent; // A 1600×900 box for every element: the frame's layer measures its own. Object.defineProperty(HTMLElement.prototype, "clientWidth", { configurable: true, value: 1600 }); Object.defineProperty(HTMLElement.prototype, "clientHeight", { configurable: true, value: 900 }); }); const WIDE = 16 / 9; function Harness({ aspect = WIDE, ratio = null, start = { x: 0.2, y: 0.2, w: 0.4, h: 0.4 }, }: { aspect?: number; ratio?: number | null; start?: Frac; }) { const [rect, setRect] = useState(start); return (
{[rect.x, rect.y, rect.w, rect.h].map((v) => v.toFixed(3)).join(" ")}
); } const rect = () => screen.getByTestId("rect").textContent; const picture = () => screen.getByRole("group", { name: "common.cropFrame.frame" }).parentElement!; describe("CropFrame", () => { it("lies on the picture, between a 4:3 picture's bars in a 16:9 box", () => { render(); const p = picture(); expect([p.style.left, p.style.top, p.style.width, p.style.height]).toEqual([ "200px", "0px", "1200px", "900px", ]); }); it("moves with the arrows, further with Shift, and keeps them from the player", () => { const player = vi.fn(); render(
, ); const frame = screen.getByRole("group", { name: "common.cropFrame.frame" }); fireEvent.keyDown(frame, { key: "ArrowRight" }); expect(rect()).toBe("0.210 0.200 0.400 0.400"); fireEvent.keyDown(frame, { key: "ArrowUp", shiftKey: true }); expect(rect()).toBe("0.210 0.100 0.400 0.400"); expect(player).not.toHaveBeenCalled(); // Not an arrow: the player's (Escape steps out of the crop there). fireEvent.keyDown(frame, { key: "Escape" }); expect(player).toHaveBeenCalledTimes(1); }); it("resizes from a focused corner, the opposite corner staying put", () => { render(); const se = screen.getByRole("group", { name: "common.cropFrame.se" }); fireEvent.keyDown(se, { key: "ArrowRight", shiftKey: true }); fireEvent.keyDown(se, { key: "ArrowDown" }); expect(rect()).toBe("0.200 0.200 0.500 0.410"); const nw = screen.getByRole("group", { name: "common.cropFrame.nw" }); fireEvent.keyDown(nw, { key: "ArrowLeft" }); expect(rect()).toBe("0.190 0.200 0.510 0.410"); }); it("moves with a drag, in fractions of the picture", () => { render(); const frame = screen.getByRole("group", { name: "common.cropFrame.frame" }); fireEvent.pointerDown(frame, { button: 0, clientX: 500, clientY: 300 }); fireEvent.pointerMove(frame, { clientX: 660, clientY: 390 }); fireEvent.pointerUp(frame, { clientX: 660, clientY: 390 }); // 160 px of 1600 and 90 px of 900: a tenth each way. expect(rect()).toBe("0.300 0.300 0.400 0.400"); }); it("follows the pointer that took it, not a second finger landing on it", () => { render(); const frame = screen.getByRole("group", { name: "common.cropFrame.frame" }); fireEvent.pointerDown(frame, { button: 0, clientX: 500, clientY: 300, pointerId: 1 }); // A second finger lands 200 px away and stays: it must not become the drag's origin. fireEvent.pointerDown(frame, { button: 0, clientX: 700, clientY: 300, pointerId: 2 }); fireEvent.pointerMove(frame, { clientX: 660, clientY: 390, pointerId: 1 }); expect(rect()).toBe("0.300 0.300 0.400 0.400"); fireEvent.pointerMove(frame, { clientX: 900, clientY: 300, pointerId: 2 }); expect(rect()).toBe("0.300 0.300 0.400 0.400"); fireEvent.pointerUp(frame, { clientX: 660, clientY: 390, pointerId: 1 }); }); it("takes the focus on a press, so the arrows go on from where the pointer left it", () => { render(); const frame = screen.getByRole("group", { name: "common.cropFrame.frame" }); fireEvent.pointerDown(frame, { button: 0, clientX: 500, clientY: 300 }); fireEvent.pointerUp(frame, { clientX: 500, clientY: 300 }); expect(document.activeElement).toBe(frame); const se = screen.getByRole("group", { name: "common.cropFrame.se" }); fireEvent.pointerDown(se, { button: 0, clientX: 960, clientY: 540 }); expect(document.activeElement).toBe(se); fireEvent.pointerUp(se, { clientX: 960, clientY: 540 }); // An edge is the pointer's only: its press puts the focus on the frame it belongs to. fireEvent.pointerDown(document.querySelector(".cursor-ew-resize")!, { button: 0, clientX: 960, clientY: 400, }); expect(document.activeElement).toBe(frame); }); it("shrinks its handles on a small frame, so the frame itself stays there to grab", () => { const corner = () => screen.getByRole("group", { name: "common.cropFrame.se" }); const edge = () => document.querySelector(".cursor-ew-resize")!; // 640 × 360 px of a 1600 × 900 picture: the full handles. render(); expect(corner().className).toContain("size-5"); expect(edge().className).toContain("w-4"); cleanup(); // A 1:4 picture in that box is 225 px wide: a tenth of it is a 22 px frame. render(); expect(corner().className).toContain("size-3"); expect(edge().className).toContain("w-2"); }); it("lets a press outside the frame through to the player, and keeps one on the frame", () => { const click = vi.fn(); render(
, ); expect(screen.getByTestId("crop-frame").className).toContain("pointer-events-none"); fireEvent.click(screen.getByRole("group", { name: "common.cropFrame.frame" })); expect(click).not.toHaveBeenCalled(); }); });