Files
dogfather-universe/pruef-portfolio.mjs
T
DogFatherGitandClaude Opus 5 686e8924bb Portfolio: Kacheln einer Reihe sind gleich gross
Die Projekttexte sind unterschiedlich lang, also waren auch die Kacheln
unterschiedlich hoch. Jetzt bekommen alle Kacheln einer Reihe dieselbe
Hoehe, naemlich die der hoechsten.

Dafuer waren drei Dinge noetig, und nur das erste ist offensichtlich:

1. "align-items: start" musste weg. Es liess jede Kachel ihre
   natuerliche Hoehe behalten -- genau das war der Fehler.

2. Der Inhalt muss die zusaetzliche Hoehe auch aufnehmen koennen.
   Ohne Flex-Aufbau in Kachel und Inhalt waere die Kachel zwar hoeher,
   ihr Inhalt bliebe aber oben kleben und darunter entstuende ein
   leeres Feld.

3. Das letzte Element sitzt am unteren Rand.

DER PUNKT, DER FAST DURCHGERUTSCHT WAERE

Gleich hohe Kacheln heissen NICHT automatisch, dass der Inhalt buendig
steht. Nach Schritt 1 und 2 waren die Kacheln exakt gleich hoch -- und
die Knoepfe standen trotzdem auf verschiedener Hoehe.

Grund: Die Abstaende standen als style-Angabe direkt im HTML
(style="margin-top:1rem"). Eine solche Angabe schlaegt jede Regel aus
dem Stilblatt, das "margin-top: auto" lief also ins Leere. Fuenf
Vorkommen entfernt.

Danach blieben 33 gegen 48 Punkte Abstand zum Kachelboden: Ein Absatz
bringt einen eigenen Abstand nach unten mit, eine Knopfreihe nicht.
Auch das ist jetzt vereinheitlicht.

Die Regel greift ueber :last-child statt nur ueber die Knopfreihe --
die Buchhaltungs-Kachel hat naemlich gar keinen Knopf, sondern einen
Hinweistext an dieser Stelle. Der soll genauso unten stehen.

DIE PRUEFUNG MISST BEIDES GETRENNT

Einmal "jede Reihe ist in sich gleich hoch", einmal "die letzten
Elemente stehen auf einer Linie". Ein einzelner Test auf die Hoehe
haette den Knopf-Versatz nie bemerkt -- die Kacheln waren ja bereits
gleich hoch, als die Knoepfe noch verrutscht waren.

Gemessen: 1129/1129, 881/881, 831 -- und ueberall 33 Punkte Abstand zum
Kachelboden.

Geprueft: 313 Pruefungen gruen (Portfolio 34, Bewegung 34, Verwaltung 62,
CRYONOVA 53, Blickfang 13, System 5, Abmelden 16, Abbruch 42,
Angebot 54).

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-25 13:28:31 +02:00

340 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 FUENF 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("fuenf Projekte auf der Seite", projekte.length === 5, 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)) {
if (/\b(Zwei|Two|Deux|Dois|Beide|Both|Ambos)\b/i.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);
});