Files
dogfather-universe/server/pruef-kalender.mjs
T
DogFatherGitandClaude Opus 5 d7cf598b00 "Heute" war zwei Stunden lang gestern -- und vier Fehler auf Handy und PC
Auftrag: "einen grossen Check machen, ob alles klappt, auf dem Handy und
PC." Dafuer ein neuer Rundgang (pruef-grosscheck.mjs), der stumpf ueber
alles geht: 19 Workspace-Seiten mal 4 Rollen mal 2 Bildschirmgroessen
plus 33 oeffentliche Seiten, zweimal. 208 Seiten, 50 774 Elemente.

Solche Rundgaenge finden andere Fehler als gezielte Pruefungen: nicht
den falsch gerechneten Wert, sondern die Seite, die bei genau einer
Rolle ueberlaeuft.

=== DER WICHTIGSTE FUND: "heute" war in UTC gerechnet ===

Der Check lief um 01:10 Uhr. Ortszeit war der 2. September, in UTC noch
der 1. -- und in diesem Fenster rechnete die Anwendung an ZWOELF Stellen
"heute" als toISOString(), also in UTC. Server und Benutzer stehen beide
auf Europe/Berlin.

Was das im Alltag bedeutete, jede Nacht zwischen 0 und 2 Uhr:
  * eine heute faellige Aufgabe galt noch nicht als faellig
  * eine um Mitternacht ueberfaellig gewordene erschien erst um 2 Uhr
  * der Filter "Heute faellig" zeigte den Vortag
  * Datumsfelder schlugen gestern vor
  * der Kalender begann seine Vorgabe einen Tag zu frueh

Also genau dann, wenn nach einem Stream gearbeitet wird.

kalender.js machte es die ganze Zeit RICHTIG -- samt Begruendung, warum
die ARITHMETIK trotzdem in UTC laufen muss (UTC-Mittag ueberlebt die
Zeitumstellung; wer lokal rechnet, verliert am 27. Oktober einen Tag).
Diese Trennung gilt jetzt ueberall, aus je einer Quelle:
  RECHNEN mit Datumsangaben  -> UTC-Mittag, unveraendert
  WELCHER TAG IST HEUTE      -> Ortszeit (heuteLokal/tagLokal im Server,
                                window.heuteLokal in kopf.js)

WIE ES AUFFIEL, und das ist die eigentliche Lehre: Zuerst schlugen zwei
Pruefungen fehl -- und die Ursache lag in IHNEN, sie rechneten selbst in
UTC (36 Stellen in 17 Dateien). Nach deren Reparatur schlugen sie WIEDER
fehl, und erst da zeigten sie auf die Anwendung. Wer beim ersten Mal
aufgehoert haette ("ist ja nur die Pruefung"), haette den echten Fehler
nie gesehen.

Nachtrag desselben Musters: pruef-uebersicht legte den Termin weiterhin
in UTC an, waehrend die Erwartung schon auf Ortszeit stand. Wer eine
Datumsrechnung umstellt, muss BEIDE Seiten umstellen -- die, die
schreibt, und die, die prueft.

=== VIER FEHLER AUF HANDY UND PC ===

1. Ein langer Creator-Name ("SpongBobSchwammKopf") schob die Startseite
   auf dem Handy um 48 Pixel aus dem Bild -- ein Wort ohne Trennstelle,
   und die Seite liess sich seitlich wegschieben. Trifft echte Namen:
   Creator heissen selten "Tim".

2. Die klebende Speicherleiste verdeckte auf dem Handy ein Textfeld.
   Beim Tippen sieht man die eigene Zeile nicht. Behoben mit
   scroll-margin-bottom (WCAG 2.2, 2.4.11 "Focus Not Obscured").

3./4. Zwei Beschriftungen waren mit 9,6 px (Uebersicht: "ueberfaellig",
   "dringend", "offen") und 9,9 px (Kalender: "heute") zu klein. Fuers
   Handy gab es laengst eine Ausnahme -- nur der Rechner war vergessen
   worden. Ausgerechnet die Woerter, die den Zahlen ihre Bedeutung geben.

=== WAS KEINE FEHLER WAREN ===

Der erste Durchgang meldete 19 Maengel, die keine waren. Alle einzeln im
Quelltext nachgeprueft und dem Rundgang beigebracht:
  * Kacheln und Kopfzeilen "abgeschnitten" -- das Wasserzeichen ragt
    ABSICHTLICH ueber den Rand (steht so im Quelltext)
  * "verdeckt: wahl2__echt" -- das echte <select> liegt absichtlich
    unsichtbar unter seinem Knopf
  * "zurueck-knopf__text abgeschnitten" -- das uebliche Muster fuer
    "nur fuer Vorleseprogramme"
  * drei "zu kleine" Verweise -- WCAG 2.5.8 nimmt Verweise im Fliesstext
    AUSDRUECKLICH aus. Eine Pruefung, die ihre eigene Messlatte nicht
    kennt, misst nichts.

