diff --git a/src/components/home/demos/baeckerei/BaeckereiSeite.tsx b/src/components/home/demos/baeckerei/BaeckereiSeite.tsx index f51f150..f663f9d 100644 --- a/src/components/home/demos/baeckerei/BaeckereiSeite.tsx +++ b/src/components/home/demos/baeckerei/BaeckereiSeite.tsx @@ -1,5 +1,6 @@ -import { memo, useId, useLayoutEffect, useRef } from "react"; +import { memo, useLayoutEffect, useRef } from "react"; import { FLAECHE } from "../gemeinsam"; +import { Logo, Pfeil, Stempel } from "./teile"; import "./baeckerei.css"; /** @@ -14,45 +15,6 @@ import "./baeckerei.css"; const FOTOS = "/relaunch/demos/baeckerei"; const foto = (name: string) => `${FOTOS}/${name}.webp`; -/** Logo: kleine Sonne über einem Brotlaib, in der Farbe der Kruste. */ -const Logo = () => ( - -); - -/** Runder Stempel „Handwerk · seit 1987 · Altstadt ·“ (Schrift auf einer Kreisbahn, auf den Umfang gestreckt). */ -const Stempel = () => { - const id = `${useId().replace(/:/g, "")}-kreis`; - return ( - - ); -}; - -const Pfeil = () => ( - -); - const SORTIMENT = [ { name: "Roggenbrot", text: "36 Stunden Sauerteig, kräftig und lange saftig.", preis: "4,90 €", bild: "roggenbrot-anschnitt-400", neu: true }, { name: "Kaiserbrötchen", text: "Außen knusprig, innen fluffig – ab 6 Uhr.", preis: "0,55 €", bild: "broetchen-400" }, diff --git a/src/components/home/demos/baeckerei/teile.tsx b/src/components/home/demos/baeckerei/teile.tsx new file mode 100644 index 0000000..b0f3d76 --- /dev/null +++ b/src/components/home/demos/baeckerei/teile.tsx @@ -0,0 +1,46 @@ +import { useId } from "react"; + +/** + * Gemeinsame Teile der Bäckerei Sonnenschein: Logo, Stempel und Pfeil (aus BaeckereiSeite.tsx unverändert hierher + * verschoben, damit die Seite der Station „Öffnungszeiten live“ sie ebenfalls nutzt). Die Klassen (.bk-logo__zeichen, + * .bk-stempel) stylt baeckerei.css; wer die Teile nutzt, bindet diese Datei ein. + */ + +/** Logo: kleine Sonne über einem Brotlaib, in der Farbe der Kruste. */ +export const Logo = () => ( + +); + +/** Runder Stempel „Handwerk · seit 1987 · Altstadt ·“ (Schrift auf einer Kreisbahn, auf den Umfang gestreckt). */ +export const Stempel = () => { + const id = `${useId().replace(/:/g, "")}-kreis`; + return ( + + ); +}; + +export const Pfeil = () => ( + +); diff --git a/src/components/home/demos/zeiten/ablauf.ts b/src/components/home/demos/zeiten/ablauf.ts new file mode 100644 index 0000000..fc6805a --- /dev/null +++ b/src/components/home/demos/zeiten/ablauf.ts @@ -0,0 +1,191 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { useAblauf } from "../gemeinsam"; + +/** + * Ablauf der Station „Öffnungszeiten live“ (Bäckerei Sonnenschein), Spezifikation 10. + * Reine Logik, kein CSS und keine Adresse: die Komponenten werden später per React.lazy nachgeladen. + * + * Geschichte (Demo-Datum: Montag 5. Oktober 2026, die Uhr zählt Minuten ab Mo 00:00): + * start (kurz Ruhe bei 05:50, „Geschlossen · öffnet heute um 6 Uhr“) + * -> lauf (die Uhr läuft in Zehn-Minuten-Schritten bis 18:10; um 06:00 springt der Status auf „Jetzt geöffnet“, + * um 18:00 auf „Geschlossen · öffnet morgen um 6 Uhr“) + * -> tippen (auf dem Handy gleitet der Finger zu „Hinweis-Leiste anlegen“ und tippt) + * -> leiste (die Hinweis-Leiste „Betriebsurlaub 12.–26. Oktober“ gleitet auf der Website ein, bleibt stehen) -> von vorn. + * + * Bedienen: Wer den Uhr-Regler anfasst (`setzeUhr(minute)`), stoppt den Ablauf. Die Demo zeigt dann den ganzen Stand + * (mit Leiste) bei der gewählten Uhrzeit, ohne Timer. `setzeUhr(null)` gibt an den Ablauf zurück (von vorn). + */ + +export type ZeitenPhase = "start" | "lauf" | "tippen" | "leiste"; + +export type ZeitenStatus = { + offen: boolean; + /** Langtext der Status-Pille, z. B. „Jetzt geöffnet · schließt um 18 Uhr“ (nie „24“). */ + text: string; + /** Kurztext: „Geöffnet“ oder „Geschlossen“. */ + kurz: string; +}; + +export type ZeitenZustand = { + phase: ZeitenPhase; + /** Minuten ab Montag, 5. Oktober 2026, 00:00. */ + uhr: number; + status: ZeitenStatus; + /** Ob die Hinweis-Leiste auf der Website steht (Phase „leiste“, im Endzustand und beim Bedienen). */ + leiste: boolean; + /** + * Zähler der Tipps für DemoZeiger: 0 solange der Finger nur gleitet oder nicht zu sehen ist, ab dem Tipp eine je + * Durchlauf wachsende Zahl (so erscheint die Klickwelle in jeder Runde neu). Der Finger ist nur in Phase „tippen“ zu sehen. + */ + klick: number; + /** true = der Regler wurde angefasst: der Ablauf steht, die Uhr zeigt den gewählten Wert. */ + bedient: boolean; + /** true = der Automatiklauf spielt gerade (nicht beim Bedienen, nicht ohne Bewegung). */ + laeuft: boolean; + /** true = keine Bewegung erlaubt: die Demo zeigt ihren Endzustand (18:10 mit Leiste). */ + statisch: boolean; + /** Uhr setzen (Regler bedient) oder mit `null` zurück an den Ablauf geben. Die Funktion bleibt über Renderings gleich. */ + setzeUhr: (min: number | null) => void; +}; + +// ── Konstanten ───────────────────────────────────────────────────────────────── + +/** Reglerbereich in Minuten ab Mo 00:00: 05:00 bis 22:00 in Schritten von 10 Minuten. */ +export const ZEIT_MIN = 300; +export const ZEIT_MAX = 1320; +export const ZEIT_SCHRITT = 10; +/** Uhrzeit, mit der der Automatiklauf beginnt (05:50) und bei der er endet (18:10). */ +export const START_UHR = 350; +export const ENDE_UHR = 1090; + +/** Text der Hinweis-Leiste (der Demo-Tag liegt im 7-Tage-Vorlauf des Urlaubs). */ +export const HINWEIS_TEXT = "Betriebsurlaub 12.–26. Oktober"; + +/** + * Platzhalter: Mitte des Knopfes „Hinweis-Leiste anlegen“ im Portal-Screenshot (Designpunkte der Handy-Fläche 402 x 874). + * Der Einbau setzt später die echte Position aus der Aufnahme ein (Prop `punkt` der Handy-Ebene). + */ +export const HINWEIS_ANLEGEN_PUNKT = { x: 201, y: 740 } as const; + +// ── Status: Bäckerei Sonnenschein, Montag 6 bis 18 Uhr ────────────────────────── + +const OEFFNET = 6 * 60; +const SCHLIESST = 18 * 60; + +const ZU_HEUTE: ZeitenStatus = Object.freeze({ offen: false, text: "Geschlossen · öffnet heute um 6 Uhr", kurz: "Geschlossen" }); +const OFFEN: ZeitenStatus = Object.freeze({ offen: true, text: "Jetzt geöffnet · schließt um 18 Uhr", kurz: "Geöffnet" }); +const ZU_MORGEN: ZeitenStatus = Object.freeze({ offen: false, text: "Geschlossen · öffnet morgen um 6 Uhr", kurz: "Geschlossen" }); + +/** Status am Montag (Minute 0 bis 1439 ab Mo 00:00): vor 06:00 zu, bis 17:59 offen, ab 18:00 zu bis morgen früh. */ +export const statusFuer = (minute: number): ZeitenStatus => + minute < OEFFNET ? ZU_HEUTE : minute < SCHLIESST ? OFFEN : ZU_MORGEN; + +/** Minuten ab Mitternacht als „05:50“ (die Stunde läuft 00 bis 23, nie „24:00“). */ +export const formatUhr = (min: number): string => { + const tag = ((Math.round(min) % 1440) + 1440) % 1440; + return `${String(Math.floor(tag / 60)).padStart(2, "0")}:${String(tag % 60).padStart(2, "0")}`; +}; + +// ── Schritte ─────────────────────────────────────────────────────────────────── + +/** Alle Dauern sind Vielfache von 50 ms (die Tests spulen in 50-ms-Schritten vor). */ +const START_HALTEN = 1000; +/** Dauer eines Uhr-Schrittes (10 Minuten) im Lauf. */ +const TAKT = 100; +/** Wie lange 18:10 („Geschlossen · öffnet morgen um 6 Uhr“) stehen bleibt, bevor der Finger kommt. */ +const ENDE_HALTEN = 1500; +/** Der Finger gleitet zum Knopf (Phase „tippen“), dann folgt der Tipp (ebenfalls Phase „tippen“). */ +const GLEITEN = 1000; +const TIPPEN = 450; +/** Die Leiste gleitet ein und bleibt stehen. */ +const LEISTE_HALTEN = 5000; + +type Schritt = { phase: ZeitenPhase; uhr: number; dauer: number; tipp?: boolean }; + +const SCHRITTE: readonly Schritt[] = [ + { phase: "start", uhr: START_UHR, dauer: START_HALTEN }, + ...Array.from({ length: (ENDE_UHR - START_UHR) / ZEIT_SCHRITT }, (_, i): Schritt => { + const uhr = START_UHR + (i + 1) * ZEIT_SCHRITT; + return { phase: "lauf", uhr, dauer: uhr === ENDE_UHR ? ENDE_HALTEN : TAKT }; + }), + { phase: "tippen", uhr: ENDE_UHR, dauer: GLEITEN }, + { phase: "tippen", uhr: ENDE_UHR, dauer: TIPPEN, tipp: true }, + { phase: "leiste", uhr: ENDE_UHR, dauer: LEISTE_HALTEN }, +]; +const DAUERN = SCHRITTE.map((s) => s.dauer); +const TIPP_SCHRITT = SCHRITTE.findIndex((s) => s.tipp); + +/** Gesamtdauer eines Durchlaufs in Millisekunden (für Tests und die Feinabstimmung der Bühne). */ +export const ZEITEN_RUNDE_MS = DAUERN.reduce((a, b) => a + b, 0); + +/** Anfangszustand (Phase „start“, 05:50, ohne Leiste): die Bühne zeigt die Website damit sofort, bevor der Taktgeber geladen ist. */ +export const ZEITEN_START: ZeitenZustand = { + phase: "start", + uhr: START_UHR, + status: statusFuer(START_UHR), + leiste: false, + klick: 0, + bedient: false, + laeuft: false, + statisch: false, + setzeUhr: () => {}, +}; + +// ── Hook ─────────────────────────────────────────────────────────────────────── + +/** + * Taktgeber der Station. `laeuft` = Station aktiv, im Bild, Tab sichtbar, Bewegung erlaubt. + * Läuft sie nicht, steht die Demo bei „start“ (05:50, nichts bewegt sich, kein Timer); eine Bedienung verfällt dann. + * Mit `statisch` (keine Bewegung erlaubt) zeigt sie sofort den Endzustand (18:10 mit Leiste), ohne Timer; + * der Regler bleibt auch dann bedienbar (eine Eingabe ist keine Animation). + */ +export const useZeitenAblauf = (laeuft: boolean, statisch: boolean): ZeitenZustand => { + const [manuell, setManuell] = useState(null); + // Eine Bedienung gilt nur, solange die Station läuft (oder statisch steht); sonst verfällt sie + const gewaehlt = laeuft || statisch ? manuell : null; + const spielt = laeuft && !statisch && gewaehlt === null; + const { schritt, runde } = useAblauf(spielt, DAUERN, true); + + useEffect(() => { + if (!laeuft && !statisch && manuell !== null) setManuell(null); + }, [laeuft, statisch, manuell]); + + const setzeUhr = useCallback((min: number | null) => { + if (min === null) { + setManuell(null); + return; + } + if (!Number.isFinite(min)) return; + setManuell(Math.min(ZEIT_MAX, Math.max(ZEIT_MIN, Math.round(min)))); + }, []); + + return useMemo(() => { + if (statisch || gewaehlt !== null) { + // Endzustand bzw. bedient: die Demo steht, die Leiste ist da + const uhr = gewaehlt ?? ENDE_UHR; + return { + phase: "leiste", + uhr, + status: statusFuer(uhr), + leiste: true, + klick: 0, + bedient: gewaehlt !== null, + laeuft: false, + statisch, + setzeUhr, + }; + } + const aktuell = SCHRITTE[spielt ? Math.min(schritt, SCHRITTE.length - 1) : 0]; + return { + phase: aktuell.phase, + uhr: aktuell.uhr, + status: statusFuer(aktuell.uhr), + leiste: aktuell.phase === "leiste", + klick: spielt && schritt >= TIPP_SCHRITT ? runde + 1 : 0, + bedient: false, + laeuft: spielt, + statisch: false, + setzeUhr, + }; + }, [statisch, gewaehlt, spielt, schritt, runde, setzeUhr]); +}; diff --git a/src/test/demo-chat-vorschau.test.tsx b/src/test/demo-chat-vorschau.test.tsx index 5c36c4c..c534e1d 100644 --- a/src/test/demo-chat-vorschau.test.tsx +++ b/src/test/demo-chat-vorschau.test.tsx @@ -176,7 +176,7 @@ describe("Quelltext", () => { }); it("lädt nichts von fremden Servern", () => { - for (const datei of ["chat/ChatEbene.tsx", "chat/ChatSeite.tsx", "chat/ChatHandy.tsx", "chat/chat.css", "baeckerei/BaeckereiSeite.tsx", "baeckerei/baeckerei.css"]) { + for (const datei of ["chat/ChatEbene.tsx", "chat/ChatSeite.tsx", "chat/ChatHandy.tsx", "chat/chat.css", "baeckerei/BaeckereiSeite.tsx", "baeckerei/teile.tsx", "baeckerei/baeckerei.css"]) { expect(quelle(datei), datei).not.toMatch(/https?:\/\//); expect(quelle(datei), datei).not.toMatch(/googleapis|gstatic|unsplash/i); } diff --git a/src/test/demo-zeiten.test.tsx b/src/test/demo-zeiten.test.tsx new file mode 100644 index 0000000..7ad4e82 --- /dev/null +++ b/src/test/demo-zeiten.test.tsx @@ -0,0 +1,379 @@ +import { describe, it, expect, vi, afterEach } from "vitest"; +import { render, renderHook, act, cleanup } from "@testing-library/react"; +import { readFileSync } from "node:fs"; +import { Logo, Stempel, Pfeil } from "@/components/home/demos/baeckerei/teile"; +import { + useZeitenAblauf, + statusFuer, + formatUhr, + ZEIT_MIN, + ZEIT_MAX, + ZEIT_SCHRITT, + START_UHR, + ENDE_UHR, + HINWEIS_TEXT, + HINWEIS_ANLEGEN_PUNKT, + ZEITEN_START, + ZEITEN_RUNDE_MS, +} 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*=`)); + }); +});