import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, within, act, cleanup, fireEvent } from "@testing-library/react";
import { readFileSync } from "node:fs";
import { useHeatmapAblauf, AUFBAU_DAUER } from "@/components/home/demos/heatmap/ablauf";
import HeatmapSeite from "@/components/home/demos/heatmap/HeatmapSeite";
import HeatmapDiagnose from "@/components/home/demos/heatmap/HeatmapDiagnose";
import {
ZONEN,
SUMME_KLICKS,
TOP_ZONE,
anteil,
rangliste,
ersatzRechtecke,
ZONEN_LAYOUT,
} from "@/components/home/demos/heatmap/zonen";
// Spezifikation 3.2 (Website-Analyse, Boulderhalle Überhang)
/** Zeigt Website, Handy-Fassung und Diagnose mit einem gemeinsamen Zustand. */
const Probe = ({ laeuft, statisch }: { laeuft: boolean; statisch: boolean }) => {
const z = useHeatmapAblauf(laeuft, statisch);
return (
);
};
/** Spult die Uhr in kleinen Schritten vor: jeder Schritt des Ablaufs setzt seinen nächsten Timer erst nach dem Rendern. */
const vorspulen = (ms: number) => {
for (let t = 0; t < ms; t += 50) {
act(() => {
vi.advanceTimersByTime(50);
});
}
};
/** Spult in kleinen Schritten, bis die Phase erreicht ist (höchstens 30 s). Gibt die vergangene Zeit zurück. */
const spuleBis = (wurzel: HTMLElement, phase: string) => {
let vergangen = 0;
while (wurzel.getAttribute("data-phase") !== phase && vergangen < 30_000) {
vorspulen(50);
vergangen += 50;
}
return vergangen;
};
beforeEach(() => {
// jsdom hat keine 2D-Leinwand; die Zeichnung wird übersprungen
vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue(null);
});
afterEach(() => {
cleanup();
vi.useRealTimers();
vi.restoreAllMocks();
});
describe("Zonen und Rangliste", () => {
it("die Klicks summieren sich auf 1.284, mit 8 bis 10 Zonen", () => {
expect(ZONEN.reduce((s, z) => s + z.klicks, 0)).toBe(1284);
expect(SUMME_KLICKS).toBe(1284);
expect(ZONEN.length).toBeGreaterThanOrEqual(8);
expect(ZONEN.length).toBeLessThanOrEqual(10);
expect(new Set(ZONEN.map((z) => z.id)).size).toBe(ZONEN.length);
});
it("die Rangliste ist absteigend, die Top-Zone ist Tageskarte kaufen mit 31 %", () => {
const liste = rangliste();
for (let i = 1; i < liste.length; i++) expect(liste[i - 1].klicks).toBeGreaterThanOrEqual(liste[i].klicks);
expect(TOP_ZONE.name).toBe("Tageskarte kaufen");
expect(TOP_ZONE.klicks).toBe(402);
expect(anteil(TOP_ZONE)).toBe(31);
expect(rangliste(4)).toHaveLength(4);
});
it("die Prozente sind ganze Zahlen und ergeben ungefähr 100", () => {
const summe = ZONEN.reduce((s, z) => s + anteil(z), 0);
for (const z of ZONEN) expect(Number.isInteger(anteil(z))).toBe(true);
expect(summe).toBeGreaterThanOrEqual(98);
expect(summe).toBeLessThanOrEqual(102);
});
it("nur das Routenbild ist ein toter Klick, jede Zone hat einen Satz ohne Erfolgsversprechen", () => {
expect(ZONEN.filter((z) => z.tot).map((z) => z.id)).toEqual(["routenbild"]);
for (const z of ZONEN) {
expect(z.satz.length).toBeGreaterThan(10);
expect(z.satz).not.toMatch(/Umsatz|mehr Kunden|Erfolg|garantiert|steiger|verdoppel/i);
}
expect(TOP_ZONE.satz).toBe("Am meisten geklickt: „Tageskarte kaufen“ – 31\u00a0% aller Klicks.");
});
it("die Ersatz-Rechtecke liegen innerhalb der Fläche", () => {
for (const variante of ["desktop", "handy"] as const) {
const rechtecke = ersatzRechtecke(variante);
expect(Object.keys(rechtecke).sort()).toEqual(ZONEN.map((z) => z.id).sort());
for (const r of Object.values(rechtecke)) {
expect(r.x).toBeGreaterThanOrEqual(0);
expect(r.y).toBeGreaterThanOrEqual(0);
expect(r.x + r.b).toBeLessThanOrEqual(1);
expect(r.y + r.h).toBeLessThanOrEqual(1);
}
}
});
});
describe("Lage der Zonen auf der Bühne", () => {
it("keine Zone liegt unter dem iPhone der Bühne (es deckt die Fläche ab x 1158 und y 419)", () => {
for (const [id, r] of Object.entries(ZONEN_LAYOUT.desktop)) {
const links = r.x + r.b <= 1150;
const oben = r.y + r.h <= 410;
expect(links || oben, `${id} liegt unter dem Handy`).toBe(true);
}
});
it("die Diagnose hat Legende (wenig bis viel) und die Satzlänge des Routenbilds passt in vier bis fünf Zeilen", () => {
const { container } = render();
expect(container.querySelector(".hmd__legende")).toHaveTextContent("wenig");
expect(container.querySelector(".hmd__legende")).toHaveTextContent("viel");
const routen = ZONEN.find((z) => z.id === "routenbild")!;
expect(routen.satz.length).toBeLessThan(95);
expect(routen.satz).toMatch(/^Auffällig: 12\u00a0% klicken auf das Routenbild\. Es ist kein Link/);
});
it("Website und Diagnose stehen ohne Zustand im Anfangszustand da (Taktgeber lädt noch)", () => {
const { container } = render();
expect(container.firstElementChild).toHaveAttribute("data-phase", "aufbau");
expect(container.querySelector(".hm-rahmen")).toBeNull();
});
});
describe("Statischer Endzustand (ohne Bewegung)", () => {
it("zeigt die volle Heatmap, keinen Zeiger und wählt die meistgeklickte Zone", () => {
const { container } = render();
for (const wurzel of container.querySelectorAll(".hm-seite")) {
expect(wurzel).toHaveAttribute("data-phase", "voll");
expect(wurzel.querySelector(".dz-zeiger")).toBeNull();
expect(wurzel.querySelector(".hm-heat")).toHaveAttribute("data-anteil", "1284");
}
const satz = container.querySelector("[aria-live='polite']") as HTMLElement;
expect(satz).toHaveTextContent("Am meisten geklickt: „Tageskarte kaufen“ – 31 % aller Klicks.");
expect(screen.getByRole("button", { name: /Tageskarte kaufen/ })).toHaveAttribute("aria-pressed", "true");
// Rahmen und Schildchen an der Zone auf der Website
expect(screen.getAllByText("Tageskarte kaufen · 402 Klicks · 31 %").length).toBeGreaterThan(0);
});
it("die Diagnose zeigt Kopf, Summe und die vier meistgeklickten Zonen", () => {
render();
expect(screen.getByText("Diagnose")).toBeInTheDocument();
expect(screen.getByText(/letzte 30 Tage · Beispieldaten/)).toBeInTheDocument();
expect(screen.getByText("1.284 Klicks")).toBeInTheDocument();
expect(screen.getAllByRole("button")).toHaveLength(4);
expect(screen.getByText("toter Klick")).toBeInTheDocument();
expect(screen.getByText("31 %")).toBeInTheDocument();
});
});
describe("Auswahl über die Rangliste", () => {
it("ein Klick auf eine Zeile wählt die Zone, der Satz folgt, der Rahmen erscheint auf der Website", () => {
const { container } = render();
const routenbild = screen.getByRole("button", { name: /Routenbild/ });
expect(routenbild).toHaveAttribute("aria-pressed", "false");
fireEvent.click(routenbild);
expect(routenbild).toHaveAttribute("aria-pressed", "true");
expect(screen.getByRole("button", { name: /Tageskarte kaufen/ })).toHaveAttribute("aria-pressed", "false");
const satz = container.querySelector("[aria-live='polite']") as HTMLElement;
expect(satz).toHaveTextContent(/Auffällig: 12 % klicken auf das Routenbild\. Es ist kein Link/);
const desktop = screen.getByTestId("desktop");
expect(within(desktop).getByText(/Routenbild · 154 Klicks · 12 %/)).toBeInTheDocument();
expect(desktop.querySelector(".hm-rahmen")).not.toBeNull();
});
it("Tippen auf eine Zone der Website wählt sie, nochmal Tippen hebt auf (Handy)", () => {
const { container } = render();
const handy = screen.getByTestId("handy");
const preise = handy.querySelector('[data-zone="preise"]') as HTMLElement;
fireEvent.click(preise);
expect(container.querySelector("[aria-live='polite']")).toHaveTextContent("9 % aller Klicks");
expect(handy.querySelector(".hm-rahmen")).not.toBeNull();
fireEvent.click(preise);
expect(handy.querySelector(".hm-rahmen")).toBeNull();
});
});
describe("Website ist reine Dekoration", () => {
it.each(["desktop", "handy"] as const)("%s: aria-hidden, data-phase, Zonen und nichts Fokussierbares", (variante) => {
render();
const wurzel = screen.getByTestId(variante).querySelector(".hm-seite") as HTMLElement;
expect(wurzel).toHaveAttribute("aria-hidden", "true");
expect(wurzel).toHaveAttribute("data-phase");
expect(wurzel).toHaveAttribute("data-variante", variante);
const fokussierbar = wurzel.querySelectorAll(
"a, button, input, select, textarea, iframe, [tabindex], [contenteditable]",
);
expect(fokussierbar).toHaveLength(0);
for (const z of ZONEN) expect(wurzel.querySelector(`[data-zone="${z.id}"]`)).not.toBeNull();
expect(wurzel.querySelector("canvas")).not.toBeNull();
});
it("enthält nur selbst gezeichnete Grafiken: keine Bilder, keine Webadressen", () => {
const { container } = render();
expect(container.querySelectorAll("img, video, iframe, link, script")).toHaveLength(0);
for (const datei of ["HeatmapSeite.tsx", "HeatmapDiagnose.tsx", "heatmap.css", "ablauf.ts", "zonen.ts"]) {
const text = readFileSync(`${process.cwd()}/src/components/home/demos/heatmap/${datei}`, "utf8");
expect(text).not.toMatch(/https?:\/\/(?!www\.w3\.org)/);
}
});
it("ablauf.ts und zonen.ts importieren kein CSS", () => {
for (const datei of ["ablauf.ts", "zonen.ts"]) {
const text = readFileSync(`${process.cwd()}/src/components/home/demos/heatmap/${datei}`, "utf8");
expect(text).not.toMatch(/import\s+["'][^"']+\.css["']/);
expect(text).not.toMatch(/from\s+["'][^"']*(HeatmapSeite|HeatmapDiagnose)/);
}
});
});
describe("Ablauf", () => {
it("läuft nichts, solange laeuft=false ist: keine Timer, leere Heatmap, kein sichtbarer Zeiger", () => {
vi.useFakeTimers();
const { container } = render();
expect(vi.getTimerCount()).toBe(0);
vorspulen(30_000);
const wurzel = container.querySelector(".hm-desktop") as HTMLElement;
expect(wurzel).toHaveAttribute("data-phase", "aufbau");
expect(wurzel).toHaveAttribute("data-laeuft", "nein");
expect(wurzel.querySelector(".hm-heat")).toHaveAttribute("data-anteil", "0");
expect(wurzel.querySelector(".dz-zeiger")).toHaveAttribute("data-sichtbar", "nein");
});
it("baut sich in etwa 10 Sekunden auf, hält, blendet aus und beginnt von vorn", () => {
vi.useFakeTimers();
const { container } = render();
const wurzel = container.querySelector(".hm-desktop") as HTMLElement;
const heat = () => Number(wurzel.querySelector(".hm-heat")?.getAttribute("data-anteil"));
expect(AUFBAU_DAUER).toBeGreaterThan(9_000);
expect(AUFBAU_DAUER).toBeLessThan(11_500);
expect(wurzel).toHaveAttribute("data-laeuft", "ja");
expect(wurzel.querySelector(".dz-zeiger")).toHaveAttribute("data-sichtbar", "ja");
vorspulen(3_000);
const frueh = heat();
expect(frueh).toBeGreaterThan(0);
expect(frueh).toBeLessThan(1284);
expect(wurzel).toHaveAttribute("data-phase", "aufbau");
const bisVoll = spuleBis(wurzel, "voll") + 3_000;
expect(bisVoll).toBeGreaterThan(AUFBAU_DAUER - 200);
expect(bisVoll).toBeLessThan(AUFBAU_DAUER + 1_500);
expect(wurzel).toHaveAttribute("data-phase", "voll");
expect(heat()).toBe(1284);
expect(wurzel.querySelector(".dz-zeiger")).toHaveAttribute("data-sichtbar", "nein");
const halten = spuleBis(wurzel, "ausblenden");
expect(halten).toBeGreaterThanOrEqual(5_900);
expect(halten).toBeLessThanOrEqual(6_300);
spuleBis(wurzel, "aufbau");
expect(wurzel).toHaveAttribute("data-phase", "aufbau");
expect(heat()).toBe(0);
});
it("endet laeuft mitten im Aufbau, stehen alle Timer still und der Ablauf beginnt später von vorn", () => {
vi.useFakeTimers();
const { container, rerender } = render();
vorspulen(4_000);
const wurzel = container.querySelector(".hm-desktop") as HTMLElement;
expect(Number(wurzel.querySelector(".hm-heat")?.getAttribute("data-anteil"))).toBeGreaterThan(0);
rerender();
expect(vi.getTimerCount()).toBe(0);
expect(wurzel.querySelector(".hm-heat")).toHaveAttribute("data-anteil", "0");
});
it("Maus über der Website: Zeiger aus, Heatmap voll, Ablauf pausiert; danach geht es von dort aus weiter", () => {
vi.useFakeTimers();
let z: ReturnType | undefined;
const Hol = () => {
z = useHeatmapAblauf(true, false);
return null;
};
render();
vorspulen(2_000);
expect(z?.phase).toBe("aufbau");
act(() => z?.setzeMaus(true));
expect(z?.maus).toBe(true);
expect(z?.phase).toBe("voll");
expect(z?.punkte).toBe(1284);
expect(z?.zeiger.desktop.sichtbar).toBe(false);
expect(z?.laeuft).toBe(false);
expect(vi.getTimerCount()).toBe(0);
vorspulen(60_000);
expect(z?.phase).toBe("voll");
act(() => z?.setzeMaus(false));
expect(z?.phase).toBe("voll");
expect(vi.getTimerCount()).toBe(1);
});
});