Files
Webklar/src/test/demo-zeiten.test.tsx
T
rootandClaude Opus 5.5 a23f2e52c2 Relaunch: Station Öffnungszeiten live
Vierter Eintrag der Funktionen-Gruppe (nach der News-Pinnwand, „Bald verfügbar“, ohne Preis):
- Website: Bäckerei Sonnenschein mit großer Status-Pille und Hinweis-Leiste „Betriebsurlaub 12.–26. Oktober“
  (gleitet per transform ein, der Inhalt rückt nach), Desktop und Handy.
- Handy-Ebene: Finger tippt „Hinweis-Leiste anlegen“ (HINWEIS_PUNKT, Platzhalter bis zur Aufnahme).
- Uhr-Regler: echter Schieberegler unter dem MacBook (inaktiv: aria-hidden + inert) und in der Handy-Karte.
- ZeitenSteuerung (setzeUhr(null) beim Verlassen), lazy-Chunks, Kundenportal importiert aus ablauf.ts nur Typen.
- Pins in relaunch-kundenportal.test.tsx auf neun Stationen angepasst (Als App = Marke 8), Spezifikations-Zähler mitgezogen.

ACHTUNG: public/relaunch/portal/phone-12-zeiten-{390,780}.webp sind vorläufig Kopien von phone-9-termine
(damit Build und Tests laufen); Task 36 ersetzt sie durch die echte Aufnahme.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 08:05:49 +00:00

893 lines
43 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<number>();
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(
<>
<Logo />
<Stempel />
<Pfeil />
</>,
);
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(
<>
<Stempel />
<Stempel />
</>,
);
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> = {}): 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<string, string> }[] = [];
for (const m of css.matchAll(/([^{}]+)\{([^{}]*)\}/g)) {
const werte: Record<string, string> = {};
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(<ZeitenSeite zustand={START} variante={variante} />);
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(<ZeitenSeite zustand={bei(700, { phase: "lauf", laeuft: true })} variante={variante} />);
expect(container.querySelector(".zt-pille")).toHaveTextContent(OFFEN);
expect(container.querySelector(".zt-pille")).toHaveAttribute("data-offen", "ja");
expect(container.firstElementChild).toHaveAttribute("data-laeuft", "ja");
rerender(<ZeitenSeite zustand={ENDE} variante={variante} />);
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(<ZeitenSeite variante={variante} />);
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(<ZeitenSeite zustand={START} variante={variante} />);
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(<ZeitenSeite zustand={START} variante={variante} />);
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(<ZeitenHandy zustand={bei(ENDE_UHR, { phase: "lauf" })} />);
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(<ZeitenHandy zustand={bei(ENDE_UHR, { phase: "tippen" })} />);
expect(container.querySelector(".dz-zeiger")).toHaveAttribute("data-sichtbar", "ja");
rerender(<ZeitenHandy zustand={bei(ENDE_UHR, { phase: "leiste", leiste: true })} />);
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(<ZeitenHandy zustand={bei(ENDE_UHR, { phase: "tippen", klick: 0 })} />);
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(<ZeitenHandy zustand={bei(ENDE_UHR, { phase: "tippen", klick: 1 })} punkt={{ x: 120, y: 500 }} />);
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(<ZeitenHandy zustand={bei(700, { phase: "lauf" })} />);
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(<ZeitenRegler zustand={bei(ENDE_UHR, { phase: "leiste", leiste: true })} />);
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(<ZeitenRegler zustand={bei(START_UHR)} />);
expect(container.querySelector(".zr__uhr")).toHaveTextContent("05:50");
expect(container.querySelector(".zr__status")).toHaveTextContent(ZU_HEUTE);
rerender(<ZeitenRegler zustand={bei(700)} />);
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(<ZeitenRegler zustand={bei(ZEIT_MAX)} />);
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(<ZeitenRegler zustand={{ ...START, setzeUhr }} />);
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(<ZeitenRegler zustand={bei(700, { phase: "lauf", laeuft: true })} />);
expect(container.querySelector(".zr__status")).toHaveAttribute("aria-live", "off");
rerender(<ZeitenRegler zustand={bei(700, { phase: "leiste", leiste: true, bedient: true })} />);
expect(container.querySelector(".zr__status")).toHaveAttribute("aria-live", "polite");
});
it("kompakt für die Handy-Karte; ohne Zustand im Anfangszustand", () => {
const { container } = render(<ZeitenRegler kompakt />);
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(<ZeitenSteuerung {...props} onZustand={onZustand} />);
return { ...r, onZustand };
};
const letzter = (f: ReturnType<typeof vi.fn>) => 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(<ZeitenSteuerung laeuft={false} statisch={false} aktiv={false} onZustand={onZustand} />);
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(<ZeitenSteuerung laeuft={false} statisch aktiv={false} onZustand={onZustand} />);
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(
<>
<ZeitenSeite zustand={ENDE} variante="desktop" />
<ZeitenSeite zustand={START} variante="handy" />
<ZeitenHandy zustand={bei(ENDE_UHR, { phase: "tippen" })} />
<ZeitenRegler zustand={START} />
</>,
);
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(<Kundenportal />);
};
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<HTMLElement>("#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(<Kundenportal />);
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(<Kundenportal />);
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*\{/);
});
});