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