Files
dogfather-universe/server/pruef-start-ansicht.mjs
T
DogFatherGitandClaude Opus 5 1816ed9e1f Hinweisliste "Was ist dran": Zeichen, Zahl und Farbe der Zielkachel
Die Zeilen waren die letzten schmucklosen Elemente der Startseite. Jetzt
traegt jede das Zeichen UND die Farbe der Kachel, zu der sie fuehrt --
Hinweis und Ziel gehoeren dadurch sichtbar zusammen, ohne dass die
Zuordnung ein zweites Mal irgendwo steht (sie kommt aus derselben
Tabelle wie die Zahl auf der Kachel). Die Anzahl steht vorn, getrennt vom
Satz und in gleicher Zeichenbreite; beim Ueberfliegen von sieben Zeilen
liest man genau sie zuerst. Ueberfaellig schlaegt Bereichsfarbe und
bleibt rot -- wenn etwas brennt, zaehlt zuerst, DASS es brennt.

Dabei gefunden und behoben: Die Farbtoene hingen an .kachel[data-ton=n].
Um sie fuer die Hinweiszeilen mitzunutzen, wurde .kachel entfernt --
damit gewann aber die Voreinstellung ".kachel { --ton: var(--akzent) }"
weiter unten in der Datei den Wettstreit der Regeln, und alle siebzehn
Kacheln waeren blau gewesen. Die Voreinstellung steht jetzt in :where()
und zaehlt dabei als nicht vorhanden. Die Browserpruefung hat das
gemeldet ("1 Farbe auf 17 Kacheln"), nicht das Auge.

"Nichts offen" ist kein gestrichelter Kasten mehr, sondern eine gute
Nachricht mit ruhigem Gruen und Haken. Weil er jetzt display:flex hat,
war er staerker als das hidden des Browsers und haette IMMER dagestanden
-- eigene Regel dafuer plus eine Pruefung, die beide Richtungen misst.

Neue Pruefungen (pruef-start-ansicht): Zeichen, vorangestellte Zahl,
keine doppelte Zahl im Satz, ganzer Satz fuer Vorleseprogramme, Farbe
gleich der Kachelfarbe an echten Bildpunkten, rot nur bei ueberfaellig.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-01 09:39:59 +02:00

358 lines
19 KiB
JavaScript

