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(
,
);
/** Rendert eine einzelne Komponente im Router. */
const zeige = (ui: React.ReactElement) =>
render(
{ui}
,
);
// ── 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();
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();
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();
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();
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();
expect(screen.getByRole("link", { name: TELEFON })).toHaveAttribute("href", TELEFON_HREF);
});
it("der Kontaktabschnitt der Startseite zeigt dieselbe Nummer", () => {
zeige();
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(/(? {
expect(lies("index.html")).toContain(`content="${STARTSEITE}"`);
});
it("Standard-Beschreibung des Meta-Hooks und der Startseite", () => {
const Probe = () => {
usePageMeta("Probe");
return null;
};
render();
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(
,
);
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();
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();
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();
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();
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();
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(/(? {
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: , datei: "problem_blitz.jpg" },
{ name: "SolutionSection", ui: , datei: "loesung.jpg" },
{ name: "Values", ui: , 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(`[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();
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();
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();
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();
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();
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();
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: , anzahl: 6, dunkel: "hsl(0 0% 6%)" },
{ name: "Kontaktabschnitt (Der nächste Schritt)", ui: , anzahl: 1, dunkel: "#120F17" },
{ name: "Über uns (Der Unterschied)", ui: , 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(".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(".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/);
});
});