Relaunch: Ablauf der Station Öffnungszeiten live

Reine Logik (zeiten/ablauf.ts): statusFuer/formatUhr für die Bäckerei am
Montag (6 bis 18 Uhr), useZeitenAblauf mit Uhr 05:50 bis 18:10, Tipp auf
„Hinweis-Leiste anlegen“ und Hinweis-Leiste; Bedienen stoppt den Ablauf,
ohne Bewegung steht der Endzustand. Logo, Stempel und Pfeil der
Bäckerei-Seite unverändert nach baeckerei/teile.tsx verschoben.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-10-05 07:48:16 +00:00
1 parent 4139f31925
commit f7147fcd30
5 files changed
+619 -41

No files matched your search

@@ -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 = () => (
<svg className="bk-logo__zeichen" viewBox="0 0 48 48" aria-hidden="true" focusable="false">
<circle cx="24" cy="24" r="23" fill="#2a1a10" />
<g stroke="#f2b544" strokeWidth="2.6" strokeLinecap="round">
<path d="M24 8v4M13 12.5l2.6 2.8M35 12.5l-2.6 2.8M8.5 22h3.6M39.5 22h-3.6" />
</g>
<path d="M16.5 26a7.5 7.5 0 0 1 15 0" fill="#f2b544" />
<path d="M10.5 33.5c0-4.2 6-6.4 13.5-6.4s13.5 2.2 13.5 6.4c0 1.7-1.4 2.6-3.2 2.6H13.7c-1.8 0-3.2-.9-3.2-2.6z" fill="#e8d3b2" />
<path d="M17 30.5l2.4 2.6M23 29.8l2.4 2.6M29 30.5l2.4 2.6" stroke="#2a1a10" strokeWidth="1.6" strokeLinecap="round" />
</svg>
);
/** Runder Stempel „Handwerk · seit 1987 · Altstadt ·“ (Schrift auf einer Kreisbahn, auf den Umfang gestreckt). */
const Stempel = () => {
const id = `${useId().replace(/:/g, "")}-kreis`;
return (
<svg className="bk-stempel" viewBox="0 0 120 120" aria-hidden="true" focusable="false">
<defs>
<path id={id} d="M60 60m-44 0a44 44 0 1 1 88 0a44 44 0 1 1-88 0" />
</defs>
<circle cx="60" cy="60" r="58" fill="#f2b544" />
<text fontSize="10.5" fontWeight="600" fill="#2a1a10">
<textPath href={`#${id}`} textLength="272" lengthAdjust="spacing">
HANDWERK · SEIT 1987 · ALTSTADT ·
</textPath>
</text>
<path d="M44 66c0-6 7.2-9.2 16-9.2s16 3.2 16 9.2c0 2.2-1.8 3.4-4 3.4H48c-2.2 0-4-1.2-4-3.4z" fill="#2a1a10" />
<path d="M52 62l2.6 2.8M59 61l2.6 2.8M66 62l2.6 2.8" stroke="#f2b544" strokeWidth="1.8" strokeLinecap="round" />
</svg>
);
};
const Pfeil = () => (
<svg viewBox="0 0 16 16" aria-hidden="true" focusable="false">
<path d="M3 8h9.5M8.5 4l4 4-4 4" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
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" },
@@ -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 = () => (
<svg className="bk-logo__zeichen" viewBox="0 0 48 48" aria-hidden="true" focusable="false">
<circle cx="24" cy="24" r="23" fill="#2a1a10" />
<g stroke="#f2b544" strokeWidth="2.6" strokeLinecap="round">
<path d="M24 8v4M13 12.5l2.6 2.8M35 12.5l-2.6 2.8M8.5 22h3.6M39.5 22h-3.6" />
</g>
<path d="M16.5 26a7.5 7.5 0 0 1 15 0" fill="#f2b544" />
<path d="M10.5 33.5c0-4.2 6-6.4 13.5-6.4s13.5 2.2 13.5 6.4c0 1.7-1.4 2.6-3.2 2.6H13.7c-1.8 0-3.2-.9-3.2-2.6z" fill="#e8d3b2" />
<path d="M17 30.5l2.4 2.6M23 29.8l2.4 2.6M29 30.5l2.4 2.6" stroke="#2a1a10" strokeWidth="1.6" strokeLinecap="round" />
</svg>
);
/** Runder Stempel „Handwerk · seit 1987 · Altstadt ·“ (Schrift auf einer Kreisbahn, auf den Umfang gestreckt). */
export const Stempel = () => {
const id = `${useId().replace(/:/g, "")}-kreis`;
return (
<svg className="bk-stempel" viewBox="0 0 120 120" aria-hidden="true" focusable="false">
<defs>
<path id={id} d="M60 60m-44 0a44 44 0 1 1 88 0a44 44 0 1 1-88 0" />
</defs>
<circle cx="60" cy="60" r="58" fill="#f2b544" />
<text fontSize="10.5" fontWeight="600" fill="#2a1a10">
<textPath href={`#${id}`} textLength="272" lengthAdjust="spacing">
HANDWERK · SEIT 1987 · ALTSTADT ·
</textPath>
</text>
<path d="M44 66c0-6 7.2-9.2 16-9.2s16 3.2 16 9.2c0 2.2-1.8 3.4-4 3.4H48c-2.2 0-4-1.2-4-3.4z" fill="#2a1a10" />
<path d="M52 62l2.6 2.8M59 61l2.6 2.8M66 62l2.6 2.8" stroke="#f2b544" strokeWidth="1.8" strokeLinecap="round" />
</svg>
);
};
export const Pfeil = () => (
<svg viewBox="0 0 16 16" aria-hidden="true" focusable="false">
<path d="M3 8h9.5M8.5 4l4 4-4 4" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
+191
View File
@@ -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<number | null>(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<ZeitenZustand>(() => {
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]);
};
+1 -1
View File
@@ -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);
}
+379
View File
@@ -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<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*=`));
});
});