Files
dogfather-universe/server/pruef-teamlage-karten.mjs
T
DogFatherGit 077925ab25 Dreizehn rote Pruefungen, eine Ursache: openssl steht nicht im PATH
DER BEFUND

Im naechtlichen Lauf standen dreizehn Pruefungen mit derselben Zeile
rot:

    [uncaughtException] Error: spawnSync openssl ENOENT

Kein Programmfehler. Die Pruefungen brauchen einen https-Vorbau (der
Sitzungskeks des Hauses ist `secure` und kommt ueber http nicht an),
und dafuer erzeugen sie ein Wegwerf-Zertifikat mit openssl.

GEMESSEN, NICHT VERMUTET

  openssl liegt hier:   C:\Program Files\Git\mingw64\bin\openssl.exe
                        C:\Program Files\Git\usr\bin\openssl.exe
  im System-PATH steht: C:\Program Files\Git\cmd   (nur git.exe)
  im Benutzer-PATH:     nichts mit Git

Wer aus Git Bash startet, erbt die mingw-Pfade und merkt nie etwas.
Die Aufgabenplanung startet `node.exe` DIREKT, ohne Shell -- und
bekommt sie nicht. Bei mir gruen, nachts rot, und der Grund steht
nicht im Code.

Das Schlimmste daran ist nicht der Ausfall: Dreizehn dauerhaft rote
Zeilen in einer Notiz, die Filipe morgens liest, gewoehnen einem das
Hinsehen ab -- und decken dabei die echten Befunde zu.

DIE REPARATUR

`server/helfer-openssl.mjs` sucht openssl erst im PATH (ohne `where`
oder `which` -- beides sind selbst Programme und koennen genauso
fehlen), danach an den Orten, an denen es auf einem Windows-Rechner
mit Git wirklich liegt.

Dazu `zertifikatBauen(schluessel, zertifikat, host)`. Die acht
Aufrufformen im Haus waren identisch bis auf die Variablennamen
(schl/zert, schluesselDatei/zertDatei, zKey/zCrt, sk/zt, key/crt) --
eine Stelle traegt alle neunundzwanzig. Zwei Tage zuvor war eine
davon um ein `-addext` aermer als die anderen; gemerkt hat es
niemand, weil der Browser den fehlenden Alternativnamen erst bei
einer Weiterleitung anmahnt. Eine Stelle kann nicht von sich selbst
abweichen.

KEIN EINTRAG IN DEN SYSTEM-PATH. `mingw64\bin` enthaelt rund hundert
Programme mit Unix-Namen (`find`, `sort`, `link`), die gleichnamige
Windows-Befehle verdecken. Das fuer eine Pruefung zu aendern haette
an ganz anderen Stellen Fehler verursacht, die niemand hierher
zurueckverfolgt.

GEGENPROBE IN BEIDE RICHTUNGEN, mit dem PATH des Nachtlaufs:

  vorher (direkter Aufruf):  ABSTURZ: spawnSync openssl ENOENT
  nachher (ueber den Helfer): Zertifikat gebaut, 1236 Bytes

Und eine echte Pruefung unter denselben Bedingungen:

  PATH ohne mingw64 -> pruef-chat-neu-stelle
  63 Pruefungen, 0 Fehler, 0 Abstuerze

Genau diese Datei stand im Nachtlauf mit ENOENT rot.

EINE WACHE DAGEGEN

pruef-struktur prueft ab jetzt, dass niemand openssl wieder direkt
ruft -- der naechste merkt es dort und nicht erst in einem
Nachtlauf, den niemand liest. Drei Gegenproben.

Beim ersten Anlauf schlug sie auf ihre EIGENEN Probetexte an: Sie
liest alle Serverdateien, und dazu gehoert sie selbst. Die Texte
werden jetzt zusammengesetzt. Derselbe Selbsttreffer ist mir heute
schon zweimal passiert.

DER DRITTE AUSGANG BLEIBT, WO ER HINGEHOERT

`helfer-kachel-echtfarbe.mjs` faengt den Fehler weiterhin ab und
meldet `moeglich: false` mit Grund, statt abzubrechen. Eine
Sicherung abzuschaffen, weil ihr Anlass gerade behoben ist, ist der
Anfang des naechsten stillen Fehlschlags.

NEBENBEI: pruef-community-sicht

