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>
653 lines
29 KiB
TypeScript
653 lines
29 KiB
TypeScript
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/);
|
||
});
|
||
});
|