Gemessen: 165 Pruefdateien, 129 verschiedene Nummern -- NEUNZEHN
doppelt, vier davon dreifach. Niemand hatte das gewollt; jede neue
Pruefung wurde von einer vorhandenen abgeschrieben, und die Nummer kam
mit. Meine eigene Notiz sagte "sieben" -- auch eine Bestandsliste
altert.
Der Waechter faengt den Schaden ab, aber er kann nur melden, was schon
passiert ist: Zwei Pruefungen mit derselben Nummer koennen nie
gleichzeitig laufen, und ein liegengebliebener Prozess der einen laesst
die andere abbrechen mit einer Meldung, die wie ein Befund aussieht.
Genau das ist mir am 20.09. zweimal passiert.
Jetzt leitet jede Datei ihre Nummer aus ihrer STELLE IM ALPHABET ab
(eigenerPort in helfer-port.mjs), zwei je Datei. Nicht ueber eine
Pruefsumme: Bei 165 Namen in 4900 Nummern waeren nach dem
Geburtstagsproblem rund DREI Zusammenstoesse zu erwarten -- ein Hash
tauscht eine sichtbare Doppelung gegen eine unsichtbare. Die Stelle im
Alphabet ist eindeutig von der Bauart her.
--- ZWEI FEHLER AUF DEM WEG, BEIDE LEHRREICH -------------------------
1. DER ERSTE VERSUCH WAR GRUEN UND KAPUTT. Ersetzt wurde mit einem
Muster: "([^"]*4231[^"]*)". Das hielt
{ host: "127.0.0.1", port: 4231, path: "/404.html" }
fuer eine Zeichenkette -- ein Muster kann eine oeffnende nicht von
einer schliessenden Anfuehrung unterscheiden. Heraus kam
{ host: "127.0.0.1`, port: ${PORT}, path: `/404.html" }
also GUELTIGER Code ohne port-Feld. `node --check` sagte gruen fuer
alle 164 Dateien. Aufgefallen ist es erst, weil ich vier Vertreter
gegen eine vorher gemessene Grundlinie laufen liess: pruef-schranke
39/0 vorher, 38/1 nachher.
Alles zurueckgenommen und mit einem Zerleger neu gemacht, der weiss,
ob eine Stelle Code, Zeichenkette, Vorlage, Kommentar oder
regulaerer Ausdruck ist. In pruef-ics stand die Nummer in einem
regulaeren Ausdruck -- der wird jetzt gebaut statt hingeschrieben.
2. EIN MODUL, DAS BEIM IMPORTIEREN ARBEITET, IST EINE FALLE. Der
zweite Durchgang importierte den ersten, um seine Mechanik zu
benutzen -- und fuehrte dessen Hauptlauf gleich mit aus. Die
zweiten Nummern wurden dadurch als erste behandelt, zwei Aufrufe
bekamen dieselbe Nummer, und in pruef-content stand `const PORT`
zweimal.
--- WAS DAS DAUERHAFT HAELT -----------------------------------------
pruef-portnummern.mjs (neu, 8 Pruefungen) fragt nicht "welche Nummern
sind doppelt", sondern "wer traegt ueberhaupt noch eine von Hand ein"
und "wer startet einen Server, ohne seine Nummer abzuleiten". Die
zweite Frage hat sofort etwas gefunden, das in KEINER Doppelungsliste
stand: pruef-push-weg belegte 4341 und 4342, rief den Waechter aber
gar nicht auf -- dieselbe Nummer wie pruef-agentur. Eine Liste zeigt
nur, was auf ihr steht.
Mit Gegenprobe: Eine unbekannte Datei bekommt keine geratene Nummer,
sondern einen Abbruch, und eine dritte Nummer je Datei gibt es nicht.
--- NACHGEMESSEN ----------------------------------------------------
Sechzehn Pruefungen gegen ihre vorher gemessene Grundlinie, je eine
Vertreterin jeder umgestellten Bauweise (eine Nummer, zwei Nummern,
Nummer in einer Zeichenkette, in einer Vorlage, in einem regulaeren
Ausdruck, dynamische Einfuhr, Nachtlauf mit zwei Laeufen):
crew-adresse 132/0 · schranke 39/0 · content 45/0 · entwicklung 46/0
anruf 127/0 · ics 37/0 · push-weg 20/0 · arten 28/0 · video 67/0
kanalzeile 14/0 · agentur 62/0 · spicy 83/0 · creator-anlegen 50/0
push-ziel 10/0 · portnummern 8/0
Alle exakt wie vorher. Zwei waren schon vorher rot und sind es
unveraendert geblieben (crew-wand-bild 41/4, teilen 14/3) -- per
`git stash` belegt, nicht angenommen.
Co-Authored-By: Claude Opus 5 <[email protected]>
812 lines
40 KiB
JavaScript
812 lines
40 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, writeFileSync, readdirSync } 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 { eigenerPort } = await import("./helfer-port.mjs");
|
||
const PORT = await eigenerPort(import.meta, "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;
|
||
/* DER DRITTE AUSGANG (11.09.2026). Diese Datei schreibt die
|
||
Serverausgabe immer schon mit -- und zeigte sie nur, wenn der Server
|
||
gar nicht erst hochkam. Faellt er spaeter mit 503 um (die Antwort bei
|
||
einem Datenbankfehler), standen hier 31-mal "FEHL" und kein einziges
|
||
Wort dazu, WARUM. Eine Pruefung, die nicht sagen kann, woran sie
|
||
scheitert, kostet eine Stunde Suchen. */
|
||
let letzteAusgabe = () => "";
|
||
/* UND SIE MUSS AUCH DANN KOMMEN, WENN DER LAUF ABSTUERZT. Playwright
|
||
wirft bei einem fehlenden Knopf eine Ausnahme -- der Prozess stirbt,
|
||
und mit ihm die Zusammenfassung samt Diagnose. Genau dann braucht
|
||
man sie am dringendsten. */
|
||
function serverAusgabeZeigen(warum) {
|
||
const t = letzteAusgabe();
|
||
console.log("");
|
||
console.log(`=== Was der Server dazu gesagt hat (${warum}, letzte 1800 Zeichen) ===`);
|
||
console.log(t ? t.slice(-1800) : "(nichts -- der Server hat geschwiegen)");
|
||
/* Und vollstaendig auf Zuruf, falls die Ursache weiter oben steht:
|
||
SERVERLOG=<datei>. Die letzten 1800 Zeichen zeigen den Schaden,
|
||
nicht immer seine Ursache. */
|
||
if (t && process.env.SERVERLOG) {
|
||
writeFileSync(process.env.SERVERLOG, t);
|
||
console.log(`(vollstaendig in ${process.env.SERVERLOG})`);
|
||
}
|
||
}
|
||
for (const art of ["uncaughtException", "unhandledRejection"]) {
|
||
process.on(art, (e) => {
|
||
console.log(`
|
||
!! ${art}: ${e?.message || e}`);
|
||
serverAusgabeZeigen(art);
|
||
process.exit(1);
|
||
});
|
||
}
|
||
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;
|
||
letzteAusgabe = () => aus;
|
||
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();
|
||
}
|
||
|
||
/* NICHT AUF DEN WORTLAUT FESTNAGELN (11.09.2026). Hier stand
|
||
/Bereich 'agentur' freigeschaltet/ -- der Satz des alten, von Hand
|
||
abgeschriebenen Blocks. Seit die Umstellung ueber
|
||
checkListeErweitern laeuft, heisst es "'agentur' in
|
||
eintraege.bereich freigeschaltet". Gemeint ist beides Mal dasselbe;
|
||
geprueft wird deshalb die AUSSAGE, nicht der Satzbau. */
|
||
ok(/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 || {};
|
||
/* ABGELEITET STATT GEZAEHLT (11.09.2026). Hier stand `=== 6`. Das
|
||
war richtig, als es sechs Bereiche gab; inzwischen sind es elf,
|
||
und die Zeile war seither still falsch -- versteckt hinter einem
|
||
Absturz weiter oben, der den Lauf vorher beendete. Dieselbe feste
|
||
Zahl, dieselbe Falle wie in der Umstellung, die diese Datei
|
||
aufgedeckt hat.
|
||
|
||
Verglichen wird jetzt gegen die Bereiche, die die DATENBANK
|
||
ueberhaupt zulaesst -- die CHECK-Regel ist die einzige Liste, an
|
||
der niemand vorbeikommt. Damit stimmt die Pruefung auch beim
|
||
zwoelften Bereich noch, ohne dass jemand sie anfasst. */
|
||
const erlaubt = (() => {
|
||
const dd = new DatabaseSync(DB, { readOnly: true });
|
||
const plan = dd.prepare(
|
||
"SELECT sql FROM sqlite_master WHERE type='table' AND name='eintraege'").get()?.sql || "";
|
||
dd.close();
|
||
/* Ohne regulaeren Ausdruck: Der Bauplan enthaelt die Regel
|
||
woertlich als "bereich IN (...)". Ein Muster mit Klammern und
|
||
Leerzeichenklassen waere hier nur eine weitere Stelle, an der
|
||
man sich vertun kann. */
|
||
const auf = plan.indexOf("bereich IN (");
|
||
if (auf < 0) return [];
|
||
const zu = plan.indexOf(")", auf);
|
||
if (zu < 0) return [];
|
||
return plan.slice(auf + "bereich IN (".length, zu)
|
||
.split(",").map((t) => t.trim().split("'").join(""));
|
||
})();
|
||
const fehlend = erlaubt.filter((n) => !(n in b));
|
||
ok(erlaubt.length > 0 && fehlend.length === 0,
|
||
`der Wochenbericht kennt alle ${erlaubt.length} Bereiche der Datenbank`
|
||
+ (fehlend.length ? ` -- es fehlen: ${fehlend.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();
|
||
}
|
||
|
||
/* =======================================================================
|
||
6. UND SIE LAEUFT GENAU EINMAL (11.09.2026)
|
||
=======================================================================
|
||
Der Fall, der auf dem echten Server gilt: Dort kennt die CHECK-Regel
|
||
'agentur' laengst. Ein Neustart darf die Tabelle dann NICHT noch
|
||
einmal umbauen -- jeder Umbau ist ein Kopieren von Hand auf echten
|
||
Daten, und was man nicht tun muss, tut man auf einer Live-Datenbank
|
||
nicht.
|
||
|
||
GEMESSEN AN DEN SICHERUNGEN, nicht an der Absicht: Vor jedem Umbau
|
||
legt die Anwendung eine Datei ".vor-agentur-<stempel>" an. Diese
|
||
Datei ist der Beweis, dass umgebaut wurde. Waeren es zwei, haette
|
||
ein Neustart erneut angefasst -- und genau das soll hier auffallen.
|
||
Der Server ist zwischendurch mehrfach neu gestartet worden, der Fall
|
||
ist also wirklich durchgespielt und nicht nur behauptet. */
|
||
{
|
||
const spuren = readdirSync(ordner).filter((n) => n.includes(".vor-agentur-"));
|
||
ok(spuren.length === 1,
|
||
`die Agentur-Umstellung lief genau EINMAL, trotz mehrerer Neustarts `
|
||
+ `(${spuren.length} Sicherung(en))`);
|
||
}
|
||
|
||
await serverStoppen();
|
||
try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ }
|
||
|
||
melde(`\n${gemacht} Pruefungen`);
|
||
if (fehler) {
|
||
const t = letzteAusgabe();
|
||
melde("");
|
||
melde("=== Was der Server dazu gesagt hat (letzte 1800 Zeichen) ===");
|
||
melde(t ? t.slice(-1800) : "(nichts -- der Server hat geschwiegen)");
|
||
}
|
||
melde(fehler === 0 ? "ALLES IN ORDNUNG" : `${fehler} FEHLER`);
|
||
process.exit(fehler ? 1 : 0);
|