/* ===================================================================== Prueft die Buehne im Verwaltungsbereich. Das Konzept hat sich umgedreht, und damit auch die Pruefung. Vorher: ein schmaler Streifen, auf 190 % gezoomt, davon ein Ausschnitt gewaehlt und die linke Haelfte voll zugedeckt -- damit Text darauf lesbar bleibt. Gemessen wurde, ob das Bild leise genug ist. Uebrig blieb ein Zipfel. Jetzt: Das Motiv ist die Buehne -- bildschirmfuellend, ungeschnitten, ungedimmt. Der Inhalt schwebt als Milchglas darueber. Text steht nie direkt auf dem Bild. Deshalb wird jetzt das Gegenteil geprueft: 1. Ist das Bild wirklich GANZ da? (kein Zoom, kein Ausschnitt) 2. Traegt jede Flaeche, auf der gelesen wird, auch Glas? 3. Bleibt der Text darauf lesbar -- an echten Bildpunkten gemessen? ===================================================================== */ 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 : "")); } }; 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); } const BEREICHE = ["uebersicht", "anfragen", "projekte", "kunden", "zahlungen", "postfach"]; /* Setzt einen Bereich und legt Beispielinhalt ein. Ohne Inhalt liesse sich nicht messen, ob Text ueber der Buehne lesbar bleibt -- und genau das ist die Frage, an der das ganze Konzept haengt. Der Aufbau passiert vor JEDER Messung neu: Der Datenabruf laeuft im Hintergrund weiter und klappt den Arbeitsbereich verzoegert wieder zu (Zugangspruefung, kein Fehler). */ async function bereichAufbauen(seite, bereich) { await seite.evaluate((b) => { document.querySelector("#vw-anmeldung").setAttribute("hidden", ""); const bereichEl = document.querySelector("#vw-bereich"); bereichEl.removeAttribute("hidden"); document.documentElement.setAttribute("data-vw-bereich", b); document.querySelectorAll("[data-tab]").forEach((x) => x.classList.toggle("vw-an", x.dataset.tab === b)); let probe = document.querySelector("#vw-probe"); if (!probe) { probe = document.createElement("div"); probe.id = "vw-probe"; probe.innerHTML = '
' + '

Vanessa Geske

' + '

vanessa@example.com

' + '

Zwei Projekte, eines davon laufend.

'; bereichEl.appendChild(probe); } }, bereich); await seite.waitForTimeout(700); } /* Misst die Schwankung des Untergrunds an echten Bildpunkten. Hohe Schwankung heisst: Da liegt Bildstruktur unter der Schrift. */ async function unruheAn(seite, wahl) { const bild = await seite.locator(wahl).first().screenshot(); return seite.evaluate(async (daten) => { const bl = await fetch("data:image/png;base64," + daten).then((r) => r.blob()); const bm = await createImageBitmap(bl); const c = new OffscreenCanvas(bm.width, bm.height); const x = c.getContext("2d"); x.drawImage(bm, 0, 0); const d = x.getImageData(0, 0, bm.width, bm.height).data; const grund = []; for (let i = 0; i < d.length; i += 4) { const h = 0.2126 * d[i] + 0.7152 * d[i + 1] + 0.0722 * d[i + 2]; if (h < 120) grund.push(h); } if (grund.length < 50) return null; const m = grund.reduce((a, b) => a + b, 0) / grund.length; return Math.round(Math.sqrt(grund.reduce((a, b) => a + (b - m) ** 2, 0) / grund.length)); }, bild.toString("base64")); } srv.listen(4404, async () => { const browser = await chromium.launch(); const seite = await browser.newPage({ viewport: { width: 1440, height: 900 } }); const skriptfehler = []; seite.on("pageerror", (e) => skriptfehler.push(String(e).slice(0, 140))); await seite.goto("http://127.0.0.1:4404/webdesign/verwaltung.html"); await seite.waitForTimeout(1500); console.log("===== 1. IST DAS BILD GANZ DA? ====="); await bereichAufbauen(seite, "kunden"); const buehne = await seite.evaluate(() => { const el = document.querySelector(".vw-buehne"); if (!el) return null; const st = getComputedStyle(el); const r = el.getBoundingClientRect(); const treffer = st.backgroundImage.match(/img\/([a-z0-9-]+\.webp)/i); return { datei: treffer ? treffer[1] : "(keins)", groesse: st.backgroundSize, lage: st.backgroundPosition, haltung: st.position, breite: Math.round(r.width), hoehe: Math.round(r.height), z: parseInt(st.zIndex, 10), klicks: st.pointerEvents, versteckt: el.getAttribute("aria-hidden"), }; }); if (!buehne) { pruefe("Buehne vorhanden", false); } else { /* Das ist der Kern der Korrektur. Beim ersten Anlauf stand hier "190% auto" plus ein Ausschnitt wie "84% 46%" -- damit war der groesste Teil des Bildes weggeschnitten. */ pruefe("das Bild wird NICHT gezoomt", buehne.groesse === "cover", buehne.groesse); pruefe("und NICHT einseitig ausgeschnitten", /50%\s+50%|center/.test(buehne.lage), buehne.lage); pruefe("die Buehne fuellt den ganzen Bildschirm", buehne.breite >= 1430 && buehne.hoehe >= 890, `${buehne.breite}x${buehne.hoehe}`); pruefe("sie steht fest, der Inhalt scrollt darueber", buehne.haltung === "fixed", buehne.haltung); pruefe("sie liegt hinter allem", buehne.z < 0, "z-index " + buehne.z); pruefe("sie faengt keine Klicks ab", buehne.klicks === "none", buehne.klicks); pruefe("sie ist als Zierde ausgezeichnet", buehne.versteckt === "true"); } console.log("\n===== 2. TRAEGT ALLES GLAS, WORAUF GELESEN WIRD? ====="); const glas = await seite.evaluate(() => { const pruefen = [".wd-karte", ".vw-filter-knopf", ".vw-wer"]; const raus = {}; for (const w of pruefen) { const el = document.querySelector("#vw-bereich " + w); if (!el) { raus[w] = "(nicht gefunden)"; continue; } const st = getComputedStyle(el); raus[w] = st.backdropFilter || st.webkitBackdropFilter || "keins"; } return raus; }); for (const [w, wert] of Object.entries(glas)) { if (wert === "(nicht gefunden)") continue; pruefe(` ${w} hat Rueckseiten-Unschaerfe`, /blur/.test(wert), wert.slice(0, 34)); } console.log("\n===== 3. BLEIBT DER TEXT LESBAR? ====="); for (const bereich of BEREICHE) { await bereichAufbauen(seite, bereich); const aufGlas = await unruheAn(seite, "#vw-probe .wd-karte"); await seite.evaluate(() => { document.querySelector(".vw-buehne").style.visibility = "hidden"; }); await seite.waitForTimeout(350); await bereichAufbauen(seite, bereich); const ohneBuehne = await unruheAn(seite, "#vw-probe .wd-karte"); await seite.evaluate(() => { document.querySelector(".vw-buehne").style.visibility = ""; }); await seite.waitForTimeout(250); const mehr = (aufGlas ?? 99) - (ohneBuehne ?? 0); /* Etwas Struktur DARF durchkommen -- das ist ja der Reiz. Sie darf nur nicht so stark werden, dass die Schrift darauf flimmert. */ pruefe(` ${bereich}: der Kachelgrund bleibt ruhig`, aufGlas !== null && ohneBuehne !== null && mehr <= 8, `mit Buehne ${aufGlas}, ohne ${ohneBuehne} → plus ${mehr}`); await bereichAufbauen(seite, bereich); /* Der tatsaechliche Kachelgrund wird aus dem BILD gelesen, nicht aus dem Stilblatt: Die Kachel ist halbdurchsichtig, ihr rechnerischer Wert sagt nichts darueber, was am Ende darunter liegt. */ const stelle = await seite.evaluate(() => { const k = document.querySelector("#vw-probe .wd-karte"); const r = k.getBoundingClientRect(); return { x: Math.round(r.x + r.width * 0.72), y: Math.round(r.y + r.height * 0.62) }; }); const punkt = await seite.screenshot({ clip: { x: stelle.x, y: stelle.y, width: 10, height: 10 } }); const grundfarbe = await seite.evaluate(async (daten) => { const bl = await fetch("data:image/png;base64," + daten).then((r) => r.blob()); const bm = await createImageBitmap(bl); const c = new OffscreenCanvas(bm.width, bm.height); const x = c.getContext("2d"); x.drawImage(bm, 0, 0); const d = x.getImageData(0, 0, bm.width, bm.height).data; let r = 0, g = 0, b = 0, n = 0; for (let i = 0; i < d.length; i += 4) { r += d[i]; g += d[i + 1]; b += d[i + 2]; n++; } return [Math.round(r / n), Math.round(g / n), Math.round(b / n)]; }, punkt.toString("base64")); const schrift = await seite.evaluate(() => getComputedStyle(document.querySelector("#vw-probe-text")) .color.match(/[\d.]+/g).slice(0, 3).map(Number)); const v = verhaeltnis(schrift, grundfarbe); pruefe(` ${bereich}: Text auf der Kachel erfuellt AA (4,5:1)`, v >= 4.5, v.toFixed(2) + ":1"); } console.log("\n===== 3b. FOLGT DAS LICHT DER MAUS? ====="); await bereichAufbauen(seite, "kunden"); const karte = seite.locator("#vw-probe .wd-karte"); const kasten = await karte.boundingBox(); /* WICHTIG: mit steps bewegen. Ein Sprung ohne Zwischenschritte erzeugt kein pointermove -- der Test wuerde "Licht folgt nicht" melden, obwohl alles stimmt. Diese Falle hat hier schon einmal einen Fehlalarm ausgeloest. */ await seite.mouse.move(kasten.x + 30, kasten.y + 20, { steps: 8 }); await seite.waitForTimeout(320); const lichtA = await karte.evaluate((e) => ({ x: getComputedStyle(e).getPropertyValue("--wd-lichtx").trim(), y: getComputedStyle(e).getPropertyValue("--wd-lichty").trim(), })); await seite.mouse.move(kasten.x + kasten.width - 40, kasten.y + kasten.height - 20, { steps: 16 }); await seite.waitForTimeout(400); const lichtB = await karte.evaluate((e) => { const st = getComputedStyle(e); const na = getComputedStyle(e, "::after"); return { x: st.getPropertyValue("--wd-lichtx").trim(), y: st.getPropertyValue("--wd-lichty").trim(), lumen: st.getPropertyValue("--wd-lumen").trim(), kante: st.backgroundImage.includes("radial-gradient"), kegel: na.backgroundImage, }; }); pruefe("das Licht wandert mit dem Zeiger", lichtA.x !== lichtB.x && lichtA.y !== lichtB.y, lichtA.x + "/" + lichtA.y + " -> " + lichtB.x + "/" + lichtB.y); pruefe("die Kachelkante leuchtet an der Zeigerstelle", lichtB.kante); /* Der Lichtkegel muss die Leitfarbe des Bereichs tragen, nicht die Grundfarbe -- sonst leuchtet im Kundenbereich Baby Blue statt Indigo. */ pruefe("der Lichtkegel traegt die Leitfarbe", /97,\s*92,\s*255/.test(lichtB.kegel), lichtB.lumen); console.log("\n===== 3c. BEWEGT SICH DIE BUEHNE MIT? ====="); await seite.mouse.move(160, 780, { steps: 12 }); await seite.waitForTimeout(650); const lage1 = await seite.evaluate(() => getComputedStyle(document.querySelector(".vw-buehne")).translate); await seite.mouse.move(1320, 130, { steps: 20 }); await seite.waitForTimeout(750); const lage2 = await seite.evaluate(() => getComputedStyle(document.querySelector(".vw-buehne")).translate); pruefe("die Buehne verschiebt sich gegen den Zeiger", lage1 !== lage2 && lage1 !== "none", lage1 + " -> " + lage2); /* Der Ausschlag muss klein bleiben. Waere er gross, wuerde beim Lesen staendig etwas im Augenwinkel wandern. */ const weite = Math.max(...String(lage2).match(/-?[\d.]+/g).map((n) => Math.abs(+n))); pruefe("und zwar nur wenige Bildpunkte", weite <= 16, weite.toFixed(1) + "px"); console.log("\n===== 3d. DER LEUCHTBALKEN ====="); /* Der Balken muss GENAU am aktiven Reiter sitzen. Gemessen wird gegen die echten Masse des Reiters -- feste Werte waeren hier zwangslaeufig falsch, weil die Reiter unterschiedlich breit sind und beim Sprachwechsel wandern. */ const balkenPruefen = async (bereich) => { /* Erst den Bereich sichtbar machen, DANN klicken: Ein Klick auf einen zugeklappten Arbeitsbereich misst zwangslaeufig 0. Danach wieder aufmachen -- der Klick loest die Zugangspruefung aus, die korrekterweise zur Anmeldung zurueckschaltet. */ await bereichAufbauen(seite, bereich); await seite.click(`[data-tab="${bereich}"]`).catch(() => {}); await seite.waitForTimeout(500); await bereichAufbauen(seite, bereich); await seite.evaluate(() => window.vwBalkenSetzen && window.vwBalkenSetzen()); await seite.waitForTimeout(400); return seite.evaluate(() => { const l = document.querySelector("#vw-bereich .vw-tabs"); const a = l.querySelector(".vw-filter-knopf.vw-an"); const lr = l.getBoundingClientRect(); const ar = a.getBoundingClientRect(); const st = l.style; return { gesetztX: parseInt(st.getPropertyValue("--vw-balken-x"), 10), gesetztB: parseInt(st.getPropertyValue("--vw-balken-b"), 10), echtX: Math.round(ar.left - lr.left), echtB: Math.round(ar.width), sichtbar: st.getPropertyValue("--vw-balken-da"), reiter: a.textContent.trim().slice(0, 12), }; }); }; const b1 = await balkenPruefen("uebersicht"); pruefe("der Balken sitzt am aktiven Reiter", Math.abs(b1.gesetztX - b1.echtX) <= 2 && Math.abs(b1.gesetztB - b1.echtB) <= 2, `${b1.reiter}: gesetzt ${b1.gesetztX}/${b1.gesetztB}, echt ${b1.echtX}/${b1.echtB}`); pruefe("und er ist sichtbar", b1.sichtbar === "1", b1.sichtbar); /* Der haeufigste Fehlerfall: Beim Start ist der Arbeitsbereich noch versteckt, die Leiste also 0 Punkte breit. Wer nur auf "resize" horcht, misst nie nach -- der Balken bleibt auf Breite 0. */ pruefe("er hat eine echte Breite (nicht 0)", b1.gesetztB > 20, b1.gesetztB + "px"); const b2 = await balkenPruefen("zahlungen"); pruefe("er wandert zum neuen Reiter mit", b2.gesetztX !== b1.gesetztX && Math.abs(b2.gesetztX - b2.echtX) <= 2, `${b1.gesetztX}px -> ${b2.gesetztX}px`); console.log("\n===== 3e. TAUCHEN DIE KACHELN GESTAFFELT AUF? ====="); const staffel = await seite.evaluate(() => { const be = document.querySelector("#vw-bereich"); const probe = document.querySelector("#vw-probe"); probe.innerHTML = ""; for (let i = 0; i < 4; i++) { const k = document.createElement("div"); k.className = "wd-karte"; k.style.padding = "1rem"; k.textContent = "Kachel " + (i + 1); probe.appendChild(k); } return [...probe.querySelectorAll(".wd-karte")].map((k) => ({ name: getComputedStyle(k).animationName, versatz: getComputedStyle(k).animationDelay, })); }); pruefe("die Kacheln haben eine Auftauch-Animation", staffel.every((k) => k.name === "vw-kachel-auf"), staffel.map((k) => k.name).join(", ")); pruefe("und jede startet etwas spaeter als die davor", new Set(staffel.map((k) => k.versatz)).size === staffel.length, staffel.map((k) => k.versatz).join(" ")); console.log("\n===== 4. WECHSELT DER RAUM WIRKLICH? ====="); const gesehen = []; for (const bereich of BEREICHE) { await bereichAufbauen(seite, bereich); const d = await seite.evaluate(() => { const st = getComputedStyle(document.querySelector(".vw-buehne")); const t = st.backgroundImage.match(/img\/([a-z0-9-]+\.webp)/i); return { datei: t ? t[1] : "(keins)", farbe: getComputedStyle(document.documentElement) .getPropertyValue("--vw-farbe").trim(), }; }); gesehen.push({ bereich, ...d }); console.log(` ${bereich.padEnd(11)} ${d.datei.padEnd(22)} ${d.farbe}`); } pruefe("jeder Bereich hat eine eigene Leitfarbe", new Set(gesehen.map((g) => g.farbe)).size === 6, new Set(gesehen.map((g) => g.farbe)).size + " von 6"); pruefe("mindestens fuenf verschiedene Motive", new Set(gesehen.map((g) => g.datei)).size >= 5, new Set(gesehen.map((g) => g.datei)).size + " verschiedene"); pruefe("kein Skriptfehler", skriptfehler.length === 0, skriptfehler[0] || ""); await bereichAufbauen(seite, "kunden"); await seite.screenshot({ path: "pruef-vw-schmuck-computer.png" }); await seite.close(); console.log("\n===== 5. REDUZIERTE BEWEGUNG ====="); const ruhig = await browser.newPage({ viewport: { width: 1440, height: 900 }, reducedMotion: "reduce" }); await ruhig.goto("http://127.0.0.1:4404/webdesign/verwaltung.html"); await ruhig.waitForTimeout(1300); await bereichAufbauen(ruhig, "projekte"); const bewegung = await ruhig.evaluate(() => { const st = getComputedStyle(document.querySelector(".vw-buehne")); return { animation: st.animationName, sichtbar: st.visibility }; }); pruefe("keine Animation bei reduzierter Bewegung", bewegung.animation === "none", bewegung.animation); /* Abschalten heisst NICHT verschwinden. */ pruefe("die Buehne ist trotzdem da", bewegung.sichtbar === "visible", bewegung.sichtbar); await ruhig.mouse.move(200, 700, { steps: 10 }); await ruhig.waitForTimeout(400); await ruhig.mouse.move(1200, 200, { steps: 16 }); await ruhig.waitForTimeout(600); const ruhigeLage = await ruhig.evaluate(() => { const b = document.querySelector(".vw-buehne"); const vor = getComputedStyle(b, "::before"); return { translate: getComputedStyle(b).translate, reflex: vor.animationName, reflexSichtbar: parseFloat(vor.opacity), }; }); pruefe("keine Parallaxe bei reduzierter Bewegung", ruhigeLage.translate === "none", ruhigeLage.translate); const ruhigExtra = await ruhig.evaluate(() => { const be = document.querySelector("#vw-bereich"); const k = document.createElement("div"); k.className = "wd-karte"; be.appendChild(k); const l = document.querySelector("#vw-bereich .vw-tabs"); return { kachel: getComputedStyle(k).animationName, balken: getComputedStyle(l, "::after").transitionDuration, }; }); pruefe("keine Auftauch-Animation der Kacheln", ruhigExtra.kachel === "none", ruhigExtra.kachel); /* Geprueft wird "praktisch sofort", nicht wortwoertlich "0s". Die Testumgebung emuliert reduzierte Bewegung, indem sie alle Uebergaenge auf eine Mikrosekunde setzt statt auf null -- gemeldet wird dann "1e-06s". Wahrnehmbar ist das identisch; ein Test auf exakt "0s" wuerde nur die Emulation messen, nicht die Regel. */ const laengste = Math.max(...ruhigExtra.balken.split(",") .map((t) => parseFloat(t) || 0)); pruefe("und der Balken gleitet nicht, sondern springt", laengste <= 0.01, ruhigExtra.balken); pruefe("und kein Lichtreflex", ruhigeLage.reflex === "none" && ruhigeLage.reflexSichtbar === 0, ruhigeLage.reflex + ", Deckkraft " + ruhigeLage.reflexSichtbar); await ruhig.close(); console.log("\n===== 6. HANDY ====="); const handy = await browser.newPage({ viewport: { width: 390, height: 844 } }); await handy.goto("http://127.0.0.1:4404/webdesign/verwaltung.html"); await handy.waitForTimeout(1300); await bereichAufbauen(handy, "zahlungen"); const hd = await handy.evaluate(() => { const st = getComputedStyle(document.querySelector(".vw-buehne")); const t = st.backgroundImage.match(/img\/([a-z0-9-]+\.webp)/i); return { datei: t ? t[1] : "(keins)", groesse: st.backgroundSize }; }); pruefe("Handy: kleine Bildfassung", hd.datei.includes("-klein"), hd.datei); pruefe("Handy: auch hier kein Zoom", hd.groesse === "cover", hd.groesse); const ueberlauf = await handy.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth); pruefe("Handy: nichts laeuft seitlich ueber", ueberlauf <= 1, ueberlauf + "px"); await handy.screenshot({ path: "pruef-vw-schmuck-handy.png" }); await handy.close(); const verpasst = fehlt.filter((f) => /\.(webp|jpg|png|css|js|svg)$/.test(f)); pruefe("keine fehlende Datei", verpasst.length === 0, verpasst.join(", ") || "keine"); console.log(`\n===== ${ok} bestanden, ${fehl} fehlgeschlagen =====`); await browser.close(); srv.close(); process.exit(fehl ? 1 : 0); });