Faehrt der Zeiger ueber ein Bild, tritt es leicht naeher und wandert ein Stueck GEGEN die Zeigerrichtung -- als schaue man durch ein Fenster und lehne sich zur Seite. Warum gegen die Richtung: Bewegt sich das Bild MIT dem Zeiger, wirkt es wie ein Aufkleber, der verrutscht. Nur die Gegenbewegung liest das Auge als Tiefe hinter dem Rahmen. Derselbe Grund wie bei der Buehne im Verwaltungsbereich, eine Ebene kleiner. Der Effekt liegt im GRUNDSYSTEM, nicht in einer einzelnen Seite. Er gilt damit ueberall: Portfolio, Startseite, "Ueber mich", Zugangswand und die Vorschaubilder im Verwaltungsbereich. Eine Sonderloesung je Seite waere beim naechsten neuen Bild wieder vergessen worden. Zwei Zahlen, die bewusst klein sind: - Der Ausschlag liegt bei hoechstens 14 Punkten (gemessen 13,9). - Der Zoom bei 1,055. Zusammen ergibt das Bewegung, ohne dass ein Bild beim blossen Vorbeifahren seinen Ausschnitt merklich aendert. Ein groesserer Wert waere kein Effekt mehr, sondern ein Bildsprung. Umgerechnet wird auf die Groesse des jeweiligen Bildes (-1 bis +1), nicht in festen Bildpunkten. Sonst wanderten ein Vorschaubild von 1200 Punkten Breite und ein Logo von 80 gleich weit -- beim kleinen saehe das aus wie ein Ruck. Zwei Faelle, die im Code ausdruecklich abgefangen sind: - Der Zeiger liegt ueber einem Element, das das Bild UEBERDECKT (etwa einem Textblock in derselben Kachel). Ohne Behandlung bliebe das Bild stehen, sobald man den Rahmen verlaesst, aber die Kachel noch nicht. - Der Zeiger steht neben dem Bild, aber noch in der Kachel. Die Werte liefen dann weit ueber 1 hinaus und das Bild schoesse aus dem Rahmen. Deshalb wird auf -1 bis +1 begrenzt. Beim Verlassen stellt sich alles zurueck -- sonst bliebe das Bild verschoben stehen, nachdem der Zeiger laengst weg ist. Bei prefers-reduced-motion ist der Effekt aus. Geprueft: 265 Pruefungen gruen (Portfolio 20, Verwaltung 62, CRYONOVA 53, Blickfang 13, System 5, Abmelden 16, Abbruch 42, Angebot 54). Der Test prueft ausdruecklich die RICHTUNG der Bewegung, nicht nur, dass sich ueberhaupt etwas tut. Co-Authored-By: Claude Opus 5 <[email protected]>
262 lines
11 KiB
JavaScript
262 lines
11 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("\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,
|
|
}));
|
|
|
|
pruefe("das Bild reagiert auf den Zeiger",
|
|
linksOben.bx < -0.4 && rechtsUnten.bx > 0.4,
|
|
linksOben.bx + " -> " + rechtsUnten.bx);
|
|
|
|
/* Die Richtung ist der Kern: Bewegt sich das Bild MIT dem Zeiger,
|
|
wirkt es wie ein verrutschter Aufkleber. Nur die Gegenbewegung
|
|
liest das Auge als Tiefe hinter dem Rahmen. */
|
|
const holeX = (m) => parseFloat(m.slice(m.indexOf("(") + 1, -1).split(",")[4]);
|
|
const xLinks = holeX(linksOben.versatz);
|
|
const xRechts = holeX(rechtsUnten.versatz);
|
|
pruefe("und wandert GEGEN die Zeigerrichtung",
|
|
xLinks > 0 && xRechts < 0,
|
|
xLinks.toFixed(1) + "px / " + xRechts.toFixed(1) + "px");
|
|
|
|
/* Klein halten: Ein grosser Ausschnittswechsel beim blossen
|
|
Vorbeifahren waere ein Bildsprung, kein Effekt. */
|
|
pruefe("der Ausschlag bleibt klein",
|
|
Math.abs(xLinks) <= 18 && Math.abs(xRechts) <= 18,
|
|
Math.max(Math.abs(xLinks), Math.abs(xRechts)).toFixed(1) + "px");
|
|
|
|
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);
|
|
});
|