Files
HiveGrid-Wireframes/03 Quellen und Referenzen/hivegrid-cloud/apps/web/app/modules/users/components/AvatarCropDialog.test.tsx
T

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();
});
});