/* ===================================================================== Prueft die Portfolio-Seite nach den drei neuen Eintraegen. Der wichtigste Test hier ist NICHT "sieht huebsch aus", sondern: Hat jeder data-i18n-Schluessel im HTML auch eine Uebersetzung? Ein fehlender Schluessel faellt im Deutschen nicht auf -- der Text steht ja als Rueckfalltext im HTML -- und bricht erst auf, wenn jemand die Sprache wechselt. Genau deshalb wird hier in ALLEN fuenf Sprachen geprueft, nicht nur in einer. ===================================================================== */ import { chromium } from "playwright"; import http from "http"; import fs from "fs"; import path from "path"; const TYP = { ".html": "text/html", ".css": "text/css", ".js": "text/javascript", ".png": "image/png", ".svg": "image/svg+xml", ".webp": "image/webp", ".json": "application/json", ".ico": "image/x-icon", ".jpg": "image/jpeg", }; const fehlt = []; const srv = http.createServer((q, r) => { const u = decodeURIComponent(q.url.split("?")[0]); const p = path.join(process.cwd(), u); if (fs.existsSync(p) && !p.endsWith("/")) { r.writeHead(200, { "content-type": TYP[path.extname(p)] || "text/plain" }); return r.end(fs.readFileSync(p)); } fehlt.push(u); r.writeHead(404); r.end(""); }); let ok = 0, fehl = 0; const pruefe = (name, gut, info) => { if (gut) { ok++; console.log(" ok " + name + (info ? " -> " + info : "")); } else { fehl++; console.log(" FEHL " + name + (info ? " -> " + info : "")); } }; /* Kontrast nach WCAG 2.2. Halbtransparente Flaechen muessen ueber ihrem Untergrund verrechnet werden, sonst misst man Unsinn. */ function leuchtkraft([r, g, b]) { const f = (v) => { v /= 255; return v <= 0.04045 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4); }; return 0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b); } function verhaeltnis(a, b) { const l1 = leuchtkraft(a), l2 = leuchtkraft(b); return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05); } srv.listen(4403, async () => { const browser = await chromium.launch(); const seite = await browser.newPage({ viewport: { width: 1440, height: 950 } }); const skriptfehler = []; seite.on("pageerror", (e) => skriptfehler.push(String(e).slice(0, 120))); await seite.goto("http://127.0.0.1:4403/webdesign/portfolio.html"); await seite.waitForTimeout(1500); /* Die Vorschaubilder tragen loading="lazy" -- der Browser laedt sie erst, wenn sie in Sichtweite kommen. Ohne dieses Vorbeischauen misst der Test "Bild nicht geladen" und meint damit nur, dass er selbst nie hingesehen hat. */ await seite.evaluate(async () => { for (const el of document.querySelectorAll(".pfo-bild")) { el.scrollIntoView(); await new Promise((r) => setTimeout(r, 250)); } window.scrollTo(0, 0); }); await seite.waitForTimeout(1200); console.log("1. SIND ALLE SECHS PROJEKTE DA?"); const projekte = await seite.evaluate(() => [...document.querySelectorAll(".pfo-projekt")].map((a) => ({ titel: a.querySelector("h2")?.textContent.trim(), bild: a.querySelector("img")?.getAttribute("src")?.replace(/.*\//, ""), geladen: a.querySelector("img")?.naturalWidth > 0, link: a.querySelector("a[href^='http']")?.href || null, }))); pruefe("sechs Projekte auf der Seite", projekte.length === 6, String(projekte.length)); for (const p of projekte) { pruefe(` ${p.titel}: Vorschaubild geladen`, p.geladen, p.bild); } /* Die Buchhaltung bekommt bewusst KEINEN Live-Knopf: Dahinter liegt nur eine Anmeldemaske, ein Knopf dorthin waere ein leeres Versprechen. Das wird hier festgehalten, damit es niemand spaeter "der Vollstaendigkeit halber" wieder einbaut. */ const buch = projekte.find((p) => /Buchhaltung/i.test(p.titel || "")); pruefe("die Buchhaltung hat absichtlich keinen Live-Knopf", buch && !buch.link, buch ? String(buch.link) : "nicht gefunden"); console.log("\n1b. STEHEN GENAU ZWEI KACHELN NEBENEINANDER?"); for (const [name, breite] of [["Gross", 1920], ["Computer", 1440], ["Laptop", 1200], ["Tablet", 900], ["klein", 700], ["Handy", 390]]) { await seite.setViewportSize({ width: breite, height: 900 }); await seite.waitForTimeout(500); const r = await seite.evaluate(() => { const g = document.querySelector(".pfo-raster"); if (!g) return null; const k = g.querySelector(".pfo-projekt"); return { spalten: getComputedStyle(g).gridTemplateColumns.split(" ").length, kachel: Math.round(k.getBoundingClientRect().width), ueberlauf: document.documentElement.scrollWidth - document.documentElement.clientWidth, }; }); if (!r) { pruefe("Raster vorhanden", false, "kein .pfo-raster"); break; } /* Zwei Spalten ab 900 Punkten, darunter untereinander. Unter 760 waeren zwei Spalten so schmal, dass Ueberschrift und Schild im Kachelkopf umbrechen. */ const erwartet = breite >= 900 ? 2 : 1; pruefe(` ${name} (${breite}px): ${erwartet} Spalte(n)`, r.spalten === erwartet, r.spalten + " Spalten, Kachel " + r.kachel + "px"); pruefe(` ${name}: nichts laeuft seitlich ueber`, r.ueberlauf <= 1, r.ueberlauf + "px"); } await seite.setViewportSize({ width: 1440, height: 950 }); await seite.waitForTimeout(500); console.log("\n1c. SIND DIE KACHELN EINER REIHE GLEICH GROSS?"); await seite.evaluate(async () => { for (const el of document.querySelectorAll(".pfo-bild")) { el.scrollIntoView(); await new Promise((r) => setTimeout(r, 180)); } window.scrollTo(0, 0); }); await seite.waitForTimeout(800); const reihen = await seite.evaluate(() => { const k = [...document.querySelectorAll(".pfo-projekt")]; const nach = {}; k.forEach((x) => { const r = x.getBoundingClientRect(); const oben = Math.round(r.top); const letzte = x.querySelector(".pfo-inhalt > :last-child"); (nach[oben] = nach[oben] || []).push({ hoehe: Math.round(r.height), /* Abstand vom letzten Element bis zum Kachelboden. */ unten: letzte ? Math.round(r.bottom - letzte.getBoundingClientRect().bottom) : -1, }); }); return Object.values(nach); }); pruefe("es gibt Reihen mit zwei Kacheln", reihen.some((r) => r.length === 2), reihen.map((r) => r.length).join("+")); /* Der eigentliche Punkt: Die Projekttexte sind unterschiedlich lang. Ohne "stretch" behaelt jede Kachel ihre natuerliche Hoehe, und dann stehen zwei sichtbar verschieden hohe Kacheln nebeneinander. */ pruefe("jede Reihe ist in sich gleich hoch", reihen.every((r) => new Set(r.map((x) => x.hoehe)).size === 1), reihen.map((r) => r.map((x) => x.hoehe).join("/")).join(" ")); /* Und die Knoepfe muessen auf einer Linie stehen. Das ist NICHT dasselbe: Die Kacheln koennen gleich hoch sein und der Inhalt trotzdem unterschiedlich weit oben kleben. Genau so war es, weil die Abstaende als style-Angabe im HTML standen -- und eine solche Angabe schlaegt jede Regel aus dem Stilblatt. */ pruefe("und die letzten Elemente stehen auf einer Linie", reihen.every((r) => new Set(r.map((x) => x.unten)).size === 1), reihen.map((r) => r.map((x) => x.unten + "px").join("/")).join(" ")); console.log("\n2. UEBERSETZUNGEN — IN ALLEN FUENF SPRACHEN"); /* Geprueft werden die SEITENEIGENEN Schluessel (pf_, p1_-p5_, seiten_titel). Navigations- und Markentexte liegen bewusst nicht hier, sondern in wd-core.js -- sie gelten fuer alle Seiten und werden von server/pruefe-webdesign-i18n.mjs abgedeckt. Sie hier mitzuzaehlen hat nur Fehlalarm erzeugt. Und geprueft wird das ERGEBNIS, nicht das Woerterbuch: Die Seite wird wirklich auf jede der fuenf Sprachen umgeschaltet, danach darf kein seiteneigener Text leer sein. */ const luecken = []; for (const sprache of ["de", "de-CH", "en", "fr", "pt"]) { const kaputt = await seite.evaluate((spr) => { window.WD.setSprache(spr); const raus = []; const eigen = /^(pf_|p[1-5]_|seiten_titel)/; for (const el of document.querySelectorAll("[data-i18n]")) { const k = el.getAttribute("data-i18n"); if (eigen.test(k) && !el.textContent.trim()) raus.push(k + " (leer)"); } for (const el of document.querySelectorAll("[data-i18n-attr]")) { const k = el.getAttribute("data-i18n-attr").split(":")[1]; const wert = el.getAttribute("alt"); if (eigen.test(k) && !(wert && wert.trim())) raus.push(k + " (alt leer)"); } return raus; }, sprache); kaputt.forEach((k) => luecken.push(sprache + ": " + k)); } pruefe("in jeder Sprache ist jeder seiteneigene Text gefuellt", luecken.length === 0, luecken.slice(0, 4).join(", ") || "alle fuenf Sprachen sauber"); /* Gegenprobe: Der Test darf nicht deshalb gruen sein, weil die Umschaltung in Wahrheit gar nichts tut. */ const wechsel = await seite.evaluate(() => { const el = document.querySelector('[data-i18n="pf_h1"]'); window.WD.setSprache("de"); const deutsch = el.textContent.trim(); window.WD.setSprache("en"); const englisch = el.textContent.trim(); window.WD.setSprache("de"); return { deutsch, englisch }; }); pruefe("die Umschaltung wirkt wirklich", wechsel.deutsch !== wechsel.englisch, wechsel.englisch); await seite.waitForTimeout(300); console.log("\n3. DIE SEITE SPRICHT NICHT MEHR VON ZWEI PROJEKTEN"); const zahlwort = await seite.evaluate(() => { const t = window.I18N_WD; const treffer = []; for (const [k, v] of Object.entries(t)) { for (const [s, txt] of Object.entries(v)) { /* Gesucht ist die veraltete AUSSAGE "es gibt zwei Projekte", nicht jedes Vorkommen des Zahlworts. Ohne den Bezug auf Projekt/Seite schlug der Test bei einem Satz an, der voellig richtig von zwei MENSCHEN sprach (p6_hinweis, 26.08.2026). Solche Fehlalarme sind gefaehrlich: Sie verleiten beim naechsten Mal dazu, den Test einfach abzuschalten -- und dann faengt er auch den echten Fall nicht mehr. */ /* Hoechstens EIN Wort dazwischen ("zwei echte Projekte"), sonst faengt das Muster Saetze, die etwas voellig anderes sagen: "Two people work together on that site" enthaelt beide Woerter und meint zwei MENSCHEN. */ const veraltet = /\b(zwei|two|deux|dois|beide|both|ambos)\b(\s+\w+)?\s+\b(projekt|projekte|projects|project|projets|projet|projetos|projeto|seiten|sites|site|referenzen)\b/i; if (veraltet.test(txt)) treffer.push(k + "/" + s); } } return treffer; }); pruefe("keine Stelle behauptet noch 'zwei' oder 'beide'", zahlwort.length === 0, zahlwort.join(", ") || "sauber"); console.log("\n4. LESBARKEIT DER NEUEN SCHILDER"); const schilder = await seite.evaluate(() => { const holen = (sel) => { const el = document.querySelector(sel); if (!el) return null; const s = getComputedStyle(el); /* Untergrund suchen: das erste Elternteil mit deckender Farbe. */ let p = el.parentElement, unter = null; while (p && !unter) { const bg = getComputedStyle(p).backgroundColor; const m = bg.match(/[\d.]+/g); if (m && (m.length === 3 || parseFloat(m[3]) === 1)) unter = m.slice(0, 3).map(Number); p = p.parentElement; } return { text: el.textContent.trim(), farbe: s.color.match(/[\d.]+/g).slice(0, 3).map(Number), eigen: s.backgroundColor.match(/[\d.]+/g), unter: unter || [3, 8, 16], }; }; return { aqua: holen(".wd-tag--aqua"), indigo: holen(".wd-tag--indigo") }; }); for (const [name, d] of Object.entries(schilder)) { if (!d) { pruefe(`Schild ${name} gefunden`, false); continue; } /* Eigene halbtransparente Flaeche ueber den Untergrund rechnen. */ const a = d.eigen.length === 4 ? parseFloat(d.eigen[3]) : 1; const flaeche = d.unter.map((u, i) => Math.round(Number(d.eigen[i]) * a + u * (1 - a))); const v = verhaeltnis(d.farbe, flaeche); pruefe(`Schild "${d.text}" ist gut lesbar (AA = 4,5:1)`, v >= 4.5, v.toFixed(2) + ":1"); } console.log("\n5. BEWEGEN SICH DIE BILDER?"); const bild = seite.locator(".pfo-bild").first(); await bild.scrollIntoViewIfNeeded(); await seite.waitForTimeout(800); const rahmen = await bild.boundingBox(); /* Mit steps bewegen, sonst entsteht kein pointermove. */ await seite.mouse.move(rahmen.x + 30, rahmen.y + 30, { steps: 10 }); await seite.waitForTimeout(600); const linksOben = await bild.evaluate((e) => ({ bx: parseFloat(e.style.getPropertyValue("--wd-bx")), versatz: getComputedStyle(e).transform, })); await seite.mouse.move(rahmen.x + rahmen.width - 30, rahmen.y + rahmen.height - 30, { steps: 18 }); await seite.waitForTimeout(650); const rechtsUnten = await bild.evaluate((e) => ({ bx: parseFloat(e.style.getPropertyValue("--wd-bx")), versatz: getComputedStyle(e).transform, })); /* Die Portfolio-Bilder stecken in einer Kachel. Dort bewegt sich die KACHEL, das Bild bekommt nur den Zoom -- beides zusammen schaukelt sich auf: Die kippende Kachel schiebt das Bild unter dem Zeiger weg, der landet auf einem Nachbarelement, das Bild springt zurueck auf null. Genau das war hier messbar: an einer Ecke sauber, an der anderen dauerhaft 0. Geprueft wird deshalb die Arbeitsteilung, nicht mehr die Bildverschiebung. */ const holeX = (m) => parseFloat(m.slice(m.indexOf("(") + 1, -1).split(",")[4]); const xLinks = holeX(linksOben.versatz); const xRechts = holeX(rechtsUnten.versatz); pruefe("das Bild in der Kachel verschiebt sich NICHT selbst", Math.abs(xLinks) < 1 && Math.abs(xRechts) < 1, xLinks.toFixed(1) + "px / " + xRechts.toFixed(1) + "px"); const kachel = await seite.locator(".pfo-projekt").first() .evaluate((e) => getComputedStyle(e).transform); pruefe("dafuer kippt die Kachel um das Bild herum", kachel.startsWith("matrix3d"), kachel.slice(0, 24) + "..."); const zoom = parseFloat(rechtsUnten.versatz.slice( rechtsUnten.versatz.indexOf("(") + 1, -1).split(",")[0]); pruefe("und das Bild tritt leicht naeher", zoom > 1.01 && zoom < 1.12, zoom.toFixed(3) + "-fach"); await seite.mouse.move(10, 10, { steps: 12 }); await seite.waitForTimeout(700); const zurueck = await bild.evaluate((e) => e.style.getPropertyValue("--wd-bx")); pruefe("beim Verlassen stellt es sich zurueck", zurueck === "0", zurueck); console.log("\n6. KEINE FEHLER, KEINE FEHLENDEN DATEIEN"); pruefe("kein Skriptfehler", skriptfehler.length === 0, skriptfehler[0] || ""); const verpasst = fehlt.filter((f) => /\.(jpg|webp|png|css|js|svg)$/.test(f)); pruefe("keine fehlende Datei", verpasst.length === 0, verpasst.join(", ") || "keine"); await seite.screenshot({ path: "pruef-portfolio-computer.png", fullPage: true }); const handy = await browser.newPage({ viewport: { width: 390, height: 844 } }); await handy.goto("http://127.0.0.1:4403/webdesign/portfolio.html"); await handy.waitForTimeout(1500); const ueberlauf = await handy.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth); pruefe("auf dem Handy laeuft nichts seitlich ueber", ueberlauf <= 1, ueberlauf + "px"); await handy.screenshot({ path: "pruef-portfolio-handy.png", fullPage: true }); console.log(`\n===== ${ok} bestanden, ${fehl} fehlgeschlagen =====`); await browser.close(); srv.close(); process.exit(fehl ? 1 : 0); });