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>
This commit is contained in:
1 parent
f7147fcd30
commit
a23f2e52c2
14 files changed
+1234
-56
No files matched your search
@@ -1,7 +1,12 @@
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import { render, renderHook, act, cleanup } from "@testing-library/react";
|
||||
import { readFileSync } from "node:fs";
|
||||
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,
|
||||
@@ -15,6 +20,7 @@ import {
|
||||
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
|
||||
@@ -377,3 +383,510 @@ describe("Quelltext", () => {
|
||||
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*\{/);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user