Files
Webklar/src/test/sofort-paket.test.tsx
T
rootandClaude Sonnet 5.5 897d594dde Karten im hellen Modus lesbar: Kartenfarbe nach Theme
Die BorderGlow-Karten in Values, Kontaktabschnitt und Über uns hatten
dunkle Hintergründe fest verdrahtet, ihr Text nutzt aber Theme-Farben
(Kontrast 1,07:1 im hellen Modus). BorderGlow kennt jetzt
lightBackgroundColor und wählt die Kartenfarbe per CSS nach Theme
(--card-bg-dark / --card-bg-light, html:not(.dark)), ohne Flackern beim
Laden. Dunkel behält exakt die bisherigen Farben (hsl(0 0% 6%), #120F17,
hsl(0 0% 4%)); hell nutzt hsl(0 0% 96%) mit sichtbarem Rand. Der
Kantenschein wird im hellen Modus normal statt mit plus-lighter gemischt
und in Violett gezeichnet, sonst wäre er auf Weiß unsichtbar.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 10:48:36 +00:00

653 lines
29 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, it, expect, vi, afterEach } from "vitest";
import { render, screen, within, fireEvent, act, cleanup } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
import { join } from "node:path";
import { AppRoutes } from "@/routes";
import { usePageMeta } from "@/hooks/use-page-meta";
import Header from "@/components/Header";
import Footer from "@/components/Footer";
import Hero from "@/components/Hero";
import ProblemSection from "@/components/ProblemSection";
import SolutionSection from "@/components/SolutionSection";
import Values from "@/components/Values";
import Services from "@/components/Services";
import Process from "@/components/Process";
import KontaktAbschnitt from "@/components/Contact";
import About from "@/pages/About";
import { TextHoverEffect } from "@/components/ui/text-hover-effect";
import { Toaster } from "@/components/ui/toaster";
// WebGL, Canvas und Netzwerk laden wir in jsdom nicht: die Effekt-Komponenten und
// das Kontaktformular-Backend werden durch Attrappen ersetzt.
vi.mock("@/components/Silk", () => ({ default: () => null }));
vi.mock("@/components/PixelBlast", () => ({ default: () => null }));
vi.mock("@/components/LightRays", () => ({ default: () => null }));
vi.mock("@/components/ui/flickering-grid", () => ({ FlickeringGrid: () => null }));
vi.mock("@/lib/appwrite", () => ({ createContactDocument: vi.fn().mockResolvedValue(undefined) }));
// Dieselben Future-Flags wie der BrowserRouter in App.tsx, sonst warnt React Router in der Testausgabe.
const routerFuture = { v7_startTransition: true, v7_relativeSplatPath: true };
const DATENSCHUTZ = "https://project.webklar.com/datenschutz.html";
const TELEFON = "0176 23726355";
const TELEFON_HREF = "tel:+4917623726355";
/** Rendert die komplette Routenliste (mit gemeinsamem Footer) an einem Pfad. */
const seite = (pfad: string) =>
render(
<MemoryRouter initialEntries={[pfad]} future={routerFuture}>
<AppRoutes />
</MemoryRouter>,
);
/** Rendert eine einzelne Komponente im Router. */
const zeige = (ui: React.ReactElement) =>
render(
<MemoryRouter future={routerFuture}>
{ui}
</MemoryRouter>,
);
// ── Quelltext-Scan ─────────────────────────────────────────────────────────────
// vitest läuft im Projektverzeichnis
const WURZEL = `${process.cwd()}/`;
const dateien = (ordner: string): string[] =>
readdirSync(ordner).flatMap((name) => {
const pfad = join(ordner, name);
return statSync(pfad).isDirectory() ? dateien(pfad) : [pfad];
});
const QUELLEN = [
...dateien(join(WURZEL, "src")).filter((p) => /\.(tsx?|css)$/.test(p) && !p.includes("/src/test/")),
join(WURZEL, "index.html"),
].map((pfad) => ({ pfad: pfad.replace(WURZEL, ""), text: readFileSync(pfad, "utf8") }));
/** Pfade aller Quelldateien (ohne Tests), in denen das Muster vorkommt. */
const vorkommen = (muster: RegExp) => QUELLEN.filter(({ text }) => muster.test(text)).map(({ pfad }) => pfad);
const lies = (pfad: string) => readFileSync(join(WURZEL, pfad), "utf8");
const gibtEs = (pfad: string) => existsSync(join(WURZEL, pfad));
const meta = (name: string) => document.querySelector(`meta[name="${name}"]`)?.getAttribute("content");
afterEach(() => {
cleanup();
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
describe("B1 Hero: ehrliche Fakten statt Zahlenleiste", () => {
const FAKTEN = [
["Falkenstein", "Server in Deutschland"],
["Live-Vorschau", "im Kundenportal"],
["ab 20 €", "Hosting im Monat"],
];
it("zeigt genau drei Fakten mit Wert und Beschriftung", () => {
zeige(<Hero />);
for (const [wert, beschriftung] of FAKTEN) {
expect(screen.getByText(wert)).toBeInTheDocument();
expect(screen.getByText(beschriftung)).toBeInTheDocument();
}
const raster = screen.getByText("Falkenstein").closest(".grid");
expect(raster?.children).toHaveLength(3);
expect(raster).toHaveClass("grid-cols-1", "md:grid-cols-3");
});
it("die alte Zahlenleiste ist weg: kein Projektzähler, kein »Am Markt«, keine 99,9 %, keine Flagge", () => {
const { container } = zeige(<Hero />);
expect(screen.queryByText("Am Markt")).toBeNull();
expect(screen.queryByText("Projekte")).toBeNull();
expect(screen.queryByText("Systemverfügbarkeit")).toBeNull();
expect(container).not.toHaveTextContent(/99[,.]9|🇩🇪|Serverstandort/);
});
it("hat nur noch den Potenzialanalyse-Button, keinen »Projekte ansehen«", () => {
zeige(<Hero />);
expect(screen.getAllByRole("button").map((b) => b.getAttribute("aria-label"))).toEqual([
"Kostenlose Potenzialanalyse sichern",
]);
});
it("lädt nichts vom Netz und braucht keinen QueryClient", () => {
const abruf = vi.fn();
vi.stubGlobal("fetch", abruf);
zeige(<Hero />);
expect(abruf).not.toHaveBeenCalled();
expect(gibtEs("src/lib/projects.ts")).toBe(false);
expect(vorkommen(/api\/projects|fetchProjects|FOUNDING_DATE/)).toEqual([]);
});
});
describe("B2 Projekte-Bereich ausgeblendet", () => {
it("ProjectShowcase ist gelöscht; nichts verweist auf #projects oder Unsplash", () => {
expect(gibtEs("src/components/ProjectShowcase.tsx")).toBe(false);
expect(vorkommen(/#projects|id="projects"|images\.unsplash\.com|ProjectShowcase/)).toEqual([]);
});
it("die Startseite hat keinen Projekte-Abschnitt und keinen Nav-Link dorthin", () => {
const { container } = seite("/");
expect(container.querySelector("#projects")).toBeNull();
expect(container.querySelector('a[href$="#projects"]')).toBeNull();
expect(screen.queryByRole("link", { name: "Projekte" })).toBeNull();
expect(screen.queryByText("Verbatim Labs")).toBeNull();
// die übrigen Nav-Punkte bleiben
for (const name of ["Über uns", "Leistungen", "Ablauf"]) {
expect(screen.getAllByRole("link", { name }).length).toBeGreaterThan(0);
}
});
});
describe("B3 Eine Telefonnummer überall", () => {
it("die alte Nummer 0170 4969375 kommt in keiner Quelldatei mehr vor", () => {
expect(vorkommen(/0170\s?4969375|491704969375/)).toEqual([]);
});
it("Kontaktseite zeigt 0176 23726355 mit passendem tel:-Link", () => {
seite("/kontakt");
const links = screen.getAllByRole("link", { name: TELEFON });
expect(links.length).toBeGreaterThan(0);
for (const link of links) expect(link).toHaveAttribute("href", TELEFON_HREF);
});
it("der Footer zeigt dieselbe Nummer", () => {
zeige(<Footer />);
expect(screen.getByRole("link", { name: TELEFON })).toHaveAttribute("href", TELEFON_HREF);
});
it("der Kontaktabschnitt der Startseite zeigt dieselbe Nummer", () => {
zeige(<KontaktAbschnitt />);
expect(screen.getByRole("link", { name: TELEFON })).toHaveAttribute("href", TELEFON_HREF);
});
});
describe("B4 Antwortzeit: spätestens am nächsten Werktag", () => {
const STARTSEITE =
"WEBklar digitalisiert KMU: Website, Prozesse und Systeme aus einer Hand. Kostenlose Potenzialanalyse – Antwort spätestens am nächsten Werktag.";
const KONTAKT =
"Beschreiben Sie Ihr Projekt – WEBklar meldet sich spätestens am nächsten Werktag mit den nächsten Schritten. Unverbindlich und kostenlos.";
it("kein 24-Stunden-Versprechen mehr in Quelltext und index.html (Tailwind-Klassen wie mt-24 h-48 zählen nicht)", () => {
expect(vorkommen(/(?<![\w-])24[\s-]*(Stunden|Std\b|h(?![\w-]))/i)).toEqual([]);
});
it("index.html: Beschreibung mit der neuen Antwortzeit", () => {
expect(lies("index.html")).toContain(`content="${STARTSEITE}"`);
});
it("Standard-Beschreibung des Meta-Hooks und der Startseite", () => {
const Probe = () => {
usePageMeta("Probe");
return null;
};
render(<Probe />);
expect(meta("description")).toBe(STARTSEITE);
cleanup();
seite("/");
expect(meta("description")).toBe(STARTSEITE);
});
it("Kontaktseite: Meta-Beschreibung und Einleitung", () => {
seite("/kontakt");
expect(meta("description")).toBe(KONTAKT);
expect(screen.getByText(/den größten Hebel haben\./)).toHaveTextContent(
"den größten Hebel haben. Antwort spätestens am nächsten Werktag, unverbindlich.",
);
});
it("Kontaktformular: Erfolgsmeldung mit der neuen Antwortzeit", async () => {
render(
<MemoryRouter initialEntries={["/kontakt"]} future={routerFuture}>
<AppRoutes />
<Toaster />
</MemoryRouter>,
);
fireEvent.change(screen.getByLabelText(/^Name/), { target: { value: "Erika Muster" } });
fireEvent.change(screen.getByLabelText(/^E-Mail/), { target: { value: "erika@example.de" } });
fireEvent.change(screen.getByLabelText(/^Nachricht/), { target: { value: "Wir brauchen eine neue Website." } });
fireEvent.click(screen.getByRole("button", { name: /Nachricht senden/ }));
expect(await screen.findByText("Wir melden uns spätestens am nächsten Werktag bei Ihnen.")).toBeInTheDocument();
});
});
describe("B5 Erfolgsversprechen entschärft", () => {
it("die alten Versprechen kommen nirgends mehr vor", () => {
expect(vorkommen(/Planbare Neukunden|Sichtbar bei Google|Umsatz bringt/)).toEqual([]);
});
it("die toten Dateien AgitationSection und DifferentiationSection sind gelöscht", () => {
expect(gibtEs("src/components/AgitationSection.tsx")).toBe(false);
expect(gibtEs("src/components/DifferentiationSection.tsx")).toBe(false);
});
it("Values: Karte »Kundenportal« statt »Planbare Neukunden«", () => {
zeige(<Values />);
expect(screen.getByRole("heading", { level: 3, name: "Kundenportal" })).toBeInTheDocument();
expect(screen.getByText("Fortschritt, Chat und Rechnungen an einem Ort, im Browser oder als App.")).toBeInTheDocument();
expect(screen.getAllByRole("heading", { level: 3 })).toHaveLength(6);
});
it("SolutionSection: Ergebnis-Überschrift ohne Umsatzversprechen", () => {
zeige(<SolutionSection />);
expect(
screen.getByRole("heading", { level: 3, name: "Sie sehen, was läuft. Mehr Zeit für Kunden statt für Tool-Pflege:" }),
).toBeInTheDocument();
});
it("Services: Punkt 04 heißt »Hosting & Betreuung«", () => {
zeige(<Services />);
expect(screen.getByRole("heading", { level: 3, name: "Hosting & Betreuung" })).toBeInTheDocument();
expect(
screen.getByText("Hosting auf unserem Server in Deutschland, Pflege und Updates im Wartungs-Abo."),
).toBeInTheDocument();
expect(screen.queryByText(/SEO/)).toBeNull();
});
it("Process: Schritt 4 verweist auf das Wartungs-Abo", () => {
zeige(<Process />);
expect(screen.getByRole("heading", { level: 3, name: "Launch & Betreuung" })).toBeInTheDocument();
expect(
screen.getByText("Go-live mit Tests und Übergabe. Pflege und Updates danach im Wartungs-Abo."),
).toBeInTheDocument();
});
it("Über uns: Karte »Betreuung im Abo« statt »Langfristige Partnerschaft«", () => {
zeige(<About />);
expect(screen.getByRole("heading", { level: 3, name: "Betreuung im Abo" })).toBeInTheDocument();
expect(
screen.getByText("Nach dem Go-live kümmern wir uns im Wartungs-Abo um Updates und kleine Änderungen."),
).toBeInTheDocument();
expect(screen.queryByText("Langfristige Partnerschaft")).toBeNull();
});
});
describe("C1 Footer auf allen Unterseiten", () => {
const unterseiten = ["/kontakt", "/ueber-uns", "/agb", "/impressum", "/wartungs-abo", "/hosting-abo", "/gibt-es-nicht"];
it.each(unterseiten)("%s zeigt genau einen Footer mit Impressum und Datenschutz", (pfad) => {
seite(pfad);
const footer = screen.getByRole("contentinfo");
expect(within(footer).getByRole("link", { name: "Impressum" })).toHaveAttribute("href", "/impressum");
expect(within(footer).getByRole("link", { name: "Datenschutz" })).toHaveAttribute("href", DATENSCHUTZ);
});
it("die Startseite hat weiterhin genau einen Footer", () => {
seite("/");
expect(screen.getAllByRole("contentinfo")).toHaveLength(1);
});
it("der Footer steht hinter dem Seiteninhalt", () => {
seite("/impressum");
const inhalt = screen.getByRole("main");
const footer = screen.getByRole("contentinfo");
expect(inhalt.compareDocumentPosition(footer) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
});
});
// ── Kontrast-Hilfen (WCAG 2.x), gelesen werden die Farb-Token aus src/index.css ──
type RGB = [number, number, number];
const rgbAusHsl = (h: number, s: number, l: number): RGB => {
s /= 100;
l /= 100;
const k = (n: number) => (n + h / 30) % 12;
const a = s * Math.min(l, 1 - l);
const f = (n: number) => l - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1)));
return [f(0) * 255, f(8) * 255, f(4) * 255];
};
const leuchtdichte = (rgb: RGB) => {
const [r, g, b] = rgb.map((v) => {
const c = v / 255;
return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
});
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
};
const kontrast = (a: RGB, b: RGB) => {
const [hell, dunkel] = [leuchtdichte(a), leuchtdichte(b)].sort((x, y) => y - x);
return (hell + 0.05) / (dunkel + 0.05);
};
/** "#120F17" oder "hsl(0 0% 6%)" als RGB. */
const farbe = (wert: string): RGB => {
const hex = wert.trim().match(/^#([0-9a-f]{6})$/i);
if (hex) return [0, 2, 4].map((i) => parseInt(hex[1].slice(i, i + 2), 16)) as RGB;
const hsl = wert.trim().match(/^hsl\(\s*([\d.]+)(?:deg)?[\s,]+([\d.]+)%[\s,]+([\d.]+)%\s*\)$/);
if (hsl) return rgbAusHsl(Number(hsl[1]), Number(hsl[2]), Number(hsl[3]));
throw new Error(`Unbekanntes Farbformat: ${wert}`);
};
type Thema = "hell" | "dunkel";
/** Farb-Token ("--foreground: 0 0% 9%") eines Themes aus index.css. */
const tokenFarbe = (thema: Thema, name: string): RGB => {
const css = lies("src/index.css");
const block = css.match(thema === "dunkel" ? /\.dark\s*\{([^}]*)\}/ : /:root\s*\{([^}]*)\}/)?.[1] ?? "";
const wert = block.match(new RegExp(`--${name}:\\s*([\\d.]+)\\s+([\\d.]+)%\\s+([\\d.]+)%`));
if (!wert) throw new Error(`Token --${name} fehlt im Theme ${thema}`);
return rgbAusHsl(Number(wert[1]), Number(wert[2]), Number(wert[3]));
};
const THEMEN: Thema[] = ["hell", "dunkel"];
describe("C5 404-Seite", () => {
it("zeigt Überschrift, Erklärung und Link zur Startseite auf Deutsch", () => {
vi.spyOn(console, "error").mockImplementation(() => {});
seite("/gibt-es-nicht");
expect(screen.getByRole("heading", { level: 1, name: "Seite nicht gefunden" })).toBeInTheDocument();
expect(screen.getByText("Diese Seite gibt es nicht oder nicht mehr.")).toBeInTheDocument();
expect(screen.getByRole("link", { name: "Zur Startseite" })).toHaveAttribute("href", "/");
expect(screen.queryByText(/Oops|Return to Home|Page not found/)).toBeNull();
});
it("nutzt die Theme-Farben statt eines festen grauen Hintergrunds", () => {
vi.spyOn(console, "error").mockImplementation(() => {});
const { container } = seite("/gibt-es-nicht");
expect(container.querySelector(".bg-background.text-foreground")).not.toBeNull();
expect(container.querySelector(".bg-muted")).toBeNull();
expect(screen.getByRole("link", { name: "Zur Startseite" })).toHaveClass("text-foreground");
});
it("Textfarben der Seite erreichen auf dem Hintergrund in beiden Themes mindestens 4,5:1", () => {
for (const thema of THEMEN) {
for (const name of ["foreground", "muted-foreground"]) {
expect(kontrast(tokenFarbe(thema, name), tokenFarbe(thema, "background"))).toBeGreaterThanOrEqual(4.5);
}
}
});
});
describe("C4 Schreibweise WEBklar", () => {
it("kein sichtbares »Webklar« mehr im Quelltext (URLs, Mailadressen und Kleinschreibung ausgenommen)", () => {
expect(vorkommen(/(?<![\w./@:-])Webklar(?![\w@.-])/)).toEqual([]);
});
it.each(["/", "/kontakt", "/ueber-uns", "/agb", "/impressum", "/wartungs-abo", "/hosting-abo", "/gibt-es-nicht"])(
"%s: die Marke steht als WEBklar da",
(pfad) => {
vi.spyOn(console, "error").mockImplementation(() => {});
seite(pfad);
expect(screen.queryAllByText(/Webklar/)).toHaveLength(0);
expect(screen.getAllByText("WEBklar").length).toBeGreaterThan(0);
},
);
});
describe("C2 Datenschutzhinweis am Kontaktformular", () => {
const HINWEIS =
"Wir verwenden Ihre Angaben nur, um Ihre Anfrage zu beantworten. Mehr dazu in unserer Datenschutzerklärung.";
it("steht unter dem Senden-Button und verlinkt die Datenschutzerklärung", () => {
seite("/kontakt");
const senden = screen.getByRole("button", { name: /Nachricht senden/ });
const formular = senden.closest("form") as HTMLFormElement;
const hinweis = within(formular).getByText((_, el) => el?.tagName === "P" && el.textContent === HINWEIS);
expect(senden.compareDocumentPosition(hinweis) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
expect(within(hinweis).getByRole("link", { name: "Datenschutzerklärung" })).toHaveAttribute("href", DATENSCHUTZ);
});
it("ist kleiner, gedämpfter Text mit gut lesbarem Link (Farb-Token siehe C5)", () => {
seite("/kontakt");
const hinweis = screen.getByRole("link", { name: "Datenschutzerklärung" }).closest("p") as HTMLElement;
expect(hinweis).toHaveClass("text-muted-foreground");
expect(hinweis.className).toMatch(/text-(xs|sm)/);
expect(screen.getByRole("link", { name: "Datenschutzerklärung" })).toHaveClass("text-foreground", "underline");
});
});
describe("C3 Seitentitel und Beschreibung der Unterseiten", () => {
// Beschreibungen nur aus Fakten, die auf der jeweiligen Seite stehen (Preis, Mindestlaufzeit, Leistungen).
const FAELLE: { pfad: string; titel: string; beschreibung: string; auf_der_seite: string[] }[] = [
{
pfad: "/wartungs-abo",
titel: "Wartungs-Abo | WEBklar",
beschreibung:
"Betrieb, Pflege und Betreuung Ihrer Website: 50,00 € pro Monat, 12 Monate Mindestlaufzeit. Mit Domain, Postfächern und 1 Stunde Arbeitszeit pro Monat.",
auf_der_seite: [
"Betrieb, Pflege und Betreuung Ihrer Website",
"50,00 € pro Monat",
"12 Monate Mindestlaufzeit",
"1 Domain inklusive",
"Postfächer",
"1 Stunde Arbeitszeit pro Monat",
],
},
{
pfad: "/hosting-abo",
titel: "Hosting-Abo | WEBklar",
beschreibung:
"Ihre statische Website auf unserem Server in Deutschland: 20,00 € pro Monat, 12 Monate Mindestlaufzeit. Inklusive Domain und E-Mail-Postfach.",
auf_der_seite: [
"Ihre statische Website auf unserem Server in Deutschland: 20,00 € pro Monat, 12 Monate Mindestlaufzeit.",
"1 Domain inklusive",
"1 E-Mail-Postfach",
],
},
{
pfad: "/agb",
titel: "AGB | WEBklar",
beschreibung: "Allgemeine Geschäftsbedingungen der WEBklar GbR.",
auf_der_seite: [],
},
{
pfad: "/impressum",
titel: "Impressum | WEBklar",
beschreibung: "Impressum der WEBklar GbR, Winnweiler.",
auf_der_seite: ["WEBklar GbR", "67722 Winnweiler"],
},
];
it.each(FAELLE)("$pfad setzt Titel und Beschreibung", ({ pfad, titel, beschreibung }) => {
seite(pfad);
expect(document.title).toBe(titel);
expect(meta("description")).toBe(beschreibung);
expect(beschreibung.length).toBeLessThanOrEqual(160);
});
it.each(FAELLE)("$pfad: jede Aussage der Beschreibung steht auch auf der Seite", ({ pfad, auf_der_seite }) => {
seite(pfad);
const text = (document.body.textContent ?? "").replace(/\s+/g, " ");
for (const fakt of auf_der_seite) expect(text).toContain(fakt);
});
it("die 404-Seite setzt den Titel »Seite nicht gefunden«", () => {
vi.spyOn(console, "error").mockImplementation(() => {});
seite("/gibt-es-nicht");
expect(document.title).toBe("Seite nicht gefunden | WEBklar");
});
});
describe("A5 Anker landen nicht unter dem fixen Header", () => {
it("section[id] hat ein scroll-margin-top von etwa 6rem", () => {
expect(lies("src/index.css")).toMatch(/section\[id\]\s*\{[^}]*scroll-margin-top:\s*6rem/);
});
});
describe("A2 Hintergrundbilder nur im dunklen Modus", () => {
const BILDER: { name: string; ui: React.ReactElement; datei: string }[] = [
{ name: "ProblemSection", ui: <ProblemSection />, datei: "problem_blitz.jpg" },
{ name: "SolutionSection", ui: <SolutionSection />, datei: "loesung.jpg" },
{ name: "Values", ui: <Values />, datei: "backgroud_effect.webp" },
];
it.each(BILDER)("$name: $datei ist im hellen Modus ausgeblendet und im dunklen sichtbar", ({ ui, datei }) => {
const { container } = zeige(ui);
const bild = container.querySelector<HTMLElement>(`[style*="${datei}"]`);
expect(bild).not.toBeNull();
expect(bild).toHaveClass("hidden", "dark:block");
});
});
describe("A3 Navigation: Theme-Schalter, Kontakt und Logo bekommen echte Mausklicks", () => {
it("der absolut gelegte Link-Container fängt keine Mausklicks ab, die Links selbst schon", () => {
zeige(<Header />);
const link = screen.getAllByRole("link", { name: "Leistungen" })[0];
const container = link.parentElement as HTMLElement;
// bleibt mittig über die ganze Leiste gelegt (Optik unverändert) …
expect(container).toHaveClass("absolute", "inset-0", "justify-center");
// … lässt Klicks aber zum Theme-Schalter, zum Kontakt-Button und zum Logo durch
expect(container).toHaveClass("pointer-events-none");
for (const name of ["Über uns", "Leistungen", "Ablauf"]) {
expect(screen.getAllByRole("link", { name })[0]).toHaveClass("pointer-events-auto");
}
});
});
describe("A4 Glow-Überschrift (Fix A)", () => {
const BOX = { left: 100, top: 400, right: 1300, bottom: 624, width: 1200, height: 224, x: 100, y: 400 };
/** Browser-Umgebung für die Überschrift: Maus-Gerät, feste Box, manuell abgespielte Animation-Frames. */
const umgebung = (feinerZeiger = true) => {
const frames: FrameRequestCallback[] = [];
vi.spyOn(window, "requestAnimationFrame").mockImplementation((cb) => frames.push(cb));
vi.spyOn(window, "matchMedia").mockImplementation(
(query) =>
({
matches: feinerZeiger,
media: query,
onchange: null,
addListener: () => {},
removeListener: () => {},
addEventListener: () => {},
removeEventListener: () => {},
dispatchEvent: () => false,
}) as MediaQueryList,
);
vi.spyOn(Element.prototype, "getBoundingClientRect").mockReturnValue({ ...BOX, toJSON: () => ({}) });
// jsdom hat weder getScreenCTM noch DOMPoint: Abbildung Bildschirm -> viewBox (300 x 60) mit Faktor 4
Object.defineProperty(SVGSVGElement.prototype, "getScreenCTM", {
configurable: true,
value: () => ({ inverse: () => ({}) }),
});
vi.stubGlobal(
"DOMPoint",
class {
constructor(
public x: number,
public y: number,
) {}
matrixTransform() {
return { x: (this.x - BOX.left) / 4, y: (this.y - BOX.top) / 4 };
}
},
);
return () => act(() => frames.splice(0).forEach((cb) => cb(0)));
};
afterEach(() => {
delete (SVGSVGElement.prototype as { getScreenCTM?: unknown }).getScreenCTM;
});
/** jsdom kennt keine PointerEvents: MouseEvent mit pointerType reicht dem Fix. */
const zeiger = (x: number, y: number, pointerType = "mouse") => {
const e = new MouseEvent("pointermove", { clientX: x, clientY: y, bubbles: true });
Object.defineProperty(e, "pointerType", { value: pointerType });
window.dispatchEvent(e);
};
const glow = () => document.querySelector("svg text[mask]") as SVGTextElement;
const mitte = () => document.querySelector("radialGradient") as SVGRadialGradientElement;
it("wird als ein Bild vorgelesen, die Textknoten sind für Screenreader versteckt", () => {
umgebung();
render(<TextHoverEffect text="Leistungen" />);
expect(screen.getByRole("img", { name: "Leistungen" })).toBeInTheDocument();
const texte = [...document.querySelectorAll("svg text")];
expect(texte).toHaveLength(3);
for (const t of texte) expect(t).toHaveAttribute("aria-hidden", "true");
});
it("weckt den Glow ab etwa 180 px vor der Box und bildet den Zeiger korrekt auf die viewBox ab", () => {
const abspielen = umgebung();
render(<TextHoverEffect text="Leistungen" />);
expect(glow().style.opacity).toBe("0");
expect(glow().style.transition).toContain("opacity");
act(() => zeiger(700, BOX.top - 180));
abspielen();
expect(glow().style.opacity).toBe("1");
expect(mitte().getAttribute("cx")).toBe("150");
expect(mitte().getAttribute("cy")).toBe("-45");
act(() => zeiger(700, BOX.top - 400));
abspielen();
expect(glow().style.opacity).toBe("0");
});
it("Touch lässt den Glow nicht kleben, und Verlassen der Seite schaltet ihn aus", () => {
const abspielen = umgebung();
render(<TextHoverEffect text="Leistungen" />);
act(() => zeiger(700, BOX.top + 100, "touch"));
abspielen();
expect(glow().style.opacity).toBe("0");
act(() => zeiger(700, BOX.top + 100));
abspielen();
expect(glow().style.opacity).toBe("1");
act(() => {
document.documentElement.dispatchEvent(new MouseEvent("mouseleave"));
});
expect(glow().style.opacity).toBe("0");
});
it("auf Geräten ohne feinen Zeiger (Touch) werden gar keine Zeiger-Listener angehängt", () => {
const abspielen = umgebung(false);
const hinzu = vi.spyOn(window, "addEventListener");
render(<TextHoverEffect text="Leistungen" />);
expect(hinzu.mock.calls.filter(([typ]) => typ === "pointermove")).toHaveLength(0);
act(() => zeiger(700, BOX.top + 100));
abspielen();
expect(glow().style.opacity).toBe("0");
});
it("räumt seine Listener beim Entfernen wieder auf", () => {
umgebung();
const weg = vi.spyOn(window, "removeEventListener");
const { unmount } = render(<TextHoverEffect text="Leistungen" />);
unmount();
expect(weg.mock.calls.map(([typ]) => typ)).toEqual(expect.arrayContaining(["pointermove", "scroll"]));
});
});
describe("A1 Karten (BorderGlow) im hellen Modus lesbar", () => {
// Dunkel bleibt exakt wie vorher; hell bekommt eine helle Kartenfarbe.
const KARTEN: { name: string; ui: React.ReactElement; anzahl: number; dunkel: string }[] = [
{ name: "Values (Was Sie bekommen)", ui: <Values />, anzahl: 6, dunkel: "hsl(0 0% 6%)" },
{ name: "Kontaktabschnitt (Der nächste Schritt)", ui: <KontaktAbschnitt />, anzahl: 1, dunkel: "#120F17" },
{ name: "Über uns (Der Unterschied)", ui: <About />, anzahl: 3, dunkel: "hsl(0 0% 4%)" },
];
it.each(KARTEN)("$name: dunkle Kartenfarbe unverändert, helle Variante vorhanden", ({ ui, anzahl, dunkel }) => {
const { container } = zeige(ui);
const karten = [...container.querySelectorAll<HTMLElement>(".border-glow-card")];
expect(karten).toHaveLength(anzahl);
for (const karte of karten) {
expect(karte.style.getPropertyValue("--card-bg-dark")).toBe(dunkel);
const hell = karte.style.getPropertyValue("--card-bg-light");
expect(hell).not.toBe("");
expect(hell).not.toBe(dunkel);
expect(leuchtdichte(farbe(hell))).toBeGreaterThan(0.85);
}
});
it.each(KARTEN)("$name: Text und Icons erreichen in beiden Themes mindestens 4,5:1", ({ ui }) => {
const { container } = zeige(ui);
const karte = container.querySelector<HTMLElement>(".border-glow-card") as HTMLElement;
const hintergrund = { hell: farbe(karte.style.getPropertyValue("--card-bg-light")), dunkel: farbe(karte.style.getPropertyValue("--card-bg-dark")) };
for (const thema of THEMEN) {
// text-foreground (Titel, Hover-Zustand der Icons) und text-muted-foreground (Fließtext, Icons).
// Der blaue CTA-Button im Kontaktabschnitt hat eigene Farben (Weiß auf #0793CF, 3,45:1, in beiden Themes gleich)
// und gehört nicht zu den Theme-Farben; siehe Bericht.
for (const name of ["foreground", "muted-foreground"]) {
expect(kontrast(tokenFarbe(thema, name), hintergrund[thema === "hell" ? "hell" : "dunkel"])).toBeGreaterThanOrEqual(4.5);
}
}
});
it("BorderGlow wählt die Kartenfarbe per CSS nach Theme und zeigt im hellen Modus einen sichtbaren Rand", () => {
const css = lies("src/components/BorderGlow.css");
expect(css).toMatch(/--card-bg:\s*var\(--card-bg-dark/);
expect(css).toMatch(/html:not\(\.dark\)\s+\.border-glow-card\s*\{[^}]*--card-bg:\s*var\(--card-bg-light/);
expect(css).toMatch(/html:not\(\.dark\)\s+\.border-glow-card\s*\{[^}]*border-color:/);
});
it("der Kantenschein der Karten wird im hellen Modus normal statt mit plus-lighter gemischt (sonst unsichtbar auf Weiß)", () => {
const css = lies("src/components/BorderGlow.css");
expect(css).toMatch(/html:not\(\.dark\)\s+\.border-glow-card\s*>\s*\.edge-light\s*\{[^}]*mix-blend-mode:\s*normal/);
// dunkel bleibt wie vorher
expect(css).toMatch(/\.border-glow-card\s*>\s*\.edge-light\s*\{[^}]*mix-blend-mode:\s*plus-lighter/);
});
});