Die Station „Funktionen“ ist durch drei Stationen ersetzt (Gruppe „Funktionen zum Dazubuchen“). Auf dem MacBook läuft je eine erfundene Kunden-Website als Live-Komponente, auf dem iPhone die Portal-Ansicht der Funktion (echter Portal-Code, Fake-Daten): - Terminplaner (Salon Schnittstelle): Buchung im Widget, Bestätigung per Link, Eintrag im Kalender-Abo, Mitteilung „Neue Buchung …“ auf dem Handy - Website-Analyse (Boulderhalle Überhang): simulierte Klicks bauen die Heatmap auf (Algorithmus wie analytics/client/h.js); Maus über der Seite zeigt Zone + Klickzahl, darunter die Diagnose mit Rangliste und Satz (Beispieldaten) - News-Pinnwand (Ortsgemeinde Musterbach): „Aufhängen“ im Portal, Zettel fällt auf die Pinnwand Demos laden als eigene Chunks, laufen nur bei aktiver, sichtbarer Station und ohne reduzierte Bewegung. Unter 900 px zeigen die Karten die Website in der Handy-Fassung. Spezifikation: docs/superpowers/specs/2026-10-04-funktionen-demos.md Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
302 lines
13 KiB
TypeScript
302 lines
13 KiB
TypeScript
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 (
|
||
<div>
|
||
<div data-testid="desktop">
|
||
<HeatmapSeite zustand={z} variante="desktop" />
|
||
</div>
|
||
<div data-testid="handy">
|
||
<HeatmapSeite zustand={z} variante="handy" />
|
||
</div>
|
||
<HeatmapDiagnose zustand={z} />
|
||
</div>
|
||
);
|
||
};
|
||
|
||
/** 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(<HeatmapDiagnose />);
|
||
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(<HeatmapSeite variante="desktop" />);
|
||
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(<Probe laeuft={false} statisch />);
|
||
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(<Probe laeuft={false} statisch />);
|
||
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(<Probe laeuft={false} statisch />);
|
||
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(<Probe laeuft={false} statisch={false} />);
|
||
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(<Probe laeuft={false} statisch />);
|
||
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(<Probe laeuft={false} statisch />);
|
||
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(<Probe laeuft={false} statisch={false} />);
|
||
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(<Probe laeuft statisch={false} />);
|
||
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(<Probe laeuft statisch={false} />);
|
||
vorspulen(4_000);
|
||
const wurzel = container.querySelector(".hm-desktop") as HTMLElement;
|
||
expect(Number(wurzel.querySelector(".hm-heat")?.getAttribute("data-anteil"))).toBeGreaterThan(0);
|
||
rerender(<Probe laeuft={false} statisch={false} />);
|
||
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<typeof useHeatmapAblauf> | undefined;
|
||
const Hol = () => {
|
||
z = useHeatmapAblauf(true, false);
|
||
return null;
|
||
};
|
||
render(<Hol />);
|
||
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);
|
||
});
|
||
});
|