150 lines
5.7 KiB
TypeScript
150 lines
5.7 KiB
TypeScript
import "@testing-library/jest-dom";
|
|
import { fireEvent, render, screen } from "@testing-library/react";
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
import AvatarCropDialog from "./AvatarCropDialog";
|
|
import { OUTPUT_SIZE } from "../avatarCrop";
|
|
|
|
vi.mock("react-i18next", () => ({
|
|
useTranslation: () => ({ i18n: { language: "en" }, t: (key: string) => key }),
|
|
}));
|
|
|
|
const SOURCE = "data:image/png;base64,source";
|
|
const CROPPED = "data:image/jpeg;base64,cropped";
|
|
|
|
let drawImage: ReturnType<typeof vi.fn>;
|
|
let toDataURL: ReturnType<typeof vi.fn>;
|
|
|
|
/** jsdom has no canvas backend, so the 2d context is stubbed with the calls we assert on. */
|
|
function stubCanvas({ context = true }: { context?: boolean } = {}) {
|
|
drawImage = vi.fn();
|
|
toDataURL = vi.fn(() => CROPPED);
|
|
vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockImplementation(
|
|
() => (context ? ({ fillStyle: "", fillRect: vi.fn(), drawImage } as unknown as CanvasRenderingContext2D) : null)
|
|
);
|
|
vi.spyOn(HTMLCanvasElement.prototype, "toDataURL").mockImplementation(toDataURL);
|
|
}
|
|
|
|
/** Report a landscape source picture: jsdom reports 0x0 for images it never fetches. */
|
|
function loadImage({ width = 800, height = 400 }: { width?: number; height?: number } = {}) {
|
|
const image = screen.getByAltText("settings.profile.avatar.crop.previewAlt") as HTMLImageElement;
|
|
Object.defineProperty(image, "naturalWidth", { value: width, configurable: true });
|
|
Object.defineProperty(image, "naturalHeight", { value: height, configurable: true });
|
|
fireEvent.load(image);
|
|
return image;
|
|
}
|
|
|
|
function renderDialog(overrides: Partial<React.ComponentProps<typeof AvatarCropDialog>> = {}) {
|
|
const onConfirm = vi.fn();
|
|
const onCancel = vi.fn();
|
|
render(<AvatarCropDialog source={SOURCE} onConfirm={onConfirm} onCancel={onCancel} {...overrides} />);
|
|
return { onConfirm, onCancel };
|
|
}
|
|
|
|
describe("AvatarCropDialog", () => {
|
|
beforeEach(() => {
|
|
vi.restoreAllMocks();
|
|
stubCanvas();
|
|
});
|
|
|
|
it("shows the picked file so the user decides the section before anything is stored", () => {
|
|
renderDialog();
|
|
|
|
expect(screen.getByAltText("settings.profile.avatar.crop.previewAlt")).toHaveAttribute("src", SOURCE);
|
|
expect(screen.getByRole("dialog")).toHaveAccessibleName("settings.profile.avatar.crop.title");
|
|
});
|
|
|
|
it("keeps confirm disabled until the image has reported its size", () => {
|
|
renderDialog();
|
|
const confirm = screen.getByRole("button", { name: "settings.profile.avatar.crop.confirm" });
|
|
|
|
expect(confirm).toBeDisabled();
|
|
loadImage();
|
|
expect(confirm).toBeEnabled();
|
|
});
|
|
|
|
it("hands back the centred square section of a wide picture", () => {
|
|
const { onConfirm } = renderDialog();
|
|
loadImage();
|
|
|
|
fireEvent.click(screen.getByRole("button", { name: "settings.profile.avatar.crop.confirm" }));
|
|
|
|
// 800x400 centred: the 400px square starts 200px in from the left edge.
|
|
expect(drawImage).toHaveBeenCalledWith(expect.anything(), 200, 0, 400, 400, 0, 0, OUTPUT_SIZE, OUTPUT_SIZE);
|
|
expect(onConfirm).toHaveBeenCalledWith(CROPPED);
|
|
});
|
|
|
|
it("crops a smaller section after zooming in", () => {
|
|
renderDialog();
|
|
loadImage();
|
|
|
|
fireEvent.change(screen.getByLabelText("settings.profile.avatar.crop.zoom"), { target: { value: "2" } });
|
|
fireEvent.click(screen.getByRole("button", { name: "settings.profile.avatar.crop.confirm" }));
|
|
|
|
const [, sx, sy, size] = drawImage.mock.calls[0];
|
|
expect(size).toBe(200);
|
|
expect(sx).toBe(300);
|
|
expect(sy).toBe(100);
|
|
});
|
|
|
|
it("follows an arrow-key pan instead of always cropping the centre", () => {
|
|
renderDialog();
|
|
loadImage();
|
|
|
|
fireEvent.keyDown(screen.getByRole("group", { name: "settings.profile.avatar.crop.viewportLabel" }), { key: "ArrowRight" });
|
|
fireEvent.click(screen.getByRole("button", { name: "settings.profile.avatar.crop.confirm" }));
|
|
|
|
// The 288px viewport covers 400 source pixels, so one 12px key press shifts the section by 12 / 0.72.
|
|
expect(drawImage.mock.calls[0][1]).toBeCloseTo(200 + 12 / (288 / 400), 5);
|
|
});
|
|
|
|
it("returns to the centred section on reset", () => {
|
|
renderDialog();
|
|
loadImage();
|
|
|
|
fireEvent.change(screen.getByLabelText("settings.profile.avatar.crop.zoom"), { target: { value: "3" } });
|
|
fireEvent.click(screen.getByRole("button", { name: "settings.profile.avatar.crop.reset" }));
|
|
fireEvent.click(screen.getByRole("button", { name: "settings.profile.avatar.crop.confirm" }));
|
|
|
|
expect(drawImage).toHaveBeenCalledWith(expect.anything(), 200, 0, 400, 400, 0, 0, OUTPUT_SIZE, OUTPUT_SIZE);
|
|
});
|
|
|
|
it("cancels without handing back a picture", () => {
|
|
const { onCancel, onConfirm } = renderDialog();
|
|
loadImage();
|
|
|
|
fireEvent.click(screen.getByRole("button", { name: "shared.cancel" }));
|
|
|
|
expect(onCancel).toHaveBeenCalledTimes(1);
|
|
expect(onConfirm).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("closes on Escape like any other modal", () => {
|
|
const { onCancel } = renderDialog();
|
|
|
|
fireEvent.keyDown(document, { key: "Escape" });
|
|
|
|
expect(onCancel).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("explains an unreadable file instead of submitting nothing", () => {
|
|
const { onConfirm } = renderDialog();
|
|
|
|
fireEvent.error(screen.getByAltText("settings.profile.avatar.crop.previewAlt"));
|
|
|
|
expect(screen.getByRole("alert")).toHaveTextContent("settings.profile.avatar.crop.failed");
|
|
expect(onConfirm).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("reports a missing canvas context in Klartext rather than crashing", () => {
|
|
stubCanvas({ context: false });
|
|
const { onConfirm } = renderDialog();
|
|
loadImage();
|
|
|
|
fireEvent.click(screen.getByRole("button", { name: "settings.profile.avatar.crop.confirm" }));
|
|
|
|
expect(screen.getByRole("alert")).toHaveTextContent("settings.profile.avatar.crop.failed");
|
|
expect(onConfirm).not.toHaveBeenCalled();
|
|
});
|
|
});
|