${escapeHtml(person.name)}
@@ -43,27 +74,41 @@ if (container) {- ${(person.schwerpunkte || []).map((focus) => `
- ${escapeHtml(focus)} `).join("")}
diff --git a/docs/berater-tests.md b/docs/berater-tests.md new file mode 100644 index 0000000..e3b1d5a --- /dev/null +++ b/docs/berater-tests.md @@ -0,0 +1,36 @@ +# Beraterprofile und Bildergalerie testen + +Die Berater-Tests laufen auf dem Branch `feature/beraterbilder`. Dieser enthält auch die Profiländerungen aus `feature/beraterprofile`. + +## Node-Tests + +```sh +npm test +``` + +Die Node-Suite prüft die Beraterdaten, eindeutige IDs, Kontaktanzeigenamen, vorhandene Porträt- und Aktionsbilder sowie den Navigationslink zur eigenständigen Profilseite. + +## Browser-Tests + +```sh +npm run test:browser -- src/tests/browser/berater.spec.js +``` + +Die Browser-Tests prüfen die Profilkarten, den Kontakt-Dialog, die Bildgröße und das manuelle Wechseln der Galerie mit Pfeilen und Positionspunkten. Playwright startet dafür standardmäßig die Testserver auf den Ports 3000 und 5173. Falls diese Ports bereits von den lokalen Projektservern belegt sind, kann Playwright sie wiederverwenden: + +```sh +PW_REUSE_EXISTING=1 npm run test:browser -- src/tests/browser/berater.spec.js +``` + +PowerShell: + +```powershell +$env:PW_REUSE_EXISTING = "1" +npm run test:browser -- src/tests/browser/berater.spec.js +``` + +Beim ersten Browser-Testlauf kann Playwright Chromium benötigen: + +```sh +npx playwright install chromium +``` diff --git a/playwright.config.js b/playwright.config.js index 892f592..bfa81fb 100644 --- a/playwright.config.js +++ b/playwright.config.js @@ -8,13 +8,13 @@ export default defineConfig({ { command: "node src/tests/helpers/browser-server.js", url: "http://127.0.0.1:3000/api/angebote", - reuseExistingServer: false, + reuseExistingServer: process.env.PW_REUSE_EXISTING === "1", gracefulShutdown: { signal: "SIGTERM", timeout: 3000 }, }, { command: "npm run dev", url: "http://127.0.0.1:5173/booking.html", - reuseExistingServer: false, + reuseExistingServer: process.env.PW_REUSE_EXISTING === "1", }, ], }); diff --git a/src/features/berater/berater-entry.js b/src/features/berater/berater-entry.js index e8761aa..16eb5e4 100644 --- a/src/features/berater/berater-entry.js +++ b/src/features/berater/berater-entry.js @@ -4,27 +4,58 @@ import "./berater.css"; const container = document.querySelector("#advisor-profiles"); function escapeHtml(value) { - return String(value).replace(/[&<>"']/g, (character) => ({ - "&": "&", - "<": "<", - ">": ">", - '"': """, - "'": "'", - })[character]); + return String(value).replace( + /[&<>"']/g, + (character) => + ({ + "&": "&", + "<": "<", + ">": ">", + '"': """, + "'": "'", + })[character], + ); +} + +function contactName(person) { + return ( + (typeof person.spitzname === "string" && person.spitzname.trim()) || + person.vorname || + person.name.trim().split(/\s+/)[0] + ); } if (container) { if (!Array.isArray(berater) || berater.length === 0) { - container.innerHTML = '
Aktuell sind keine Beraterprofile verfügbar.
'; + container.innerHTML = + 'Aktuell sind keine Beraterprofile verfügbar.
'; } else { - container.innerHTML = berater.map((person) => ` + container.innerHTML = berater + .map( + (person) => `Das Kontaktformular ist momentan nicht verfügbar.
'); + console.error( + "Das Kontaktformular ist derzeit nicht verfügbar.", + error, + ); + button.insertAdjacentHTML( + "afterend", + 'Das Kontaktformular ist momentan nicht verfügbar.
', + ); button.disabled = true; } }); diff --git a/src/features/berater/berater.css b/src/features/berater/berater.css index 2bb7301..cd65b80 100644 --- a/src/features/berater/berater.css +++ b/src/features/berater/berater.css @@ -133,49 +133,103 @@ } .advisor-gallery__frame { + position: relative; padding: 0.7rem; + overflow: hidden; border: 1px dashed #f8fafc38; border-radius: 1.5rem; background: #ffffff05; } +.advisor-gallery__track { + display: flex; + width: 100%; + height: clamp(240px, 34vw, 390px); + transition: transform 0.45s cubic-bezier(0.22, 1, 0.36, 1); +} + +.advisor-gallery__slide { + flex: 0 0 100%; + width: 100%; + height: 100%; + margin: 0; +} + .advisor-gallery__image { display: block; width: 100%; - height: clamp(240px, 34vw, 390px); + height: 100%; border-radius: 1.1rem; background: #071720; object-fit: cover; object-position: center; } -.advisor-gallery__controls { +.advisor-gallery__arrow { + position: absolute; + top: 50%; + display: grid; + width: 2.75rem; + height: 2.75rem; + padding: 0; + place-items: center; + transform: translateY(-50%); + border: 1px solid #ffffff55; + border-radius: 50%; + color: #fff; + background: #071720bb; + font: inherit; + font-size: 2rem; + line-height: 1; + cursor: pointer; + transition: border-color 0.2s ease, background 0.2s ease; +} + +.advisor-gallery__arrow:hover { + border-color: #53d5cd; + background: #071720ee; +} + +.advisor-gallery__arrow--previous { + left: 1.2rem; +} + +.advisor-gallery__arrow--next { + right: 1.2rem; +} + +.advisor-gallery__pagination { display: flex; - flex-wrap: wrap; - gap: 0.6rem; + justify-content: center; + gap: 0.55rem; margin-top: 0.85rem; } -.advisor-gallery__button { - min-height: 2.5rem; - padding: 0.45rem 0.9rem; - border: 1px solid #f8fafc1f; - border-radius: 999px; - color: #cbd5e1; - background: #ffffff06; - font: inherit; - font-size: 0.8rem; +.advisor-gallery__dot { + width: 0.7rem; + height: 0.7rem; + padding: 0; + border: 1px solid #94a3b8; + border-radius: 50%; + background: #ffffff12; cursor: pointer; + transition: background 0.2s ease, border-color 0.2s ease, transform 0.2s ease; } -.advisor-gallery__button:hover, -.advisor-gallery__button[aria-pressed="true"] { +.advisor-gallery__dot[aria-current="true"] { + transform: scale(1.15); border-color: #53d5cd; - color: #75e2dc; - background: #53d5cd14; + background: #53d5cd; } -.advisor-gallery__button:focus-visible { +.advisor-gallery__arrow:focus-visible, +.advisor-gallery__dot:focus-visible { outline: 3px solid #53d5cd; outline-offset: 3px; } + +@media (prefers-reduced-motion: reduce) { + .advisor-gallery__track { + transition: none; + } +} diff --git a/src/tests/berater.test.js b/src/tests/berater.test.js new file mode 100644 index 0000000..982833b --- /dev/null +++ b/src/tests/berater.test.js @@ -0,0 +1,83 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { access, readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import berater from "../shared/berater-daten.json" with { type: "json" }; +import { advisorDisplayName, findAdvisor } from "../shared/berater.js"; + +const projectRoot = fileURLToPath(new URL("../../", import.meta.url)); + +test("Beraterdaten haben eindeutige IDs und vollständige Profilfelder", () => { + assert.ok(berater.length >= 2); + assert.equal(new Set(berater.map(({ id }) => id)).size, berater.length); + + for (const person of berater) { + assert.ok(person.id.trim()); + assert.ok(person.name.trim()); + assert.match(person.email, /^[^@\s]+@tri-hub\.de$/); + assert.ok(person.biographie.trim()); + assert.ok(Array.isArray(person.qualifikationen)); + assert.ok(Array.isArray(person.schwerpunkte)); + assert.ok("bildquelle" in person); + } +}); + +test("Berater lassen sich per ID finden; unbekannte IDs liefern undefined", () => { + assert.equal(findAdvisor("relindis-agethen")?.spitzname, "Lilli"); + assert.equal(findAdvisor("maren-hoffmann")?.vorname, "Maren"); + assert.equal(findAdvisor("nicht-vorhanden"), undefined); + assert.equal(findAdvisor(null), undefined); +}); + +test("Kontaktname bevorzugt Spitzname und fällt auf den Vornamen zurück", () => { + assert.equal( + advisorDisplayName({ spitzname: " Lilli ", vorname: "Relindis" }), + "Lilli", + ); + assert.equal( + advisorDisplayName({ spitzname: "", vorname: "Maren" }), + "Maren", + ); + assert.equal( + advisorDisplayName({ spitzname: null, vorname: "Maren" }), + "Maren", + ); + assert.equal(advisorDisplayName({ spitzname: " ", vorname: " " }), ""); +}); + +test("jedes Profil hat Porträt und Aktionsbild, deren Dateien vorhanden sind", async () => { + for (const person of berater) { + assert.equal( + person.bilder?.length, + 2, + `${person.id} muss zwei Bilder haben`, + ); + assert.deepEqual( + person.bilder.map(({ typ }) => typ), + ["Porträt", "Aktionsbild"], + ); + + for (const { quelle } of person.bilder) { + assert.match(quelle, /^\/images\//); + await access(join(projectRoot, "public", quelle.slice(1))); + } + } +}); + +test("Profilseite ist separat verlinkt und die Landingpage rendert keine Beraterkarten", async () => { + const [page, landingPage, navigation] = await Promise.all([ + readFile(join(projectRoot, "berater.html"), "utf8"), + readFile(join(projectRoot, "index.html"), "utf8"), + readFile( + join(projectRoot, "src/components/navigationsbar/navigation.html"), + "utf8", + ), + ]); + + assert.match(page, /id="advisor-profiles"/); + assert.match(page, /src="\/src\/features\/berater\/berater-entry\.js"/); + assert.match(navigation, /href="\/berater\.html"[^>]*>Beraterprofil/); + assert.doesNotMatch(landingPage, /id="advisor-profiles"/); + assert.doesNotMatch(landingPage, /berater-entry\.js/); +}); diff --git a/src/tests/browser/berater.spec.js b/src/tests/browser/berater.spec.js new file mode 100644 index 0000000..d2db396 --- /dev/null +++ b/src/tests/browser/berater.spec.js @@ -0,0 +1,113 @@ +import { test, expect } from "@playwright/test"; + +test.beforeEach(async ({ page }) => { + await page.goto("/berater.html"); +}); + +test("Navigation öffnet die separate Beraterseite und Profile werden geladen", async ({ + page, +}) => { + const profileLink = page.locator('.navbar__link[href="/berater.html"]'); + if (!(await profileLink.isVisible())) { + await page.getByRole("button", { name: "Menü öffnen" }).click(); + } + await expect(profileLink).toBeVisible(); + await expect( + page.getByRole("heading", { name: /Ernährungsberater/ }), + ).toBeVisible(); + + const cards = page.locator(".advisor-card"); + await expect(cards).toHaveCount(2); + await expect(cards.nth(0)).toContainText("Relindis Agethen (Lilli)"); + await expect(cards.nth(0)).toContainText("Kurzbiografie"); + await expect(cards.nth(0)).toContainText("Qualifikationen"); + await expect(cards.nth(0)).toContainText("Schwerpunkte"); + const roleBackground = await cards + .nth(0) + .locator(".advisor-role") + .evaluate((element) => getComputedStyle(element).backgroundColor); + const detailsBackground = await cards + .nth(0) + .locator(".package-details-box") + .evaluate((element) => getComputedStyle(element).backgroundColor); + expect(roleBackground).toBe("rgba(0, 0, 0, 0)"); + expect(detailsBackground).not.toBe("rgba(0, 0, 0, 0)"); + await expect( + cards.nth(0).getByRole("button", { name: "Lilli kontaktieren" }), + ).toBeVisible(); + await expect( + cards.nth(1).getByRole("button", { name: "Maren kontaktieren" }), + ).toBeVisible(); + + await cards + .nth(0) + .getByRole("button", { name: "Lilli kontaktieren" }) + .click(); + const contactDialog = page.getByRole("dialog"); + await expect(contactDialog).toBeVisible(); + await expect(contactDialog).toContainText("Lilli"); +}); + +test("Beraterbilder sind präsent und lassen sich mit Karussell-Pfeilen wechseln", async ({ + page, +}) => { + const lilli = page + .locator(".advisor-card") + .filter({ hasText: "Relindis Agethen" }); + const image = lilli.locator( + '.advisor-gallery__slide[aria-hidden="false"] .advisor-gallery__image', + ); + const track = lilli.locator(".advisor-gallery__track"); + const previous = lilli.getByRole("button", { + name: "Vorheriges Bild von Relindis Agethen (Lilli)", + }); + const next = lilli.getByRole("button", { + name: "Nächstes Bild von Relindis Agethen (Lilli)", + }); + const portraitDot = lilli.getByRole("button", { name: "Bild 1: Porträt" }); + const actionDot = lilli.getByRole("button", { + name: "Bild 2: Aktionsbild", + }); + + await expect(image).toBeVisible(); + await expect(image).toHaveAttribute("src", "/images/Lilli1.jpeg"); + const bounds = await image.boundingBox(); + expect(bounds.width).toBeGreaterThan(150); + expect(bounds.height).toBeGreaterThan(150); + await expect(portraitDot).toHaveAttribute("aria-current", "true"); + await expect(actionDot).toHaveAttribute("aria-current", "false"); + + await next.click(); + await expect(track).toHaveCSS("transform", /matrix\(1, 0, 0, 1, -/); + await expect(image).toHaveAttribute("src", "/images/Lilli2.jpeg"); + await expect(image).toHaveAttribute( + "alt", + "Relindis Agethen (Lilli) – Aktionsbild", + ); + await expect(actionDot).toHaveAttribute("aria-current", "true"); + await expect(portraitDot).toHaveAttribute("aria-current", "false"); + + await previous.click(); + await expect(image).toHaveAttribute("src", "/images/Lilli1.jpeg"); + await expect(portraitDot).toHaveAttribute("aria-current", "true"); + + await portraitDot.click(); + await expect(image).toHaveAttribute("src", "/images/Lilli1.jpeg"); +}); + +test("Maren hat ebenfalls ein umschaltbares Porträt- und Aktionsbild", async ({ + page, +}) => { + const maren = page + .locator(".advisor-card") + .filter({ hasText: "Maren Hoffmann" }); + const image = maren.locator( + '.advisor-gallery__slide[aria-hidden="false"] .advisor-gallery__image', + ); + + await expect(image).toHaveAttribute("src", "/images/Maren1.jpeg"); + await maren + .getByRole("button", { name: "Nächstes Bild von Maren Hoffmann" }) + .click(); + await expect(image).toHaveAttribute("src", "/images/Maren2.jpeg"); +});