Files
dogfather-universe/server/pruef-personen-kachel.mjs
T
DogFatherGitandClaude Opus 5 6e46a08543 Portnummern werden abgeleitet, nicht mehr vergeben
Gemessen: 165 Pruefdateien, 129 verschiedene Nummern -- NEUNZEHN
doppelt, vier davon dreifach. Niemand hatte das gewollt; jede neue
Pruefung wurde von einer vorhandenen abgeschrieben, und die Nummer kam
mit. Meine eigene Notiz sagte "sieben" -- auch eine Bestandsliste
altert.

Der Waechter faengt den Schaden ab, aber er kann nur melden, was schon
passiert ist: Zwei Pruefungen mit derselben Nummer koennen nie
gleichzeitig laufen, und ein liegengebliebener Prozess der einen laesst
die andere abbrechen mit einer Meldung, die wie ein Befund aussieht.
Genau das ist mir am 20.09. zweimal passiert.

Jetzt leitet jede Datei ihre Nummer aus ihrer STELLE IM ALPHABET ab
(eigenerPort in helfer-port.mjs), zwei je Datei. Nicht ueber eine
Pruefsumme: Bei 165 Namen in 4900 Nummern waeren nach dem
Geburtstagsproblem rund DREI Zusammenstoesse zu erwarten -- ein Hash
tauscht eine sichtbare Doppelung gegen eine unsichtbare. Die Stelle im
Alphabet ist eindeutig von der Bauart her.

--- ZWEI FEHLER AUF DEM WEG, BEIDE LEHRREICH -------------------------

