Gemessen am 07.09.2026, bevor irgendetwas geaendert wurde: Von drei Agentur-Eintraegen sah eine Creatorin genau EINEN -- den, der ihr zugeordnet war. Ein Event fuer alle traf weder `creator_id = ich` noch `erstellt_von = ich`; die Agentur-Seite war fuer jeden Creator leer. Nicht kaputt, nicht fehlerhaft: leer, so wie eine Seite aussieht, auf der noch nichts steht. Die Pruefung dazu war gruen. Sie legte ihre Testeintraege mit `creator_id: idLuna` an und pruefte damit einen Fall, den es im Alltag nicht gibt. - Bereichseinstellung `fuerAlle` + `ohneCreatorBezug`, daraus abgeleitet sichtbarEintrag(). BEWUSST neben sichtbar() statt darin: an derselben Funktion haengen Aufgaben, Dateien, Termine und Calls -- wer dort "1=1" einschleust, gibt nebenbei fremde Akten frei. Eine Gegenprobe mit einer zweiten Creatorin haelt das fest. - Die Zuordnung bietet nur noch "Agentur" an. Der Server verwirft eine Zuordnung ausserdem selbst -- inklusive des frei getippten Namens, und zwar NACH externPruefen: davor haette der Name den Riegel wieder aufgemacht. - "Event & Kampagne" heisst jetzt "Agentur-Events" und hat ein eigenes Formular: Von/Bis, Titel, Beschreibung, Aufgaben (Punkte und Preise), Regeln. Die Karte zeigt den Zustand als WORT (laeuft bis / startet / vorbei seit), nicht nur als Farbe. - Drei neue Spalten -- und sie stehen auch im Tabellenneubau vom 06.09. Der laeuft NACH dem Spaltennachtrag und haette sie samt Inhalt weggeworfen, ohne Fehler und mit stimmender Zeilenzahl. - Creator sehen weiterhin alles und tragen weiterhin nichts ein (403). Die Unterzeile sagt jetzt "alles, was hier steht" statt "alles, was zu dir gehoert" -- eine vollstaendige Liste soll sich nicht wie ein Ausschnitt lesen. pruef-agentur: 61 Pruefungen (vorher 40), alle gruen. Zwei eigene Fehler nebenbei gefunden und behoben: eine Beschriftung mit 11,2 px (Grenze 11,5) und ein Testdatum aus UTC statt Ortszeit. Zusaetzlich gruen: css-klassen, struktur, formulare, barrierefrei-workspace, handy. Co-Authored-By: Claude Opus 5 <[email protected]>
714 lines
35 KiB
JavaScript
714 lines
35 KiB
JavaScript
/* DER SECHSTE BEREICH — "Die Agentur bleibt angebunden"
|
||
|
||
Kernprinzip 04 des Konzepts, und bis zum 06.09.2026 gab es dafuer
|
||
nichts: Fuenf Bereiche standen (LIVE, Content, Technik, Community,
|
||
Schutz), der sechste fehlte vollstaendig.
|
||
|
||
---------------------------------------------------------------------
|
||
DAS EIGENTLICHE RISIKO IST NICHT DER BEREICH, SONDERN DER UMBAU
|
||
|
||
Der erlaubte Wertebereich einer Spalte steckt in SQLite in einem
|
||
CHECK, und ein CHECK laesst sich nicht aendern -- die Tabelle muss
|
||
neu gebaut werden. Dabei werden ALLE vorhandenen Eintraege
|
||
umgeschrieben: LIVE-Auswertungen, Content-Ideen, Technikprobleme,
|
||
Community-Konflikte, Vorfaelle. Wer dort eine Spalte vergisst,
|
||
verliert ihren Inhalt still.
|
||
|
||
Und das ist ein Weg, den man nicht zurueckdrehen kann. Deshalb wird
|
||
er hier nicht "im Prinzip" geprueft, sondern WIRKLICH GEGANGEN: Die
|
||
Pruefung baut eine Datenbank im ALTEN Stand nach, fuellt sie, laesst
|
||
die echte Anwendung darueberlaufen und zaehlt danach jede Zeile und
|
||
jedes Feld nach.
|
||
|
||
GEGENPROBEN durchgehend -- vor allem die eine, auf die es ankommt:
|
||
Der CHECK muss danach immer noch BEISSEN. Eine Umstellung, die
|
||
nebenbei die Schranke entfernt, sieht aus wie ein Erfolg und ist der
|
||
schlimmere Ausgang.
|
||
===================================================================== */
|
||
|
||
import { mkdtempSync, rmSync, appendFileSync } from "node:fs";
|
||
import { tmpdir } from "node:os";
|
||
import { join, dirname } from "node:path";
|
||
import { fileURLToPath } from "node:url";
|
||
import { spawn } from "node:child_process";
|
||
import { DatabaseSync } from "node:sqlite";
|
||
import { scryptSync, randomBytes } from "node:crypto";
|
||
import { heuteLokal } from "./helfer-zeit.mjs";
|
||
|
||
const HIER = dirname(fileURLToPath(import.meta.url));
|
||
const { portMussFreiSein } = await import("./helfer-port.mjs");
|
||
const PORT = 4341;
|
||
await portMussFreiSein(PORT, "die Agentur-Pruefung");
|
||
const BASIS = `http://127.0.0.1:${PORT}`;
|
||
|
||
const ordner = mkdtempSync(join(tmpdir(), "ws-agentur-"));
|
||
const DB = join(ordner, "workspace.db");
|
||
|
||
const PROTOKOLL = join(process.cwd(), "pruef-agentur-lauf.txt");
|
||
try { rmSync(PROTOKOLL, { force: true }); } catch { /* gab es noch nicht */ }
|
||
const melde = (z) => {
|
||
console.log(z);
|
||
try { appendFileSync(PROTOKOLL, z + "\n"); } catch { /* Zugabe */ }
|
||
};
|
||
let fehler = 0;
|
||
let gemacht = 0;
|
||
const ok = (b, t) => { gemacht++; melde((b ? " ok " : " FEHL ") + t); if (!b) fehler++; };
|
||
|
||
/* ---------------------------------------------------------------------
|
||
Die Anwendung als eigener Prozess -- weil sie ZWEIMAL starten muss:
|
||
einmal, um die Datenbank anzulegen, und einmal nach dem Zurueckbau,
|
||
damit die Wanderung wirklich laeuft. In einem Prozess ginge das
|
||
nicht: Ein Modul wird nur einmal geladen.
|
||
--------------------------------------------------------------------- */
|
||
let laufend = null;
|
||
async function serverStarten() {
|
||
const kind = spawn(process.execPath, [join(HIER, "index.js")], {
|
||
env: {
|
||
...process.env,
|
||
WORKSPACE_DB: DB,
|
||
PORT: String(PORT),
|
||
SITE_ACCESS_SECRET: "lokaler-test",
|
||
SITE_PUBLIC_LAUNCH_AT: "2020-01-01T00:00:00+01:00",
|
||
},
|
||
stdio: ["ignore", "pipe", "pipe"],
|
||
});
|
||
let aus = "";
|
||
kind.stdout.on("data", (d) => { aus += d; });
|
||
kind.stderr.on("data", (d) => { aus += d; });
|
||
for (let i = 0; i < 80; i++) {
|
||
await new Promise((r) => setTimeout(r, 250));
|
||
if (kind.exitCode !== null) break;
|
||
try {
|
||
const a = await fetch(BASIS + "/workspace/", { redirect: "manual" });
|
||
if (a.status < 500) { laufend = kind; return { kind, aus: () => aus }; }
|
||
} catch { /* noch nicht oben */ }
|
||
}
|
||
throw new Error("Server startete nicht:\n" + aus.slice(-1200));
|
||
}
|
||
async function serverStoppen() {
|
||
if (!laufend) return;
|
||
laufend.kill();
|
||
laufend = null;
|
||
/* Warten, bis der Port wirklich frei ist -- sonst startet der zweite
|
||
Lauf gegen einen halb geschlossenen Anschluss und misst Unsinn. */
|
||
for (let i = 0; i < 40; i++) {
|
||
await new Promise((r) => setTimeout(r, 150));
|
||
try { await fetch(BASIS + "/workspace/", { redirect: "manual" }); } catch { return; }
|
||
}
|
||
}
|
||
|
||
/* =======================================================================
|
||
1. Erster Start: die Datenbank entsteht
|
||
======================================================================= */
|
||
melde("=== Die Datenbank anlegen ===");
|
||
{
|
||
const { aus } = await serverStarten();
|
||
ok(true, "die Anwendung ist oben und hat das Schema angelegt");
|
||
void aus;
|
||
}
|
||
|
||
const jetzt = new Date().toISOString();
|
||
/* ORTSZEIT fuer das Tagesdatum, nicht der Schnitt aus dem UTC-Stempel.
|
||
`jetzt.slice(0, 10)` liegt zwischen Mitternacht und zwei Uhr einen Tag
|
||
daneben -- die Pruefung waere dann nachts rot, ohne dass sich etwas
|
||
geaendert haette. Gefunden von pruef-struktur, das genau danach sucht. */
|
||
const heute = heuteLokal();
|
||
|
||
function anlegen(d, name, rolle, code) {
|
||
const salz = randomBytes(16).toString("hex");
|
||
const hash = scryptSync(code, salz, 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, salz, 32768, jetzt);
|
||
return d.prepare("SELECT last_insert_rowid() AS id").get().id;
|
||
}
|
||
|
||
/* Mia kam am 07.09.2026 dazu, und sie ist kein Beiwerk: Sie legt nichts
|
||
an, ihr gehoert nichts, sie betreut niemanden. Genau deshalb beweist
|
||
erst SIE, dass ein Agentur-Eintrag wirklich fuer alle sichtbar ist --
|
||
bei Luna allein bliebe offen, ob es an ihr oder an der Regel lag. */
|
||
let idDogi, idLuna, idMia;
|
||
{
|
||
const d = new DatabaseSync(DB);
|
||
idDogi = anlegen(d, "Filipe", "admin", "CODE-DOGI-0001");
|
||
idLuna = anlegen(d, "Luna", "creator", "CODE-CREA-0001");
|
||
idMia = anlegen(d, "Mia", "creator", "CODE-CREA-0002");
|
||
d.close();
|
||
}
|
||
|
||
/* =======================================================================
|
||
2. Die Datenbank auf den ALTEN Stand zurueckbauen
|
||
=======================================================================
|
||
|
||
Genau so sieht sie auf dem Server aus, bevor die Wanderung laeuft.
|
||
Ohne diesen Schritt pruefte die ganze Datei nur eine frisch angelegte
|
||
Tabelle -- und damit ausgerechnet den Fall, den es in der Wirklichkeit
|
||
nicht gibt. */
|
||
melde("\n=== Zurueck auf den alten Stand (fuenf Bereiche) ===");
|
||
await serverStoppen();
|
||
|
||
const ALTE_EINTRAEGE = [
|
||
["live", "auswertung", "Auswertung Montag", "Der Hook saß, die Mitte hing.", 4, "hoch"],
|
||
["content", "idee", "Serie: Basteln mit Kindern", "Fünf Teile.", null, "mittel"],
|
||
["technik", "problem", "Ton bricht ab", "Nur bei OBS 30.", null, "hoch"],
|
||
["community", "konflikt", "Streit in den Kommentaren", "Zwei Zuschauer.", null, "niedrig"],
|
||
["schutz", "vorfall", "Meldung wegen Sprache", "Geklärt.", null, "hoch"],
|
||
];
|
||
|
||
{
|
||
const d = new DatabaseSync(DB);
|
||
d.exec("PRAGMA foreign_keys = OFF");
|
||
d.exec("DROP TABLE IF EXISTS eintraege");
|
||
/* WOERTLICH der alte Bauplan, mit allen Spalten, die zu diesem
|
||
Zeitpunkt schon da waren. */
|
||
d.exec(`
|
||
CREATE TABLE eintraege (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
bereich TEXT NOT NULL
|
||
CHECK (bereich IN ('live','content','technik','community','schutz')),
|
||
art TEXT NOT NULL,
|
||
titel TEXT NOT NULL,
|
||
text TEXT,
|
||
datum TEXT NOT NULL,
|
||
bewertung INTEGER CHECK (bewertung IS NULL OR (bewertung BETWEEN 1 AND 5)),
|
||
dringlichkeit TEXT NOT NULL DEFAULT 'mittel'
|
||
CHECK (dringlichkeit IN ('hoch','mittel','niedrig')),
|
||
status TEXT NOT NULL DEFAULT 'offen'
|
||
CHECK (status IN ('offen','erledigt')),
|
||
creator_id INTEGER REFERENCES personen(id) ON DELETE SET NULL,
|
||
erstellt TEXT NOT NULL,
|
||
erstellt_von INTEGER REFERENCES personen(id) ON DELETE SET NULL,
|
||
geaendert TEXT,
|
||
hook TEXT,
|
||
format TEXT,
|
||
saeule_id INTEGER,
|
||
geplant TEXT,
|
||
creator_extern TEXT
|
||
);
|
||
CREATE INDEX IF NOT EXISTS idx_eintraege_bereich ON eintraege (bereich, creator_id);`);
|
||
|
||
const setzen = d.prepare(`INSERT INTO eintraege
|
||
(bereich, art, titel, text, datum, bewertung, dringlichkeit, status,
|
||
creator_id, erstellt, erstellt_von, hook, creator_extern)
|
||
VALUES (?,?,?,?,?,?,?, 'offen', ?,?,?,?,?)`);
|
||
for (const [b, art, titel, text, bew, dring] of ALTE_EINTRAEGE) {
|
||
setzen.run(b, art, titel, text, heute, bew, dring, idLuna, jetzt, idDogi,
|
||
b === "content" ? "Der Hook: 'Das habt ihr noch nie gesehen'" : null,
|
||
b === "live" ? "Gastauftritt Mia" : null);
|
||
}
|
||
d.exec("PRAGMA foreign_keys = ON");
|
||
|
||
const n = d.prepare("SELECT COUNT(*) AS n FROM eintraege").get().n;
|
||
ok(n === ALTE_EINTRAEGE.length, `${n} Eintraege im alten Stand angelegt`);
|
||
|
||
/* GEGENPROBE: Der alte CHECK muss "agentur" WIRKLICH ablehnen --
|
||
sonst gaebe es nichts umzustellen, und der Rest dieser Datei
|
||
bewiese nichts. */
|
||
let abgelehnt = false;
|
||
try {
|
||
d.prepare(`INSERT INTO eintraege (bereich, art, titel, datum, erstellt)
|
||
VALUES ('agentur','kampagne','Darf noch nicht',?,?)`).run(heute, jetzt);
|
||
} catch { abgelehnt = true; }
|
||
ok(abgelehnt, "im alten Stand wird 'agentur' von der Datenbank abgelehnt");
|
||
d.close();
|
||
}
|
||
|
||
/* =======================================================================
|
||
3. Die Wanderung -- der Weg, den man nicht zurueckdrehen kann
|
||
======================================================================= */
|
||
melde("\n=== Die Umstellung ===");
|
||
let ausgabe = "";
|
||
{
|
||
const { aus } = await serverStarten();
|
||
await new Promise((r) => setTimeout(r, 600));
|
||
ausgabe = aus();
|
||
}
|
||
|
||
ok(/Bereich 'agentur' freigeschaltet/.test(ausgabe),
|
||
"die Anwendung meldet die Umstellung"
|
||
+ (/agentur/.test(ausgabe) ? "" : ` — Ausgabe: ${ausgabe.slice(-300)}`));
|
||
|
||
{
|
||
const d = new DatabaseSync(DB, { readOnly: true });
|
||
const plan = d.prepare(
|
||
"SELECT sql FROM sqlite_master WHERE type='table' AND name='eintraege'").get()?.sql || "";
|
||
ok(/'agentur'/.test(plan), "der neue Bauplan kennt 'agentur'");
|
||
|
||
/* KEINE ZEILE DARF FEHLEN -- und keine darf sich VERAENDERT haben.
|
||
Die Zeilenzahl allein reichte nicht: Wer beim Umschreiben eine
|
||
Spalte vergisst, behaelt alle Zeilen und verliert ihren Inhalt.
|
||
Genau das faellt sonst erst Wochen spaeter auf. */
|
||
const alle = d.prepare("SELECT * FROM eintraege ORDER BY id").all();
|
||
ok(alle.length === ALTE_EINTRAEGE.length,
|
||
`alle ${alle.length} Eintraege sind noch da (vorher ${ALTE_EINTRAEGE.length})`);
|
||
|
||
let abweichungen = [];
|
||
ALTE_EINTRAEGE.forEach(([b, art, titel, text, bew, dring], i) => {
|
||
const z = alle[i];
|
||
if (!z) { abweichungen.push(`${b}: Zeile fehlt`); return; }
|
||
if (z.bereich !== b) abweichungen.push(`${b}: bereich = ${z.bereich}`);
|
||
if (z.art !== art) abweichungen.push(`${b}: art = ${z.art}`);
|
||
if (z.titel !== titel) abweichungen.push(`${b}: titel = ${z.titel}`);
|
||
if (z.text !== text) abweichungen.push(`${b}: text = ${z.text}`);
|
||
if (z.bewertung !== bew) abweichungen.push(`${b}: bewertung = ${z.bewertung}`);
|
||
if (z.dringlichkeit !== dring) abweichungen.push(`${b}: dringlichkeit = ${z.dringlichkeit}`);
|
||
if (z.creator_id !== idLuna) abweichungen.push(`${b}: creator_id = ${z.creator_id}`);
|
||
if (z.erstellt_von !== idDogi) abweichungen.push(`${b}: erstellt_von = ${z.erstellt_von}`);
|
||
});
|
||
ok(abweichungen.length === 0,
|
||
abweichungen.length ? `Inhalte haben sich veraendert: ${abweichungen.slice(0, 5).join(" | ")}`
|
||
: `jedes Feld jeder Zeile ist unveraendert (${alle.length} Zeilen x 8 Felder geprueft)`);
|
||
|
||
/* Auch die SELTENEN Spalten -- gerade die vergisst man beim
|
||
Abschreiben des Bauplans. */
|
||
const mitHook = alle.find((z) => z.bereich === "content");
|
||
const mitExtern = alle.find((z) => z.bereich === "live");
|
||
ok(/Das habt ihr noch nie gesehen/.test(mitHook?.hook || ""),
|
||
`die Spalte "hook" hat ihren Inhalt behalten ("${String(mitHook?.hook).slice(0, 30)}…")`);
|
||
ok(mitExtern?.creator_extern === "Gastauftritt Mia",
|
||
`die Spalte "creator_extern" ebenfalls ("${mitExtern?.creator_extern}")`);
|
||
|
||
/* DIE DREI EVENT-SPALTEN MUESSEN DEN TAUSCH UEBERLEBEN (07.09.2026).
|
||
|
||
Sie entstehen frueher als diese Umstellung (Spalten-Nachtrag in
|
||
workspace.js), und der Neubau hier schreibt eine feste Spaltenliste
|
||
ab. Wer sie dort vergisst, verliert sie samt Inhalt -- ohne
|
||
Fehlermeldung, und die Zeilenzahl stimmte weiterhin. Genau dieser
|
||
Weg wird hier wirklich gegangen: Die Tabelle stand vorher im ALTEN
|
||
Bauplan, ohne diese Spalten.
|
||
|
||
`PRAGMA table_info` und nicht "SELECT laeuft durch": Eine fehlende
|
||
Spalte faellt beim SELECT zwar auch auf -- aber erst irgendwann,
|
||
beim naechsten Event, in einem 503. */
|
||
const spalten = d.prepare("PRAGMA table_info(eintraege)").all().map((s) => s.name);
|
||
const fehlend = ["event_ende", "event_aufgaben", "event_regeln"].filter((s) => !spalten.includes(s));
|
||
ok(fehlend.length === 0,
|
||
fehlend.length ? `nach der Umstellung fehlen: ${fehlend.join(", ")}`
|
||
: "die drei Event-Spalten haben die Umstellung ueberlebt");
|
||
|
||
/* Der Index muss nach dem Tausch wieder da sein -- er wird beim DROP
|
||
mit entfernt. Ohne ihn laeuft alles weiter, nur langsam, und
|
||
niemand merkt es je. */
|
||
const indizes = d.prepare(
|
||
"SELECT name FROM sqlite_master WHERE type='index' AND tbl_name='eintraege'").all()
|
||
.map((r) => r.name);
|
||
ok(indizes.includes("idx_eintraege_bereich"),
|
||
`der Index steht wieder (${indizes.join(", ")})`);
|
||
d.close();
|
||
}
|
||
|
||
/* GEGENPROBE, DIE WICHTIGSTE: Die Schranke muss noch BEISSEN. Eine
|
||
Umstellung, die nebenbei den CHECK entfernt, sieht aus wie ein Erfolg
|
||
und ist der schlimmere Ausgang -- ab da kann jeder Tippfehler einen
|
||
Bereich erfinden, den es nicht gibt. */
|
||
{
|
||
const d = new DatabaseSync(DB);
|
||
let abgelehnt = false;
|
||
try {
|
||
d.prepare(`INSERT INTO eintraege (bereich, art, titel, datum, erstellt)
|
||
VALUES ('erfundenerbereich','x','Darf nicht',?,?)`).run(heute, jetzt);
|
||
} catch { abgelehnt = true; }
|
||
ok(abgelehnt, "ein erfundener Bereich wird weiterhin abgelehnt — der CHECK beisst noch");
|
||
|
||
let angenommen = false;
|
||
try {
|
||
d.prepare(`INSERT INTO eintraege (bereich, art, titel, datum, erstellt)
|
||
VALUES ('agentur','kampagne','Direkt in die Datenbank',?,?)`).run(heute, jetzt);
|
||
angenommen = true;
|
||
d.exec("DELETE FROM eintraege WHERE titel = 'Direkt in die Datenbank'");
|
||
} catch { /* bleibt false */ }
|
||
ok(angenommen, "'agentur' wird jetzt angenommen");
|
||
d.close();
|
||
}
|
||
|
||
/* =======================================================================
|
||
4. Der Bereich im Betrieb
|
||
======================================================================= */
|
||
melde("\n=== Der Bereich im Betrieb ===");
|
||
|
||
async function anmelden(rolle, code) {
|
||
const a = await fetch(BASIS + "/workspace/api/anmelden", {
|
||
method: "POST", headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ rolle, code }) });
|
||
const keks = (a.headers.getSetCookie?.() || []).map((z) => z.split(";")[0]).join("; ");
|
||
if (!keks) throw new Error(`Anmeldung ${rolle} fehlgeschlagen (${a.status})`);
|
||
return keks;
|
||
}
|
||
const keksDogi = await anmelden("admin", "CODE-DOGI-0001");
|
||
const keksLuna = await anmelden("creator", "CODE-CREA-0001");
|
||
const keksMia = await anmelden("creator", "CODE-CREA-0002");
|
||
|
||
async function ruf(art, weg, keks, koerper) {
|
||
const a = await fetch(BASIS + weg, {
|
||
method: art,
|
||
headers: { "Content-Type": "application/json", cookie: keks, origin: BASIS },
|
||
body: koerper ? JSON.stringify(koerper) : undefined,
|
||
});
|
||
let daten = null;
|
||
try { daten = await a.json(); } catch { /* kein JSON */ }
|
||
return { status: a.status, daten };
|
||
}
|
||
|
||
/* Die vier Arten -- jede einzeln, denn eine, die der Server nicht kennt,
|
||
waere im Formular waehlbar und beim Speichern ein Fehler. */
|
||
const ARTEN = ["kampagne", "schulung", "anliegen", "zustaendig"];
|
||
{
|
||
const { daten } = await ruf("GET", "/workspace/api/bereich/agentur", keksDogi);
|
||
ok(daten?.einstellung?.name === "Agentur",
|
||
`der Bereich meldet sich als "${daten?.einstellung?.name}"`);
|
||
const arten = Object.keys(daten?.einstellung?.arten || {});
|
||
ok(arten.length === 4 && ARTEN.every((a) => arten.includes(a)),
|
||
`vier Arten: ${arten.join(", ")}`);
|
||
ok(daten?.einstellung?.dringlichkeit === true,
|
||
"Dringlichkeit ist an — ein Anliegen von gestern ist etwas anderes als eines von vor drei Wochen");
|
||
ok(daten?.einstellung?.bewertung === false,
|
||
"Bewertung ist aus — hier wird zusammengearbeitet, nicht benotet");
|
||
}
|
||
|
||
/* ANGELEGT WIE IM ECHTEN BETRIEB: OHNE ZUORDNUNG (07.09.2026).
|
||
|
||
Hier stand `creator_id: idLuna` -- und damit prueften die Zeilen
|
||
weiter unten einen Fall, den es im Alltag nicht gibt. Ein Agentur-
|
||
Event gehoert niemandem einzeln; Filipe legt es fuer alle an, das
|
||
Feld bleibt leer. Weil die Pruefung es aber jedes Mal Luna zuwies,
|
||
sah Luna es auch -- und die Zeile "sie sieht ihre 4 Eintraege" war
|
||
grün.
|
||
|
||
In Wirklichkeit war die Agentur-Seite fuer JEDEN Creator leer: Ein
|
||
Eintrag ohne creator_id traf weder `creator_id = ich` noch
|
||
`erstellt_von = ich`. Gemessen am 07.09.2026, bevor es repariert
|
||
wurde: von drei Eintraegen sah die Creatorin genau den einen, den
|
||
die Pruefung ihr zugeschoben hatte.
|
||
|
||
Das ist die dritte Sorte Fehler aus CLAUDE.md -- nicht uebersprungen,
|
||
nicht kaputt: gelaufen, gruen, und am Thema vorbei. Deshalb wird ab
|
||
jetzt der Weg gegangen, den es wirklich gibt. */
|
||
let angelegt = 0;
|
||
for (const art of ARTEN) {
|
||
const { status } = await ruf("POST", "/workspace/api/bereich/agentur", keksDogi, {
|
||
art, titel: `Test ${art}`, text: "Inhalt", datum: heute,
|
||
dringlichkeit: "mittel",
|
||
});
|
||
if (status === 200 || status === 201) angelegt++;
|
||
else ok(false, `Art "${art}" liess sich nicht anlegen (HTTP ${status})`);
|
||
}
|
||
ok(angelegt === ARTEN.length, `alle ${angelegt} Arten lassen sich anlegen`);
|
||
|
||
/* GEGENPROBE: eine erfundene Art muss abgelehnt werden. Sonst pruefte
|
||
die Zeile darueber nur, dass der Server irgendetwas annimmt. */
|
||
{
|
||
const { status } = await ruf("POST", "/workspace/api/bereich/agentur", keksDogi, {
|
||
art: "erfundeneart", titel: "Darf nicht", datum: heute,
|
||
});
|
||
ok(status >= 400, `eine erfundene Art wird abgelehnt (HTTP ${status})`);
|
||
}
|
||
|
||
/* =======================================================================
|
||
4b. FUER ALLE, NICHT FUER EINEN
|
||
|
||
Die Agentur ist der einzige Bereich, in dem ein Eintrag ALLEN gilt.
|
||
Geprueft wird deshalb beides: dass jeder Creator alles sieht -- und
|
||
dass diese Ausnahme NICHT auf die Betreuungsbereiche abfaerbt. Der
|
||
zweite Teil ist der wichtigere: Eine Sichtbarkeitsregel, die man an
|
||
der falschen Stelle aufmacht, gibt fremde Akten frei, und man sieht
|
||
es der Zeile nicht an.
|
||
======================================================================= */
|
||
melde("\n=== Fuer alle, nicht fuer einen ===");
|
||
{
|
||
const { status, daten } = await ruf("GET", "/workspace/api/bereich/agentur", keksLuna);
|
||
ok(status === 200, `Luna darf den Bereich oeffnen (HTTP ${status})`);
|
||
ok((daten?.eintraege || []).length === ARTEN.length,
|
||
`sie sieht alle ${(daten?.eintraege || []).length} Eintraege — obwohl keiner ihr zugeordnet ist`);
|
||
ok((daten?.eintraege || []).every((e) => e.creator_id === null),
|
||
"und keiner davon traegt eine Creator-Zuordnung");
|
||
}
|
||
{
|
||
/* Mia ist eine ZWEITE Creatorin, die nichts angelegt hat und der
|
||
nichts gehoert. Wenn die Regel nur ueber `erstellt_von` gestolpert
|
||
waere, saehe Luna vielleicht etwas und Mia nichts. */
|
||
const { daten } = await ruf("GET", "/workspace/api/bereich/agentur", keksMia);
|
||
ok((daten?.eintraege || []).length === ARTEN.length,
|
||
`auch Mia sieht alle ${(daten?.eintraege || []).length} — sie hat mit keinem davon etwas zu tun`);
|
||
}
|
||
{
|
||
/* Eine ausdrueckliche Zuordnung muss der Server verwerfen. Sonst
|
||
genuegt ein Aufruf an der Oberflaeche vorbei -- oder ein spaeteres
|
||
Formular, das das Feld wieder mitschickt --, und das Event ist
|
||
fuer alle anderen wieder unsichtbar. */
|
||
await ruf("POST", "/workspace/api/bereich/agentur", keksDogi, {
|
||
art: "kampagne", titel: "Versuch mit Zuordnung", datum: heute, creator_id: idLuna,
|
||
});
|
||
const { daten } = await ruf("GET", "/workspace/api/bereich/agentur", keksMia);
|
||
const e = (daten?.eintraege || []).find((x) => x.titel === "Versuch mit Zuordnung");
|
||
ok(!!e, "ein Eintrag, der ausdruecklich Luna zugewiesen wurde, ist trotzdem fuer Mia da");
|
||
ok(e?.creator_id === null, `und seine Zuordnung wurde verworfen (creator_id = ${e?.creator_id})`);
|
||
/* Und derselbe Riegel fuer den freien Namen -- er wird an einer
|
||
ANDEREN Stelle gesetzt (externPruefen) und waere sonst das Loch
|
||
neben der geschlossenen Tuer. */
|
||
await ruf("POST", "/workspace/api/bereich/agentur", keksDogi, {
|
||
art: "kampagne", titel: "Versuch mit Namen", datum: heute, creator_extern: "Marke XY",
|
||
});
|
||
const { daten: d2 } = await ruf("GET", "/workspace/api/bereich/agentur", keksMia);
|
||
const n = (d2?.eintraege || []).find((x) => x.titel === "Versuch mit Namen");
|
||
ok(n?.creator_extern === null || n?.creator_extern === undefined,
|
||
`auch ein frei getippter Name wird verworfen (creator_extern = ${JSON.stringify(n?.creator_extern)})`);
|
||
}
|
||
{
|
||
/* DIE ENTSCHEIDENDE GEGENPROBE.
|
||
|
||
Die Ausnahme haette man auch in sichtbar() einbauen koennen -- eine
|
||
Zeile weniger, und dieselbe Wirkung fuer die Agentur. Nur haengen
|
||
an derselben Funktion auch Aufgaben, Dateien, Termine und Calls.
|
||
Diese Pruefung ist der Grund, warum das auffiele. */
|
||
await ruf("POST", "/workspace/api/bereich/technik", keksDogi, {
|
||
art: "problem", titel: "Nur fuer Mia", datum: heute, creator_id: idMia,
|
||
});
|
||
const { daten } = await ruf("GET", "/workspace/api/bereich/technik", keksLuna);
|
||
const fremd = (daten?.eintraege || []).filter((e) => e.titel === "Nur fuer Mia");
|
||
ok(fremd.length === 0,
|
||
`Luna sieht Mias Technik-Eintrag NICHT — die Agentur-Ausnahme faerbt nicht ab (${fremd.length} gefunden)`);
|
||
}
|
||
{
|
||
/* Sehen ja, schreiben nein -- ausdruecklicher Wunsch: "die sollen
|
||
alles sehen was wir eintragen aber nicht eintragen koennen". */
|
||
const { status } = await ruf("POST", "/workspace/api/bereich/agentur", keksLuna, {
|
||
art: "kampagne", titel: "Von Luna", datum: heute,
|
||
});
|
||
ok(status === 403, `Luna darf hier NICHTS eintragen (HTTP ${status})`);
|
||
}
|
||
|
||
/* =======================================================================
|
||
4c. AGENTUR-EVENTS: Zeitraum, Aufgaben, Regeln
|
||
======================================================================= */
|
||
melde("\n=== Agentur-Events ===");
|
||
{
|
||
/* ORTSZEIT, nicht UTC. `new Date(...).toISOString().slice(0,10)` waere
|
||
zwischen Mitternacht und zwei Uhr einen Tag daneben -- und weil der
|
||
Server den Zeitraum gegen `heute` (Ortszeit) prueft, waere diese
|
||
Pruefung nachts rot, ohne dass sich etwas geaendert haette.
|
||
Gefunden von pruef-struktur, das genau danach sucht. Mittags
|
||
gerechnet, damit auch eine Zeitumstellung im Zeitraum nichts
|
||
verschiebt. */
|
||
const ende = (() => {
|
||
const d = new Date(`${heute}T12:00:00`);
|
||
d.setDate(d.getDate() + 14);
|
||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
||
})();
|
||
const { status } = await ruf("POST", "/workspace/api/bereich/agentur", keksDogi, {
|
||
art: "kampagne", titel: "Sommer-Aktion", text: "Beschreibung", datum: heute,
|
||
event_ende: ende, event_aufgaben: "1 Punkt je Live-Stunde", event_regeln: "Nur eigenes Konto",
|
||
});
|
||
ok(status === 201, `ein Event mit Zeitraum, Aufgaben und Regeln laesst sich anlegen (HTTP ${status})`);
|
||
|
||
const { daten } = await ruf("GET", "/workspace/api/bereich/agentur", keksLuna);
|
||
const e = (daten?.eintraege || []).find((x) => x.titel === "Sommer-Aktion");
|
||
ok(e?.event_ende === ende, `das Ende kommt beim Creator an (${e?.event_ende})`);
|
||
ok(e?.event_aufgaben === "1 Punkt je Live-Stunde", "die Aufgaben kommen an");
|
||
ok(e?.event_regeln === "Nur eigenes Konto", "die Regeln kommen an");
|
||
|
||
/* GEGENPROBEN. Ohne sie hiesse "es laesst sich anlegen" nur, dass der
|
||
Server irgendetwas annimmt. */
|
||
const verdreht = await ruf("POST", "/workspace/api/bereich/agentur", keksDogi, {
|
||
art: "kampagne", titel: "Verdreht", datum: ende, event_ende: heute });
|
||
ok(verdreht.status === 400, `ein Ende VOR dem Beginn wird abgelehnt (HTTP ${verdreht.status})`);
|
||
|
||
const unsinn = await ruf("POST", "/workspace/api/bereich/agentur", keksDogi, {
|
||
art: "kampagne", titel: "Unsinn", datum: heute, event_ende: "31.12.2026" });
|
||
ok(unsinn.status === 400, `ein Datum in falscher Schreibweise wird abgelehnt (HTTP ${unsinn.status})`);
|
||
|
||
/* Beim AENDERN gilt dasselbe -- und zwar auch dann, wenn nur EINE
|
||
der beiden Seiten mitgeschickt wird. Genau hier waere die Pruefung
|
||
sonst blind: pruefe() sieht nur, was im Aufruf steht. */
|
||
const { daten: d3 } = await ruf("GET", "/workspace/api/bereich/agentur", keksDogi);
|
||
const id = (d3?.eintraege || []).find((x) => x.titel === "Sommer-Aktion")?.id;
|
||
const nurEnde = await ruf("PATCH", `/workspace/api/bereich/agentur/${id}`, keksDogi,
|
||
{ event_ende: "2020-01-01" });
|
||
ok(nurEnde.status === 400,
|
||
`auch beim Aendern nur des Endes schlaegt der verdrehte Zeitraum an (HTTP ${nurEnde.status})`);
|
||
const nurBeginn = await ruf("PATCH", `/workspace/api/bereich/agentur/${id}`, keksDogi,
|
||
{ datum: "2099-01-01" });
|
||
ok(nurBeginn.status === 400,
|
||
`und ebenso, wenn nur der Beginn nach hinten geschoben wird (HTTP ${nurBeginn.status})`);
|
||
|
||
/* Die Event-Felder gehoeren der Agentur. In einem anderen Bereich
|
||
muessen sie stillschweigend verfallen -- sonst haengt eines Tages
|
||
ein Preisausschreiben an einem Schutzvorfall. */
|
||
await ruf("POST", "/workspace/api/bereich/schutz", keksDogi, {
|
||
art: "vorfall", titel: "Kein Event", datum: heute, event_regeln: "darf nicht ankommen" });
|
||
const { daten: d4 } = await ruf("GET", "/workspace/api/bereich/schutz", keksDogi);
|
||
const v = (d4?.eintraege || []).find((x) => x.titel === "Kein Event");
|
||
ok(!v?.event_regeln, `im Schutz-Bereich verfallen die Event-Felder (${JSON.stringify(v?.event_regeln)})`);
|
||
}
|
||
|
||
/* =======================================================================
|
||
5. Kommt er ueberall an, wo Bereiche vorkommen?
|
||
======================================================================= */
|
||
melde("\n=== Ueberall, wo Bereiche vorkommen ===");
|
||
{
|
||
/* Der Wochenbericht. Hier stand die Liste der fuenf Bereiche noch
|
||
einmal von Hand abgeschrieben -- der neue waere schlicht nicht
|
||
vorgekommen, ohne Fehler und ohne Luecke. */
|
||
const { daten } = await ruf("GET", "/workspace/api/report", keksDogi);
|
||
const b = daten?.bereiche || {};
|
||
ok(Object.keys(b).length === 6,
|
||
`der Wochenbericht kennt ${Object.keys(b).length} Bereiche: ${Object.keys(b).join(", ")}`);
|
||
ok(b.agentur && b.agentur.neu >= ARTEN.length,
|
||
`und zaehlt ${b.agentur?.neu} neue Agentur-Eintraege`);
|
||
}
|
||
{
|
||
/* Die Suche. Ein Treffer ohne Ueberschrift sieht aus wie ein Fehler. */
|
||
const { daten } = await ruf("GET", "/workspace/api/suche?q=" + encodeURIComponent("Test kampagne"), keksDogi);
|
||
const text = JSON.stringify(daten);
|
||
ok(/Agentur/.test(text),
|
||
"die Suche beschriftet Agentur-Treffer mit dem richtigen Namen");
|
||
}
|
||
|
||
/* =======================================================================
|
||
6. Die Kachel — mit einem echten Browser
|
||
======================================================================= */
|
||
melde("\n=== Die Kachel ===");
|
||
{
|
||
const pw = await import("file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs");
|
||
const browser = await pw.chromium.launch();
|
||
const ctx = await browser.newContext({ viewport: { width: 1280, height: 900 } });
|
||
const seite = await ctx.newPage();
|
||
await seite.goto(BASIS + "/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.waitForURL("**/start.html", { timeout: 25000 });
|
||
await seite.waitForTimeout(1100);
|
||
|
||
const kacheln = await seite.$$eval(".kachel", (k) => k.map((x) => ({
|
||
name: x.querySelector(".kachel__name")?.textContent.trim(),
|
||
ziel: x.querySelector(".kachel__link")?.getAttribute("href"),
|
||
ton: x.dataset.ton,
|
||
pfade: x.querySelectorAll(".kachel__zeichen path").length,
|
||
})));
|
||
const a = kacheln.find((x) => x.name === "Agentur");
|
||
ok(!!a, `die Kachel "Agentur" steht da (${kacheln.length} Kacheln insgesamt)`);
|
||
ok(a?.ziel === "bereich.html?b=agentur", `sie fuehrt nach "${a?.ziel}"`);
|
||
ok(Number(a?.pfade) >= 4, `ihr Zeichen hat ${a?.pfade} Pfade — es ist wirklich gezeichnet`);
|
||
|
||
/* KEINE ZWEI KACHELN MIT DEMSELBEN FARBTON NEBENEINANDER. Das Auge
|
||
vergleicht nur, was benachbart ist -- zwei gleiche Toene direkt
|
||
nebeneinander lesen sich als Fehler. */
|
||
const toene = kacheln.map((x) => x.ton);
|
||
const nachbarnGleich = toene.filter((t, i) => i > 0 && t === toene[i - 1]);
|
||
ok(nachbarnGleich.length === 0,
|
||
nachbarnGleich.length ? `zwei Nachbarkacheln haben denselben Ton (${nachbarnGleich.join(", ")})`
|
||
: `keine zwei benachbarten Kacheln teilen einen Farbton (${toene.length} geprueft)`);
|
||
|
||
/* Die Seite selbst muss aufgehen und ihren Namen tragen. */
|
||
await seite.goto(BASIS + "/workspace/bereich.html?b=agentur", { waitUntil: "networkidle" });
|
||
await seite.waitForTimeout(900);
|
||
const titel = await seite.$eval(".titel", (e) => e.textContent.trim()).catch(() => "");
|
||
ok(/Agentur/i.test(titel), `die Bereichsseite heisst "${titel}"`);
|
||
|
||
/* .eintrag-karte -- der Name aus bereich.js. Beim ersten Anlauf stand
|
||
hier geraten ".eintrag, .karte"; das traf nichts und meldete "0
|
||
Eintraege", obwohl vier dastanden. Ein falscher Selektor meldet
|
||
einen Fehler, den es nicht gibt -- genauso schaedlich wie einer,
|
||
der etwas uebersieht. */
|
||
const eintraege = await seite.$$eval(".eintrag-karte", (e) => e.length).catch(() => 0);
|
||
ok(eintraege >= ARTEN.length,
|
||
`sie zeigt ${eintraege} Eintraege (mindestens die ${ARTEN.length} angelegten)`);
|
||
|
||
/* Und die Titel muessen die sein, die oben angelegt wurden -- sonst
|
||
bewiese die Zahl nur, dass IRGENDWAS gezeichnet wurde. */
|
||
const kartenTitel = await seite.$$eval(".eintrag-titel", (e) => e.map((x) => x.textContent.trim()));
|
||
const fehlend = ARTEN.map((a) => `Test ${a}`).filter((t) => !kartenTitel.includes(t));
|
||
ok(fehlend.length === 0,
|
||
fehlend.length ? `diese Eintraege fehlen auf der Seite: ${fehlend.join(", ")}`
|
||
: `alle vier Titel stehen da: ${kartenTitel.join(" · ")}`);
|
||
|
||
/* ---- Das Event auf dem Bildschirm --------------------------------
|
||
Bis hierher ist bewiesen, dass die Daten stimmen. Das sagt noch
|
||
nichts darueber, ob jemand sie SIEHT: Zeitraum, Aufgaben und
|
||
Regeln koennen vollstaendig in der Antwort stehen und trotzdem
|
||
nirgends gezeichnet werden -- ein vergessener Aufruf in karte()
|
||
reicht, und niemand merkt es, weil die Karte ja da ist. */
|
||
const event = await seite.$$eval(".eintrag-karte", (karten) => {
|
||
const k = karten.find((x) => x.querySelector(".eintrag-titel")?.textContent.trim() === "Sommer-Aktion");
|
||
if (!k) return null;
|
||
return {
|
||
stand: k.dataset.event,
|
||
marke: k.querySelector(".event-zeitraum__marke")?.textContent.trim(),
|
||
spanne: k.querySelector(".event-zeitraum__spanne")?.textContent.trim(),
|
||
schilder: [...k.querySelectorAll(".event-block__schild")].map((s) => s.textContent.trim()),
|
||
texte: [...k.querySelectorAll(".event-block__text")].map((s) => s.textContent.trim()),
|
||
};
|
||
});
|
||
ok(!!event, "die Event-Karte Sommer-Aktion steht auf der Seite");
|
||
ok(event?.stand === "laeuft", `sie ist als laufend gekennzeichnet ("${event?.stand}")`);
|
||
ok(/läuft bis/.test(event?.marke || ""),
|
||
`der Zustand steht als WORT da, nicht nur als Farbe ("${event?.marke}")`);
|
||
ok(/–/.test(event?.spanne || ""), `und der Zeitraum als Spanne ("${event?.spanne}")`);
|
||
ok(event?.schilder.join(",") === "Aufgaben,Regeln",
|
||
`beide Bloecke sind beschriftet (${event?.schilder.join(", ")})`);
|
||
ok(event?.texte.includes("1 Punkt je Live-Stunde") && event?.texte.includes("Nur eigenes Konto"),
|
||
"und tragen wirklich ihren Inhalt");
|
||
|
||
/* GEGENPROBE dazu: Eine Karte, die KEIN Event ist, darf nichts davon
|
||
haben. Sonst bewiese der Block oben nur, dass die Elemente
|
||
existieren -- nicht, dass sie zur richtigen Art gehoeren. */
|
||
const schulung = await seite.$$eval(".eintrag-karte", (karten) => {
|
||
const k = karten.find((x) => x.querySelector(".eintrag-titel")?.textContent.trim() === "Test schulung");
|
||
return k ? { event: k.dataset.event ?? null, bloecke: k.querySelectorAll(".event-block").length } : null;
|
||
});
|
||
ok(schulung && !schulung.event && schulung.bloecke === 0,
|
||
`eine Schulung bekommt weder Zustand noch Event-Bloecke (${JSON.stringify(schulung)})`);
|
||
|
||
/* ---- Die Zuordnung bietet nur "Agentur" an ------------------------
|
||
Der Ausgangspunkt des ganzen Umbaus: Hier stand die Creator-Liste,
|
||
und ein versehentlicher Klick darauf machte das Event fuer alle
|
||
anderen unsichtbar. */
|
||
await seite.click("#neu-oeffnen");
|
||
await seite.waitForTimeout(400);
|
||
const wahl = await seite.$$eval("#f-creator option", (o) => o.map((x) => x.textContent.trim()));
|
||
ok(wahl.length === 1 && wahl[0] === "Agentur",
|
||
`die Zuordnung bietet genau eine Wahl an: ${JSON.stringify(wahl)}`);
|
||
const hinweis = await seite.$eval("#hinweis-creator", (e) => e.textContent.trim()).catch(() => "");
|
||
ok(/allen/.test(hinweis), `und sagt, was das bedeutet ("${hinweis}")`);
|
||
|
||
/* ---- Das Formular wechselt mit der Art ---------------------------
|
||
Beim Oeffnen steht die erste Art (Agentur-Events) drin, also
|
||
muessen die Event-Felder sichtbar sein. Wechselt man auf Schulung,
|
||
muessen sie verschwinden -- und geleert werden, sonst faehrt ein
|
||
unsichtbares Enddatum an einer Schulung mit. */
|
||
const sichtbar = (id) => seite.$eval(id, (e) => !e.hidden);
|
||
ok(await sichtbar("#feld-ende") && await sichtbar("#feld-aufgaben") && await sichtbar("#feld-regeln"),
|
||
"bei Agentur-Events stehen Zeitraum, Aufgaben und Regeln im Formular");
|
||
const schild = await seite.$eval("#schild-titel", (e) => e.textContent.trim());
|
||
ok(schild === "Titel des Events", `und die Beschriftung passt zur Art ("${schild}")`);
|
||
|
||
await seite.fill("#f-ende", "2030-01-01");
|
||
await seite.selectOption("#f-art", "schulung");
|
||
await seite.waitForTimeout(300);
|
||
ok(!(await sichtbar("#feld-ende")) && !(await sichtbar("#feld-aufgaben")),
|
||
"bei Schulung verschwinden sie wieder");
|
||
const restEnde = await seite.$eval("#f-ende", (e) => e.value);
|
||
ok(restEnde === "",
|
||
`und das Enddatum faehrt nicht unsichtbar mit (Wert: "${restEnde}")`);
|
||
|
||
/* GEGENPROBE zum Selektor: Ein erfundener Bereich darf NICHT wie ein
|
||
echter aussehen -- weder in der Ueberschrift noch mit Eintraegen.
|
||
Die Anwendung schickt einen dorthin zurueck, wo er hingehoert. */
|
||
await seite.goto(BASIS + "/workspace/bereich.html?b=gibtesnicht", { waitUntil: "networkidle" });
|
||
await seite.waitForTimeout(900);
|
||
const falsch = await seite.$eval(".titel", (e) => e.textContent.trim()).catch(() => "");
|
||
const falschKarten = await seite.$$eval(".eintrag-karte", (e) => e.length).catch(() => 0);
|
||
ok(!/Agentur/i.test(falsch) && falschKarten === 0,
|
||
`ein erfundener Bereich zeigt weder die Agentur noch Eintraege `
|
||
+ `("${falsch}", ${falschKarten} Karten)`);
|
||
|
||
await ctx.close();
|
||
await browser.close();
|
||
}
|
||
|
||
await serverStoppen();
|
||
try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ }
|
||
|
||
melde(`\n${gemacht} Pruefungen`);
|
||
melde(fehler === 0 ? "ALLES IN ORDNUNG" : `${fehler} FEHLER`);
|
||
process.exit(fehler ? 1 : 0);
|