Sie war rot mit "ZU VIEL: reaktion.html" -- mein eigener Rueckstand
vom 28.09. Die Reaction steht als Kachel im Community-Bereich; die
Liste war nicht nachgezogen. Jetzt 10 / 0.

Die Liste bleibt bewusst von Hand gepflegt: Sie ist eine ABSICHT,
keine Ableitung. Aus rechte.js gelesen verglichen sich zwei Kopien
derselben Quelle -- immer gruen, nie ein Beweis.

GEPRUEFT
  pruef-struktur         ALLES IN ORDNUNG (mit der neuen Wache)
  pruef-community-sicht  10 / 0
  pruef-chat-neu-stelle  63 / 0  (mit dem PATH des Nachtlaufs)
  pruef-notizen, -teamlage-karten, -werdegang: EXIT 0, keine Abstuerze
  mess-quer              EXIT 0
  29 Dateien: node --check auf allen, kein direkter Aufruf mehr
2026-09-29 16:14:13 +02:00

566 lines
27 KiB
JavaScript

/* =====================================================================
DIE PERSONENKARTEN DER TEAM-LAGE — im echten Browser
=====================================================================
ENTSTANDEN AUS ZWEI MELDUNGEN AM 22.09.2026.
(1) „wenn ich auf diese sachen drücke werd ich auf die startseite
geschickt, scheck das bitte ab."
(2) „was verstehst du nicht wenn ich sage die sollen viel krasser
geiler und übersichtlicher sein. und auch den rollen
entsprechend reihenfolge. nicht einfach so sondern die rolle
soll die reihenfolge ausmachen."
---------------------------------------------------------------------
WARUM DAS IM BROWSER GEMESSEN WIRD UND NICHT AM SERVER
Beide Befunde waren am Server NICHT SICHTBAR.
Zu (1): Der Server antwortete völlig korrekt mit 404 — die rechte
Hand hat keine Stufe, also gibt es dort nichts zu setzen. Den
Schaden richtete der Browser an: Er machte aus jedem 404 einen
Rauswurf. `pruef-team-stufen.mjs` misst den 404 und ist grün; sie
kann den Sprung gar nicht sehen.
Zu (2): Die Reihenfolge war am Server seit Tagen richtig
(ROLLEN_SORTIERUNG in der Abfrage) — und Filipe hat sie trotzdem
nicht wiedererkannt, weil auf der Karte nur die STUFE stand, nie die
Rolle. Eine Ordnung, die man nicht sehen kann, sieht aus wie Zufall.
Auch das ist am Server nicht messbar: Die Antwort war richtig.
Beides zusammen ist dieselbe Lehre: Wo die Antwort stimmt und das
Ergebnis trotzdem falsch ankommt, muss dort gemessen werden, wo der
Mensch steht.
---------------------------------------------------------------------
DIE GEGENPROBE STECKT IM ZWEITEN DRUCK
Ein Druck auf einen Knopf, der schon gewählt ist, darf NICHTS tun —
kein Abruf, keine Änderung. Ohne diesen zweiten Druck wäre nicht
bewiesen, dass die Messung „hat sich etwas geändert" überhaupt einen
Unterschied bemerkt: Eine Seite, die auf jeden Druck alles neu malt,
bestünde den ersten Teil genauso.
===================================================================== */
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 { execFileSync } from "node:child_process";
import { createServer as httpsServer } from "node:https";
import { notbremse } from "./helfer-notbremse.mjs";
import { eigenerPort } from "./helfer-port.mjs";
import { zertifikatBauen } from "./helfer-openssl.mjs";
const CREW = "crew.dogfather-universe.com";
const PORT = await eigenerPort(import.meta, "pruef-teamlage-karten");
const ordner = mkdtempSync(join(tmpdir(), "ws-tlk-"));
process.env.WORKSPACE_DB = join(ordner, "workspace.db");
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(240_000, "pruef-teamlage-karten");
await new Promise((r) => setTimeout(r, 900));
let fehler = 0, geprueft = 0, offen = 0;
const ok = (b, t) => { geprueft++; console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; };
const unklar = (t) => { offen++; console.log(" -- " + t); };
/* ---------- Leute anlegen --------------------------------------------- */
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");
/* DIE LINKE HAND GEHOERT IN DIESE PROBE (23.09.2026).
Sie fehlte hier -- und deshalb war die Pruefung gruen, waehrend die
Seite sie gar nicht anzeigte: `WHERE rolle IN ('hand','modi')` in
workspace-teamlage.js. Eine Rolle, die in den Testdaten nicht
vorkommt, kann auch nicht vermisst werden.
Filipe, mit dem Bildschirmfoto: „ich will dass die linke hand auch
ueberall zu sehen ist. reihenfolge immer. rechte hand, dan linke
hand und dan modis." */
anlegen("Lino", "linke", "CODE-LINK-0001");
/* DIE STUFEN KOMMEN AUS DEM SERVERMODUL, nicht aus einer Liste hier.
Genau so bleibt die Pruefung gueltig, wenn morgen eine sechste
dazukommt -- und sie prueft dann auch die sechste mit. */
const { STUFEN, STUFEN_NAME } = await import("./workspace-teamlage.js");
const modiNamen = ["Frida", "Nele", "Ghost", "Maja", "Liv", "Tara", "Juno", "Pia"];
const modis = STUFEN.map((stufe, i) => {
const name = modiNamen[i] || `Modi${i + 1}`;
const id = anlegen(name, "modi", `CODE-MODI-00${10 + i}`);
d.prepare("UPDATE personen SET stufe = ? WHERE id = ?").run(stufe, id);
return { id, name, stufe };
});
d.close();
/* ---------- Der Vorbau, damit der Host-Kopf ankommt ------------------- */
/* `fetch` in Node verwirft den Host-Kopf, und Chromium hebt crew.… auf
https. Ein selbstsigniertes Zertifikat plus MAP-Regel ist der Weg,
den auch die anderen Browserpruefungen dieses Hauses gehen. */
const schl = join(ordner, "p.key"), zert = join(ordner, "p.crt");
zertifikatBauen(schl, zert, CREW);
const HP = PORT + 1;
const vorbau = httpsServer({ key: readFileSync(schl), cert: readFileSync(zert) },
(von, nach) => {
const w = httpAnfrage({ host: "127.0.0.1", port: PORT, path: von.url,
method: von.method, headers: { ...von.headers, host: `${CREW}:${HP}` } },
(a) => { nach.writeHead(a.statusCode || 502, a.headers); a.pipe(nach); });
von.pipe(w);
});
await new Promise((r) => vorbau.listen(HP, "127.0.0.1", r));
const SEITE = `https://${CREW}:${HP}`;
/* ---------- Browser --------------------------------------------------- */
const pw = await import(
"file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs");
const browser = await pw.chromium.launch({
args: [`--host-resolver-rules=MAP ${CREW} 127.0.0.1`, "--ignore-certificate-errors"] });
const ctx = await browser.newContext({ viewport: { width: 1420, height: 1200 } });
const seite = await ctx.newPage();
const meckern = [];
const abrufe = [];
seite.on("pageerror", (f) => meckern.push("Absturz: " + String(f).slice(0, 160)));
seite.on("console", (m) => {
if (m.type() === "error" && !/favicon|ERR_CERT|net::ERR/i.test(m.text())) {
meckern.push(m.text().slice(0, 160));
}
});
seite.on("response", (r) => {
if (/\/workspace\/api\/teamlage\//.test(r.url())) {
abrufe.push(`${r.status()} ${r.url().split("/workspace")[1]}`);
}
});
console.log("=== Die Personenkarten der Team-Lage ===");
console.log("");
await seite.goto(`${SEITE}/workspace/`, { waitUntil: "networkidle" });
await seite.click('.rolle[data-rolle="admin"]');
await seite.fill("#code", "CODE-DOGI-0001");
await seite.click("button[type=submit]");
await seite.waitForTimeout(900);
await seite.goto(`${SEITE}/workspace/teamlage.html`, { waitUntil: "networkidle" });
await seite.waitForTimeout(1600);
const angekommen = new URL(seite.url()).pathname;
if (angekommen !== "/workspace/teamlage.html") {
unklar(`die Seite hat uns nach ${angekommen} geschickt —`
+ " ab hier laesst sich nichts messen.");
} else {
/* ===================================================================
1. DIE KARTEN UND IHRE REIHENFOLGE
=================================================================== */
console.log("--- Die Reihenfolge macht die Rolle ---");
const lage = await seite.evaluate(() => {
const karten = [...document.querySelectorAll(".t-leute .tperson")];
return {
karten: karten.map((k) => ({
name: k.querySelector(".tperson__name")?.textContent.trim() || "?",
rolle: k.dataset.rolle || "",
stufe: k.dataset.stufe || "",
rollenwort: k.querySelector(".tmerkmal--rolle")?.textContent.trim() || "",
stufenwort: k.querySelector(".tmerkmal--stufe")?.textContent.trim() || "",
knoepfe: k.querySelectorAll(".tstufen__knopf").length,
})),
gruppen: [...document.querySelectorAll(".t-gruppe")].map((g) => ({
rolle: g.dataset.rolle || "",
wort: g.querySelector(".t-gruppe__wort")?.textContent.trim() || "",
zahl: Number(g.querySelector(".t-gruppe__zahl")?.textContent.trim()),
})),
/* Wie viele Karten stehen in demselben Block wie die Ueberschrift
davor? Gemessen am Baum, nicht gezaehlt aus den Daten. */
bloecke: [...document.querySelectorAll(".t-leute")]
.map((l) => l.querySelectorAll(".tperson").length),
};
});
ok(lage.karten.length === 2 + modis.length,
`${lage.karten.length} Karten stehen da (erwartet ${2 + modis.length})`);
/* DIE ROLLE STEHT AUF JEDER KARTE. Ohne das Wort ist die Sortierung
unsichtbar -- genau der Befund vom 22.09. */
const ohneWort = lage.karten.filter((k) => !k.rollenwort);
ok(lage.karten.length > 0 && ohneWort.length === 0,
`jede Karte nennt ihre Rolle im Klartext`
+ ` (${ohneWort.map((k) => k.name).join(", ") || "keine Luecke"})`);
/* GRUPPIERT, NICHT NUR SORTIERT: Keine Rolle darf zweimal auftauchen,
nachdem eine andere dazwischenstand. Abgeleitet aus dem, was da
steht -- ohne eine Erwartung, welche Rollen es gibt. */
const folge = lage.karten.map((k) => k.rolle);
const bloecke = folge.filter((r, i) => r !== folge[i - 1]);
ok(folge.length > 0 && new Set(bloecke).size === bloecke.length,
`die Rollen stehen in geschlossenen Bloecken (${bloecke.join(" → ")})`);
/* Und jede Gruppe hat ihre Ueberschrift mit der richtigen Zahl. */
ok(lage.gruppen.length === bloecke.length,
`${lage.gruppen.length} Ueberschriften fuer ${bloecke.length} Bloecke`);
const zahlenStimmen = lage.gruppen.length > 0
&& lage.gruppen.every((g, i) => g.zahl === lage.bloecke[i])
&& lage.gruppen.every((g, i) => g.rolle === bloecke[i]);
ok(zahlenStimmen,
`jede Ueberschrift nennt ihre Rolle und ihre Anzahl richtig`
+ ` (${lage.gruppen.map((g) => `${g.wort} ${g.zahl}`).join(" · ")})`);
/* DIE REIHENFOLGE IST DIE AUSSAGE, nicht nur die Gruppierung.
Geprueft gegen ROLLEN_REIHE -- die eine Liste des Hauses -- und
nicht gegen drei hier hingeschriebene Namen. Wer die Reihenfolge
dort aendert, aendert sie hier mit. */
{
const { ROLLEN_REIHE } = await import("./workspace.js");
const erwartet = ROLLEN_REIHE.filter((r) => bloecke.includes(r));
ok(bloecke.length === 3,
`drei Rollen stehen auf der Seite (${bloecke.join(" → ")})`);
ok(JSON.stringify(bloecke) === JSON.stringify(erwartet),
`und zwar in der Reihenfolge des Hauses: ${erwartet.join(" → ")}`);
ok(bloecke[0] === "hand" && bloecke[1] === "linke",
"rechte Hand zuerst, dann die linke -- genau so gewuenscht");
}
/* ===================================================================
2. DIE STUFE STEHT NUR DA, WO ES EINE GIBT
=================================================================== */
console.log("");
console.log("--- Wer eine Stufe hat, und wer nicht ---");
const mitStufe = lage.karten.filter((k) => k.rolle === "modi");
const ohneStufe = lage.karten.filter((k) => k.rolle !== "modi");
ok(mitStufe.length === modis.length && ohneStufe.length >= 1,
`${mitStufe.length} Modis und ${ohneStufe.length} ohne Stufe im Bild`);
ok(mitStufe.length > 0 && mitStufe.every((k) => k.knoepfe === STUFEN.length),
`jeder Modi hat alle ${STUFEN.length} Stufenknoepfe`
+ ` (${[...new Set(mitStufe.map((k) => k.knoepfe))].join("/")})`);
/* DER EIGENTLICHE BEFUND VOM 22.09.: Hier standen die Knoepfe auch an
der rechten Hand. Der Server lehnte mit 404 ab, und der 404 warf
von der Seite. */
ok(ohneStufe.length > 0 && ohneStufe.every((k) => k.knoepfe === 0),
`wer keine Stufe hat, hat auch keine Knoepfe`
+ ` (${ohneStufe.map((k) => `${k.name}:${k.knoepfe}`).join(", ")})`);
ok(ohneStufe.length > 0 && ohneStufe.every((k) => !k.stufenwort),
`und auch kein Stufen-Schild — "Probe" an der rechten Hand waere`
+ ` eine Auskunft, die niemand aendern kann`
+ ` (${ohneStufe.map((k) => k.stufenwort || "leer").join(", ")})`);
/* Jede Stufe traegt ihren lesbaren Namen. */
const falschBeschriftet = mitStufe.filter(
(k) => k.stufenwort !== (STUFEN_NAME[k.stufe] || ""));
ok(mitStufe.length > 0 && falschBeschriftet.length === 0,
`jede Stufe steht mit ihrem Namen auf der Karte`
+ ` (${mitStufe.map((k) => k.stufenwort).join(", ")})`);
/* ===================================================================
3. EIN DRUCK AUF EINEN STUFENKNOPF
=================================================================== */
console.log("");
console.log("--- Was ein Druck auf die Stufe tut ---");
/* Die erste Karte, die Knoepfe hat, und darin ein Knopf, der noch
nicht gewaehlt ist. */
const ziel = await seite.evaluate(() => {
const k = [...document.querySelectorAll(".t-leute .tperson")]
.find((x) => x.querySelectorAll(".tstufen__knopf").length > 0);
if (!k) return null;
const b = [...k.querySelectorAll(".tstufen__knopf")]
.find((x) => x.dataset.an !== "ja");
if (!b) return null;
k.dataset.pruefziel = "ja";
b.dataset.pruefknopf = "ja";
return { name: k.querySelector(".tperson__name")?.textContent.trim(),
vorher: k.dataset.stufe, nach: b.dataset.stufe, wort: b.textContent.trim() };
});
if (!ziel) {
unklar("kein waehlbarer Stufenknopf gefunden — ohne ihn ist der Druck nicht messbar");
} else {
const adresseVorher = new URL(seite.url()).pathname;
abrufe.length = 0;
await seite.click('.tstufen__knopf[data-pruefknopf="ja"]');
await seite.waitForTimeout(1400);
const adresseNachher = new URL(seite.url()).pathname;
ok(adresseNachher === adresseVorher,
`die Adresse bleibt ${adresseNachher} — kein Sprung zur Startseite`);
ok(abrufe.length === 1 && abrufe[0].startsWith("200"),
`genau ein Abruf, und er geht durch (${abrufe.join(" | ") || "keiner"})`);
const danach = await seite.evaluate(() => {
const k = document.querySelector('.tperson[data-pruefziel="ja"]');
return k ? {
stufe: k.dataset.stufe,
schild: k.querySelector(".tmerkmal--stufe")?.textContent.trim() || "",
gewaehlt: [...k.querySelectorAll('.tstufen__knopf[data-an="ja"]')]
.map((b) => b.dataset.stufe),
meldung: document.getElementById("fehler")?.textContent.trim() || "",
} : null;
});
ok(danach?.gewaehlt.length === 1 && danach.gewaehlt[0] === ziel.nach,
`der gedrueckte Knopf ist gewaehlt und nur er (${(danach?.gewaehlt || []).join(", ")})`);
ok(danach?.schild === (STUFEN_NAME[ziel.nach] || ziel.nach),
`das Schild zieht mit (${danach?.schild})`);
/* DIE KARTENFARBE HAENGT AN data-stufe. Blieb sie stehen, sah die
Karte nach dem Speichern aus wie vorher -- und "gespeichert"
fuehlt sich dann an wie "nicht gespeichert". */
ok(danach?.stufe === ziel.nach,
`und die Karte traegt die neue Stufe, faerbt sich also mit um (${danach?.stufe})`);
ok(!danach?.meldung,
`keine Fehlermeldung auf der Seite (${danach?.meldung || "leer"})`);
/* ---- GEGENPROBE: derselbe Knopf noch einmal ---------------------- */
abrufe.length = 0;
await seite.click('.tstufen__knopf[data-pruefknopf="ja"]');
await seite.waitForTimeout(900);
const nochmal = await seite.evaluate(() => {
const k = document.querySelector('.tperson[data-pruefziel="ja"]');
return { stufe: k?.dataset.stufe,
gewaehlt: k ? [...k.querySelectorAll('.tstufen__knopf[data-an="ja"]')].length : -1 };
});
ok(abrufe.length === 0 && nochmal.stufe === ziel.nach && nochmal.gewaehlt === 1,
`Gegenprobe: derselbe Knopf noch einmal ruft nichts ab und aendert nichts`
+ ` (${abrufe.length} Abrufe)`);
}
}
/* =====================================================================
4. DIE BAENDER AUF EINEM SCHMALEN SCHIRM
Das Kopfband und das Zahlenband laufen mit NEGATIVEN Aussenabstaenden
bis an die Kanten der Karte. Solche Werte sind Gegenmasse zum Polster
der Karte -- und die Karte hat auf schmalen Schirmen ein anderes
(14/15 px statt 17/19). Passt das nicht zusammen, steht das Band
ueber, und waagerechtes Scrollen ist auf einem Handy der schlimmste
Fehler.
Gemessen statt gerechnet: 390 px ist die Breite, auf der Filipe
nachsieht. Geprueft wird die SEITE (scrollt sie waagerecht?) und
jedes einzelne Band (steht es ueber seiner Karte?) -- das zweite
deshalb, weil `clip-path` an der Karte ein ueberstehendes Band
abschneiden wuerde: Die Seite waere still in Ordnung, und der Text
im Band waere trotzdem weg.
===================================================================== */
console.log("");
console.log("--- Die Baender auf 390 px ---");
/* EIN EIGENER KONTEXT MIT FINGER (29.09.2026).
Hier stand `seite.setViewportSize({ width: 390 })`. Das aendert
nur die Groesse: `hasTouch` gehoert zum KONTEXT und laesst sich
danach nicht mehr setzen. Ohne ihn meldet der Browser
`pointer: fine`, und KEINE Regel aus `@media (pointer: coarse)`
greift -- dort stehen die 44-Pixel-Beruehrziele. Gerade fuer die
Frage, ob ein Band ueber seiner Karte steht, ist das
entscheidend: Die Karte ist am Finger anders gebaut.
Die Anmeldung wandert als Keks mit, statt sie zu wiederholen --
eine zweite Anmeldung waere eine zweite Stelle, die veralten
kann. Und die Adresse kommt von der Seite selbst, damit hier
keine zweite Wahrheit darueber entsteht, welche Seite gemessen
wird. */
const handyKontext = await browser.newContext({
viewport: { width: 390, height: 844 }, hasTouch: true, isMobile: true });
await handyKontext.addCookies(await ctx.cookies());
const handy = await handyKontext.newPage();
await handy.goto(seite.url(), { waitUntil: "networkidle" });
await handy.waitForTimeout(1500);
const schmal = await handy.evaluate(() => {
const karten = [...document.querySelectorAll(".t-leute .tperson")];
const ueber = [];
for (const k of karten) {
const kr = k.getBoundingClientRect();
for (const wahl of [".tperson__kopf", ".tperson__zahlen"]) {
const b = k.querySelector(wahl);
if (!b) continue;
const br = b.getBoundingClientRect();
/* Ein halber Pixel Spiel: Die Fase rechnet mit Bruchteilen. */
if (br.left < kr.left - 0.5 || br.right > kr.right + 0.5) {
ueber.push(`${wahl} ${Math.round(br.left - kr.left)}/${Math.round(br.right - kr.right)}`);
}
}
}
return {
karten: karten.length,
scrollBreite: document.documentElement.scrollWidth,
sichtbar: window.innerWidth,
ueber,
/* Bei einer Spalte darf keine Karte breiter sein als der Schirm. */
breiteste: Math.round(Math.max(0, ...karten.map((k) => k.getBoundingClientRect().width))),
};
});
ok(schmal.karten > 0, `${schmal.karten} Karten auch auf 390 px`);
ok(schmal.karten > 0 && schmal.scrollBreite <= schmal.sichtbar + 1,
`die Seite scrollt nicht waagerecht (${schmal.scrollBreite} von ${schmal.sichtbar})`);
ok(schmal.karten > 0 && schmal.ueber.length === 0,
`kein Band steht ueber seiner Karte (${schmal.ueber.join(", ") || "keines"})`);
ok(schmal.karten > 0 && schmal.breiteste <= schmal.sichtbar,
`die breiteste Karte passt (${schmal.breiteste} von ${schmal.sichtbar})`);
/* =======================================================================
DIE BEIDEN HAENDE TRAGEN IHRE FARBE (23.09.2026)
Filipe, mit dem Bildschirmfoto: „die farbe der kachel soll die
gleiche sein wie die farbe die der text linke hand hat, aber mach es
richtig knallig so wie die farben bei den modis und linke hand.
soll schoen auffallen bitte."
DREI FRAGEN, UND KEINE DAVON LAESST SICH AM QUELLTEXT BEANTWORTEN:
1. Hat die Karte DIESELBE Farbe wie ihre Marke? („die gleiche")
2. Ist sie kraeftig genug, um aufzufallen? („richtig knallig")
3. Kann man den Text darauf noch lesen? -- das ist die Bedingung,
unter der 1 und 2 ueberhaupt gelten duerfen.
GEMESSEN WIRD AM BILDPUNKT, nicht an der Stilvorlage. Eine Regel
kann richtig dastehen und von einer spaeteren ueberschrieben sein;
was am Ende leuchtet, sagt nur der gezeichnete Bildschirm.
DIE ERSTE FASSUNG DIESER MESSUNG WAR FALSCH, und das gehoert
hierhin: Sie las `rgb(13, 8, 23)` und `color(srgb 0.87 0.71 0.96)`
mit derselben Rechnung. Die zweite Schreibweise zaehlt aber in
0..1, nicht in 0..255 -- die helle Rollenmarke kam damit auf einen
Kontrast von 1,09. Eine Zahl, die aussieht wie ein Befund und keiner
ist. Deshalb steht die Umrechnung hier ausgeschrieben.
======================================================================= */
{
/* Das Handyfenster ist ein eigener Kontext und wird hier zugemacht.
`seite` war nie schmal, deshalb gibt es auch nichts
zurueckzustellen -- frueher stand hier ein `setViewportSize` auf
1280, das die Umstellung von 390 rueckgaengig machte. */
await handyKontext.close();
await seite.waitForTimeout(400);
const farben = await seite.evaluate(() => {
const zahl = (c) => { const x = c / 255; return x <= 0.04045 ? x / 12.92 : ((x + 0.055) / 1.055) ** 2.4; };
const leucht = (r) => 0.2126 * zahl(r[0]) + 0.7152 * zahl(r[1]) + 0.0722 * zahl(r[2]);
/* ZWEI SCHREIBWEISEN, ZWEI ZAHLENBEREICHE -- siehe oben. */
const lies = (t) => {
const roh = (String(t).match(/[\d.]+/g) || []).slice(0, 3).map(Number);
if (roh.length < 3) return [0, 0, 0];
return /color\(/.test(String(t)) ? roh.map((x) => x * 255) : roh;
};
const kontrast = (a2, b2) => {
const la = leucht(lies(a2)), lb = leucht(lies(b2));
return Math.round(((Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05)) * 100) / 100;
};
/* Der DECKENDE Grund unter einem Element -- eine durchsichtige
Toenung daraufzurechnen waere geraten. */
const grundVon = (el) => {
let e = el;
while (e) {
const f = getComputedStyle(e).backgroundColor;
const teile = (String(f).match(/[\d.]+/g) || []);
if (teile.length >= 3 && (teile[3] === undefined || Number(teile[3]) > 0.85)) return f;
e = e.parentElement;
}
return "rgb(0,0,0)";
};
const mess = (rolle) => {
const k = [...document.querySelectorAll(".tperson")].find((x) => x.dataset.rolle === rolle);
if (!k) return null;
const name = k.querySelector(".tperson__name");
const leise = k.querySelector(".tperson__zuletzt");
const marke = k.querySelector(".tmerkmal--rolle");
const grund = grundVon(k);
const stil = getComputedStyle(k);
return {
/* Der Ton, den die Karte fuehrt, und der Ton ihrer Marke --
die Frage "die gleiche Farbe?" ist genau dieser Vergleich. */
kartenton: stil.getPropertyValue("--kartenton").trim(),
markenton: marke ? getComputedStyle(marke).getPropertyValue("--rm").trim() : "",
/* Traegt die FLAECHE Farbe, oder nur die Kante? "knallig"
heisst genau das: Der Verlauf muss im Bild ankommen. */
schichten: (stil.backgroundImage.match(/gradient/g) || []).length,
toenung: stil.backgroundImage.includes("34%") || /oklab|color\(/.test(stil.backgroundImage),
leiste: getComputedStyle(k, "::before").width,
nameKontrast: name ? kontrast(getComputedStyle(name).color, grund) : null,
leiseKontrast: leise ? kontrast(getComputedStyle(leise).color, grund) : null,
markeKontrast: marke
? kontrast(getComputedStyle(marke).color, getComputedStyle(marke).backgroundColor) : null,
grund,
};
};
return { hand: mess("hand"), linke: mess("linke"), modi: mess("modi") };
});
/* DIE ZAHL GEHOERT IN DIE BEDINGUNG: Faende der Wahlausdruck keine
Karte, waere `farben.linke` null -- und jede Aussage darueber
waere gruen, ohne etwas angesehen zu haben. */
ok(!!farben.hand && !!farben.linke,
"beide Haende haben eine Karte auf dem Bildschirm");
/* ---- 1. „die gleiche Farbe wie der Text Linke Hand" ------------- */
ok(!!farben.linke && farben.linke.kartenton !== ""
&& farben.linke.kartenton === farben.linke.markenton,
`die Karte der linken Hand fuehrt den Ton ihrer Marke (${farben.linke?.kartenton} = ${farben.linke?.markenton})`);
ok(!!farben.hand && farben.hand.kartenton === farben.hand.markenton,
`dasselbe bei der rechten Hand (${farben.hand?.kartenton})`);
/* Und es sind ZWEI Toene, nicht einer. Waeren beide gleich, waere
die Zeile darueber trotzdem gruen -- und die Unterscheidung weg. */
ok(!!farben.hand && !!farben.linke
&& farben.hand.kartenton !== farben.linke.kartenton,
"und es sind zwei verschiedene Toene — Gold und Lila");
/* ---- 2. „richtig knallig" --------------------------------------- */
ok(!!farben.linke && farben.linke.schichten >= 2,
`die Farbe liegt in der FLAECHE, nicht nur an der Kante (${farben.linke?.schichten} Verlaeufe)`);
ok(!!farben.hand && !!farben.linke
&& farben.hand.schichten === farben.linke.schichten,
"beide Haende gleich stark — der Unterschied ist der Ton, nicht die Wirkung");
ok(farben.linke?.leiste === "3px" && farben.hand?.leiste === "3px",
`beide tragen die Leiste an der linken Kante (${farben.linke?.leiste} / ${farben.hand?.leiste})`);
/* GEGENPROBE: Eine Modi-Karte hat das NICHT. Ohne sie bewiese alles
oben nur, dass jede Karte so aussieht -- und dann faellt keine auf. */
ok(!!farben.modi && farben.modi.leiste !== "3px",
`Gegenprobe: eine Modi-Karte traegt die Leiste nicht (${farben.modi?.leiste})`);
/* ---- 3. Und man kann es lesen ----------------------------------- */
for (const [wer, m] of [["linke Hand", farben.linke], ["rechte Hand", farben.hand]]) {
ok(!!m && m.nameKontrast >= 4.5,
`${wer}: der Name steht mit ${m?.nameKontrast}:1 auf der Flaeche (verlangt 4,5)`);
ok(!!m && m.leiseKontrast >= 4.5,
`${wer}: auch das Kleingedruckte (${m?.leiseKontrast}:1)`);
ok(!!m && m.markeKontrast >= 4.5,
`${wer}: und die Rollenmarke auf ihrem eigenen Grund (${m?.markeKontrast}:1)`);
}
}
ok(meckern.length === 0,
`der Browser meldet nichts (${meckern.slice(0, 2).join(" | ") || "still"})`);
/* ---------- Ende ------------------------------------------------------ */
await browser.close();
await new Promise((r) => vorbau.close(r));
try { rmSync(ordner, { recursive: true, force: true }); } catch { /* Windows haelt manchmal */ }
console.log("");
console.log(`${fehler === 0 ? "BESTANDEN" : "FEHLGESCHLAGEN"}`
+ ` -- ${geprueft} geprueft, ${fehler} Fehler, ${offen} nicht nachsehbar`);
process.exit(fehler === 0 ? 0 : 1);