/* Browserpruefung der Startseite: Gruppen, Zeichen, Farbtoene, Zahlen auf
den Kacheln -- und was jede Rolle davon zu sehen bekommt. */
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
const ordner = mkdtempSync(join(tmpdir(), "ws-start-"));
process.env.WORKSPACE_DB = join(ordner, "workspace.db");
process.env.PORT = "4186";
process.env.SITE_ACCESS_SECRET = "lokaler-test";
process.env.SITE_ACCESS_CODE_DOGI = "DOGI-TEST-1";
process.env.SITE_ACCESS_CODE_VANVAN = "VANVAN-TEST-1";
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");
await new Promise((r) => setTimeout(r, 700));
const BASIS = "http://127.0.0.1:4186";
setTimeout(() => { console.log("ABBRUCH"); process.exit(1); }, 200_000).unref?.();
let fehler = 0;
const ok = (b, t) => { console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; };
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");
const d = new DatabaseSync(process.env.WORKSPACE_DB);
const jetzt = new Date().toISOString();
const heute = jetzt.slice(0, 10);
const gestern = new Date(Date.now() - 86400_000).toISOString().slice(0, 10);
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, aktiv, erstellt) VALUES (?,?,?,?,?,1,?)")
.run(name, rolle, hash, salt, 32768, jetzt);
return d.prepare("SELECT last_insert_rowid() AS id").get().id;
}
const idDogi = anlegen("Filipe", "admin", "CODE-DOGI-0001");
const idScout = anlegen("Patrick", "scout", "CODE-SCOU-0001");
const idAnna = anlegen("Tili", "creator", "CODE-ANNA-0001");
d.prepare("INSERT INTO betreuung (creator_id, betreuer_id, seit) VALUES (?,?,?)").run(idAnna, idScout, jetzt);
/* Arbeit an mehreren Stellen -- damit auf den Kacheln wirklich Zahlen
stehen und man sieht, ob sie an der richtigen landen. */
for (const [status, frist] of [["offen", gestern], ["offen", gestern], ["offen", heute],
["arbeit", null], ["review", null]]) {
d.prepare("INSERT INTO aufgaben (titel, status, creator_id, frist, erstellt, erstellt_von) VALUES ('Test',?,?,?,?,?)")
.run(status, idAnna, frist, jetzt, idDogi);
}
d.prepare("INSERT INTO termine (titel, beginn, creator_id, erledigt, erstellt, erstellt_von) VALUES ('Heute',?,?,0,?,?)")
.run(heute + "T18:00", idAnna, jetzt, idDogi);
d.prepare("INSERT INTO eintraege (bereich, art, titel, datum, dringlichkeit, status, creator_id, erstellt, erstellt_von) VALUES ('technik','problem','Ton weg',?,'hoch','offen',?,?,?)")
.run(heute, idAnna, jetzt, idDogi);
d.close();
const { chromium } = await import(
"file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs");
const browser = await chromium.launch();
async function anmelden(kontext, rolle, code) {
const seite = await kontext.newPage();
await seite.goto(BASIS + "/workspace/", { waitUntil: "domcontentloaded" });
await seite.click(`.rolle[data-rolle="${rolle}"]`);
await seite.fill("#code", code);
await seite.click("button[type=submit]");
await seite.waitForURL("**/start.html", { timeout: 15000 });
await seite.waitForTimeout(900);
return seite;
}
const lesen = (seite) => seite.evaluate(() => ({
gruss: document.getElementById("gruss")?.textContent,
gruppen: [...document.querySelectorAll(".gruppe")].map((g) => ({
name: g.querySelector(".gruppe__name")?.textContent,
kacheln: g.querySelectorAll(".kachel").length,
})),
kacheln: [...document.querySelectorAll(".kachel")].map((k) => ({
name: k.querySelector(".kachel__name")?.textContent,
unter: k.querySelector(".kachel__unter")?.textContent,
ton: k.dataset.ton,
farbe: getComputedStyle(k.querySelector(".kachel__svg")).stroke,
pfade: k.querySelectorAll(".kachel__svg path").length,
zahl: k.querySelector(".kachel__zahl")?.textContent || null,
warn: k.querySelector(".kachel__zahl")?.dataset.warn === "ja",
ziel: k.querySelector(".kachel__link")?.getAttribute("href"),
})),
/* Abgeschnittener Text ist der haeufigste stille Fehler in einer
Kachel -- er sieht auf den ersten Blick nach Absicht aus. Gemessen
wird die tatsaechliche Textbreite gegen die verfuegbare. */
abgeschnitten: [...document.querySelectorAll(".kachel__name, .kachel__unter")]
.filter((e) => e.scrollWidth > e.clientWidth + 1)
.map((e) => e.textContent),
hinweise: [...document.querySelectorAll("#dran .dran__punkt")]
.filter((z) => z.offsetParent !== null).length,
hinweiseGesamt: document.querySelectorAll("#dran .dran__punkt").length,
/* Jede Hinweiszeile traegt Zeichen und Farbe der Kachel, zu der sie
fuehrt. Geprueft wird beides an den echten Bildpunkten, nicht an der
Absicht im Quelltext. */
dranZeilen: [...document.querySelectorAll("#dran .dran__punkt")].map((z) => ({
ton: z.dataset.ton || null,
stufe: z.dataset.stufe,
pfade: z.querySelectorAll(".dran__svg path").length,
strich: getComputedStyle(z.querySelector(".dran__svg")).stroke,
zahl: z.querySelector(".dran__zahl")?.textContent || null,
zahlFarbe: getComputedStyle(z.querySelector(".dran__zahl")).color,
text: z.querySelector(".dran__text")?.textContent || "",
label: z.querySelector(".dran__link")?.getAttribute("aria-label") || "",
})),
zeichenGroesse: Math.round(document.querySelector(".kachel__zeichen")?.getBoundingClientRect().width || 0),
wasserzeichen: document.querySelectorAll(".kachel__wasserzeichen").length,
wasserzeichenDeckung: getComputedStyle(document.querySelector(".kachel__wasserzeichen") || document.body).opacity,
wasserzeichenSchwach: Number(getComputedStyle(
document.querySelector(".kachel__wasserzeichen") || document.body).opacity) <= 0.15,
namenGroesse: Math.round(parseFloat(getComputedStyle(document.querySelector(".kachel__name")).fontSize) || 0),
kachelHoehe: Math.round(document.querySelectorAll(".kachel")[1]?.getBoundingClientRect().height || 0),
zahlenLeer: !document.getElementById("zahlen-leer")?.hidden,
zahlen: [...document.querySelectorAll(".zahl")].length,
ueber: document.documentElement.scrollWidth - document.documentElement.clientWidth,
ueberstehend: [...document.querySelectorAll("main *")]
.filter((e) => e.getBoundingClientRect().right > window.innerWidth + 1)
.map((e) => String(e.className).slice(0, 40)).slice(0, 4),
}));
for (const [breite, wie] of [[1440, "Computer"], [390, "Handy"]]) {
console.log(`\n=== DogFather · ${wie}`);
const kontext = await browser.newContext({ viewport: { width: breite, height: 1200 } });
const konsole = [];
const seite = await anmelden(kontext, "admin", "CODE-DOGI-0001");
seite.on("console", (m) => { if (m.type() === "error") konsole.push(m.text()); });
seite.on("pageerror", (e) => konsole.push("pageerror: " + e.message));
await seite.reload({ waitUntil: "networkidle" });
await seite.waitForTimeout(900);
const r = await lesen(seite);
console.log(" Gruß: ", r.gruss);
console.log(" Gruppen:", r.gruppen.map((g) => `${g.name} (${g.kacheln})`).join(" · "));
console.log(" Zahlen: ", r.kacheln.filter((k) => k.zahl).map((k) => `${k.name}=${k.zahl}${k.warn ? "!" : ""}`).join(" · "));
ok(r.gruppen.length === 3, `drei Gruppen (${r.gruppen.length})`);
ok(r.kacheln.length === 17, `siebzehn Kacheln (${r.kacheln.length})`);
ok(/^(Guten Morgen|Guten Tag|Guten Abend|Noch wach), Filipe$/.test(r.gruss || ""),
`Begrüßung nach Tageszeit ("${r.gruss}")`);
/* Jede Kachel muss ein Zeichen haben -- eine ohne waere ein leeres
Kaestchen und faellt sofort auf. */
/* Das Wasserzeichen: dasselbe Zeichen noch einmal, riesig und schwach
im Hintergrund. Es macht jede Kachel unverwechselbar -- fehlt es,
sehen alle wieder gleich aus. */
ok(r.wasserzeichen === r.kacheln.length,
`jede Kachel trägt ihr Wasserzeichen (${r.wasserzeichen} von ${r.kacheln.length})`);
ok(r.wasserzeichenSchwach, `und es ist schwach genug, um nicht zu stören (${r.wasserzeichenDeckung})`);
ok(r.kacheln.every((k) => k.pfade > 0),
`jede Kachel hat ein Zeichen (${r.kacheln.filter((k) => !k.pfade).map((k) => k.name).join(", ") || "alle"})`);
ok(r.kacheln.every((k) => k.unter && k.unter.length > 4), "jede Kachel sagt, was drin ist");
ok(r.kacheln.every((k) => k.ziel && k.ziel !== "#"), "jede Kachel führt irgendwohin");
/* Acht verschiedene Farben, und nirgends steht "ÖFFNEN" mehr. */
const toene = new Set(r.kacheln.map((k) => k.farbe));
/* JEDE Kachel ihre eigene Farbe -- siebzehn verschiedene, keine
doppelt. (Der Creator sieht "Mein Profil" statt "Creator-Profile";
das sind zwei Namen fuer denselben Bereich und teilen sich einen
Ton -- deshalb wird hier gegen die Zahl der KACHELN geprueft.) */
ok(toene.size === r.kacheln.length,
`jede Kachel hat ihre eigene Farbe (${toene.size} Farben auf ${r.kacheln.length} Kacheln)`);
const oeffnen = await seite.evaluate(() => document.body.textContent.includes("ÖFFNEN"));
ok(!oeffnen, "das Wort ÖFFNEN kommt nicht mehr vor");
/* Die Zahlen müssen auf der RICHTIGEN Kachel stehen. */
const auf = (n) => r.kacheln.find((k) => k.name === n);
ok(auf("Aufgaben")?.zahl === "4",
`Aufgaben trägt 4 (2 überfällig + 1 heute + 1 Review) (${auf("Aufgaben")?.zahl})`);
ok(auf("Aufgaben")?.warn === true, "und ist als Warnung markiert");
ok(auf("Kalender")?.zahl === "1", `Kalender trägt 1 (${auf("Kalender")?.zahl})`);
ok(auf("LIVE-Analyse")?.zahl === "1", `der dringende Bereichspunkt (${auf("LIVE-Analyse")?.zahl})`);
ok(!auf("Wissens-Bibliothek")?.zahl, "wo nichts liegt, steht keine Zahl");
/* Die Kacheln sollen spürbar größer sein als vorher (Zeichenfeld 38px,
Name .93rem). Gemessen statt geglaubt -- "sieht größer aus" ist kein
Nachweis. */
ok(r.zeichenGroesse >= 46, `Zeichenfeld mindestens 46px (${r.zeichenGroesse}px)`);
ok(r.namenGroesse >= 16, `Name mindestens 16px (${r.namenGroesse}px)`);
ok(r.kachelHoehe >= 80, `Kachel mindestens 80px hoch (${r.kachelHoehe}px)`);
ok(r.abgeschnitten.length === 0,
`kein Text wird abgeschnitten (${r.abgeschnitten.join(" | ") || "keiner"})`);
ok(r.hinweise <= 4, `höchstens vier Hinweise sichtbar (${r.hinweise} von ${r.hinweiseGesamt})`);
/* --- Die Hinweiszeilen ------------------------------------------------
Eine Zeile ohne Zeichen oder ohne Zahl waere ein halbes Bauteil und
faellt beim Ueberfliegen sofort auf. */
ok(r.dranZeilen.length > 0, `es liegen Hinweise vor (${r.dranZeilen.length})`);
ok(r.dranZeilen.every((z) => z.pfade > 0),
`jede Hinweiszeile trägt ihr Zeichen (${r.dranZeilen.filter((z) => !z.pfade).length} ohne)`);
ok(r.dranZeilen.every((z) => /^[1-9]\d*$/.test(z.zahl || "")),
`jede Hinweiszeile stellt ihre Zahl voran (${r.dranZeilen.map((z) => z.zahl).join(",")})`);
/* Die Zahl steht vorn -- im Satz darf sie nicht noch einmal stehen. */
ok(r.dranZeilen.every((z) => !/^\d/.test(z.text)),
`und keine Zahl steht doppelt (${r.dranZeilen.map((z) => z.text).find((t) => /^\d/.test(t)) || "keine"})`);
/* Vorleseprogramme bekommen den ganzen Satz, nicht "2" und "Aufgaben sind
überfällig" als zwei Bruchstuecke. */
ok(r.dranZeilen.every((z) => z.label.includes(z.zahl) && z.label.length > 10),
"und der ganze Satz steht für Vorleseprogramme dran");
/* Farbe folgt dem Bereich, nicht dem Bauteil: Der Ton der Zeile muss
der Ton einer wirklich vorhandenen Kachel sein. */
const kachelToene = new Set(r.kacheln.map((k) => k.ton));
ok(r.dranZeilen.every((z) => z.ton && kachelToene.has(z.ton)),
`jede Zeile trägt den Farbton ihrer Kachel (${r.dranZeilen.map((z) => z.ton).join(",")})`);
/* Und zwar wirklich DIESELBE Farbe, an echten Bildpunkten gemessen --
"hat einen Ton" reicht nicht, sonst faellt nicht auf, wenn alle
Zeilen denselben tragen. */
const kachelFarbe = new Map(r.kacheln.map((k) => [k.ton, k.farbe]));
const falsch = r.dranZeilen.filter((z) => z.stufe !== "warnung")
.filter((z) => z.zahlFarbe !== kachelFarbe.get(z.ton));
ok(falsch.length === 0,
`und die Farbe ist die der Kachel (${falsch.map((z) => `${z.ton}: ${z.zahlFarbe} statt ${kachelFarbe.get(z.ton)}`).join(" | ") || "alle"})`);
/* Ueberfaellig schlaegt Bereichsfarbe -- dort muss Rot stehen, sonst
geht die Dringlichkeit in siebzehn Farben unter. */
const warnZeilen = r.dranZeilen.filter((z) => z.stufe === "warnung");
ok(warnZeilen.length > 0 && warnZeilen.every((z) => z.zahlFarbe === "rgb(255, 154, 162)"),
`überfällige Zeilen sind rot (${warnZeilen.length} Stück, ${warnZeilen[0]?.zahlFarbe})`);
/* Und die uebrigen eben NICHT rot -- sonst waere die Regel wertlos. */
ok(r.dranZeilen.filter((z) => z.stufe !== "warnung")
.every((z) => z.zahlFarbe !== "rgb(255, 154, 162)"),
"und nur die");
ok(r.zahlen === 6, `die Aufgabenzahlen stehen (${r.zahlen})`);
ok(!r.zahlenLeer, "und nicht der Leer-Satz");
ok(r.ueber <= 0, `nichts steht über (${r.ueber}px, ${r.ueberstehend.join(", ")})`);
ok(konsole.length === 0, `keine Fehler in der Konsole${konsole.length ? " -> " + konsole.join(" | ") : ""}`);
await seite.screenshot({ path: breite === 1440 ? "pruef-start-computer.png" : "pruef-start-handy.png", fullPage: true });
await kontext.close();
}
console.log("\n=== Creator");
{
const kontext = await browser.newContext({ viewport: { width: 1440, height: 1200 } });
const seite = await anmelden(kontext, "creator", "CODE-ANNA-0001");
const r = await lesen(seite);
console.log(" Gruppen:", r.gruppen.map((g) => `${g.name} (${g.kacheln})`).join(" · "));
const namen = r.kacheln.map((k) => k.name);
ok(namen.includes("Mein Profil"), "sieht sein eigenes Profil");
ok(!namen.includes("Creator-Profile"), "und nicht die Liste aller Creator");
ok(!namen.includes("Personen & Zugänge"), "keine Personenverwaltung");
ok(!namen.includes("Scout-Pipeline"), "keine Scout-Pipeline");
ok(!namen.includes("Automationen"), "keine Automationen");
ok(r.gruppen.length === 3, `trotzdem drei Gruppen (${r.gruppen.length})`);
ok(r.kacheln.every((k) => k.pfade > 0), "alle Zeichen da");
await seite.screenshot({ path: "pruef-start-creator.png", fullPage: true });
await kontext.close();
}
console.log("\n=== Scout");
{
const kontext = await browser.newContext({ viewport: { width: 1440, height: 1200 } });
const seite = await anmelden(kontext, "scout", "CODE-SCOU-0001");
const r = await lesen(seite);
const namen = r.kacheln.map((k) => k.name);
ok(namen.includes("Scout-Pipeline"), "sieht seine Pipeline");
ok(!namen.includes("Personen & Zugänge"), "keine Personenverwaltung");
ok(!namen.includes("Automationen"), "keine Automationen");
ok(namen.includes("Creator-Profile"), "aber die Creator-Profile");
await kontext.close();
}
/* Der Satz "Nichts offen" ist ein Kasten mit display:flex geworden. Damit
ist er staerker als das hidden des Browsers -- ohne eine eigene Regel
stuende er IMMER da, auch wenn Arbeit offen ist. Genau das wird hier
gemessen, in beide Richtungen. */
console.log("\n=== Der Satz 'Nichts offen' erscheint nur, wenn nichts offen ist");
{
const kontext = await browser.newContext({ viewport: { width: 1440, height: 1200 } });
const seite = await anmelden(kontext, "admin", "CODE-DOGI-0001");
const r = await seite.evaluate(() => {
const k = document.getElementById("zahlen-leer");
const zu = getComputedStyle(k).display;
k.hidden = false;
const auf = getComputedStyle(k).display;
const haken = k.querySelectorAll("svg path").length;
k.hidden = true;
return { zu, auf, haken };
});
ok(r.zu === "none", `versteckt heisst versteckt (${r.zu})`);
ok(r.auf !== "none", `und sichtbar heisst sichtbar (${r.auf})`);
ok(r.haken > 0, `der Haken ist da (${r.haken})`);
await kontext.close();
}
console.log("\n=== Weniger Bewegung: keine Animation");
{
const kontext = await browser.newContext({
viewport: { width: 1440, height: 1000 }, reducedMotion: "reduce" });
const seite = await anmelden(kontext, "admin", "CODE-DOGI-0001");
const bewegung = await seite.evaluate(() => {
const k = document.querySelector(".kachel");
const s = getComputedStyle(k);
return { animation: s.animationName, sichtbar: s.opacity };
});
ok(bewegung.animation === "none", `keine Animation (${bewegung.animation})`);
ok(Number(bewegung.sichtbar) === 1, `und trotzdem voll sichtbar (${bewegung.sichtbar})`);
await kontext.close();
}
console.log("\n=== Das Licht folgt dem Zeiger");
{
const kontext = await browser.newContext({ viewport: { width: 1440, height: 1200 } });
const seite = await anmelden(kontext, "admin", "CODE-DOGI-0001");
const kachel = seite.locator(".kachel").nth(1);
const kasten = await kachel.boundingBox();
const ruhe = await kachel.evaluate((k) => k.style.getPropertyValue("--mx") || "(Ruhelage)");
ok(ruhe === "(Ruhelage)", `ruhend keine eigene Position gesetzt (${ruhe})`);
await seite.mouse.move(kasten.x + kasten.width * 0.2, kasten.y + kasten.height * 0.3);
await seite.waitForTimeout(250);
const links = await kachel.evaluate((k) => k.style.getPropertyValue("--mx"));
await seite.mouse.move(kasten.x + kasten.width * 0.85, kasten.y + kasten.height * 0.6);
await seite.waitForTimeout(250);
const rechts = await kachel.evaluate((k) => k.style.getPropertyValue("--mx"));
console.log(` links: ${links} · rechts: ${rechts}`);
ok(parseFloat(links) < 40, `links steht der Fleck links (${links})`);
ok(parseFloat(rechts) > 60, `rechts steht er rechts (${rechts})`);
/* Beim Verlassen zurück in die Ruhelage. Ohne das blieben die Kacheln
nach einer Weile alle unterschiedlich beleuchtet stehen, ohne dass
jemand wüsste, warum. */
await seite.mouse.move(kasten.x - 250, kasten.y - 250);
await seite.waitForTimeout(250);
const danach = await kachel.evaluate((k) => k.style.getPropertyValue("--mx") || "(Ruhelage)");
ok(danach === "(Ruhelage)", `nach dem Verlassen wieder in Ruhe (${danach})`);
await kontext.close();
}
console.log("\n=== Ohne feinen Zeiger bleibt es aus");
{
const kontext = await browser.newContext({
viewport: { width: 390, height: 844 }, hasTouch: true, isMobile: true });
const seite = await anmelden(kontext, "admin", "CODE-DOGI-0001");
await seite.touchscreen.tap(200, 700).catch(() => {});
await seite.waitForTimeout(200);
const gesetzt = await seite.evaluate(() =>
[...document.querySelectorAll(".kachel")].some((k) => k.style.getPropertyValue("--mx")));
ok(!gesetzt, "auf dem Handy wird nichts gesetzt");
await kontext.close();
}
await browser.close();
console.log(`\n${fehler === 0 ? "Alles in Ordnung." : fehler + " Prüfung(en) fehlgeschlagen."}`);
try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ }
process.exit(fehler === 0 ? 0 : 1);