import { describe, it, expect, vi, afterEach } from "vitest"; import { render, screen, within, fireEvent, cleanup } from "@testing-library/react"; import { MemoryRouter } from "react-router-dom"; import { readFileSync } from "node:fs"; import { join } from "node:path"; import { AppRoutes } from "@/routes"; import { NAVIGATION } from "@/components/site/navigation"; import { projekte } from "@/data/projekte"; // Netzwerk gibt es in jsdom nicht: das Kontaktformular-Backend wird durch eine Attrappe ersetzt. vi.mock("@/lib/contact", async (original) => ({ ...(await original()), sendContactMessage: 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 }; /** Alle Seiten im gemeinsamen Layout (src/routes.tsx). /datenschutz leitet nur weiter und hat kein Layout. */ const ROUTEN = ["/", "/kontakt", "/ueber-uns", "/agb", "/impressum", "/wartungs-abo", "/hosting-abo", "/gibt-es-nicht"]; const UNTERSEITEN = ROUTEN.slice(1); const seite = (pfad: string) => render( , ); const lies = (pfad: string) => readFileSync(join(process.cwd(), pfad), "utf8"); afterEach(() => { cleanup(); document.body.style.overflow = ""; }); describe.each(ROUTEN)("Layout auf %s", (pfad) => { it("hat genau eine Kopfleiste mit der Hauptnavigation", () => { seite(pfad); const kopf = screen.getByRole("banner"); expect(screen.getAllByRole("banner")).toHaveLength(1); const nav = within(kopf).getByRole("navigation", { name: "Hauptnavigation" }); for (const name of ["Kundenportal", "Was wir bauen", "Preise", "Über uns"]) { expect(within(nav).getByRole("link", { name })).toBeInTheDocument(); } // „Projekte“ gibt es nur mit freigegebenen Referenzen (src/data/projekte.ts) expect(within(nav).queryByRole("link", { name: "Projekte" } as const) !== null).toBe(projekte.length > 0); expect(within(kopf).getByRole("link", { name: /WEBklar – zur Startseite/ })).toHaveAttribute("href", "/"); expect(within(kopf).getAllByRole("link", { name: "Projekt besprechen" })[0]).toHaveAttribute("href", "/kontakt"); }); it("hat genau einen Footer mit Impressum, Datenschutz, AGB, Kündigen und Widerrufen", () => { seite(pfad); const footer = screen.getByRole("contentinfo"); expect(screen.getAllByRole("contentinfo")).toHaveLength(1); for (const name of ["Impressum", "Datenschutz", "AGB", "Verträge hier kündigen", "Vertrag widerrufen"]) { expect(within(footer).getByRole("link", { name })).toBeInTheDocument(); } }); it("hat genau ein main (mit dem Sprungziel #inhalt) und genau ein h1", () => { seite(pfad); const inhalt = screen.getByRole("main"); expect(screen.getAllByRole("main")).toHaveLength(1); expect(inhalt).toHaveAttribute("id", "inhalt"); expect(screen.getAllByRole("heading", { level: 1 })).toHaveLength(1); expect(screen.getByRole("link", { name: "Zum Inhalt springen" })).toHaveAttribute("href", "#inhalt"); }); it("Kopfleiste und Footer stehen außerhalb von main, in der Reihenfolge Kopf, Inhalt, Footer", () => { seite(pfad); const kopf = screen.getByRole("banner"); const inhalt = screen.getByRole("main"); const footer = screen.getByRole("contentinfo"); expect(inhalt).not.toContainElement(kopf); expect(inhalt).not.toContainElement(footer); expect(kopf.compareDocumentPosition(inhalt) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); expect(inhalt.compareDocumentPosition(footer) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); }); }); describe("Navigation", () => { it("die Einträge der Kopfleiste verweisen auf die Anker der Startseite (Spezifikation 6.1)", () => { seite("/"); const nav = within(screen.getByRole("banner")).getByRole("navigation", { name: "Hauptnavigation" }); const erwartet = NAVIGATION.map(({ label, to }) => [label, to]); expect(erwartet).toEqual( expect.arrayContaining([ ["Kundenportal", "/#kundenportal"], ["Was wir bauen", "/#leistungen"], ["Preise", "/#preise"], ["Über uns", "/#ueber-uns"], ]), ); for (const [name, ziel] of erwartet) { expect(within(nav).getByRole("link", { name })).toHaveAttribute("href", ziel); } }); it("jede Unterseite lädt keine eigene Kopfzeile mit Zurück-Knopf mehr (alter glass-nav-Block)", () => { for (const pfad of UNTERSEITEN) { const { container } = seite(pfad); expect(container.querySelector(".glass-nav"), pfad).toBeNull(); expect(screen.queryByRole("link", { name: "Zurück" }), pfad).toBeNull(); expect(screen.queryByRole("button", { name: "Zurück" }), pfad).toBeNull(); cleanup(); } }); }); describe("Handy-Menü", () => { it("der Menü-Knopf hat aria-expanded und öffnet und schließt das Menü", () => { seite("/kontakt"); const knopf = screen.getByRole("button", { name: "Menü öffnen" }); expect(knopf).toHaveAttribute("aria-expanded", "false"); expect(knopf).toHaveAttribute("aria-controls"); expect(screen.queryByRole("navigation", { name: "Menü" })).toBeNull(); fireEvent.click(knopf); const geoeffnet = screen.getByRole("button", { name: "Menü schließen" }); expect(geoeffnet).toHaveAttribute("aria-expanded", "true"); const menue = screen.getByRole("navigation", { name: "Menü" }); expect(document.getElementById(geoeffnet.getAttribute("aria-controls") as string)).toContainElement(menue); for (const name of ["Kundenportal", "Was wir bauen", "Preise", "Über uns"]) { expect(within(menue).getByRole("link", { name })).toBeInTheDocument(); } // Seite ist gesperrt, solange das Menü offen ist expect(document.body.style.overflow).toBe("hidden"); fireEvent.click(geoeffnet); expect(screen.getByRole("button", { name: "Menü öffnen" })).toHaveAttribute("aria-expanded", "false"); expect(document.body.style.overflow).not.toBe("hidden"); }); it("Escape schließt das Menü und gibt den Fokus an den Knopf zurück", () => { seite("/"); fireEvent.click(screen.getByRole("button", { name: "Menü öffnen" })); expect(screen.getByRole("button", { name: "Menü schließen" })).toHaveAttribute("aria-expanded", "true"); fireEvent.keyDown(window, { key: "Escape" }); const knopf = screen.getByRole("button", { name: "Menü öffnen" }); expect(knopf).toHaveAttribute("aria-expanded", "false"); expect(knopf).toHaveFocus(); }); it("enthält den Button „Projekt besprechen“ und den Hell/Dunkel-Schalter; ein Klick auf einen Link schließt es", () => { seite("/impressum"); fireEvent.click(screen.getByRole("button", { name: "Menü öffnen" })); const menue = screen.getByRole("navigation", { name: "Menü" }).parentElement as HTMLElement; expect(within(menue).getByRole("link", { name: "Projekt besprechen" })).toHaveAttribute("href", "/kontakt"); expect(within(menue).getByRole("button", { name: /Modus einschalten/ })).toBeInTheDocument(); fireEvent.click(within(menue).getByRole("link", { name: "Preise" })); expect(screen.getByRole("button", { name: "Menü öffnen" })).toHaveAttribute("aria-expanded", "false"); }); it("Menü-Knopf und Schalter sind mindestens 44 × 44 px groß (Spezifikation 9.6)", () => { const css = lies("src/components/site/site.css"); for (const klasse of [".wk-header__menuknopf", ".wk-icon-btn"]) { const block = css.match(new RegExp(`${klasse.replace(".", "\\.")}\\s*\\{([^}]*)\\}`))?.[1] ?? ""; expect(block, klasse).toMatch(/width:\s*44px/); expect(block, klasse).toMatch(/height:\s*44px/); } }); }); describe("/kontakt im neuen Layout", () => { it("hat den Datenschutzhinweis mit Link und nennt das Wort »Potenzialanalyse« nirgends", () => { seite("/kontakt"); const formular = screen.getByRole("button", { name: "Nachricht senden" }).closest("form") as HTMLFormElement; expect(formular).toHaveTextContent( "Wir verwenden Ihre Angaben nur, um Ihre Anfrage zu beantworten. Mehr dazu in unserer Datenschutzerklärung.", ); expect(within(formular).getByRole("link", { name: "Datenschutzerklärung" })).toHaveAttribute( "href", "https://project.webklar.com/datenschutz.html", ); expect(document.body).not.toHaveTextContent(/Potenzialanalyse/); }); it("Felder haben Beschriftung, passendes autocomplete und mindestens 16 px Schrift", () => { seite("/kontakt"); expect(screen.getByLabelText(/^Name/)).toHaveAttribute("autocomplete", "name"); expect(screen.getByLabelText(/^E-Mail/)).toHaveAttribute("autocomplete", "email"); expect(screen.getByLabelText(/^E-Mail/)).toHaveAttribute("type", "email"); expect(screen.getByLabelText(/^Unternehmen/)).toHaveAttribute("autocomplete", "organization"); for (const name of [/^Name/, /^E-Mail/, /^Nachricht/]) expect(screen.getByLabelText(name)).toBeRequired(); expect(screen.getByLabelText(/^Unternehmen/)).not.toBeRequired(); // iOS zoomt bei weniger als 16 px: rem-Wert der Felder prüfen const css = lies("src/pages/unterseiten.css"); const block = css.match(/\.wk-feld input,\s*\.wk-feld textarea\s*\{([^}]*)\}/)?.[1] ?? ""; const rem = Number(block.match(/font-size:\s*([\d.]+)rem/)?.[1]); expect(rem * 16).toBeGreaterThanOrEqual(16); }); it("Senden-Knopf ist ein wk-btn, E-Mail und Telefon stehen genau einmal da", () => { seite("/kontakt"); expect(screen.getByRole("button", { name: "Nachricht senden" })).toHaveClass("wk-btn", "wk-btn--primary"); const haupt = screen.getByRole("main"); expect(within(haupt).getAllByRole("link", { name: "support@webklar.com" })).toHaveLength(1); const telefon = within(haupt).getAllByRole("link", { name: "0176 23726355" }); expect(telefon).toHaveLength(1); expect(telefon[0]).toHaveAttribute("href", "tel:+4917623726355"); expect(within(haupt).getByRole("link", { name: "support@webklar.com" })).toHaveAttribute( "href", "mailto:support@webklar.com", ); }); it("die Rückmeldung steht in einem aria-live-Bereich, der schon vor dem Absenden im Dokument ist", () => { seite("/kontakt"); const bereich = document.querySelector("[aria-live]"); expect(bereich).not.toBeNull(); expect(bereich).toBeEmptyDOMElement(); expect(bereich).toHaveAttribute("aria-live", "polite"); }); }); describe("Quelltext der Unterseiten", () => { const SEITEN = ["Contact", "About", "NotFound", "AGB", "Impressum", "WartungsAbo", "HostingAbo"].map((name) => ({ name, text: lies(`src/pages/${name}.tsx`), })); it.each(SEITEN)("$name: kein eigenes main, keine eigene Kopfleiste, keine Versalien, keine Alt-Buttons, keine Effekte", ({ text }) => { expect(text).not.toMatch(/ { const css = lies("src/pages/unterseiten.css"); expect(css).not.toMatch(/https?:\/\/|@import/); for (const { name, text } of SEITEN) expect(text, name).toContain('import "./unterseiten.css"'); }); it("Bewegung nur über transform und opacity, Hover nur mit feinem Zeiger", () => { const css = lies("src/pages/unterseiten.css"); // alle Übergänge der Unterseiten betreffen nur Farben (kein Layout) for (const [, wert] of css.matchAll(/transition:\s*([^;]+);/g)) { expect(wert).toMatch(/^(border-color|text-decoration-color)\b/); } // oberste Blöcke (Regel oder @media) zerlegen; :hover darf nur in @media (hover: hover) and (pointer: fine) stehen const bloecke: { kopf: string; inhalt: string }[] = []; let tiefe = 0; let start = 0; let kopfStart = 0; let kopf = ""; const ohneKommentare = css.replace(/\/\*[\s\S]*?\*\//g, ""); for (let i = 0; i < ohneKommentare.length; i++) { const z = ohneKommentare[i]; if (z === "{") { if (tiefe === 0) { kopf = ohneKommentare.slice(kopfStart, i).trim(); start = i + 1; } tiefe++; } else if (z === "}") { tiefe--; if (tiefe === 0) { bloecke.push({ kopf, inhalt: ohneKommentare.slice(start, i) }); kopfStart = i + 1; } } } expect(bloecke.length).toBeGreaterThan(20); let hoverRegeln = 0; for (const { kopf: k, inhalt } of bloecke) { const text = `${k}${inhalt}`; if (!text.includes(":hover")) continue; hoverRegeln++; expect(k, `${k} enthält :hover`).toBe("@media (hover: hover) and (pointer: fine)"); } expect(hoverRegeln).toBeGreaterThan(0); }); });