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:
1 parent
4139f31925
commit
f7147fcd30
5 files changed
+619
-41
No files matched your search
@@ -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*=`));
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user