Die Rechtsplattform der Spicy Media Agency fehlte -- dabei ist sie das technisch anspruchsvollste Stueck der Sammlung. WAS IM TEXT STEHT UND WARUM SO Die Projektdokumentation haelt ausdruecklich fest, dass die Wendung "100 Prozent rechtssicher" dort nirgends verwendet wird. Ein Portfolio, das mehr verspricht als die Sache selbst, waere genau der Fehler, den das Projekt vermeiden will. Im Ergebnis steht deshalb, dass die Plattform fertig gebaut und bewusst noch vollstaendig gesperrt ist und auf die schriftliche Freigabe einer Fachkanzlei wartet -- nicht, dass sie "im Einsatz" sei. KEIN LIVE-KNOPF Wie bei der Buchhaltung. Ein "Live ansehen", das auf eine Zugangswand fuehrt, bricht das Versprechen, das der Knopf gibt. Stattdessen steht dort ein Satz, der den Grund nennt. GESICHTER UNKENNTLICH Auf der Zugangswand stehen zwei Profilfotos. Eines davon gehoert einer Geschaeftspartnerin, die dieser Veroeffentlichung nicht zugestimmt hat. Beide sind in der Aufnahme weichgezeichnet, das Firmenzeichen bleibt scharf. Begruendet im alt-Text und in einem eigenen Abschnitt -- genau wie bei Projekt 5, wo derselbe Fall schon einmal auftrat. Nebenbei: Die strenge Inhaltsrichtlinie der Seite (style-src 'self') hat das eingefuegte Stylesheet fuers Weichzeichnen blockiert. Sie tut also genau das, wofuer sie da ist. Ueber die Stil-Eigenschaft im Skript ging es dann. ZWEI STELLEN, DIE SONST FALSCH GEBLIEBEN WAEREN Die Ueberschrift hiess "Fuenf Projekte, die man anfassen kann". Mit SafeAddress steht dort erstmals ein Projekt, das man NICHT besuchen kann -- die Ueberschrift haette also gleich doppelt daneben gelegen. Jetzt: "Sechs Projekte, die es wirklich gibt", und die Einleitung sagt ausdruecklich, dass eine der Seiten gesperrt ist. Der Schlussaufruf lud dazu ein, "das sechste" Projekt zu werden. Bei sechs vorhandenen waere das das Angebot gewesen, eines davon zu ersetzen. Jetzt das siebte. Beides in allen fuenf Sprachen, de-CH als echter Dialekt wie im Rest dieser Datei. TEST Zwei Fehlschlaege, beide berechtigt: die Projektzahl und ein zu grobes Suchmuster, das bei "zwei MENSCHEN" ansprang, obwohl es die veraltete Aussage "zwei PROJEKTE" sucht. Das Muster laesst jetzt hoechstens ein Wort zwischen Zahlwort und Substantiv zu. Mit Gegenprobe abgesichert: faengt weiterhin "Two projects you can visit", ignoriert "Two people work together on that site". Ein Fehlalarm, den man nur wegdrueckt, faengt beim naechsten Mal auch den echten Fall nicht mehr. 35 Pruefungen gruen, i18n vollstaendig. Co-Authored-By: Claude Opus 5 <[email protected]>
355 lines
15 KiB
JavaScript
355 lines
15 KiB
JavaScript
/* =====================================================================
|
|
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);
|
|
});
|