Relaunch Phase 1: Hero mit Deutschland-Karte, Kundenportal-Bühne, Leistungen, Preise, Unterseiten im neuen Layout
- Hero: wechselnde Überschrift (ein Durchlauf, Pause-Knopf), Karte zeichnet sich, roter Punkt auf Falkenstein - Kundenportal: Bühne mit Browserfenster und Handy-Rahmen (Desktop), Wisch-Reihe (Handy), Schrittzeile statt „So arbeiten wir“ - Leistungen als nummerierte Zeilen, Preise aus src/data/abos.ts - Unterseiten ohne eigenen Kopfbereich, Wortlaut der Rechtstexte unverändert - Tests für Hero, Kundenportal, Leistungen/Preise und Layout Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
69d006334f
commit
1c8cfa5c1c
54 files changed
+4761
-1166
No files matched your search
@@ -0,0 +1,279 @@
|
||||
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/SiteHeader";
|
||||
import { projekte } from "@/data/projekte";
|
||||
|
||||
// Netzwerk gibt es in jsdom nicht: das Kontaktformular-Backend wird durch eine Attrappe ersetzt.
|
||||
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 };
|
||||
|
||||
/** 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(
|
||||
<MemoryRouter initialEntries={[pfad]} future={routerFuture}>
|
||||
<AppRoutes />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
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(/<main\b/);
|
||||
expect(text).not.toMatch(/<header\b/);
|
||||
expect(text).not.toMatch(/\buppercase\b|tracking-(wider|widest)|font-display|label-tag/);
|
||||
expect(text).not.toMatch(/glass-nav|btn-minimal|btn-wrapper|className="btn\b/);
|
||||
expect(text).not.toMatch(/BorderGlow|CountUp|FlickeringGrid|PixelBlast|LightRays|Silk/);
|
||||
// Seitenrahmen kommt aus dem neuen Layout
|
||||
expect(text).toMatch(/wk-seite/);
|
||||
});
|
||||
|
||||
it("die Seiten tragen ihr Stylesheet selbst und laden nichts von fremden Servern", () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user