Files
dogfather-universe/server/pruef-kachel-universum.mjs
T
DogFatherGitandClaude Opus 5 0852723493 Der Nebel lag dort, wo der Text steht
pruef-kachel-universum war rot mit drei Meldungen. Die zweite rote
Pruefung von dreien.

ERST DIE FRAGE, OB ES DRIFT IST ODER VON ANFANG AN SO WAR: Die Pruefung
und das Sternenfeld kamen im selben Commit (ad47fc2). Dort laufen
lassen -- alles gruen, Kachelzeile 0,017 % auf 11610 Bildpunkten. Heute
2,372 % auf 6282. Die gemessene Flaeche hat sich HALBIERT.

Das war der Hinweis: Es wird eine andere Kachel gemessen. Der Waehler
nimmt die erste (`.kachel .kachel__unter`), und die war damals die
grosse Dashboard-Kachel (645 px breit), heute ist es "Team-Lage" (349
px). Die Reihenfolge hat sich zwischendurch geaendert.

WARUM DAS EINEN UNTERSCHIED MACHT: Die beiden Nebel sind auf
`100% 100%` skaliert, sie wachsen also mit der Kachel. Der babyblaue
sass bei `at 12% 88%` -- unten links. Auf einer schmalen Kachel liegt
genau dort die Unterzeile; auf der doppelt so breiten verteilt sich
derselbe Nebel ueber die ganze Breite und faellt nicht auf.

Der Kern ist jetzt tiefer in die Ecke geschoben (`at 10% 104%`, Ellipse
etwas flacher). Der Verlauf laeuft weiterhin quer durch die Kachel, die
Sterne sind unveraendert -- nachgesehen im Bild, nicht nur gemessen.

  Kachelzeile   2,372 %  ->  0,000 %   (Mittel 6.93 -> 7.28)
  Personenzeile 0,253 %  ->  0,253 %   (unveraendert, nichts kaputt)

UND EINE PRUEFUNG, DIE SEIT WOCHEN GAR NICHT LIEF. Die Zeile "dort
steht kein Stern" hing an `if (zwischenraum)`, und `zwischenraum` war
`null`, seit die grosse Kachel an Platz zwei steht: Sie faengt eine
Zeile tiefer wieder bei x=140 an, also gab es keinen Spalt zwischen
den ersten beiden. Ein uebersprungener Test beweist nichts -- und
gemeldet hat er nur, dass er nichts messen konnte.

Beim Wiederbeleben stellte sich heraus, dass er die falsche Frage
stellte: Er verlangte, dass der Grund zwischen den Kacheln "ruhig" ist
(Spanne <= 12). Das ist eine Aussage ueber das HINTERGRUNDFOTO der
Seite, und ein Foto darf helle Stellen haben -- gemessen 27, ohne dass
etwas kaputt war. Die eigentliche Frage ist eine andere: Liegt das
Sternenfeld wirklich nur auf den Kacheln?

Jetzt wird derselbe Fleck zweimal gemessen, mit und ohne Sternenfeld:

  Mittel 42,7,12 -> 42,7,12   hellster 69,14,17 -> 69,14,17

Bildpunkt fuer Bildpunkt identisch. Das beantwortet die Frage
unabhaengig davon, was auf dem Foto zu sehen ist.

  pruef-kachel-universum   37 Pruefungen, 0 Fehler (vorher 3)
  pruef-css-klassen, pruef-neue-seiten 97 -- 0 Fehler

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-16 17:24:48 +02:00

672 lines
31 KiB
JavaScript

