import { describe, it, expect, vi, afterEach, beforeEach } from "vitest"; import { render, renderHook, act, cleanup, fireEvent, screen, waitFor, within } from "@testing-library/react"; import { existsSync, readFileSync, statSync } from "node:fs"; import { Logo, Stempel, Pfeil } from "@/components/home/demos/baeckerei/teile"; import ZeitenSeite from "@/components/home/demos/zeiten/ZeitenSeite"; import ZeitenHandy from "@/components/home/demos/zeiten/ZeitenHandy"; import ZeitenRegler from "@/components/home/demos/zeiten/ZeitenRegler"; import { ZeitenSteuerung } from "@/components/home/demos/steuerung"; import Kundenportal from "@/components/home/Kundenportal"; import { useZeitenAblauf, statusFuer, formatUhr, ZEIT_MIN, ZEIT_MAX, ZEIT_SCHRITT, START_UHR, ENDE_UHR, HINWEIS_TEXT, HINWEIS_ANLEGEN_PUNKT, ZEITEN_START, ZEITEN_RUNDE_MS, type ZeitenZustand, } from "@/components/home/demos/zeiten/ablauf"; // Spezifikation 2026-10-04-oeffnungszeiten-live, Abschnitt 10 (Relaunch-Station): Bäckerei Sonnenschein, Montag 5. Oktober 2026 // (Mo bis Fr 6 bis 18 Uhr). Der Ablauf ist reine Logik: Uhr, Status, Hinweis-Leiste. const OFFEN = "Jetzt geöffnet · schließt um 18 Uhr"; const ZU_MORGEN = "Geschlossen · öffnet morgen um 6 Uhr"; const ZU_HEUTE = "Geschlossen · öffnet heute um 6 Uhr"; afterEach(() => { cleanup(); vi.useRealTimers(); }); /** Zeit in kleinen Schritten vorspulen: jeder Schritt der Demo setzt seinen Timer erst nach dem Rendern. */ const vor = (ms: number) => { for (let rest = ms; rest > 0; rest -= 50) { act(() => { vi.advanceTimersByTime(Math.min(50, rest)); }); } }; const ablauf = (laeuft: boolean, statisch: boolean) => renderHook(({ l, s }) => useZeitenAblauf(l, s), { initialProps: { l: laeuft, s: statisch } }); describe("statusFuer und formatUhr", () => { it("statusFuer deckt jede Minute ab, nie „24“", () => { for (let m = ZEIT_MIN; m <= ZEIT_MAX; m++) { const s = statusFuer(m); expect(typeof s.offen, `Minute ${m}`).toBe("boolean"); expect(s.text, `Minute ${m}`).not.toMatch(/24/); expect(s.text.length, `Minute ${m}`).toBeGreaterThan(0); expect(s.kurz, `Minute ${m}`).toBe(s.offen ? "Geöffnet" : "Geschlossen"); expect(s.text, `Minute ${m}`).toContain(s.offen ? "Jetzt geöffnet" : "Geschlossen"); } // auch außerhalb des Reglerbereichs (ganzer Montag) nie „24“ for (let m = 0; m < 1440; m++) expect(statusFuer(m).text).not.toMatch(/24/); }); it("05:50 geschlossen, 06:00 geöffnet, 18:00 geschlossen · öffnet morgen um 6 Uhr", () => { expect(statusFuer(350)).toEqual({ offen: false, text: ZU_HEUTE, kurz: "Geschlossen" }); expect(statusFuer(359).offen).toBe(false); expect(statusFuer(360)).toEqual({ offen: true, text: OFFEN, kurz: "Geöffnet" }); expect(statusFuer(1079)).toEqual({ offen: true, text: OFFEN, kurz: "Geöffnet" }); expect(statusFuer(1080)).toEqual({ offen: false, text: ZU_MORGEN, kurz: "Geschlossen" }); expect(statusFuer(1090).text).toBe(ZU_MORGEN); expect(statusFuer(ZEIT_MIN).text).toBe(ZU_HEUTE); expect(statusFuer(ZEIT_MAX).text).toBe(ZU_MORGEN); }); it("nutzt dasselbe Trennzeichen „·“ (U+00B7) wie die Portal-Texte", () => { for (const m of [ZEIT_MIN, START_UHR, 360, 700, 1080, ENDE_UHR, ZEIT_MAX]) { expect(statusFuer(m).text.split(" · ")).toHaveLength(2); expect(statusFuer(m).text).toContain("·"); } }); it("formatUhr schreibt Minuten ab Mitternacht als HH:MM, nie „24:00“", () => { expect(formatUhr(350)).toBe("05:50"); expect(formatUhr(360)).toBe("06:00"); expect(formatUhr(1090)).toBe("18:10"); expect(formatUhr(ZEIT_MIN)).toBe("05:00"); expect(formatUhr(ZEIT_MAX)).toBe("22:00"); expect(formatUhr(0)).toBe("00:00"); expect(formatUhr(1439)).toBe("23:59"); expect(formatUhr(1440)).toBe("00:00"); }); }); describe("Konstanten", () => { it("haben die Werte der Spezifikation und liegen im Bereich des Reglers", () => { expect([ZEIT_MIN, ZEIT_MAX, ZEIT_SCHRITT, START_UHR, ENDE_UHR]).toEqual([300, 1320, 10, 350, 1090]); expect(HINWEIS_TEXT).toBe("Betriebsurlaub 12.–26. Oktober"); expect(HINWEIS_TEXT).toContain("–"); expect(HINWEIS_TEXT).not.toMatch(/24/); for (const u of [START_UHR, ENDE_UHR]) { expect(u).toBeGreaterThanOrEqual(ZEIT_MIN); expect(u).toBeLessThanOrEqual(ZEIT_MAX); expect((u - ZEIT_MIN) % ZEIT_SCHRITT).toBe(0); } expect(HINWEIS_ANLEGEN_PUNKT.x).toBeGreaterThan(0); expect(HINWEIS_ANLEGEN_PUNKT.y).toBeGreaterThan(0); }); it("ZEITEN_START ist der Anfangszustand vor dem Laden des Taktgebers", () => { expect(ZEITEN_START).toMatchObject({ phase: "start", uhr: START_UHR, status: statusFuer(START_UHR), leiste: false, klick: 0, bedient: false, laeuft: false, statisch: false, }); expect(() => ZEITEN_START.setzeUhr(700)).not.toThrow(); expect(ZEITEN_START.uhr).toBe(START_UHR); }); }); describe("useZeitenAblauf", () => { it("Ablauf: Uhr läuft, Finger tippt, Leiste erscheint", () => { vi.useFakeTimers(); const { result } = ablauf(true, false); expect(result.current).toMatchObject({ phase: "start", uhr: START_UHR, leiste: false, klick: 0, bedient: false, laeuft: true, statisch: false, }); expect(result.current.status.text).toBe(ZU_HEUTE); const phasen: string[] = [result.current.phase]; let letzteUhr = result.current.uhr; let hoechsteUhr = letzteUhr; let gesehenOffen = false; let gesehenZuMorgen = false; let leisteVorLeistePhase = false; let klickVorTippPhase = false; let klickInTippen = 0; let leisteSichtbarAb = -1; for (let t = 0; t < ZEITEN_RUNDE_MS - 50; t += 50) { vor(50); const z = result.current; if (phasen[phasen.length - 1] !== z.phase) phasen.push(z.phase); // die Uhr läuft nur vorwärts, in Schritten von 10 Minuten expect(z.uhr).toBeGreaterThanOrEqual(letzteUhr); expect((z.uhr - START_UHR) % ZEIT_SCHRITT).toBe(0); letzteUhr = z.uhr; hoechsteUhr = Math.max(hoechsteUhr, z.uhr); expect(z.status).toEqual(statusFuer(z.uhr)); if (z.status.text === OFFEN) gesehenOffen = true; if (z.status.text === ZU_MORGEN) gesehenZuMorgen = true; if (z.leiste && z.phase !== "leiste") leisteVorLeistePhase = true; if (z.klick > 0 && z.phase !== "tippen" && z.phase !== "leiste") klickVorTippPhase = true; if (z.phase === "tippen") klickInTippen = Math.max(klickInTippen, z.klick); if (z.leiste && leisteSichtbarAb < 0) leisteSichtbarAb = t; expect(z.bedient).toBe(false); expect(z.statisch).toBe(false); } expect(phasen).toEqual(["start", "lauf", "tippen", "leiste"]); expect(hoechsteUhr).toBe(ENDE_UHR); expect(gesehenOffen).toBe(true); expect(gesehenZuMorgen).toBe(true); expect(leisteVorLeistePhase).toBe(false); expect(klickVorTippPhase).toBe(false); // der Finger gleitet erst (klick 0) und tippt dann (klick wächst auf 1) expect(klickInTippen).toBe(1); expect(leisteSichtbarAb).toBeGreaterThan(0); // am Ende steht die Leiste, die Uhr zeigt 18:10 expect(result.current).toMatchObject({ phase: "leiste", uhr: ENDE_UHR, leiste: true, klick: 1 }); expect(result.current.status.text).toBe(ZU_MORGEN); }); it("beginnt nach einer Runde von vorn (Uhr 05:50, Leiste weg, Tipp zählt weiter)", () => { vi.useFakeTimers(); const { result } = ablauf(true, false); vor(ZEITEN_RUNDE_MS); expect(result.current).toMatchObject({ phase: "start", uhr: START_UHR, leiste: false, klick: 0 }); expect(result.current.status.text).toBe(ZU_HEUTE); // zweite Runde: der Tipp hat eine neue Zahl (damit die Klickwelle erneut erscheint) const klicks = new Set(); for (let t = 0; t < ZEITEN_RUNDE_MS; t += 50) { vor(50); if (result.current.phase === "tippen" && result.current.klick > 0) klicks.add(result.current.klick); } expect([...klicks]).toEqual([2]); }); it("Bedienen stoppt den Ablauf", () => { vi.useFakeTimers(); const { result } = ablauf(true, false); vor(1000); expect(vi.getTimerCount()).toBe(1); act(() => result.current.setzeUhr(700)); expect(vi.getTimerCount()).toBe(0); expect(result.current).toMatchObject({ uhr: 700, bedient: true, laeuft: false, statisch: false, leiste: true, klick: 0 }); expect(result.current.status.text).toBe(OFFEN); // es bewegt sich nichts mehr vor(60_000); expect(result.current.uhr).toBe(700); expect(vi.getTimerCount()).toBe(0); // weiter bedienen: der Status folgt der Uhr act(() => result.current.setzeUhr(1100)); expect(result.current.status.text).toBe(ZU_MORGEN); act(() => result.current.setzeUhr(ZEIT_MIN)); expect(result.current.status.text).toBe(ZU_HEUTE); expect(result.current.bedient).toBe(true); }); it("Bedienen mitten im Lauf zeigt sofort den ganzen Stand (mit Leiste), nie halbfertig", () => { vi.useFakeTimers(); const { result } = ablauf(true, false); vor(100); expect(result.current.leiste).toBe(false); act(() => result.current.setzeUhr(500)); expect(result.current).toMatchObject({ phase: "leiste", leiste: true, uhr: 500, klick: 0 }); }); it("begrenzt die gesetzte Uhr auf den Bereich des Reglers und ignoriert ungültige Werte", () => { vi.useFakeTimers(); const { result } = ablauf(true, false); act(() => result.current.setzeUhr(99_999)); expect(result.current.uhr).toBe(ZEIT_MAX); act(() => result.current.setzeUhr(-5)); expect(result.current.uhr).toBe(ZEIT_MIN); act(() => result.current.setzeUhr(700)); act(() => result.current.setzeUhr(Number.NaN)); expect(result.current.uhr).toBe(700); expect(result.current.bedient).toBe(true); }); it("setzeUhr(null) gibt zurück an den Ablauf: von vorn bei 05:50, Timer läuft wieder", () => { vi.useFakeTimers(); const { result } = ablauf(true, false); act(() => result.current.setzeUhr(700)); expect(vi.getTimerCount()).toBe(0); act(() => result.current.setzeUhr(null)); expect(result.current).toMatchObject({ phase: "start", uhr: START_UHR, leiste: false, klick: 0, bedient: false, laeuft: true }); expect(vi.getTimerCount()).toBe(1); vor(5000); expect(result.current.uhr).toBeGreaterThan(START_UHR); }); it("steht ohne laeuft still (05:50, kein Timer) und wirft eine Bedienung beim Anhalten weg", () => { vi.useFakeTimers(); const { result, rerender } = ablauf(false, false); expect(vi.getTimerCount()).toBe(0); vor(60_000); expect(result.current).toMatchObject({ phase: "start", uhr: START_UHR, leiste: false, klick: 0, laeuft: false, bedient: false }); rerender({ l: true, s: false }); act(() => result.current.setzeUhr(700)); expect(result.current.bedient).toBe(true); rerender({ l: false, s: false }); expect(result.current).toMatchObject({ phase: "start", uhr: START_UHR, leiste: false, bedient: false, laeuft: false }); expect(vi.getTimerCount()).toBe(0); // beim nächsten Start beginnt der Ablauf von vorn rerender({ l: true, s: false }); expect(result.current).toMatchObject({ phase: "start", uhr: START_UHR }); vor(1000); expect(vi.getTimerCount()).toBe(1); }); it("setzt beim Anhalten mitten im Lauf auf den Anfang zurück", () => { vi.useFakeTimers(); const { result, rerender } = ablauf(true, false); vor(3000); expect(result.current.phase).toBe("lauf"); expect(result.current.uhr).toBeGreaterThan(START_UHR); rerender({ l: false, s: false }); expect(result.current).toMatchObject({ phase: "start", uhr: START_UHR, leiste: false, klick: 0 }); expect(vi.getTimerCount()).toBe(0); }); it("statisch: Endzustand 18:10 mit Leiste, ohne Timer", () => { vi.useFakeTimers(); const { result } = ablauf(true, true); expect(result.current).toMatchObject({ phase: "leiste", uhr: ENDE_UHR, leiste: true, klick: 0, bedient: false, laeuft: false, statisch: true, }); expect(result.current.status.text).toBe(ZU_MORGEN); expect(formatUhr(result.current.uhr)).toBe("18:10"); expect(vi.getTimerCount()).toBe(0); vor(30_000); expect(result.current.uhr).toBe(ENDE_UHR); expect(result.current.leiste).toBe(true); expect(vi.getTimerCount()).toBe(0); }); it("statisch: der Regler bleibt bedienbar, setzeUhr(null) führt zurück auf 18:10", () => { vi.useFakeTimers(); const { result } = ablauf(false, true); act(() => result.current.setzeUhr(400)); expect(result.current).toMatchObject({ uhr: 400, bedient: true, leiste: true, statisch: true, laeuft: false }); expect(result.current.status.text).toBe(OFFEN); expect(vi.getTimerCount()).toBe(0); act(() => result.current.setzeUhr(null)); expect(result.current).toMatchObject({ uhr: ENDE_UHR, bedient: false, leiste: true }); }); it("setzeUhr behält seine Identität (sonst feuern Effekte der Steuerung bei jedem Takt)", () => { vi.useFakeTimers(); const { result } = ablauf(true, false); const erste = result.current.setzeUhr; vor(2000); expect(result.current.setzeUhr).toBe(erste); }); }); describe("Gemeinsame Bäckerei-Teile (teile.tsx)", () => { it("Logo, Stempel und Pfeil sind unverändert nutzbar", () => { const { container } = render( <> , ); const svgs = container.querySelectorAll("svg"); expect(svgs).toHaveLength(3); for (const svg of svgs) { expect(svg).toHaveAttribute("aria-hidden", "true"); expect(svg).toHaveAttribute("focusable", "false"); } expect(container.querySelector("svg.bk-logo__zeichen")).toHaveAttribute("viewBox", "0 0 48 48"); expect(container.querySelector("svg.bk-stempel")?.textContent).toContain("HANDWERK · SEIT 1987 · ALTSTADT ·"); expect(container.querySelector("svg path[stroke='currentColor']")).not.toBeNull(); }); it("zwei Stempel auf einer Seite haben verschiedene Kreisbahn-IDs", () => { const { container } = render( <> , ); const ids = [...container.querySelectorAll("defs path")].map((p) => p.getAttribute("id")); expect(ids).toHaveLength(2); expect(ids[0]).not.toBe(ids[1]); for (const id of ids) expect(container.querySelector(`textPath[href="#${id}"]`)).not.toBeNull(); }); }); describe("Quelltext", () => { const quelle = (pfad: string) => readFileSync(`${process.cwd()}/src/components/home/demos/${pfad}`, "utf8"); it("ablauf.ts importiert kein CSS und keine URL", () => { const text = quelle("zeiten/ablauf.ts"); expect(text).not.toMatch(/import\s+["'][^"']+\.css["']|from\s+["'][^"']+\.css["']/); expect(text).not.toMatch(/https?:\/\//); expect(text).not.toMatch(/googleapis|gstatic|unsplash/i); }); it("neue Dateien halten die Textregeln des Relaunchs ein (kein „24 Stunden“, „WEBklar“, keine Zahlenversprechen)", () => { for (const datei of ["zeiten/ablauf.ts", "baeckerei/teile.tsx"]) { const text = quelle(datei); expect(text, datei).not.toMatch(/24 Stunden|24 h|24h/i); expect(text, datei).not.toContain("Webklar"); expect(text, datei).not.toContain("99,9"); expect(text, datei).not.toMatch(/https?:\/\//); } }); it("BaeckereiSeite holt Logo, Stempel und Pfeil aus teile.tsx und definiert sie nicht mehr selbst", () => { const seite = quelle("baeckerei/BaeckereiSeite.tsx"); expect(seite).toMatch(/import\s*\{[^}]*\bLogo\b[^}]*\}\s*from\s*"\.\/teile"/); expect(seite).not.toMatch(/const (Logo|Stempel|Pfeil)\s*=/); const teile = quelle("baeckerei/teile.tsx"); for (const name of ["Logo", "Stempel", "Pfeil"]) expect(teile).toMatch(new RegExp(`export const ${name}\\s*=`)); }); }); // ── Station einbauen (Task 35): Seite, Handy, Regler, Bühne ────────────────────────────────────────────── const START: ZeitenZustand = ZEITEN_START; const bei = (uhr: number, extra: Partial = {}): ZeitenZustand => ({ ...START, uhr, status: statusFuer(uhr), ...extra, }); const ENDE: ZeitenZustand = bei(ENDE_UHR, { phase: "leiste", leiste: true, statisch: true }); const FOKUSSIERBAR = "a, button, input, select, textarea, iframe, summary, [tabindex], [contenteditable]"; /** Regeln einer CSS-Datei (ohne Kommentare): Selektor -> Deklarationen (Eigenschaft -> Wert). Auch Regeln in @media. */ const regeln = (pfad: string) => { const css = readFileSync(`${process.cwd()}/src/components/home/${pfad}`, "utf8").replace(/\/\*[\s\S]*?\*\//g, ""); const liste: { selektor: string; werte: Record }[] = []; for (const m of css.matchAll(/([^{}]+)\{([^{}]*)\}/g)) { const werte: Record = {}; for (const d of m[2].split(";")) { const i = d.indexOf(":"); if (i > 0) werte[d.slice(0, i).trim()] = d.slice(i + 1).trim(); } liste.push({ selektor: m[1].trim().replace(/\s+/g, " "), werte }); } return liste; }; /** Die erste Regel mit genau diesem Selektor (der Grundzustand steht im Quelltext vor den Abwandlungen). */ const regel = (pfad: string, selektor: string) => { const r = regeln(pfad).find((x) => x.selektor === selektor); expect(r, `${pfad}: Regel ${selektor}`).toBeDefined(); return r!.werte; }; const px = (wert: string | undefined) => { const m = /^(-?[\d.]+)(?:px)?$/.exec(wert ?? ""); expect(m, `Wert in px: ${wert}`).not.toBeNull(); return Number(m![1]); }; describe("ZeitenSeite", () => { for (const variante of ["desktop", "handy"] as const) { it(`Start: Pille „Geschlossen · öffnet heute um 6 Uhr“, Wurzel ohne .bk-inhalt, aria-hidden (${variante})`, () => { const { container } = render(); const wurzel = container.firstElementChild as HTMLElement; expect(wurzel).toHaveClass("bk-seite", "zt-seite"); expect(wurzel).toHaveAttribute("aria-hidden", "true"); expect(wurzel).toHaveAttribute("data-variante", variante); expect(wurzel).toHaveAttribute("data-phase", "start"); expect(wurzel).toHaveAttribute("data-laeuft", "nein"); // eigenes Wurzelelement: sonst griffe die Scroll-Verschiebung der Live-Vorschau (--kp-v) expect(container.querySelector(".bk-inhalt")).toBeNull(); const pille = container.querySelector(".zt-pille") as HTMLElement; expect(pille).toHaveTextContent(ZU_HEUTE); expect(pille).toHaveAttribute("data-offen", "nein"); expect(container.querySelector(".zt-leiste")).toHaveTextContent(HINWEIS_TEXT); }); it(`Die Pille folgt dem Status: geöffnet um 11:40, danach „öffnet morgen“ (${variante})`, () => { const { container, rerender } = render(); expect(container.querySelector(".zt-pille")).toHaveTextContent(OFFEN); expect(container.querySelector(".zt-pille")).toHaveAttribute("data-offen", "ja"); expect(container.firstElementChild).toHaveAttribute("data-laeuft", "ja"); rerender(); expect(container.querySelector(".zt-pille")).toHaveTextContent(ZU_MORGEN); expect(container.querySelector(".zt-pille")).toHaveAttribute("data-offen", "nein"); expect(container.firstElementChild).toHaveAttribute("data-phase", "leiste"); }); it(`Ohne Zustand steht die Seite im Anfangszustand da (${variante})`, () => { const { container } = render(); expect(container.firstElementChild).toHaveAttribute("data-phase", "start"); expect(container.querySelector(".zt-pille")).toHaveTextContent(ZU_HEUTE); }); it(`Öffnungszeiten der Bäckerei: Mo–Fr 6–18, Sa 6–13, So 7–11, heute (Montag) hervorgehoben (${variante})`, () => { const { container } = render(); const zeilen = Array.from(container.querySelectorAll(".zt-zeile")); expect(zeilen.map((z) => z.textContent)).toEqual(["Mo – Fr6:00 – 18:00 Uhr", "Sa6:00 – 13:00 Uhr", "So7:00 – 11:00 Uhr"]); expect(zeilen.map((z) => z.classList.contains("is-heute"))).toEqual([true, false, false]); }); it(`Logo und Pfeil kommen aus teile.tsx; keine Bilder mit Alt-Text-Pflicht, keine Links (${variante})`, () => { const { container } = render(); expect(container.querySelector("svg.bk-logo__zeichen")).not.toBeNull(); expect(container.querySelectorAll("a, iframe, video, script, link")).toHaveLength(0); for (const bild of container.querySelectorAll("img")) { expect(bild).toHaveAttribute("alt", ""); expect(bild.getAttribute("src")).toMatch(/^\/relaunch\/demos\/baeckerei\/[a-z0-9-]+\.webp$/); expect(existsSync(`${process.cwd()}/public${bild.getAttribute("src")}`), bild.getAttribute("src")!).toBe(true); } }); } it("Seite: Pille ≥ 30 pt, Leiste ≥ 24 pt, nicht unter dem iPhone", () => { const datei = "demos/zeiten/zeiten.css"; // im MacBook ist alles auf 1512 x 893 gesetzt; das iPhone liegt über der Fläche ab x 1150 und y 410 expect(px(regel(datei, ".zt-pille")["font-size"])).toBeGreaterThanOrEqual(30); const leiste = regel(datei, ".zt-leiste"); expect(px(leiste["font-size"])).toBeGreaterThanOrEqual(24); // Leiste: oben über die ganze Breite und höchstens bis y 410 (also links von x 1150 oder oberhalb y 410) expect(px(leiste.top) + px(leiste.height)).toBeLessThanOrEqual(410); expect(leiste.left).toBe("0"); expect(leiste.right).toBe("0"); // Pille: steht in der linken Spalte des Starts, die vor x 1150 endet const held = regel(datei, ".zt-held"); const [, rechts] = held["grid-template-columns"].split(" "); const linksEnde = 1512 - px(held.padding.split(" ")[1]) - px(rechts) - px(held.gap); expect(linksEnde).toBeLessThanOrEqual(1150); // Öffnungszeiten-Karte rechts daneben: ihre rechte Kante bleibt links vom iPhone const karte = regel(datei, ".zt-karte"); const bildSpalteLinks = 1512 - px(held.padding.split(" ")[1]) - px(rechts); expect(bildSpalteLinks + px(karte.left) + px(karte.width)).toBeLessThanOrEqual(1150); // Lesbarkeit der Status-Pille: Zeilenhöhe nicht winzig, Text hell auf dunkel oder dunkel auf hell (kein Weiß auf Weiß) expect(regel(datei, ".zt-pille").background).toBeDefined(); }); it("die Leiste gleitet per transform ein und schiebt den Inhalt mit (nur in Phase „leiste“)", () => { const datei = "demos/zeiten/zeiten.css"; expect(regel(datei, ".zt-leiste").transform).toMatch(/translateY\(-100%\)/); expect(regel(datei, ".zt-leiste").opacity).toBe("0"); expect(regel(datei, '.zt-seite[data-phase="leiste"] .zt-leiste').transform).toBe("none"); expect(regel(datei, '.zt-seite[data-phase="leiste"] .zt-inhalt').transform).toMatch(/translateY\(var\(--zt-leiste-h\)\)/); }); }); describe("ZeitenHandy", () => { it("der Finger ist nur in Phase „tippen“ zu sehen, ohne Zeiger-Ereignisse und aria-hidden", () => { const { container, rerender } = render(); const wurzel = container.firstElementChild as HTMLElement; expect(wurzel).toHaveAttribute("aria-hidden", "true"); expect(wurzel.style.pointerEvents).toBe("none"); expect(container.querySelector(".dz-zeiger")).toHaveAttribute("data-sichtbar", "nein"); expect(container.querySelector(".dz-zeiger")).toHaveAttribute("data-art", "finger"); rerender(); expect(container.querySelector(".dz-zeiger")).toHaveAttribute("data-sichtbar", "ja"); rerender(); expect(container.querySelector(".dz-zeiger")).toHaveAttribute("data-sichtbar", "nein"); }); it("der Finger gleitet zum Knopf (Standard HINWEIS_ANLEGEN_PUNKT oder die Prop punkt) und tippt (Klickwelle ab klick > 0)", () => { const { container, rerender } = render(); const zeiger = () => container.querySelector(".dz-zeiger") as HTMLElement; expect(zeiger().style.transform).toBe(`translate(${HINWEIS_ANLEGEN_PUNKT.x}px, ${HINWEIS_ANLEGEN_PUNKT.y}px)`); expect(container.querySelector(".dz-welle")).toBeNull(); rerender(); expect(zeiger().style.transform).toBe("translate(120px, 500px)"); expect(container.querySelector(".dz-welle")).not.toBeNull(); }); it("wartet in der Ruhelage, bis der Finger zum Knopf gleitet (nicht schon auf dem Knopf)", () => { const { container } = render(); const ruhe = (container.querySelector(".dz-zeiger") as HTMLElement).style.transform; expect(ruhe).not.toBe(`translate(${HINWEIS_ANLEGEN_PUNKT.x}px, ${HINWEIS_ANLEGEN_PUNKT.y}px)`); }); }); describe("ZeitenRegler", () => { it("ist ein echter Regler mit Label, Bereich 05:00 bis 22:00 in Zehn-Minuten-Schritten und lesbarem Wert", () => { render(); const regler = screen.getByRole("slider", { name: "Uhrzeit testen" }) as HTMLInputElement; expect(regler.tagName).toBe("INPUT"); expect(regler).toHaveAttribute("type", "range"); expect(regler).toHaveAttribute("min", String(ZEIT_MIN)); expect(regler).toHaveAttribute("max", String(ZEIT_MAX)); expect(regler).toHaveAttribute("step", String(ZEIT_SCHRITT)); expect(regler.value).toBe(String(ENDE_UHR)); expect(regler).toHaveAttribute("aria-valuetext", "18:10 Uhr, Geschlossen"); expect(document.querySelector("label[for]")).toHaveAttribute("for", regler.id); }); it("zeigt Uhrzeit und Statussatz zur Uhrzeit (nie „24“)", () => { const { container, rerender } = render(); expect(container.querySelector(".zr__uhr")).toHaveTextContent("05:50"); expect(container.querySelector(".zr__status")).toHaveTextContent(ZU_HEUTE); rerender(); expect(container.querySelector(".zr__uhr")).toHaveTextContent("11:40"); expect(container.querySelector(".zr__status")).toHaveTextContent(OFFEN); expect(screen.getByRole("slider")).toHaveAttribute("aria-valuetext", "11:40 Uhr, Geöffnet"); rerender(); expect(container.textContent).not.toMatch(/24/); }); it("Bedienen ruft setzeUhr mit der Zahl auf (Ziehen, Pfeiltasten) und stoppt damit den Ablauf", () => { const setzeUhr = vi.fn(); render(); const regler = screen.getByRole("slider"); fireEvent.change(regler, { target: { value: "700" } }); expect(setzeUhr).toHaveBeenCalledWith(700); expect(setzeUhr).toHaveBeenCalledTimes(1); }); it("der Statussatz meldet sich nur nach einer Bedienung (aria-live), nicht beim Automatiklauf", () => { const { container, rerender } = render(); expect(container.querySelector(".zr__status")).toHaveAttribute("aria-live", "off"); rerender(); expect(container.querySelector(".zr__status")).toHaveAttribute("aria-live", "polite"); }); it("kompakt für die Handy-Karte; ohne Zustand im Anfangszustand", () => { const { container } = render(); expect(container.firstElementChild).toHaveClass("zr", "zr--kompakt"); expect((screen.getByRole("slider") as HTMLInputElement).value).toBe(String(START_UHR)); }); it("Tippfläche des Reglers ≥ 44 px, Wischen quer erlaubt die Wisch-Reihe nicht zu stören (touch-action)", () => { const werte = regel("demos/zeiten/zeiten.css", ".zr__regler"); expect(px(werte.height)).toBeGreaterThanOrEqual(44); expect(werte["touch-action"]).toBe("pan-y"); }); }); describe("ZeitenSteuerung", () => { const rendern = (props: { laeuft: boolean; statisch: boolean; aktiv: boolean }, onZustand = vi.fn()) => { const r = render(); return { ...r, onZustand }; }; const letzter = (f: ReturnType) => f.mock.calls[f.mock.calls.length - 1][0] as ZeitenZustand; it("rendert nichts und meldet den Zustand", () => { vi.useFakeTimers(); const { container, onZustand } = rendern({ laeuft: true, statisch: false, aktiv: true }); expect(container).toBeEmptyDOMElement(); expect(letzter(onZustand)).toMatchObject({ phase: "start", uhr: START_UHR, laeuft: true }); }); it("verlässt die Station (aktiv = false), geht eine Bedienung zurück an den Ablauf", () => { vi.useFakeTimers(); const { rerender, onZustand } = rendern({ laeuft: true, statisch: false, aktiv: true }); act(() => letzter(onZustand).setzeUhr(700)); expect(letzter(onZustand)).toMatchObject({ bedient: true, uhr: 700 }); rerender(); expect(letzter(onZustand)).toMatchObject({ bedient: false, phase: "start", uhr: START_UHR }); }); it("statisch: auch ohne aktive Station geht setzeUhr(null) zurück auf den Endzustand 18:10", () => { vi.useFakeTimers(); const { rerender, onZustand } = rendern({ laeuft: false, statisch: true, aktiv: true }); act(() => letzter(onZustand).setzeUhr(400)); expect(letzter(onZustand)).toMatchObject({ bedient: true, uhr: 400 }); rerender(); expect(letzter(onZustand)).toMatchObject({ bedient: false, uhr: ENDE_UHR, leiste: true }); }); }); describe("nichts fokussierbar außer dem Regler", () => { it("nichts fokussierbar außer genau einem Regler", () => { const { container } = render( <> , ); const treffer = Array.from(container.querySelectorAll(FOKUSSIERBAR)); expect(treffer).toHaveLength(1); expect(treffer[0]).toBe(screen.getByRole("slider")); // die Seiten und die Handy-Ebene enthalten selbst nichts Fokussierbares for (const wurzel of container.querySelectorAll(".zt-seite, .dz-zeiger")) expect(wurzel.querySelectorAll(FOKUSSIERBAR)).toHaveLength(0); expect(container.querySelectorAll(".zt-seite input, .zt-seite [tabindex]")).toHaveLength(0); }); }); describe("Bühne: Station Öffnungszeiten live", () => { type Eintrag = { rueckruf: IntersectionObserverCallback; optionen?: IntersectionObserverInit; ziele: Element[] }; const beobachter: Eintrag[] = []; let zuruecksetzen: (() => void) | undefined; const mitBreite = (desktop: boolean, bewegung = false) => { const vorher = window.matchMedia; window.matchMedia = ((query: string) => ({ matches: (desktop && (query.includes("min-width: 900px") || query.includes("hover: hover"))) || (bewegung && query.includes("prefers-reduced-motion: no-preference")), media: query, onchange: null, addListener: () => {}, removeListener: () => {}, addEventListener: () => {}, removeEventListener: () => {}, dispatchEvent: () => false, })) as typeof window.matchMedia; zuruecksetzen = () => { window.matchMedia = vorher; }; }; const beobachterAttrappe = () => { beobachter.length = 0; vi.stubGlobal( "IntersectionObserver", class { eintrag: Eintrag; constructor(rueckruf: IntersectionObserverCallback, optionen?: IntersectionObserverInit) { this.eintrag = { rueckruf, optionen, ziele: [] }; beobachter.push(this.eintrag); } observe(ziel: Element) { this.eintrag.ziele.push(ziel); } unobserve() {} disconnect() {} takeRecords() { return []; } }, ); }; const meldeMarke = (index: number) => { const spur = beobachter.find((b) => b.optionen?.rootMargin === "-50% 0px -50% 0px"); expect(spur).toBeDefined(); act(() => { spur!.rueckruf([{ isIntersecting: true, target: spur!.ziele[index] } as unknown as IntersectionObserverEntry], {} as IntersectionObserver); }); }; const desktop = () => { beobachterAttrappe(); mitBreite(true); return render(); }; beforeEach(() => { vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue(null); }); afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); zuruecksetzen?.(); zuruecksetzen = undefined; }); it("Bühne: neun Stationen, Station 8 ist Öffnungszeiten live mit „Bald verfügbar“", () => { const { container } = desktop(); expect(beobachter.find((b) => b.optionen?.rootMargin === "-50% 0px -50% 0px")!.ziele).toHaveLength(9); const unterliste = container.querySelector("#kp-unterliste")!; const unter = within(unterliste).getAllByRole("button", { hidden: true }); expect(unter).toHaveLength(4); expect(unter[3]).toHaveTextContent(/^Öffnungszeiten live/); expect(unter[3].querySelector(".kp-bald")).toHaveTextContent("Bald verfügbar"); meldeMarke(7); expect(container.querySelector(".kp-buehne")).toHaveAttribute("data-station", "oeffnungszeiten"); expect(unter[3]).toHaveAttribute("aria-current", "step"); // „Als App“ ist jetzt die neunte Station und bleibt die letzte meldeMarke(8); expect(container.querySelector(".kp-buehne")).toHaveAttribute("data-app", "ja"); }); it("Regler inaktiv: aria-hidden und inert; nur bei der Station Öffnungszeiten live bedienbar", async () => { const { container } = desktop(); const regler = () => container.querySelector(".kp-regler") as HTMLElement; expect(regler()).toHaveAttribute("data-an", "nein"); expect(regler()).toHaveAttribute("aria-hidden", "true"); expect(regler()).toHaveAttribute("inert"); expect(regler().querySelector("input")).toBeNull(); // noch nicht eingehängt meldeMarke(7); await waitFor(() => expect(regler().querySelector('input[type="range"]')).not.toBeNull()); expect(regler()).toHaveAttribute("data-an", "ja"); expect(regler()).not.toHaveAttribute("aria-hidden"); expect(regler()).not.toHaveAttribute("inert"); expect(within(regler()).getByRole("slider", { name: "Uhrzeit testen" })).toBeInTheDocument(); meldeMarke(6); expect(regler()).toHaveAttribute("data-an", "nein"); expect(regler()).toHaveAttribute("aria-hidden", "true"); expect(regler()).toHaveAttribute("inert"); meldeMarke(8); expect(regler()).toHaveAttribute("inert"); }); it("MacBook: die Beispiel-Website der Bäckerei (nachgeladen, aria-hidden, Beschreibung); ohne Bewegung Endzustand mit Leiste", async () => { const { container } = desktop(); meldeMarke(7); await waitFor(() => expect(container.querySelector(".kp-fenster .kp-lage.is-aktiv .zt-seite")).not.toBeNull()); const ebene = container.querySelector(".kp-fenster .kp-lage.is-aktiv") as HTMLElement; expect(ebene).toHaveAttribute("role", "img"); expect(ebene.getAttribute("aria-label")).toMatch(/Bäckerei/); expect(ebene.getAttribute("aria-label")).not.toMatch(/Google|Ranking|Umsatz/i); const seite = ebene.querySelector(".zt-seite") as HTMLElement; expect(seite).toHaveAttribute("aria-hidden", "true"); expect(seite).toHaveAttribute("data-variante", "desktop"); // diese Bühne meldet keine erlaubte Bewegung: fester Endzustand (18:10 mit Leiste) await waitFor(() => expect(seite).toHaveAttribute("data-phase", "leiste")); expect(seite.querySelector(".zt-pille")).toHaveTextContent(ZU_MORGEN); expect(container.querySelector(".dm-macbook__url")?.textContent).toBe("baeckerei-sonnenschein.example"); expect(container.querySelector(".kp-beispiel[data-an='ja']")).toHaveTextContent("Erfundenes Beispiel · baeckerei-sonnenschein.example"); }); it("iPhone: Portal-Aufnahme „Öffnungszeiten im Kundenportal bearbeiten“ mit Finger-Ebene darüber", async () => { const { container } = desktop(); meldeMarke(7); const bild = () => container.querySelector(".kp-handy .kp-lage.is-aktiv img") as HTMLImageElement; expect(bild().getAttribute("src")).toBe("/relaunch/portal/phone-12-zeiten-780.webp"); expect(bild().getAttribute("alt")).toBe("Öffnungszeiten im Kundenportal bearbeiten"); await waitFor(() => expect(container.querySelector(".kp-handy .kp-lage.is-aktiv .dz-zeiger")).not.toBeNull()); expect(container.querySelector(".kp-handy .kp-lage.is-aktiv")?.querySelector("[aria-hidden='true'] .dz-zeiger")).not.toBeNull(); }); it("die Bilddateien der Station gibt es und halten das Budget (Handy ≤ 60 KB)", () => { for (const breite of [390, 780]) { const datei = `${process.cwd()}/public/relaunch/portal/phone-12-zeiten-${breite}.webp`; expect(existsSync(datei), datei).toBe(true); expect(statSync(datei).size / 1024, datei).toBeLessThanOrEqual(60); } }); it("Handy: die Karte zeigt die Website im Rahmen und darunter den Regler, geladen erst in Sicht", async () => { beobachterAttrappe(); mitBreite(false); const { container } = render(); const karte = container.querySelector(".kp-karte--demo[data-demo='zeiten']") as HTMLElement; expect(karte).not.toBeNull(); expect(within(karte).getByRole("heading", { level: 3, name: "Öffnungszeiten live" })).toBeInTheDocument(); expect(karte.querySelector(".kp-bald")).toHaveTextContent("Bald verfügbar"); expect(karte.querySelector(".zt-seite")).toBeNull(); expect(karte.querySelector("input")).toBeNull(); act(() => { for (const b of beobachter) { const ziele = b.ziele.filter((z) => z.classList.contains("kp-karte__geraet")); if (ziele.length) b.rueckruf(ziele.map((target) => ({ isIntersecting: true, target }) as unknown as IntersectionObserverEntry), {} as IntersectionObserver); } }); await waitFor(() => expect(karte.querySelector(".zt-seite[data-variante='handy']")).not.toBeNull()); await waitFor(() => expect(karte.querySelector(".kp-karte__regler input[type='range']")).not.toBeNull()); expect(karte.querySelector(".zr")).toHaveClass("zr--kompakt"); expect(karte.querySelector(".kp-karte__beispiel")?.textContent).toBe("Erfundenes Beispiel · baeckerei-sonnenschein.example"); // ohne Bewegung: Endzustand 18:10; Regler bedienen zeigt den Status der gewählten Uhrzeit expect(karte.querySelector(".zt-seite")).toHaveAttribute("data-phase", "leiste"); fireEvent.change(karte.querySelector("input[type='range']")!, { target: { value: "700" } }); await waitFor(() => expect(karte.querySelector(".zt-pille")).toHaveTextContent(OFFEN)); expect(karte.querySelector(".zr__uhr")).toHaveTextContent("11:40"); // Seiten und Handy-Karte: außer dem Regler nichts fokussierbar expect(karte.querySelectorAll(FOKUSSIERBAR)).toHaveLength(1); }); it("Handy-Reihe und Bühne bleiben beschriftet: Neun Funktionen des Kundenportals", () => { beobachterAttrappe(); mitBreite(false); render(); expect(screen.getByRole("list", { name: /^Neun Funktionen des Kundenportals, seitlich wischen$/ })).toBeInTheDocument(); }); it("die Bühne zieht aus ablauf.ts nur Typen (der Haupt-Chunk bleibt klein), die Komponenten sind lazy", () => { const quelle = readFileSync(`${process.cwd()}/src/components/home/Kundenportal.tsx`, "utf8"); expect(quelle).toMatch(/import type \{ ZeitenZustand \} from "\.\/demos\/zeiten\/ablauf";/); expect(quelle).not.toMatch(/^import (?!type)[^;]*from "\.\/demos\/zeiten\//m); for (const name of ["ZeitenSeite", "ZeitenHandy", "ZeitenRegler"]) { expect(quelle).toMatch(new RegExp(`const ${name} = lazyMitVorladen\\(\\(\\) => import\\("\\./demos/zeiten/${name}"\\)\\)`)); } expect(quelle).toMatch(/const HINWEIS_PUNKT = \{ x: \d+, y: \d+ \}/); }); }); describe("Quelltext der Station", () => { const quelle = (pfad: string) => readFileSync(`${process.cwd()}/src/components/home/demos/${pfad}`, "utf8"); const DATEIEN = ["zeiten/ablauf.ts", "zeiten/ZeitenSeite.tsx", "zeiten/ZeitenHandy.tsx", "zeiten/ZeitenRegler.tsx", "zeiten/zeiten.css"]; it("kein https://, keine fremden Marken oder Versprechen, kein „24 h“, kein WhatsApp-Logo", () => { for (const datei of DATEIEN) { const text = quelle(datei).replace(/http:\/\/www\.w3\.org\/2000\/svg/g, ""); expect(text, datei).not.toMatch(/https?:\/\//); expect(text, datei).not.toMatch(/googleapis|gstatic|unsplash|google|ranking|mehr Kunden|mehr Umsatz|garantiert/i); expect(text, datei).not.toMatch(/24[\s-]*(Stunden|Std|h)\b/i); expect(text, datei).not.toMatch(/whatsapp/i); expect(text, datei).not.toContain("Webklar"); expect(text, datei).not.toContain("99,9"); } }); it("Bewegung nur transform/opacity hinter no-preference", () => { const css = quelle("zeiten/zeiten.css").replace(/\/\*[\s\S]*?\*\//g, ""); const mitBewegung = css.split("@media (prefers-reduced-motion: no-preference)"); expect(mitBewegung.length, "es gibt einen Block hinter no-preference").toBeGreaterThan(1); expect(mitBewegung[0]).not.toMatch(/animation\s*:|transition\s*:|animation-|transition-/); for (const treffer of css.matchAll(/@keyframes[^{]+\{([\s\S]*?)\n\}/g)) { for (const e of [...treffer[1].matchAll(/([a-z-]+)\s*:/g)].map((m) => m[1])) { expect(["transform", "opacity", "animation-timing-function"]).toContain(e); } } for (const m of css.matchAll(/transition\s*:\s*([^;]+);/g)) { for (const teil of m[1].split(/,(?![^(]*\))/)) expect(["transform", "opacity"], m[0]).toContain(teil.trim().split(/\s+/)[0]); } // keine Layout-Eigenschaften in Übergängen oder Animationen (will-change nur transform/opacity) expect(css).not.toMatch(/will-change\s*:\s*(?!transform|opacity)/); }); it("Seite und Handy-Ebene sind reine Kulisse: keine Ereignisse, keine Hover-Regeln", () => { expect(quelle("zeiten/ZeitenSeite.tsx")).not.toMatch(/\bon[A-Z][A-Za-z]+=/); expect(quelle("zeiten/ZeitenHandy.tsx")).not.toMatch(/\bon[A-Z][A-Za-z]+=/); expect(quelle("zeiten/zeiten.css")).not.toMatch(/:hover/); }); it("Seite und Regler holen ihr CSS selbst (der Taktgeber ablauf.ts nicht), baeckerei.css kommt vor zeiten.css", () => { const seite = quelle("zeiten/ZeitenSeite.tsx"); expect(seite).toMatch(/import "\.\.\/baeckerei\/baeckerei\.css";\s*import "\.\/zeiten\.css";/); expect(quelle("zeiten/ZeitenRegler.tsx")).toMatch(/import "\.\/zeiten\.css";/); expect(quelle("zeiten/ablauf.ts")).not.toMatch(/\.css/); expect(seite).toMatch(/from "\.\.\/baeckerei\/teile"/); }); it("kundenportal.css kennt .kp-regler in den Selektorlisten und .kp-karte__regler", () => { const css = readFileSync(`${process.cwd()}/src/components/home/kundenportal.css`, "utf8").replace(/\/\*[\s\S]*?\*\//g, ""); expect(css).toMatch(/\.kp-regler,/); expect(css).toMatch(/\.kp-regler\[data-an="ja"\]/); expect(css).toMatch(/\.kp-karte__regler\s*\{/); }); });