1. DER ERSTE VERSUCH WAR GRUEN UND KAPUTT. Ersetzt wurde mit einem
   Muster: "([^"]*4231[^"]*)". Das hielt

     { host: "127.0.0.1", port: 4231, path: "/404.html" }

   fuer eine Zeichenkette -- ein Muster kann eine oeffnende nicht von
   einer schliessenden Anfuehrung unterscheiden. Heraus kam

     { host: "127.0.0.1`, port: ${PORT}, path: `/404.html" }

   also GUELTIGER Code ohne port-Feld. `node --check` sagte gruen fuer
   alle 164 Dateien. Aufgefallen ist es erst, weil ich vier Vertreter
   gegen eine vorher gemessene Grundlinie laufen liess: pruef-schranke
   39/0 vorher, 38/1 nachher.

   Alles zurueckgenommen und mit einem Zerleger neu gemacht, der weiss,
   ob eine Stelle Code, Zeichenkette, Vorlage, Kommentar oder
   regulaerer Ausdruck ist. In pruef-ics stand die Nummer in einem
   regulaeren Ausdruck -- der wird jetzt gebaut statt hingeschrieben.

2. EIN MODUL, DAS BEIM IMPORTIEREN ARBEITET, IST EINE FALLE. Der
   zweite Durchgang importierte den ersten, um seine Mechanik zu
   benutzen -- und fuehrte dessen Hauptlauf gleich mit aus. Die
   zweiten Nummern wurden dadurch als erste behandelt, zwei Aufrufe
   bekamen dieselbe Nummer, und in pruef-content stand `const PORT`
   zweimal.

--- WAS DAS DAUERHAFT HAELT -----------------------------------------

pruef-portnummern.mjs (neu, 8 Pruefungen) fragt nicht "welche Nummern
sind doppelt", sondern "wer traegt ueberhaupt noch eine von Hand ein"
und "wer startet einen Server, ohne seine Nummer abzuleiten". Die
zweite Frage hat sofort etwas gefunden, das in KEINER Doppelungsliste
stand: pruef-push-weg belegte 4341 und 4342, rief den Waechter aber
gar nicht auf -- dieselbe Nummer wie pruef-agentur. Eine Liste zeigt
nur, was auf ihr steht.

Mit Gegenprobe: Eine unbekannte Datei bekommt keine geratene Nummer,
sondern einen Abbruch, und eine dritte Nummer je Datei gibt es nicht.

--- NACHGEMESSEN ----------------------------------------------------

Sechzehn Pruefungen gegen ihre vorher gemessene Grundlinie, je eine
Vertreterin jeder umgestellten Bauweise (eine Nummer, zwei Nummern,
Nummer in einer Zeichenkette, in einer Vorlage, in einem regulaeren
Ausdruck, dynamische Einfuhr, Nachtlauf mit zwei Laeufen):

  crew-adresse 132/0 · schranke 39/0 · content 45/0 · entwicklung 46/0
  anruf 127/0 · ics 37/0 · push-weg 20/0 · arten 28/0 · video 67/0
  kanalzeile 14/0 · agentur 62/0 · spicy 83/0 · creator-anlegen 50/0
  push-ziel 10/0 · portnummern 8/0

Alle exakt wie vorher. Zwei waren schon vorher rot und sind es
unveraendert geblieben (crew-wand-bild 41/4, teilen 14/3) -- per
`git stash` belegt, nicht angenommen.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-20 22:51:21 +02:00

513 lines
25 KiB
JavaScript

/* DIE PERSONENKACHEL — Aufbau, Farben, Kontrast (09.09.2026)
Filipe, mit Bildschirmfoto: "mach die ganze kachel perfekter
detailliert schönes. der hintergrund soll auch bissl dunkler sein
von der kachel."
Auf seinem Bild stand unter jeder zugeklappten Rolle ein leerer
Streifen, und die fünf Zeilen sahen fünfmal gleich aus. Gemessen
waren es 29 px Nichts je Zeile, fünfmal untereinander.
WAS HIER GEMESSEN WIRD UND WARUM SO:
Der Rollenstreifen wird an ECHTEN BILDPUNKTEN geprüft, nicht am
Stylesheet. Der Grund steht im Code: Im ersten Anlauf sagte
getComputedStyle "3 px breit, sichtbar, Deckkraft 0,55" -- und zu
sehen war nichts, weil das Kantenlicht der Kachel (module.css,
z-index 2) genau darüber lag. Eine Prüfung, die den berechneten Stil
liest, hätte grün gemeldet.
Der dunklere Grund wird als Helligkeit gemessen UND gegen die Karten
darin gehalten: Eine Vitrine ist dunkler als das, was darin steht.
Eine feste Zahl allein wäre eine Rechnung von gestern.
DREI AUSGÄNGE: 0 in Ordnung · 1 Befunde · 2 konnte nicht arbeiten. */
import { mkdtempSync, rmSync, appendFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
const ordner = mkdtempSync(join(tmpdir(), "ws-perskachel-"));
process.env.WORKSPACE_DB = join(ordner, "workspace.db");
import { notbremse } from "./helfer-notbremse.mjs";
const { eigenerPort } = await import("./helfer-port.mjs");
const PORT = await eigenerPort(import.meta, "die Personenkachel-Pruefung");
process.env.PORT = `${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 }); };
await import("./index.js");
notbremse(420_000, "pruef-personen-kachel");
await new Promise((r) => setTimeout(r, 900));
const BASIS = `http://127.0.0.1:${PORT}`;
const PROTOKOLL = join(process.cwd(), "pruef-personen-kachel-lauf.txt");
try { rmSync(PROTOKOLL, { force: true }); } catch { /* gab es noch nicht */ }
const melde = (z) => { console.log(z); try { appendFileSync(PROTOKOLL, z + "\n"); } catch { /* Zugabe */ } };
let fehler = 0, geprueft = 0;
const ok = (b, t) => { geprueft++; melde((b ? " ok " : " FEHL ") + t); if (!b) fehler++; };
/* ---- Bestand ------------------------------------------------------------ */
await fetch(BASIS + "/workspace/api/anmelden", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ rolle: "admin", code: "x" }) }).catch(() => {});
const { DatabaseSync } = await import("node:sqlite");
const { scryptSync, randomBytes } = await import("node:crypto");
for (let i = 0; i < 60; i++) {
try {
const p = new DatabaseSync(process.env.WORKSPACE_DB);
try { p.prepare("SELECT 1 FROM personen LIMIT 1").get(); break; } finally { p.close(); }
} catch { await new Promise((r) => setTimeout(r, 250)); }
}
const d = new DatabaseSync(process.env.WORKSPACE_DB);
const jetzt = new Date().toISOString();
function anlegen(name, rolle, code, aktiv = 1) {
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, aktiv, erstellt) VALUES (?,?,?,?,?,?,?)")
.run(name, rolle, hash, salt, 32768, aktiv, jetzt);
}
anlegen("Dogfather", "admin", "CODE-DOGI-0001");
anlegen("Spicy Media", "spicy", "CODE-SPIC-0001");
anlegen("Modis", "manager", "CODE-MODI-0001");
anlegen("BanaStift", "manager", "CODE-BSTF-0001", 0);
anlegen("BananaStift", "scout", "CODE-BANA-0001");
anlegen("Patrick", "scout", "CODE-PATR-0001");
/* SECHS Creator -- damit die Namenszeichen wirklich abgeschnitten
werden. Mit vier waere der "+n"-Fall nie geprueft. */
for (const n of ["Tili", "SpongBob", "Luna", "Mika", "Noa", "Robin"]) {
anlegen(n, "creator", "CODE-" + n.slice(0, 4).toUpperCase().padEnd(4, "X") + "-0001");
}
d.close();
const { chromium } = await import(
"file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs");
const browser = await chromium.launch();
const konsole = [];
async function alsDogi(breite = 1440) {
const ctx = await browser.newContext({ viewport: { width: breite, height: 1100 } });
const s = await ctx.newPage();
s.on("pageerror", (f) => konsole.push("Absturz: " + f.message));
s.on("console", (m) => { if (m.type() === "error") konsole.push("Konsole: " + m.text()); });
await s.goto(BASIS + "/workspace/", { waitUntil: "domcontentloaded" });
await s.click('.rolle[data-rolle="admin"]');
await s.fill("#code", "CODE-DOGI-0001");
await s.click("button[type=submit]");
await s.waitForURL("**/start.html", { timeout: 20000 });
await s.evaluate(() => localStorage.setItem("dfw_personen_offen", "[]"));
await s.goto(BASIS + "/workspace/personen.html", { waitUntil: "networkidle" });
await s.waitForSelector(".gruppe", { timeout: 10000 });
await s.waitForTimeout(700);
return { ctx, s };
}
const gross = await alsDogi();
const seite = gross.s;
/* Punkte aus einem Bildschirmfoto lesen -- derselbe Weg wie in
pruef-lesbarkeit: das Bild in ein Canvas, dort jeden Punkt. */
async function mittelFarbe(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;
let r = 0, gr = 0, bl = 0, n = 0;
for (let i = 0; i < dd.length; i += 4) { r += dd[i]; gr += dd[i + 1]; bl += dd[i + 2]; n++; }
return [Math.round(r / n), Math.round(gr / n), Math.round(bl / n)];
}, bild.toString("base64"));
}
const leuchtkraft = ([r, g, b]) => {
const f = (w) => { const v = w / 255; return v <= 0.03928 ? 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);
};
const kontrast = (a, b) => {
const l1 = leuchtkraft(a), l2 = leuchtkraft(b);
return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05);
};
/* =======================================================================
1. AUFBAU: fünf Rollen, jede mit ihrem Merkmal
======================================================================= */
melde("\n=== 1. Aufbau ===");
{
const g = await seite.$$eval(".liste-kachel > .gruppe", (b) => b.map((x) => ({
rolle: x.dataset.rolle, auf: x.dataset.auf,
name: x.querySelector(".gruppe__name")?.textContent,
zahl: x.querySelector(".gruppe__zahl")?.textContent,
})));
ok(g.length === 5, `fünf Rollenabschnitte (${g.length})`);
ok(g.every((x) => x.rolle), `jeder trägt seine Rolle (${g.map((x) => x.rolle).join(", ")})`);
ok(g.every((x) => x.auf === "ja" || x.auf === "nein"),
"und seinen Zustand als Merkmal — nicht nur im aria-expanded");
/* GEGENPROBE: Zustand am Merkmal und im aria-expanded müssen
dasselbe sagen. Zwei Wahrheiten wären schlimmer als eine. */
const einig = await seite.$$eval(".liste-kachel > .gruppe", (b) => b.every((x) =>
(x.dataset.auf === "ja") === (x.querySelector(".gruppe__kopf").getAttribute("aria-expanded") === "true")));
ok(einig, "Merkmal und aria-expanded stimmen überein");
}
/* =======================================================================
2. KEIN LEERES BAND MEHR
Das war der sichtbare Mangel auf Filipes Bild: 74 px Abschnitt,
45 px Zeile, 29 px Nichts.
======================================================================= */
melde("\n=== 2. Kein leeres Band ===");
{
const zu = await seite.$$eval('.liste-kachel > .gruppe[data-auf="nein"]', (b) => b.map((x) => {
const r = x.getBoundingClientRect();
const k = x.querySelector(".gruppe__kopf").getBoundingClientRect();
return { name: x.dataset.rolle, rest: Math.round(r.height - k.height), hoehe: Math.round(r.height) };
}));
ok(zu.length >= 4, `${zu.length} zugeklappte Abschnitte zum Messen`);
const schlimm = zu.filter((x) => x.rest > 3);
ok(schlimm.length === 0,
schlimm.length ? `leeres Band unter: ${schlimm.map((x) => `${x.name} ${x.rest} px`).join(", ")}`
: `unter keiner zugeklappten Zeile steht Leerraum (höchstens ${Math.max(...zu.map((x) => x.rest))} px)`);
ok(zu.every((x) => x.hoehe >= 46 && x.hoehe <= 66),
`die Zeilen sind gleich hoch und gut greifbar (${zu.map((x) => x.hoehe).join("/")} px)`);
/* GEGENPROBE: Ein OFFENER Abschnitt braucht Luft -- sonst hätte ich
das eine Übel gegen das andere getauscht. */
await seite.click('.gruppe[data-rolle="manager"] .gruppe__kopf');
await seite.waitForTimeout(400);
const auf = await seite.$eval('.gruppe[data-rolle="manager"]', (x) => {
const r = x.getBoundingClientRect();
const k = x.querySelector(".gruppe__kopf").getBoundingClientRect();
return { rest: Math.round(r.height - k.height), personen: x.querySelectorAll(".person").length };
});
ok(auf.personen === 2 && auf.rest > 100,
`offen stehen die Karten mit Abstand da (${auf.personen} Karten, ${auf.rest} px)`);
}
/* =======================================================================
3. DER ROLLENSTREIFEN — an echten Bildpunkten
getComputedStyle hilft hier nicht: Beim ersten Anlauf meldete es
tadellose Werte für einen Streifen, den das Kantenlicht der Kachel
vollständig verdeckte.
======================================================================= */
melde("\n=== 3. Der Rollenstreifen ===");
{
/* Erwartet wird der KANAL, der bei dieser Rollenfarbe führt --
nicht ein genauer Wert. Ein genauer Wert wäre eine Rechnung von
gestern: Sobald jemand die Rollenfarbe leicht verschiebt, wäre die
Prüfung rot, ohne dass etwas kaputt ist. */
const ERWARTET = {
spicy: "r", // #ef5f57
admin: "b", // #9ed3f4
manager: "b", // #8a76ff
scout: "g", // #5fc99a
creator: "r", // #c79a6d
};
/* DIESELBE STELLE, EINMAL MIT UND EINMAL OHNE STREIFEN.
Der erste Anlauf verglich den Streifen mit einem Punkt 40 px weiter
rechts -- und traf dort die Schrift. Der Unterschied war riesig und
sagte nichts über den Streifen. Zwei Stellen sind eben zwei
Stellen; es gibt keinen Grund, warum sie gleich aussehen sollten.
Richtig ist der Vergleich DERSELBEN Stelle mit sich selbst: einmal
wie sie ist, einmal mit ausgeschaltetem Streifen. Was sich dabei
ändert, IST der Streifen -- und was sich nicht ändert, ist die
Gegenprobe, ohne dass man sie eigens erfinden müsste. */
const AUS = "personen-kachel-probe";
const streifenAus = async (an) => seite.evaluate(([id, an]) => {
let s = document.getElementById(id);
if (!s) { s = document.createElement("style"); s.id = id; document.head.append(s); }
s.textContent = an ? ".liste-kachel > .gruppe::after { opacity: 0 !important; }" : "";
}, [AUS, an]);
const stellen = {};
for (const rolle of Object.keys(ERWARTET)) {
stellen[rolle] = await seite.$eval(`.gruppe[data-rolle="${rolle}"]`, (x) => {
const r = x.getBoundingClientRect();
return { x: Math.round(r.x) + 3, y: Math.round(r.y) + 18, width: 3, height: 10 };
});
}
const mit = {};
for (const [rolle, clip] of Object.entries(stellen)) mit[rolle] = await mittelFarbe(clip);
await streifenAus(true);
await seite.waitForTimeout(200);
const ohne = {};
for (const [rolle, clip] of Object.entries(stellen)) ohne[rolle] = await mittelFarbe(clip);
await streifenAus(false);
await seite.waitForTimeout(200);
const nochmal = await mittelFarbe(stellen.scout);
for (const [rolle, kanal] of Object.entries(ERWARTET)) {
const [r, g, b] = mit[rolle];
const o = ohne[rolle];
/* Welcher Kanal hat durch den Streifen am meisten GEWONNEN? Der
absolute Wert allein wäre irreführend: Auf einem bläulichen Grund
führt Blau auch dann, wenn der Streifen rot ist. */
const zu = [r - o[0], g - o[1], b - o[2]];
const fuehrt = zu[0] >= zu[1] && zu[0] >= zu[2] ? "r"
: zu[1] >= zu[0] && zu[1] >= zu[2] ? "g" : "b";
const abstand = zu.reduce((s, x) => s + Math.abs(x), 0);
ok(abstand > 25 && fuehrt === kanal,
`${rolle}: mit rgb(${mit[rolle]}) / ohne rgb(${o}) — Zuwachs ${JSON.stringify(zu)}, führend ${fuehrt}`);
}
/* GEGENPROBE: Ausgeschaltet und wieder eingeschaltet muss dieselbe
Stelle wieder aussehen wie am Anfang. Täte sie das nicht, hätte die
Messung etwas anderes gemessen als den Streifen. */
const zurueck = nochmal.reduce((s, x, i) => s + Math.abs(x - mit.scout[i]), 0);
ok(zurueck <= 6, `nach dem Wiedereinschalten sieht es aus wie vorher (Abweichung ${zurueck})`);
}
/* =======================================================================
4. DIE KACHEL IST DUNKLER — UND DUNKLER ALS IHR INHALT
======================================================================= */
melde("\n=== 4. Der Grund ===");
{
const lage = await seite.$eval('.gruppe[data-rolle="scout"]', (x) => {
const r = x.getBoundingClientRect();
return { x: Math.round(r.x), y: Math.round(r.y) };
});
const kachel = await mittelFarbe({ x: lage.x + 300, y: lage.y + 6, width: 120, height: 12 });
const hell = Math.round(leuchtkraft(kachel) * 1000) / 10;
ok(hell < 2.0, `der Kachelgrund ist dunkel (rgb(${kachel}), Leuchtkraft ${hell} %)`);
/* Die Vitrine muss dunkler sein als das, was darin steht -- sonst
sieht man die Vitrine statt des Inhalts. Manager ist offen. */
const karte = await seite.$eval('.gruppe[data-rolle="manager"] .person', (x) => {
const r = x.getBoundingClientRect();
return { x: Math.round(r.x) + 200, y: Math.round(r.y) + 6 };
});
const kartenFarbe = await mittelFarbe({ x: karte.x, y: karte.y, width: 120, height: 10 });
ok(leuchtkraft(kartenFarbe) > leuchtkraft(kachel),
`die Karten darin sind heller als die Kachel (Karte rgb(${kartenFarbe}) gegen Kachel rgb(${kachel}))`);
}
/* =======================================================================
5. LESBARKEIT — jede neue Textstelle gemessen
======================================================================= */
melde("\n=== 5. Lesbarkeit ===");
{
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 FARBE WIRD GEZEICHNET, NICHT GELESEN.
------------------------------------------------------------------
Hier stand `.color.match(/\d+/g).slice(0,3)`. Das geht gut,
solange getComputedStyle "rgb(148, 165, 187)" liefert. Sobald
eine Farbe aus `color-mix()` kommt -- und genau das tun hier
alle neuen Stellen --, antwortet Chromium mit
"color(srgb 0.36 0.51 0.42)". Aus dem Muster fielen dann die
Ziffernfolgen "0", "510588", "0" heraus, und die Prüfung meldete
775929299:1. Grün, und vollkommen wertlos.
Ein Canvas nimmt JEDE Schreibweise entgegen, die der Browser
versteht, und gibt drei Zahlen von 0 bis 255 zurück. Was der
Browser malt, ist per Definition das, was man sieht. */
const c = document.createElement("canvas");
c.width = 1; c.height = 1;
const gg = c.getContext("2d", { willReadFrequently: true });
gg.fillStyle = "#000000";
gg.fillStyle = getComputedStyle(e).color;
gg.fillRect(0, 0, 1, 1);
const px = gg.getImageData(0, 0, 1, 1).data;
const farbe = [px[0], px[1], px[2]];
e.dataset.messen = "ja";
e.style.color = "transparent";
return { x: Math.round(r.x), y: Math.round(r.y),
w: Math.round(r.width), h: Math.round(Math.min(r.height, 40)), farbe };
}, waehler);
if (!stelle) return null;
const hinten = await mittelFarbe({ x: stelle.x, y: stelle.y, width: stelle.w, height: stelle.h });
await seite.evaluate(() => {
const e = document.querySelector('[data-messen="ja"]');
if (e) { e.style.color = ""; delete e.dataset.messen; }
});
return { farbe: stelle.farbe, hinten, wert: kontrast(stelle.farbe, hinten) };
}
/* GEGENPROBE ZUM MESSGERÄT, VOR DER ERSTEN MESSUNG.
Der Umweg über das Canvas ist neu, weil der alte Weg falsche Zahlen
lieferte. Bevor ich ihm glaube: zwei Farben mit bekanntem Ergebnis
hindurchschicken -- eine in gewöhnlicher Schreibweise, eine aus
color-mix(). Kommt dabei nicht heraus, was herauskommen muss, ist
die ganze Lesbarkeitsprüfung darunter wertlos. */
{
const probe = await seite.evaluate(() => {
const c = document.createElement("canvas");
c.width = 1; c.height = 1;
const g = c.getContext("2d", { willReadFrequently: true });
const lies = (farbe) => {
g.fillStyle = "#000000";
g.fillStyle = farbe;
g.fillRect(0, 0, 1, 1);
const p = g.getImageData(0, 0, 1, 1).data;
return [p[0], p[1], p[2]];
};
return { klar: lies("rgb(148, 165, 187)"),
gemischt: lies("color-mix(in srgb, #ffffff 50%, #000000)") };
});
const nah = (a, b) => a.every((x, i) => Math.abs(x - b[i]) <= 3);
ok(nah(probe.klar, [148, 165, 187]) && nah(probe.gemischt, [128, 128, 128]),
`das Messgerät stimmt: rgb-Schreibweise ${probe.klar}, color-mix ${probe.gemischt}`);
}
for (const [name, waehler] of [
["Rollenname", '.gruppe[data-rolle="scout"] .gruppe__name'],
["Zusatztext", '.gruppe[data-rolle="scout"] .gruppe__unter'],
["Anzahl", '.gruppe[data-rolle="scout"] .gruppe__zahl'],
["Namenszeichen", '.gruppe[data-rolle="scout"] .gruppe__kopf-zeichen'],
["Namenszeichen (Creator, hellste Rolle)", '.gruppe[data-rolle="creator"] .gruppe__kopf-zeichen'],
/* DogFather steht ausdruecklich dabei: Sein Babyblau wurde am
09.09.2026 saettiger gemacht (#9ed3f4 -> #5fbdff) und dabei
dunkler. Von 12,49:1 auf 9,75:1 gegen den Grund gerechnet -- eine
Rechnung ist aber keine Messung, und gemessen wird hier gegen die
Flaeche, die wirklich dahinter liegt. */
["Anzahl (DogFather)", '.gruppe[data-rolle="admin"] .gruppe__zahl'],
["Namenszeichen (DogFather)", '.gruppe[data-rolle="admin"] .gruppe__kopf-zeichen'],
["Anzahl (Creator)", '.gruppe[data-rolle="creator"] .gruppe__zahl'],
["Name offen", '.gruppe[data-rolle="manager"] .gruppe__name'],
]) {
const m = await messen(waehler);
ok(m !== null && m.wert >= 4.5,
`${name}: ${m ? m.wert.toFixed(2) + ":1 (" + m.farbe.join(",") + " auf " + m.hinten.join(",") + ")" : "nicht gefunden"}`);
}
/* GEGENPROBE: ein absichtlich zu dunkler Text MUSS durchfallen. */
await seite.evaluate(() => {
document.querySelector('.gruppe[data-rolle="scout"] .gruppe__name').style.color = "#141a22";
});
const schlecht = await messen('.gruppe[data-rolle="scout"] .gruppe__name');
ok(schlecht !== null && schlecht.wert < 4.5,
`absichtlich zu dunkler Text fällt durch (${schlecht?.wert.toFixed(2)}:1)`);
await seite.evaluate(() => {
document.querySelector('.gruppe[data-rolle="scout"] .gruppe__name').style.color = "";
});
}
/* =======================================================================
6. WER DRINSTEHT — die Namenszeichen
======================================================================= */
melde("\n=== 6. Namenszeichen ===");
{
const creator = await seite.$eval('.gruppe[data-rolle="creator"]', (x) => ({
zahl: x.querySelector(".gruppe__zahl").textContent,
zeichen: [...x.querySelectorAll(".gruppe__kopf-zeichen")].map((z) => ({
text: z.textContent, titel: z.title, mehr: z.dataset.mehr })),
versteckt: x.querySelector(".gruppe__leute").getAttribute("aria-hidden"),
}));
ok(creator.zahl === "6", `sechs Creator sind angelegt (${creator.zahl})`);
ok(creator.zeichen.length === 5,
`vier Zeichen plus eines für den Rest (${creator.zeichen.map((z) => z.text).join(" ")})`);
ok(creator.zeichen[4]?.mehr === "ja" && creator.zeichen[4]?.text === "+2",
`das letzte sagt, wie viele fehlen ("${creator.zeichen[4]?.text}")`);
/* WELCHE zwei fehlen, hängt an der Sortierung des Servers -- die
hier abzuschreiben hiesse, sie an zwei Stellen zu pflegen. Geprüft
wird die Eigenschaft: genau zwei Namen, und keiner davon steht
schon als eigenes Zeichen davor. */
const genannt = (creator.zeichen[4]?.titel || "").split(", ").filter(Boolean);
const vorne = creator.zeichen.slice(0, 4).map((z) => z.titel);
ok(genannt.length === 2 && genannt.every((n) => !vorne.includes(n)),
`und nennt beim Darüberfahren genau die zwei, die fehlen ("${creator.zeichen[4]?.titel}")`);
ok(creator.versteckt === "true",
"für das Vorleseprogramm sind sie ausgeblendet — die Anzahl steht als Zahl daneben");
/* Zwei Wörter geben zwei Buchstaben, ein Wort nur einen. */
const spicy = await seite.$eval('.gruppe[data-rolle="spicy"] .gruppe__kopf-zeichen',
(z) => ({ text: z.textContent, titel: z.title }));
ok(spicy.text === "SM", `"Spicy Media" wird SM (${spicy.text})`);
const scout = await seite.$$eval('.gruppe[data-rolle="scout"] .gruppe__kopf-zeichen',
(b) => b.map((z) => z.textContent));
ok(scout.join("") === "BP", `"BananaStift" und "Patrick" geben B und P (${scout.join(" ")})`);
/* Gesperrt wird gekennzeichnet -- und zwar leise. */
const manager = await seite.$$eval('.gruppe[data-rolle="manager"] .gruppe__kopf-zeichen',
(b) => b.map((z) => ({ text: z.textContent, gesperrt: z.dataset.gesperrt, titel: z.title })));
const dieGesperrte = manager.find((z) => z.gesperrt === "ja");
ok(!!dieGesperrte && /gesperrt/.test(dieGesperrte.titel),
`die gesperrte Person ist gekennzeichnet ("${dieGesperrte?.titel}")`);
ok(manager.filter((z) => z.gesperrt === "ja").length === 1,
"und nur sie — die aktive nicht");
}
/* =======================================================================
7. AM HANDY
======================================================================= */
melde("\n=== 7. Am Handy (390 px) ===");
{
const handy = await alsDogi(390);
const m = await handy.s.evaluate(() => {
const k = document.getElementById("liste");
const leute = document.querySelector(".gruppe__leute");
return {
ueber: document.documentElement.scrollWidth - document.documentElement.clientWidth,
leuteSichtbar: leute ? getComputedStyle(leute).display !== "none" : null,
breiter: [...k.querySelectorAll(".gruppe__kopf")]
.filter((x) => x.getBoundingClientRect().right > 391).length,
zeilen: [...k.querySelectorAll(".gruppe__kopf")].map((x) => Math.round(x.getBoundingClientRect().height)),
};
});
ok(m.ueber <= 1, `kein seitlicher Überstand (${m.ueber} px)`);
ok(m.leuteSichtbar === false,
"die Namenszeichen treten am Handy zurück — die Zahl sagt es dort");
ok(m.breiter === 0, `keine Zeile ragt hinaus (${m.breiter})`);
ok(m.zeilen.every((h) => h >= 44), `die Zeilen bleiben greifbar (${m.zeilen.join("/")} px)`);
/* ZWEI ZEILEN, NICHT DREI.
Der Zusatztext bekommt am Handy eine eigene Zeile -- richtig, er
passt sonst nicht. Der PFEIL bekam dadurch eine dritte und stand
mittig unter dem Text wie ein vergessenes Zeichen: 106 px je Rolle,
fünfmal untereinander. Gemessen wird deshalb beides: die Höhe und
die Lage des Pfeils. */
ok(m.zeilen.every((h) => h <= 82),
`und werden nicht dreizeilig (${m.zeilen.join("/")} px)`);
const pfeil = await handy.s.evaluate(() => {
const k = document.querySelector(".gruppe__kopf");
const p = k.querySelector(".klapp-pfeil").getBoundingClientRect();
const n = k.querySelector(".gruppe__name").getBoundingClientRect();
const kr = k.getBoundingClientRect();
return { rechts: Math.round(kr.right - p.right),
mitte: Math.round((p.top + p.bottom) / 2 - (kr.top + kr.bottom) / 2),
nebenNamen: p.top < n.bottom + 4 };
});
ok(pfeil.rechts >= 0 && pfeil.rechts < 30,
`der Pfeil steht rechts (${pfeil.rechts} px vom Rand)`);
ok(Math.abs(pfeil.mitte) < 8,
`und mittig zwischen beiden Zeilen (${pfeil.mitte} px von der Mitte)`);
await handy.ctx.close();
}
/* =======================================================================
8. KEINE STILLEN FEHLER
======================================================================= */
melde("\n=== 8. Stille Fehler ===");
{
ok(konsole.length === 0, konsole.length ? "Fehler: " + konsole.slice(0, 3).join(" | ")
: "keine Skript- oder Konsolenfehler");
await seite.evaluate(() => { setTimeout(() => { throw new Error("Absicht"); }, 0); });
await seite.waitForTimeout(400);
ok(konsole.some((z) => /Absicht/.test(z)), `ein absichtlicher Fehler wird bemerkt (${konsole.length})`);
}
await gross.ctx.close();
await browser.close();
try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ }
melde(`\n${geprueft} Prüfungen`);
melde(fehler === 0 ? "ALLES IN ORDNUNG" : `${fehler} FEHLER`);
process.exit(fehler ? 1 : 0);