Files
dogfather-universe/server/pruef-teamlage-karten.mjs
T
DogFatherGitandClaude Opus 5 ba0e326585 Team-Lage: fuenf Stufen mit eigener Farbe, Karten neu gebaut, 404 wirft nicht mehr hinaus
B7 -- "wenn ich auf diese sachen drücke werd ich auf die startseite
geschickt" und "ich will das es ein zwei kategorien mehr gibt und das
soll geiler aussehen und jede seine eigene farbe, stark erkennbar"

1. DER RAUSWURF. Ein Druck auf einen Stufen-Knopf schickte auf die
   Startseite. Ursache: `hole()` stand ACHTMAL Zeichen fuer Zeichen
   gleich in acht Dateien, und darin warf JEDER 404 hinaus -- auch der
   auf eine blosse Handlung. Jetzt eine gemeinsame `assets/js/holen.js`
   mit der engeren, abgeleiteten Regel: Ein 404 wirft nur, solange die
   Seite noch gar nichts bekommen hat.

2. ZWEI STUFEN MEHR, und keine davon erfunden: "Fortgeschritten" gibt
   es im Entwicklungs-Katalog laengst (ENTWICKLUNG_ERWARTUNG), hier
   fehlte genau diese Mitte; "Vertretung" stand bis heute IN der
   Beschreibung von Senior. Keine Datenbankaenderung noetig -- die
   Spalte ist absichtlich ein TEXT ohne CHECK.

3. DIE FARBEN SIND GERECHNET. Nachgemessen lagen "Probe" und
   "Standard" bei 0,0576 OKLab-Abstand; MINDEST_ABSTAND ist 0,090 --
   die beiden waren nebeneinander dieselbe Farbe, und alle drei lagen
   unter MINDEST_BUNTHEIT. Die neuen fuenf sind ein Faecher aus fuenf
   Farbwinkeln im Abstand von 72 Grad; der Versatz ist der, bei dem der
   kleinste Abstand am groessten wird. Ergebnis: 0,1531 untereinander,
   0,1108 zur Rollenmarke daneben, Kontrast 7,4 bis 8,6.

B8 -- "die sollen viel krasser geiler und übersichtlicher sein ... und
die kacheln sollen viel krass geiler und spezieller aussehen auch der
hintergrund, veränder das komplett"

4. DIE KARTE IST NEU: Kopfband in der Farbe der Stufe bis an die
   Kanten, Zeichen von 44 auf 52 px mit Ring, Zahlenband als eigene
   Flaeche, neuer Hintergrund (Farbschein unter dem Kopfband, feine
   Schraffur, dunklere Platte). 781 px hoch gemessen, danach 746.

5. DIE ROLLE STEHT JETZT DA -- als Marke auf der Karte und als
   Ueberschrift ueber jeder Gruppe. Sortiert war schon vorher nach
   Rolle; man konnte es nur nicht sehen.

DREI FEHLER, DIE DABEI AUFFIELEN

* `auto-fit` liess die einzelne Karte der rechten Hand ueber die ganze
  Bildschirmbreite laufen, sobald gruppiert wurde. `auto-fill` haelt
  die leeren Spalten offen.
* Der Name der Person trug `tperson__namen` statt `tperson__name` --
  ein Buchstabe, und damit die Klasse fuer graues Kleingedrucktes. Auf
  einer Seite ueber Personen war der Name kleiner als die Beschriftung
  darunter. Gefunden hat es die neue Pruefung, die ueberall "?" las.
* `stufeSetzen()` suchte das Stufen-Schild mit
  `[class*="tmerkmal--"]:not(...)` -- einer Aufzaehlung dessen, was es
  NICHT ist. Sobald die Rollenmarke davorstand, traf die Suche sie:
  Ein Druck auf "Standard" haette aus "Modi" das Wort "Standard"
  gemacht. Und die Karte faerbte sich beim Setzen nicht mit um.

DREI PRUEFUNGEN, ALLE MIT GEGENPROBE

* pruef-holen.mjs (NEU, 14): fuehrt die echte Datei aus und misst fuenf
  Wege; dieselben fuenf noch einmal an der ALTEN Fassung, die bei genau
  zwei durchfallen muss. Dazu: laedt jede der acht Seiten holen.js, und
  zwar VOR ihrem Skript.
* pruef-teamlage-karten.mjs (NEU, 22): im Browser. Gruppen, Rollenwort,
  Stufenknoepfe nur bei Modis, der Druck selbst (kein Sprung, ein
  Abruf, Schild und Kartenfarbe ziehen mit), Gegenprobe mit dem zweiten
  Druck, und die neuen Baender auf 390 px.
* pruef-team-stufen.mjs (28 -> 47): die abgeschriebene Liste
  ["probe","standard","senior"] ist raus -- geprueft werden jetzt
  Eigenschaften und die Farbabstaende, gelesen aus team.css. Gegenprobe
  ist der Stand von gestern, der durch dieselbe Messung faellt.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-22 19:46:47 +02:00

394 lines
18 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";
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 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");
execFileSync("openssl", ["req", "-x509", "-newkey", "rsa:2048", "-nodes",
"-keyout", schl, "-out", zert, "-days", "2", "-subj", `/CN=${CREW}`,
"-addext", `subjectAltName=DNS:${CREW}`], { stdio: "ignore" });
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 === 1 + modis.length,
`${lage.karten.length} Karten stehen da (erwartet ${1 + 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(" · ")})`);
/* ===================================================================
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 ---");
await seite.setViewportSize({ width: 390, height: 844 });
await seite.reload({ waitUntil: "networkidle" });
await seite.waitForTimeout(1500);
const schmal = await seite.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})`);
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);