Beim vierten Punkt haette ich fast an der falschen Stelle repariert.

Und statt die Sticky-Meldung abzuschalten (dann faende sie auch echte
Ueberdeckungen nie mehr), wurde sie GENAUER: Ueberdeckt etwas Klebendes
ein Eingabefeld, ist das nur in Ordnung, wenn das Feld genug
scroll-margin-bottom hat, um darunter hervorzukommen. Aus einer vagen
Meldung wird eine pruefbare Zusage.

Vier Gegenproben belegen, dass der Rundgang ueberhaupt etwas finden
kann: ein zu breites Element, ein winziger Knopf, ein winziger Verweis
AUSSERHALB eines Satzes und ein wirklich abgeschnittenes Wort werden
alle gemeldet. Ohne diesen Nachweis waere "alles in Ordnung" wertlos.

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

342 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* Browserpruefung des Kalenders: vier Ansichten, Anlaesse, Feiertage,
Filter, Tagesdialog -- und die selbst gerechneten Tage gegen bekannte
Werte.
Die Anlassrechnung ist der Teil, der still falsch sein kann: Ein um
einen Tag verschobener Ostersonntag faellt niemandem auf, verschiebt
aber Karfreitag, Himmelfahrt und Pfingsten gleich mit. Deshalb werden
hier feste, nachschlagbare Daten geprueft und nicht nur "es steht
irgendwas da". */
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
const ordner = mkdtempSync(join(tmpdir(), "ws-kal-"));
process.env.WORKSPACE_DB = join(ordner, "workspace.db");
process.env.PORT = "4188";
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:4188";
setTimeout(() => { console.log("ABBRUCH"); process.exit(1); }, 200_000).unref?.();
/* ORTSZEIT, nicht UTC. Die Anwendung rechnet "heute" nach der Uhr des
Benutzers; toISOString() liefert dagegen UTC und damit zwischen
Mitternacht und 2 Uhr den VORTAG. In diesem Fenster schlug diese
Pruefung fehl, ohne dass etwas kaputt war. */
const tagText = (d = new Date()) =>
`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`
+ `-${String(d.getDate()).padStart(2, "0")}`;
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 = tagText();
const morgen = tagText(new Date(Date.now() + 86400_000));
const in5 = tagText(new Date(Date.now() + 5 * 86400_000));
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 idAnna = anlegen("Tili", "creator", "CODE-ANNA-0001");
/* Drei Arten von Terminen und eine Frist -- damit alle vier Filter etwas
zu zeigen haben und man sieht, ob die Farben auseinandergehalten
werden. */
const termine = [
["call", heute + "T10:00", "Wochen-Call"],
["call", heute + "T15:30", "Kurzes Rueckfragen"],
["termin", morgen + "T09:00", "Dreh im Studio"],
["review", in5 + "T14:00", "Monats-Review"],
];
for (const [art, beginn, titel] of termine) {
d.prepare(`INSERT INTO termine (titel, art, beginn, dauer_min, creator_id, teilnehmer_id,
erledigt, erstellt, erstellt_von) VALUES (?,?,?,?,?,?,0,?,?)`)
.run(titel, art, beginn, 30, idAnna, idAnna, jetzt, idDogi);
}
d.prepare(`INSERT INTO aufgaben (titel, status, creator_id, verantwortlich_id, frist,
prioritaet, erstellt, erstellt_von) VALUES (?,?,?,?,?,?,?,?)`)
.run("Thumbnail bauen", "offen", idAnna, idAnna, morgen, "hoch", 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 });
return seite;
}
const lesen = (seite) => seite.evaluate(() => ({
zeitraum: document.getElementById("zeitraum")?.textContent || "",
unter: document.getElementById("zeitraum-unter")?.textContent || "",
ansicht: document.getElementById("kalender")?.dataset.ansicht || null,
/* Monatsraster */
zellen: document.querySelectorAll(".k-tag").length,
kwSpalten: document.querySelectorAll(".k-kw").length,
kwWerte: [...document.querySelectorAll(".k-kw")].map((k) => k.textContent),
heuteZellen: document.querySelectorAll('.k-tag[data-heute="ja"]').length,
feiertagZellen: document.querySelectorAll('.k-tag[data-feiertag="ja"]').length,
fremdZellen: document.querySelectorAll('.k-tag[data-fremd="ja"]').length,
/* Eintraege */
pillen: [...document.querySelectorAll(".k-pille")].map((p) => ({
text: p.textContent,
farbe: getComputedStyle(p).borderLeftColor,
})),
anlaesse: [...document.querySelectorAll("#kalender .k-anlass")].map((a) => a.textContent),
/* Leisten */
saison: [...document.querySelectorAll("#saison .k-anlasskarte")].map((k) => ({
name: k.querySelector(".k-anlasskarte__name")?.textContent,
rest: k.querySelector(".k-anlasskarte__rest")?.textContent,
feiertag: k.classList.contains("feiertag"),
})),
monatsanlaesse: [...document.querySelectorAll("#monatsanlaesse .k-anlasskarte")]
.map((k) => k.querySelector(".k-anlasskarte__name")?.textContent),
monatTitel: document.getElementById("monat-titel")?.textContent || "",
/* Filter */
chips: [...document.querySelectorAll(".k-chip")].map((c) => ({
text: c.textContent, an: c.getAttribute("aria-pressed") === "true",
})),
/* Andere Ansichten */
wochenSpalten: document.querySelectorAll(".k-wochentag").length,
listentage: document.querySelectorAll(".k-listentag").length,
strahlTage: document.querySelectorAll(".k-strahl__tag").length,
zeilen: document.querySelectorAll(".k-zeile").length,
ueber: document.documentElement.scrollWidth - document.documentElement.clientWidth,
/* Abgeschnittener Text ist der häufigste stille Fehler: Er sieht auf
den ersten Blick nach Absicht aus. Gemessen wird die tatsächliche
Textbreite gegen die verfügbare. Die Pillen im Raster sind bewusst
ausgenommen -- eine Zelle ist 150 px breit, dort IST das Kürzen die
richtige Lösung, und der volle Text steht im Tagesdialog. */
abgeschnitten: [...document.querySelectorAll(
".k-anlasskarte__name, .k-anlasskarte__wann, .k-anlasskarte__rest, "
+ ".k-zeile__titel, .k-listentag__datum, .k-chip, .k-ansicht, .k-zeitraum")]
.filter((e) => e.scrollWidth > e.clientWidth + 1)
.map((e) => e.textContent.trim().slice(0, 30)),
}));
for (const [breite, wie] of [[1440, "Computer"], [390, "Handy"]]) {
console.log(`\n=== ${wie} (${breite}px)`);
const kontext = await browser.newContext({ viewport: { width: breite, height: 1100 } });
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.goto(BASIS + "/workspace/kalender.html", { waitUntil: "networkidle" });
await seite.waitForTimeout(700);
const r = await lesen(seite);
console.log(" Zeitraum:", r.zeitraum, "·", r.unter);
console.log(" Als Nächstes:", r.saison.map((s) => `${s.name} (${s.rest})`).join(" · "));
/* ---- Monatsraster ---- */
ok(r.ansicht === "monat", `startet im Monat (${r.ansicht})`);
/* Ein Monatsraster hat immer ganze Wochen: 28, 35 oder 42 Zellen.
Alles andere heisst, dass die Wochenberechnung daneben liegt. */
ok([28, 35, 42].includes(r.zellen), `ganze Wochen im Raster (${r.zellen} Zellen)`);
ok(r.kwSpalten === r.zellen / 7, `je Woche eine KW (${r.kwSpalten} für ${r.zellen / 7} Wochen)`);
ok(r.kwWerte.every((w) => Number(w) >= 1 && Number(w) <= 53),
`alle Kalenderwochen plausibel (${r.kwWerte.join(",")})`);
ok(r.heuteZellen === 1, `genau ein Tag ist "heute" (${r.heuteZellen})`);
ok(r.fremdZellen > 0, `die Nachbarmonatstage sind erkennbar (${r.fremdZellen})`);
/* ---- Eintraege im Raster ---- */
ok(r.pillen.length === 5, `alle fünf Einträge stehen im Raster (${r.pillen.length})`);
ok(r.pillen.some((p) => p.text.includes("Wochen-Call")), "der Call ist dabei");
ok(r.pillen.some((p) => p.text.includes("Thumbnail bauen")), "die Frist aus den Aufgaben auch");
/* Jede Art traegt ihre eigene Farbe. Zwei Calls, ein Termin, ein
Review, eine Frist -> vier verschiedene Farben. */
const farben = new Set(r.pillen.map((p) => p.farbe));
ok(farben.size === 4, `vier Arten, vier Farben (${farben.size})`);
/* Die Uhrzeit steht vorn -- ausser bei der Frist, die keine hat. */
ok(r.pillen.filter((p) => /^\d{2}:\d{2}/.test(p.text)).length === 4,
"vier Einträge mit Uhrzeit, die Frist ohne");
/* ---- Zaehler in der Kopfzeile ---- */
ok(/KW \d+( – \d+)? · 5 Einträge/.test(r.unter), `die Kopfzeile zählt mit ("${r.unter}")`);
/* ---- Filter ---- */
ok(r.chips.length === 4, `vier Filter (${r.chips.length})`);
ok(r.chips.every((c) => c.an), "und alle zunächst an");
ok(r.chips.some((c) => c.text.includes("Call (2)")), `der Call-Filter zählt zwei (${r.chips.map((c) => c.text).join(" ")})`);
ok(r.chips.some((c) => c.text.includes("Frist (1)")), "der Fristen-Filter zählt eine");
/* Ausschalten muss WIRKLICH etwas ausblenden -- ein Filter, der nur
die Farbe des Knopfes aendert, ist keiner. */
await seite.click('.k-chip:has-text("Call")');
await seite.waitForTimeout(250);
const nachFilter = await lesen(seite);
ok(nachFilter.pillen.length === 3,
`Calls ausgeschaltet: drei Einträge übrig (${nachFilter.pillen.length})`);
ok(/· 3 Einträge/.test(nachFilter.unter), `und die Kopfzeile zählt neu ("${nachFilter.unter}")`);
await seite.click('.k-chip:has-text("Call")');
await seite.waitForTimeout(250);
/* ---- Anlassleisten ---- */
ok(r.saison.length === 3, `drei kommende Anlässe (${r.saison.length})`);
ok(r.saison.every((s) => /^(heute|morgen|in \d+ Tagen)$/.test(s.rest.replace("Vorlauf läuft", "").trim())),
`jeder mit Abstand in Tagen (${r.saison.map((s) => s.rest).join(" | ")})`);
ok(r.monatTitel.startsWith("Im "), `die zweite Leiste nennt den Monat ("${r.monatTitel}")`);
/* ---- Blättern ---- */
const vorher = r.zeitraum;
await seite.click("#vor-zeit");
await seite.waitForTimeout(400);
const weiter = await lesen(seite);
ok(weiter.zeitraum !== vorher, `vorwärts blättern wechselt den Monat (${vorher} → ${weiter.zeitraum})`);
ok(weiter.heuteZellen === 0, "im nächsten Monat ist kein Tag mehr \"heute\"");
await seite.click("#heute-knopf");
await seite.waitForTimeout(400);
const zurueck = await lesen(seite);
ok(zurueck.zeitraum === vorher, `"Heute" führt zurück (${zurueck.zeitraum})`);
/* ---- Die anderen drei Ansichten ---- */
for (const [name, pruefung] of [
["woche", (x) => x.wochenSpalten === 7],
["liste", (x) => x.listentage > 0 && x.zeilen > 0],
["zeitstrahl", (x) => x.strahlTage === 42],
]) {
await seite.click(`.k-ansicht[data-ansicht="${name}"]`);
await seite.waitForTimeout(450);
const a = await lesen(seite);
ok(a.ansicht === name && pruefung(a),
`Ansicht ${name} baut auf (Spalten=${a.wochenSpalten} Tage=${a.listentage} `
+ `Striche=${a.strahlTage} Zeilen=${a.zeilen})`);
ok(a.ueber <= 0, ` und steht nicht über (${a.ueber}px)`);
}
await seite.click('.k-ansicht[data-ansicht="monat"]');
await seite.waitForTimeout(400);
/* ---- Tagesdialog ---- */
await seite.click(`.k-tag[data-tag="${heute}"]`);
await seite.waitForTimeout(300);
const dialog = await seite.evaluate(() => ({
offen: document.getElementById("tagdialog")?.open,
titel: document.getElementById("tagdialog-titel")?.textContent,
unter: document.getElementById("tagdialog-unter")?.textContent,
zeilen: document.querySelectorAll("#tagdialog-liste .k-zeile").length,
}));
ok(dialog.offen === true, "ein Klick auf den Tag öffnet den Dialog");
ok(dialog.zeilen === 2, `und zeigt die beiden Calls von heute (${dialog.zeilen})`);
ok(/heute/.test(dialog.unter), `und weiss, dass es heute ist ("${dialog.unter}")`);
await seite.keyboard.press("Escape");
await seite.waitForTimeout(250);
ok(await seite.evaluate(() => !document.getElementById("tagdialog").open),
"Esc schließt ihn wieder");
ok(r.ueber <= 0, `nichts steht über (${r.ueber}px)`);
ok(r.abgeschnitten.length === 0,
`kein Text wird abgeschnitten (${r.abgeschnitten.join(" | ") || "keiner"})`);
ok(konsole.length === 0, `keine Fehler in der Konsole${konsole.length ? " -> " + konsole.join(" | ") : ""}`);
await seite.screenshot({
path: breite === 1440 ? "pruef-kalender-computer.png" : "pruef-kalender-handy.png",
fullPage: true });
await kontext.close();
}
/* ---------------------------------------------------------------------
Die gerechneten Tage gegen bekannte Werte.
Diese Zahlen stehen in jedem Kalender und lassen sich nachschlagen.
Ohne diese Prüfung koennte die Osterformel um einen Tag daneben
liegen und niemand wuerde es merken -- bis irgendwann jemand
Karfreitag arbeitet.
--------------------------------------------------------------------- */
console.log("\n=== Die gerechneten Tage");
{
const kontext = await browser.newContext({ viewport: { width: 1440, height: 1000 } });
const seite = await anmelden(kontext, "admin", "CODE-DOGI-0001");
await seite.goto(BASIS + "/workspace/kalender.html", { waitUntil: "networkidle" });
await seite.waitForTimeout(600);
/* Zu jedem Jahr: der Monat wird angesteuert und im Raster nachgesehen,
an welchem Tag der Anlass steht. Damit wird wirklich das geprueft,
was der Kalender ANZEIGT -- nicht eine Formel, die daneben noch
einmal gerechnet wird. */
const faelle = [
["2026-04-05", "Ostersonntag"],
["2026-04-03", "Karfreitag"],
["2026-05-14", "Christi Himmelfahrt"],
["2026-05-10", "Muttertag"],
["2026-11-27", "Black Friday"],
["2026-12-20", "4. Advent"],
["2027-03-28", "Ostersonntag"],
["2027-11-26", "Black Friday"],
["2027-12-19", "4. Advent"],
["2030-04-21", "Ostersonntag"],
];
/* Zum Zielmonat blättern -- über die echten Knöpfe, nicht durch
Setzen einer inneren Variablen: So wird auch die Navigation selbst
mitgeprüft.
Wichtig ist die RICHTUNG. Ein erster Versuch klickte stur vorwärts
und erreichte April 2026 nie, weil heute September 2026 ist. Der
Test meldete daraufhin zehnmal "Monat nicht erreicht" -- und hätte
bei oberflächlichem Hinsehen wie ein Rechenfehler in der
Osterformel ausgesehen. Deshalb wird jetzt verglichen, welcher
Monat offen ist, und gezielt in die passende Richtung geblättert. */
async function zuMonat(tag) {
for (let i = 0; i < 90; i++) {
const stand = await seite.evaluate((t) => {
if (document.querySelector(`.k-tag[data-tag="${t}"]`)) return "da";
/* Der offene Monat ist der der Zellen OHNE data-fremd -- die
Randzellen gehören zum Nachbarmonat. */
const eigen = document.querySelector('.k-tag:not([data-fremd="ja"])');
return eigen ? eigen.dataset.tag.slice(0, 7) : null;
}, tag);
if (stand === "da") return true;
if (!stand) return false;
await seite.click(stand < tag.slice(0, 7) ? "#vor-zeit" : "#zurueck-zeit");
await seite.waitForTimeout(70);
}
return false;
}
for (const [tag, name] of faelle) {
const erreicht = await zuMonat(tag);
const text = erreicht ? await seite.evaluate((t) => {
const z = document.querySelector(`.k-tag[data-tag="${t}"]`);
return z ? [...z.querySelectorAll(".k-anlass")].map((a) => a.textContent).join(" | ") : null;
}, tag) : null;
ok(text !== null && text.includes(name),
`${name} liegt ${tag} (gefunden: ${text ?? "Monat nicht erreicht"})`);
}
await kontext.close();
}
await browser.close();
/* Der Testordner bleibt manchmal gesperrt, weil der Server im selben
Prozess noch auf der Datenbank sitzt. Das ist kein Prüfergebnis --
ein Aufräumfehler darf einen bestandenen Lauf nicht rot machen. */
try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ }
console.log(fehler ? `\n${fehler} Prüfung(en) fehlgeschlagen.` : "\nAlles in Ordnung.");
process.exit(fehler ? 1 : 0);