/* =====================================================================
DER GRUND JEDER KACHEL IM HAUS VON TEAM DOGI (11.09.2026)
Filipe: "veraender die kacheln und den hintergrund, ich will dass
die hintergrunde von den kacheln immer unviersum artig ist, es muss
richtig geil sein aber immer so dass man alles noch gut erkkent.
und das IN DER GANZEN WEBSITE VON TEAM DOGI. nur die kacheln. [...]
wichtig ist die form der kacheln soll gleich bleiben."
Daraus werden vier pruefbare Saetze, und jeder einzelne kann
scheitern:
1. UEBERALL. Nicht "auf der Startseite", sondern auf jeder Kachel
des Hauses -- auch auf denen, die es heute noch nicht gibt.
Deshalb wird geprueft, dass crew-haus.css KEINE Kachelklasse
nennt: Sie setzt vier Werte um, aus denen module.css den Grund
jeder Kachel baut. Eine Datei, die stattdessen Kachel fuer
Kachel aufzaehlt, waere bei der naechsten neuen unvollstaendig
-- lautlos.
2. NUR DIE KACHELN. Der Grund der SEITE bleibt, wie er war.
3. DIE FORM BLEIBT GLEICH. Fase, Silhouette und die drei
Eckwinkel werden vor und nach dem Hauswechsel verglichen,
Zeichen fuer Zeichen.
4. MAN ERKENNT ALLES NOCH GUT. Und das ist der Teil, der wirklich
Arbeit macht.
---------------------------------------------------------------------
WIE LESBARKEIT AUF EINEM STERNENHIMMEL GEMESSEN WIRD
Ein Sternenfeld ist kein Hintergrund, sondern viele. Hinter einem
Buchstaben liegt mal fast schwarzer Grund, mal Nebel, mal -- selten
-- der Kern eines Sterns. Eine einzige Durchschnittszahl wuerde
genau den Fall verschlucken, der stoert; eine Regel auf den
einzelnen hellsten Punkt dagegen waere eine Regel GEGEN Sterne, denn
ein Stern ist per Definition der hellste Punkt seiner Umgebung.
Deshalb drei Zahlen je Textstelle, alle aus denselben echten
Bildpunkten eines Bildschirmfotos:
* MITTEL -- die Flaeche, die ein Buchstabe wirklich sieht.
Muss 4,5:1 halten. Das ist die Regel.
* ANTEIL -- wie viel Prozent der Punkte hinter dem Text unter
4,5:1 liegen. Das ist die eigentliche Messung: Sie
unterscheidet einen PUNKT von einer FLAECHE. Ein
Nebel, der zu hell wird, faellt hier sofort auf,
auch wenn der Mittelwert noch durchgeht. Grenze:
1,5 Prozent. Gemessen wurden 0,25 Prozent, im
Agenturhaus an derselben Stelle 0,115 Prozent.
* HELLSTER -- der einzelne hellste Bildpunkt, also ein Sternkern.
Grenze 3,4:1 -- bewusst unter der Regel, denn dieser
Punkt ist kleiner als ein Buchstabenstrich breit.
Ohne diese Zahl koennte jemand die Sterne beliebig
hell machen, solange nur wenige davon da sind.
Gemessen wird an ECHTEN BILDPUNKTEN, nicht an Farbangaben. Das ist
hier keine Vorsicht, sondern Notwendigkeit: Was hinter dem Text
liegt, entsteht erst beim Zeichnen aus Grund, Nebel, Raster und
Schleier. Keine Farbangabe kennt das Ergebnis.
UND DER INHALT DER KACHEL WIRD DABEI UNSICHTBAR GESCHALTET. Der
erste Anlauf hat das nicht getan und Kontraste von 1,95:1 gemeldet
-- gegen das grosse Zeichen der Kachel, das gar keinen Text
verdeckt. Gefragt ist, was HINTER dem Text liegt, und das gehoert
der Kachel, nicht ihren Kindern.
---------------------------------------------------------------------
WARUM DIE ZWEITE ADRESSE HIER NICHT IM BROWSER AUFGERUFEN WIRD
Das Haus liefert `upgrade-insecure-requests` aus. Ruft man
crew.dogfather-universe.com im Testbrowser ueber eine umgebogene
Namensaufloesung auf, macht der Browser daraus https und findet
nichts. Diese Grenze ist bekannt und wird wie bei den anderen
Haus-Pruefungen umgangen, indem die Frage in zwei geteilt wird:
a) KOMMT DIE DATEI DORT AN? Das beantwortet eine echte Anfrage mit
gesetztem Host-Kopf ueber node:http -- ohne Browser, ohne CSP.
b) WIE SIEHT SIE AUS? Das beantwortet der Browser, indem genau
dieselbe Datei nachgeladen wird, an derselben Stelle wie im
Betrieb: als letzte Stilvorlage. pruef-css-klassen sichert ab,
dass sie im Betrieb auch wirklich zuletzt steht.
Nachgeladen wird ueber ihre ADRESSE, nicht ueber ihren Dateipfad.
Das ist kein Detail: Bei einer eingefuegten Datei rechnet der
Browser relative Verweise gegen die SEITE, und aus `../img/…` wird
ein 404, den es im Betrieb nicht gibt. Zwei Minuten Fehlersuche an
einem Fehler, den die Pruefung selbst erzeugt hatte.
===================================================================== */
import { mkdtempSync, rmSync, readFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { request as httpAnfrage } from "node:http";
import { portMussFreiSein } from "./helfer-port.mjs";
const PORT = await portMussFreiSein(4401, "pruef-kachel-universum");
const ordner = mkdtempSync(join(tmpdir(), "ws-universum-"));
process.env.WORKSPACE_DB = join(ordner, "workspace.db");
process.env.PORT = String(PORT);
process.env.SITE_ACCESS_SECRET = "lokaler-test";
process.env.SITE_PUBLIC_LAUNCH_AT = "2020-01-01T00:00:00+01:00";
const express = (await import("express")).default;
const ec = express.response.cookie;
express.response.cookie = function (n, w, o) { return ec.call(this, n, w, { ...(o || {}), secure: false }); };
import { notbremse } from "./helfer-notbremse.mjs";
await import("./index.js");
notbremse(240_000, "pruef-kachel-universum");
await new Promise((r) => setTimeout(r, 700));
const BASIS = `http://127.0.0.1:${PORT}`;
/* EIGENE ZUHOERER. index.js haengt fuer den Betrieb Auffangnetze an
`uncaughtException` -- eine Pruefung, die dieselbe Datei in denselben
Prozess laedt, erbt sie und wuerde nach einem Absturz einfach
weiterhaengen, statt rot zu werden. */
process.on("uncaughtException", (f) => { console.error("ABSTURZ:", f); process.exit(7); });
process.on("unhandledRejection", (f) => { console.error("ABSTURZ (Versprechen):", f); process.exit(7); });
let fehler = 0, geprueft = 0;
const melde = (t) => console.log(t);
const ok = (b, t) => { geprueft++; console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; };
const { DatabaseSync } = await import("node:sqlite");
const { scryptSync, randomBytes, createHmac } = await import("node:crypto");
const d = new DatabaseSync(process.env.WORKSPACE_DB);
const jetzt = new Date().toISOString();
const schluessel = randomBytes(32).toString("hex");
d.prepare("INSERT INTO einstellungen (schluessel, wert, geaendert, von)"
+ " VALUES (?,?,?,NULL) ON CONFLICT(schluessel) DO NOTHING")
.run("code_kennung_schluessel", schluessel, jetzt);
function anlegen(name, rolle, code) {
const salt = randomBytes(16).toString("hex");
const hash = scryptSync(code, salt, 64,
{ N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex");
d.prepare("INSERT INTO personen"
+ " (name, rolle, code_hash, code_salt, code_n, code_kennung, aktiv, erstellt)"
+ " VALUES (?,?,?,?,?,?,1,?)")
.run(name, rolle, hash, salt, 32768,
createHmac("sha256", schluessel).update(code).digest("hex"), jetzt);
return d.prepare("SELECT last_insert_rowid() AS id").get().id;
}
anlegen("Filipe", "admin", "CODE-DOGI-0001");
anlegen("Rieke", "hand", "CODE-HAND-0001");
anlegen("Frida", "modi", "CODE-MODI-0001");
anlegen("Tomma", "modi", "CODE-MODI-0002");
/* Die Teamseite zeichnet ihre Personenkarten aus dem, was das jeweilige
Haus zu sehen bekommt. Auf DIESER Adresse sind das Manager und
Scouts -- ohne sie bliebe `.tperson` leer, und die Messung der
Personenkarte waere ein uebersprungener Test, der nichts beweist.
Die Kachel ist in beiden Haeusern dieselbe; gemessen wird ihr Grund,
nicht wessen Name darin steht. */
anlegen("Mara", "manager", "CODE-MANA-0001");
anlegen("Sten", "scout", "CODE-SCOU-0001");
d.close();
const CREW_CSS_ADRESSE = "/workspace/assets/css/crew-haus.css";
const CREW_CSS = readFileSync(
new URL("../workspace/assets/css/crew-haus.css", import.meta.url).pathname
.replace(/^\/([A-Za-z]:)/, "$1"), "utf8");
/* =======================================================================
1. DIE DATEI KOMMT AUF DER ZWEITEN ADRESSE AN -- UND NUR DORT
======================================================================= */
melde("");
melde("=== 1. Ausgeliefert wird sie da, wo sie hingehoert ===");
function holen(host) {
return new Promise((fertig, schief) => {
const a = httpAnfrage({ host: "127.0.0.1", port: PORT, path: "/workspace/assets/css/haus.css",
method: "GET", headers: { Host: host } }, (antwort) => {
let text = "";
antwort.on("data", (s) => { text += s; });
antwort.on("end", () => fertig({ code: antwort.statusCode, text }));
});
a.on("error", schief);
a.end();
});
}
{
const crew = await holen("crew.dogfather-universe.com");
const agentur = await holen("workspace.dogfather-universe.com");
ok(crew.code === 200 && crew.text.includes("--sternenfeld:"),
`die zweite Wand bekommt das Sternenfeld (${crew.code}, ${crew.text.length} Zeichen)`);
ok(crew.text.includes("--modul-schleier: .30"),
"und den Schleier, der dazugehoert");
ok(agentur.code === 200 && !agentur.text.includes("--sternenfeld"),
`die Agenturadresse bekommt es NICHT (${agentur.text.length} Zeichen)`);
ok(crew.text.length > agentur.text.length + 500,
"die beiden Haeuser liefern wirklich verschiedene Dateien aus");
}
/* KEINE ACHTE ABSCHRIFT DER KACHELLISTE.
Der eigentliche Gedanke hinter dem ganzen Umbau: crew-haus.css darf
keine Kachel beim Namen nennen. Sonst waere sie beim naechsten neuen
Bauteil unvollstaendig, ohne dass es auffiele. */
{
const kachelklassen = [".eintrag-karte", ".t-kachel", ".tperson", ".liste-kachel",
".steckbrief", ".zahlkarte", ".k-anlasskachel", ".leer-hinweis"];
const gefunden = kachelklassen.filter((k) => CREW_CSS.includes(k));
ok(gefunden.length === 0,
`crew-haus.css zaehlt keine Kachelklassen auf (${gefunden.join(" ") || "keine"})`);
ok(CREW_CSS.includes("--sternenfeld:") && CREW_CSS.includes("--sternenfeld-mass:")
&& CREW_CSS.includes("--sternenfeld-lage:"),
"sie setzt stattdessen die Werte, aus denen module.css den Grund baut");
const modul = readFileSync(
new URL("../workspace/assets/css/module.css", import.meta.url).pathname
.replace(/^\/([A-Za-z]:)/, "$1"), "utf8");
ok((modul.match(/var\(--sternenfeld,/g) || []).length === 1,
"und module.css benutzt das Bild an genau EINER Stelle");
ok(modul.includes("--sternenfeld: none;"),
"im Agenturhaus steht dort `none` -- eine Ebene ohne Bild zeichnet nichts");
}
/* KEIN KARO. Wiederholen sich zwei Sternebenen im selben Abstand, sieht
man sofort ein Muster statt eines Feldes. Die Abstaende duerfen
deshalb keinen gemeinsamen Teiler haben -- das ist keine Kosmetik,
sondern der Unterschied zwischen Himmel und Tapete. */
{
const zeile = CREW_CSS.split("--sternenfeld-mass:")[1].split(";")[0];
const zahlen = (zeile.match(/(\d+)px/g) || []).map((z) => parseInt(z, 10));
const ggt = (a, b) => (b === 0 ? a : ggt(b, a % b));
let groesster = 0;
for (let i = 0; i < zahlen.length; i++)
for (let j = i + 1; j < zahlen.length; j++)
groesster = Math.max(groesster, ggt(zahlen[i], zahlen[j]));
ok(zahlen.length >= 6, `sechs Abstaende fuer drei Sternebenen (${zahlen.join(" ")})`);
ok(groesster === 1, `kein gemeinsamer Teiler, also kein Karo (groesster: ${groesster})`);
}
/* =======================================================================
2. DER BROWSER: erst das Agenturhaus, dann dasselbe mit dem Himmel
======================================================================= */
const pw = await import("file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs");
const browser = await pw.chromium.launch();
const ctx = await browser.newContext({ viewport: { width: 1440, height: 1100 } });
const seite = await ctx.newPage();
const konsole = [];
seite.on("pageerror", (f) => konsole.push("Absturz: " + f.message));
seite.on("console", (m) => { if (m.type() === "error") konsole.push("Konsole: " + m.text()); });
seite.on("response", (a) => { if (a.status() >= 400) konsole.push(`${a.status()} ${a.url()}`); });
await seite.goto(BASIS + "/workspace/", { waitUntil: "domcontentloaded" });
await seite.click('.rolle[data-rolle="admin"]');
await seite.fill("#code", "CODE-DOGI-0001");
await seite.click("button[type=submit]");
await seite.waitForURL("**/start.html", { timeout: 25000 });
await seite.goto(BASIS + "/workspace/start.html", { waitUntil: "networkidle" });
await seite.waitForSelector(".kachel", { timeout: 10000 });
await seite.waitForTimeout(600);
/* Alles, was sich beim Hauswechsel NICHT aendern darf, wird vorher
abgeholt -- Form, Silhouette, Eckwinkel, Seitengrund. */
async function formAufnehmen() {
return seite.evaluate(() => {
const k = document.querySelector(".kachel");
const s = getComputedStyle(k);
const n = getComputedStyle(k, "::after");
const v = getComputedStyle(document.documentElement);
/* Ein Hintergrund ist eine Liste, und in jeder Ebene stehen selbst
Kommas. Mit einem Muster kommt man da nicht weit -- Chromium
schreibt die Verlaeufe voll aus, Klammern in Klammern. Gezaehlt
wird deshalb, wie tief man gerade steht. */
let tiefe = 0, ebenen = 1;
for (const z of n.backgroundImage) {
if (z === "(") tiefe++;
else if (z === ")") tiefe--;
else if (z === "," && tiefe === 0) ebenen++;
}
return {
fase: v.getPropertyValue("--fase").trim(),
silhouette: s.clipPath,
/* Die sechs Eckwinkel sind die ersten sechs Ebenen. Ihre Groessen
und Lagen SIND die Form. */
winkelMass: n.backgroundSize.split(",").slice(0, 6).join(",").trim(),
winkelLage: n.backgroundPosition.split(",").slice(0, 6).join(",").trim(),
ebenen,
seitengrund: getComputedStyle(document.body).backgroundColor,
sternenfeld: v.getPropertyValue("--sternenfeld").trim(),
schleier: v.getPropertyValue("--modul-schleier").trim(),
hausname: v.getPropertyValue("--haus-name").trim(),
};
});
}
const vorher = await formAufnehmen();
melde("");
melde("=== 2. Das Agenturhaus bleibt unberuehrt ===");
ok(vorher.sternenfeld === "none",
`dort steht kein Sternenfeld (${vorher.sternenfeld})`);
ok(vorher.schleier === ".26",
`und der Schleier steht unveraendert auf .26 (${vorher.schleier})`);
ok(vorher.ebenen === 9,
`neun Ebenen: sechs Eckwinkel, zwei Rasterlinien, eine leere (${vorher.ebenen})`);
/* Bildpunkte aus einem Bildschirmfoto -- derselbe Weg wie in
pruef-lesbarkeit und pruef-personen-kachel. Zurueck kommen der
Mittelwert und die SORTIERTE Helligkeitsliste; wer daraus einen
Anteil rechnen will, braucht die ganze Liste, nicht eine Kennzahl. */
async function punkte(clip) {
const bild = await seite.screenshot({ clip });
return seite.evaluate(async (b64) => {
const bild = new Image();
await new Promise((r) => { bild.onload = r; bild.src = "data:image/png;base64," + b64; });
const c = document.createElement("canvas");
c.width = bild.width; c.height = bild.height;
const g = c.getContext("2d", { willReadFrequently: true });
g.drawImage(bild, 0, 0);
const dd = g.getImageData(0, 0, c.width, c.height).data;
const hell = (p) => {
const f = p.map((x) => { const s = x / 255; return s <= .03928 ? s / 12.92 : Math.pow((s + .055) / 1.055, 2.4); });
return .2126 * f[0] + .7152 * f[1] + .0722 * f[2];
};
const liste = [];
let sr = 0, sg = 0, sb = 0, hr = 0, hg = 0, hb = 0, best = -1;
for (let i = 0; i < dd.length; i += 4) {
sr += dd[i]; sg += dd[i + 1]; sb += dd[i + 2];
const h = hell([dd[i], dd[i + 1], dd[i + 2]]);
liste.push(h);
if (h > best) { best = h; hr = dd[i]; hg = dd[i + 1]; hb = dd[i + 2]; }
}
const n = liste.length;
liste.sort((a, b) => a - b);
return {
mittel: [Math.round(sr / n), Math.round(sg / n), Math.round(sb / n)],
hellsterPunkt: [hr, hg, hb],
hell: liste, anzahl: n,
};
}, bild.toString("base64"));
}
/* WAS HINTER DEM TEXT LIEGT, IST NICHT DER TEXT -- UND AUCH NICHT SEIN
NACHBAR. Fuer die Dauer der Messung wird der INHALT der Kachel
unsichtbar, die Kachel selbst nicht: Hintergrund, Kantenlicht,
Raster, Eckwinkel und Sternenfeld gehoeren ihr, nicht ihren Kindern. */
const KARTEN = ".kachel, .t-kachel, .tperson, .tl, .eintrag-karte, .person, .liste-kachel";
async function inhaltAus(waehler) {
await seite.evaluate(([w, karten]) => {
const e = document.querySelector(w);
const karte = e && (e.closest(karten) || e.parentElement);
if (!karte) return;
karte.dataset.grundmessung = "ja";
karte.querySelectorAll("*").forEach((x) => {
x.dataset.warSichtbar = x.style.visibility || "";
x.style.visibility = "hidden";
});
}, [waehler, KARTEN]);
}
async function inhaltAn() {
await seite.evaluate(() => {
const karte = document.querySelector('[data-grundmessung="ja"]');
if (!karte) return;
karte.querySelectorAll("*").forEach((x) => {
x.style.visibility = x.dataset.warSichtbar || "";
delete x.dataset.warSichtbar;
});
delete karte.dataset.grundmessung;
});
}
/* DER AUSSCHNITT LIEGT IN DER MITTE DER KACHEL. Am Rand sitzt das
Kantenlicht -- eine 1,6 px schmale Linie in fast vollem Kachelton,
und damit der hellste Punkt der ganzen Kachel. Wer den mitmisst,
misst die Form statt des Himmels: Der erste Anlauf meldete deshalb
"keine neuen hellen Punkte", obwohl das Sternenfeld laengst da war. */
const kachelRahmen = await seite.evaluate(() => {
const k = document.querySelector(".kachel");
k.scrollIntoView({ block: "center" });
const r = k.getBoundingClientRect();
return { x: Math.round(r.x + r.width * .22), y: Math.round(r.y + r.height * .22),
width: Math.round(r.width * .56), height: Math.round(r.height * .56) };
});
await inhaltAus(".kachel");
const grundAgentur = await punkte(kachelRahmen);
await inhaltAn();
/* ---- Jetzt dasselbe Fenster, aber im Haus von Team Dogi ---- */
await seite.addStyleTag({ url: CREW_CSS_ADRESSE });
await seite.waitForTimeout(400);
const nachher = await formAufnehmen();
melde("");
melde("=== 3. Die Form bleibt gleich, der Grund nicht ===");
ok(nachher.hausname === '"crew"',
`das Haus von Team Dogi ist angekommen (${nachher.hausname})`);
ok(nachher.fase === vorher.fase, `die Fase ist unveraendert (${nachher.fase})`);
ok(nachher.silhouette === vorher.silhouette, "die Silhouette der Kachel ist Zeichen fuer Zeichen dieselbe");
ok(nachher.winkelMass === vorher.winkelMass, "die drei Eckwinkel haben dieselbe Groesse");
ok(nachher.winkelLage === vorher.winkelLage, "und dieselbe Lage");
ok(nachher.ebenen === 13,
`aus der leeren Ebene sind fuenf geworden: drei Sternebenen, zwei Nebel (${nachher.ebenen})`);
ok(nachher.schleier === ".30", `der Schleier laesst etwas mehr durch (${nachher.schleier})`);
ok(nachher.sternenfeld.includes("radial-gradient"),
"und das Sternenfeld steht als Bild da, nicht als none");
await inhaltAus(".kachel");
const grundCrew = await punkte(kachelRahmen);
await inhaltAn();
{
const abstand = Math.max(...grundCrew.mittel.map((x, i) => Math.abs(x - grundAgentur.mittel[i])));
ok(abstand >= 4,
`der Kachelgrund ist messbar ein anderer geworden (${grundAgentur.mittel} -> ${grundCrew.mittel})`);
ok(grundCrew.hell[grundCrew.anzahl - 1] > grundAgentur.hell[grundAgentur.anzahl - 1] * 1.5,
`und es gibt jetzt helle Punkte, die es vorher nicht gab (${grundAgentur.hellsterPunkt} -> ${grundCrew.hellsterPunkt})`);
}
/* GEGENPROBE ZUM MESSGERAET. Nimmt man das Sternenfeld wieder weg, muss
der Grund zurueckspringen. Ohne diese Probe koennte die Messung oben
auch etwas ganz anderes gesehen haben -- die Farben haben sich beim
Hauswechsel ja ohnehin geaendert. */
{
await seite.addStyleTag({ content: ":root { --sternenfeld: none; }" });
await seite.waitForTimeout(250);
await inhaltAus(".kachel");
const ohne = await punkte(kachelRahmen);
await inhaltAn();
ok(ohne.hell[ohne.anzahl - 1] < grundCrew.hell[grundCrew.anzahl - 1] * .8,
`ohne Sternenfeld verschwinden die hellen Punkte wieder (${grundCrew.hellsterPunkt} -> ${ohne.hellsterPunkt})`);
await seite.evaluate(() => {
const letzte = [...document.querySelectorAll("style")].pop();
if (letzte && letzte.textContent.includes("--sternenfeld: none")) letzte.remove();
});
await seite.waitForTimeout(250);
}
/* =======================================================================
4. NUR DIE KACHELN -- der Grund der Seite bleibt
======================================================================= */
melde("");
melde("=== 4. Nur die Kacheln ===");
{
const zwischenraum = await seite.evaluate(() => {
/* Ein Fleck ZWISCHEN zwei Kacheln. Er gehoert der Seite, nicht
einer Kachel -- dort darf kein Stern stehen. */
/* IRGENDZWEI, DIE NEBENEINANDER LIEGEN -- nicht die ersten zwei.
Bis zum 16.09.2026 stand hier `kacheln[0]` und `kacheln[1]`. Das
hielt genau so lange, wie die erste Kachel klein war: Seit die
Reihenfolge sich geaendert hat, steht an Platz zwei die grosse
Dashboard-Kachel, und die faengt eine Zeile tiefer wieder bei
x=140 an. Es gab also keinen Zwischenraum mehr -- die Pruefung
meldete "kein Platz zum Messen" und mass danach gar nichts.
Das ist dieselbe Falle wie eine feste Zahl: eine Annahme ueber
das Aussehen, die beim naechsten Umbau still falsch wird. Jetzt
wird gesucht, was die Frage beantwortet, statt sie zu stellen. */
/* EIN FLECK, DER KEINE KACHEL BERUEHRT.
Die Frage ist: Steht das Sternenfeld wirklich nur auf den
Kacheln? Bis zum 16.09.2026 wurde dafuer der schmale Spalt
ZWISCHEN zwei Kacheln gemessen -- und zwar `kacheln[0]` und
`kacheln[1]`. Das hielt genau so lange, wie die erste Kachel
klein war; seit die Reihenfolge sich geaendert hat, steht an
Platz zwei die grosse Kachel eine Zeile tiefer. Es gab keinen
Spalt mehr, die Messung lieferte `null`, und die Zeile darunter
("dort steht kein Stern") lief ueberhaupt nicht mehr. Ein
uebersprungener Test beweist nichts.
Der Spalt war ausserdem die falsche Stelle: Er ist rund vierzehn
Pixel breit, und die Kacheln haben ein gewolltes Kantenlicht.
Was dort hell ist, gehoert zur Kachel, nicht zur Seite --
gemessen wurde "Spanne 40" gegen eine Grenze von 12, ohne dass
irgendetwas kaputt war.
Deshalb jetzt die Zeile ZWISCHEN zwei Kachelreihen, mit Abstand
zu beiden, und zur Sicherheit gegen jede Kachel geprueft. Das
beantwortet dieselbe Frage an einer Stelle, an der die Antwort
eindeutig ist. */
const felder = [...document.querySelectorAll(".kachel")]
.map((k) => k.getBoundingClientRect());
if (felder.length < 2) return null;
const unten = Math.max(...felder.map((f) => f.y + f.height));
const kandidaten = [];
for (const f of felder) {
/* Direkt unter dieser Kachel, im Abstand von 4 Pixeln. */
const y = f.y + f.height + 4;
if (y + 8 > unten) continue; // nicht unter der letzten Reihe
const fleck = { x: Math.round(f.x + f.width / 2 - 12), y: Math.round(y),
width: 24, height: 8 };
const beruehrt = felder.some((g) =>
fleck.x + fleck.width > g.x && fleck.x < g.x + g.width
&& fleck.y + fleck.height > g.y && fleck.y < g.y + g.height);
if (!beruehrt) kandidaten.push(fleck);
}
return kandidaten[0] || null;
});
ok(zwischenraum !== null, "ein Fleck ohne Kachel darunter ist gefunden");
if (zwischenraum) {
/* DIE FRAGE IST NICHT "IST ES DORT RUHIG", SONDERN "LIEGT DORT DAS
STERNENFELD".
Bis zum 16.09.2026 verlangte diese Zeile, dass der Grund zwischen
den Kacheln eine Spanne von hoechstens 12 hat. Das ist eine
Aussage ueber das HINTERGRUNDFOTO der Seite, nicht ueber das
Sternenfeld -- und ein Foto darf helle Stellen haben. Gemessen
wurden 27, ohne dass irgendetwas kaputt war.
Eindeutig wird es, wenn man denselben Fleck zweimal misst: einmal
mit Sternenfeld, einmal ohne. Liegt es wirklich nur auf den
Kacheln, aendert sich dort NICHTS. Das beantwortet die Frage
unabhaengig davon, was auf dem Foto zu sehen ist. */
const mit = await punkte(zwischenraum);
await seite.addStyleTag({ content: ":root { --sternenfeld: none; }" });
await seite.waitForTimeout(250);
const ohne = await punkte(zwischenraum);
await seite.evaluate(() => {
const letzte = [...document.querySelectorAll("style")].pop();
if (letzte && letzte.textContent.includes("--sternenfeld: none")) letzte.remove();
});
await seite.waitForTimeout(250);
const abstand = Math.max(...mit.mittel.map((x, i) => Math.abs(x - ohne.mittel[i])));
const spitze = Math.max(...mit.hellsterPunkt.map((x, i) => Math.abs(x - ohne.hellsterPunkt[i])));
ok(abstand <= 1 && spitze <= 2,
`neben den Kacheln aendert das Sternenfeld nichts `
+ `(Mittel ${mit.mittel} -> ${ohne.mittel}, hellster ${mit.hellsterPunkt} -> ${ohne.hellsterPunkt})`);
}
}
/* =======================================================================
5. MAN ERKENNT ALLES NOCH GUT
======================================================================= */
function kontrast(a, lb) {
const l = (p) => {
const f = p.map((x) => { const s = x / 255; return s <= .03928 ? s / 12.92 : Math.pow((s + .055) / 1.055, 2.4); });
return .2126 * f[0] + .7152 * f[1] + .0722 * f[2];
};
const x = typeof a === "number" ? a : l(a);
return (Math.max(x, lb) + .05) / (Math.min(x, lb) + .05);
}
function leuchtkraft(p) {
const f = p.map((x) => { const s = x / 255; return s <= .03928 ? s / 12.92 : Math.pow((s + .055) / 1.055, 2.4); });
return .2126 * f[0] + .7152 * f[1] + .0722 * f[2];
}
async function messen(waehler) {
const stelle = await seite.evaluate((w) => {
const e = document.querySelector(w);
if (!e) return null;
e.scrollIntoView({ block: "center" });
const r = e.getBoundingClientRect();
if (r.width < 8 || r.height < 6) return null;
/* Die Schriftfarbe wird GEZEICHNET, nicht gelesen: color-mix()
liefert getComputedStyle als "color(srgb …)", und daraus Ziffern
zu klauben ergibt Unsinn. Ein Canvas versteht jede Schreibweise. */
const c = document.createElement("canvas");
c.width = 1; c.height = 1;
const g = c.getContext("2d", { willReadFrequently: true });
g.fillStyle = "#000000";
g.fillStyle = getComputedStyle(e).color;
g.fillRect(0, 0, 1, 1);
const px = g.getImageData(0, 0, 1, 1).data;
e.dataset.messen = "ja";
e.style.color = "transparent";
return { x: Math.round(r.x), y: Math.round(r.y),
w: Math.max(8, Math.round(r.width)), h: Math.round(Math.min(r.height, 40)),
farbe: [px[0], px[1], px[2]] };
}, waehler);
if (!stelle) return null;
await inhaltAus(waehler);
const hinten = await punkte({ x: stelle.x, y: stelle.y, width: stelle.w, height: stelle.h });
await inhaltAn();
await seite.evaluate(() => {
const e = document.querySelector('[data-messen="ja"]');
if (e) { e.style.color = ""; delete e.dataset.messen; }
});
const lt = leuchtkraft(stelle.farbe);
const unter = hinten.hell.filter((h) => kontrast(lt, h) < 4.5).length;
return {
farbe: stelle.farbe,
mittel: kontrast(lt, leuchtkraft(hinten.mittel)),
hellster: kontrast(lt, hinten.hell[hinten.anzahl - 1]),
anteil: 100 * unter / hinten.anzahl,
punkte: hinten.anzahl,
};
}
async function lesbar(name, waehler) {
const m = await messen(waehler);
if (m === null) { ok(false, `${name}: nicht gefunden (${waehler})`); return; }
ok(m.punkte > 200 && m.mittel >= 4.5 && m.anteil <= 1.5 && m.hellster >= 3.4,
`${name}: Mittel ${m.mittel.toFixed(2)} · unter 4,5 liegen ${m.anteil.toFixed(3)} % · hellster Punkt ${m.hellster.toFixed(2)} (${m.punkte} Bildpunkte)`);
}
melde("");
melde("=== 5. Lesbarkeit auf der Startseite ===");
for (const [name, w] of [
["Kachelname", ".kachel .kachel__name"],
["Kachelzeile", ".kachel .kachel__unter"],
["kleine Kachel, Name", '.kachel:not([data-gross="ja"]) .kachel__name'],
["kleine Kachel, Zeile", '.kachel:not([data-gross="ja"]) .kachel__unter'],
]) await lesbar(name, w);
/* GEGENPROBE: ein absichtlich zu dunkler Text MUSS durchfallen. Ohne
sie waere nicht bewiesen, dass diese Messung ueberhaupt "nicht in
Ordnung" sagen kann. */
{
await seite.evaluate(() => {
document.querySelector(".kachel .kachel__name").style.color = "#171326";
});
const schlecht = await messen(".kachel .kachel__name");
ok(schlecht !== null && schlecht.mittel < 4.5 && schlecht.anteil > 50,
`absichtlich zu dunkler Text faellt durch (${schlecht?.mittel.toFixed(2)}:1, ${schlecht?.anteil.toFixed(1)} % unter der Grenze)`);
await seite.evaluate(() => {
document.querySelector(".kachel .kachel__name").style.color = "";
});
}
/* GEGENPROBE ZUM ANTEIL: ein zu heller Nebel MUSS auffallen, auch wenn
der Mittelwert noch durchgeht. Das ist der Fall, fuer den diese Zahl
ueberhaupt gebaut wurde. */
{
await seite.addStyleTag({ content: ":root { --sternenfeld:"
+ " radial-gradient(ellipse 90% 90% at 50% 50%, rgba(255,255,255,.95) 0%, transparent 85%);"
+ " --sternenfeld-mass: 100% 100%; --sternenfeld-lage: 0 0; }" });
await seite.waitForTimeout(300);
const grell = await messen(".kachel .kachel__unter");
ok(grell !== null && grell.anteil > 1.5,
`ein zu heller Nebel faellt durch (${grell?.anteil.toFixed(1)} % unter der Grenze)`);
await seite.evaluate(() => {
const letzte = [...document.querySelectorAll("style")].pop();
if (letzte && letzte.textContent.includes("ellipse 90% 90%")) letzte.remove();
});
await seite.waitForTimeout(300);
}
melde("");
melde("=== 6. Lesbarkeit auf der Teamseite ===");
await seite.goto(BASIS + "/workspace/team.html", { waitUntil: "networkidle" });
await seite.addStyleTag({ url: CREW_CSS_ADRESSE });
await seite.waitForSelector(".t-kachel", { timeout: 15000 });
await seite.waitForTimeout(900);
/* Zugeklappte Abschnitte messen nichts -- erst aufklappen, dann messen. */
await seite.evaluate(() => {
document.querySelectorAll("details:not([open])").forEach((x) => { x.open = true; });
});
await seite.waitForTimeout(500);
{
const da = await seite.evaluate(() => ({
kachel: !!document.querySelector(".t-kachel"),
person: !!document.querySelector(".tperson"),
titel: !!document.querySelector(".t-kopf__titel"),
haus: getComputedStyle(document.documentElement).getPropertyValue("--haus-name").trim(),
}));
ok(da.kachel && da.titel && da.person && da.haus === '"crew"',
`die Teamseite ist gezeichnet und traegt das richtige Haus (${JSON.stringify(da)})`);
}
for (const [name, w] of [
["Abschnittstitel", ".t-kachel .t-kopf__titel"],
["Personenname", ".tperson .tperson__name"],
["Personenzeile (leisester Text im Haus)", ".tperson .tperson__unter"],
]) await lesbar(name, w);
melde("");
melde("=== 7. Nichts abgestuerzt ===");
ok(konsole.length === 0, `keine Fehler im Browser (${konsole.slice(0, 3).join(" | ") || "keine"})`);
await ctx.close();
await browser.close();
/* Der Server haelt die Datenbankdatei offen -- unter Windows laesst sie
sich dann nicht loeschen. Das ist kein Befund, sondern ein
Aufraeumrest im Temp-Ordner, und darf die Pruefung nicht rot machen. */
try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ }
console.log("");
console.log(`${geprueft} Pruefungen, ${fehler} Fehler`);
console.log(fehler === 0 ? "ALLES IN ORDNUNG" : "NICHT IN ORDNUNG");
process.exit(fehler === 0 ? 0 : 1);