Files
dogfather-universe/server/pruef-anleitung.mjs
T
DogFatherGitandClaude Opus 5 df67574f90 Anleitung, Etappe 2: Erste Schritte zum Abhaken -- und eine Farbe, die vier Tage falsch war
Ein Creator hakt seine fuenf Ersten Schritte ab, sieht seinen
Fortschritt im Kopf, bekommt unter „Was ist dran" den Punkt „Erste
Schritte noch offen" und ganz oben auf der Startseite eine breite
Kachel „Fang hier an". Alle drei verschwinden von selbst, sobald er
durch ist -- niemand muss etwas wegklicken oder abschalten.

EINE QUELLE, DREI ANZEIGEN.

Der Bauplan wollte einen Punkt bei „Was ist dran" UND einen eigenen
Zaehler an der Kachel. Zwei getrennt gebaute Zaehler zeigen irgendwann
zwei verschiedene Zahlen uebereinander auf einem Bildschirm. Es gibt
deshalb nur `offeneHinweise()`: Daraus kommen die Zeile, die Zahl an
der kleinen Kachel und die Zahl an der breiten. Die breite Kachel
entsteht ausserdem aus der BEREITS GEFILTERTEN Kachelliste und erbt
damit beide Schranken (Rollenliste und Rechtetafel), statt sie ein
drittes Mal zu beantworten.

Gespeichert wird nur Erledigtes: eine Zeile heisst „abgehakt", keine
Zeile heisst „offen". Kein Feld mit 0 und 1 -- sonst gaebe es zwei
Arten, „offen" zu sagen.

DIE WICHTIGSTE ENTDECKUNG WAR NICHT TEIL DIESER ETAPPE.

`pruef-kachel-universum` wurde rot. Nachgemessen gegen den Stand VOR
Etappe 1: Sie war schon rot -- seit dem 02.10., und zwar wegen Ton 47
(Manager-Ziele, #7368ff), den ich selbst eingetragen habe. Im
Kommentar daneben steht sogar meine eigene Messung: „Abstand zum
naechsten Nachbarn 0,0899 ... knapp unter der 0,090". Ich habe die
Zahl hingeschrieben und die Pruefung nie laufen lassen. Vier Tage.

#7368ff -> #7269ff. Eine Hexziffer, Abstand zum alten Wert 0,0024 --
das sieht kein Mensch --, aber der Abstand zur naechsten Kachel im
eigenen Haus steigt von 0,0899 auf 0,0918. Nicht die am weitesten
entfernte Farbe genommen (das waere ein grelles Gruen gewesen):
Manager-Ziele ist in Benutzung und violett, gesucht war die kleinste
Aenderung, die die Regel haelt.

UND DIE REGEL SELBST WAR FUER EIN HAUS GESCHRIEBEN.

Auch mit sauberem Ton 47 blieb die Pruefung rot: Sie vergleicht ALLE
48 Toene miteinander, und rechnerisch ist 0,09 fuer 48 Farben nicht zu
halten (das Werkzeug findet als besten freien Platz 0,0793). Seit dem
24.09.2026 gibt es aber zwei Haeuser, und eine Kachel des einen steht
nie neben einer des anderen -- sie liegen auf verschiedenen Adressen.

Die Messung ist jetzt haus-bewusst: 0,09 innerhalb eines Hauses
(streng, und mit rund 24 Farden je Haus zu halten -- gemessen 0,0905),
ein Boden von 0,06 ueber die Haeuser hinweg. Das ist SCHAERFER, nicht
weicher: Vorher verteilte sich die Grenze auf 48 Farben und war
unerreichbar, weshalb die Pruefung dauerhaft rot stand -- und eine
Warnung, die immer kommt, ist keine mehr. Welcher Ton zu welchem Haus
gehoert, wird aus bereiche.js abgeleitet, nicht aufgelistet. Dazu eine
Gegenprobe, dass die Einteilung ueberhaupt trennt (553 Paare im Haus,
575 darueber hinweg).

NOCH EINE FESTE ZAHL ERSETZT: `pruef-start-ansicht` verlangte „genau
drei Gruppen" fuer einen Creator. Mit „Fang hier an" sind es
zeitweilig vier, und beides ist richtig. Gezaehlt wird nicht mehr,
sondern benannt -- auch das die schaerfere Pruefung: Die alte Zeile
waere gruen geblieben, wenn eine der drei Gruppen verschwindet und
eine fremde dazukommt.

Gemessen
  pruef-anleitung        70 Pruefungen, 0 Fehler (vorher 47)
  pruef-rollen          402 Pruefungen, 0 Fehler (vorher 401)
  pruef-kachel-universum 16 Pruefungen, 0 Fehler (vorher 13, davon 1 rot)
  pruef-start-ansicht     alles in Ordnung (vorher 1 rot)
  pruef-kachelraster      24 Pruefungen, 0 Fehler
  pruef-css-klassen       alles in Ordnung, 42 Stellen unter 11,5 px
  mess-anleitung          mit offenen Schritten: Gruppe „Fang hier an",
                          breite Kachel mit Zaehler 5, Hinweiszeile.
                          Nach dem Abhaken: alle drei weg, ein Weg
                          statt drei. Kein Querschieben bei 412/1440 px.

Sicherung: ~/sicherungen/workspace-vor-anleitung-e2-20261006-1421.db

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-06 14:22:07 +02:00

529 lines
26 KiB
JavaScript

/* =====================================================================
DIE ANLEITUNG — stimmt sie mit den Kacheln ueberein? (Etappe 1)
=====================================================================
DIE EIGENTLICHE FRAGE DIESER DATEI steht im Bauplan gar nicht; sie
kam beim Nachmessen dazu, und sie ist der Grund, warum es diese
Pruefung ueberhaupt gibt:
Erklaert die Anleitung genau die Kacheln, die es gibt --
und zwar JEDE und KEINE DARUEBER HINAUS?
Beides kann lautlos kaputtgehen, und beides auf dieselbe Weise: Wer
eine Kachel hinzufuegt, vergisst die Karte -- dann steht auf der
Startseite etwas, das die Einweisung nicht kennt. Wer eine Kachel
entfernt, laesst die Karte stehen -- dann erklaert die Einweisung
etwas, das es nicht mehr gibt, mit einem Knopf, der ins Leere
fuehrt.
Der Bauplan sieht dafuer nur einen Hinweis in der Pflege-Ansicht vor
("Fuer diese Kachel fehlt ein Eintrag"). Der hilft nur, wenn jemand
hinsieht. Diese Pruefung sieht immer hin.
---------------------------------------------------------------------
WOHER DIE KACHELLISTE KOMMT
Aus `workspace/assets/js/bereiche.js`, und zwar indem die Datei
AUSGEFUEHRT wird -- nicht, indem jemand sie mit einem Muster
durchsucht. Ein Muster waere eine zweite, ungenaue Nachbildung der
Datei; sie wuerde bei der ersten ungewohnten Schreibweise still
danebenliegen und dann "alles in Ordnung" melden.
Der Server kennt diese Liste nicht (bereicheFuer gibt fuers
Agenturhaus `null` zurueck). Deshalb muss die Pruefung sie sich
selbst holen -- genau wie der Browser es tut.
===================================================================== */
import { mkdtempSync, rmSync, readFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import vm from "node:vm";
import { eigenerPort } from "./helfer-port.mjs";
const PORT = await eigenerPort(import.meta, "pruef-anleitung");
const ordner = mkdtempSync(join(tmpdir(), "ws-anl-"));
process.env.WORKSPACE_DB = join(ordner, "workspace.db");
process.env.PORT = String(PORT);
process.env.SITE_ACCESS_SECRET = "lokaler-test";
process.env.SITE_PUBLIC_LAUNCH_AT = "2020-01-01T00:00:00+01:00";
const express = (await import("express")).default;
const ec = express.response.cookie;
express.response.cookie = function (n, w, o) { return ec.call(this, n, w, { ...(o || {}), secure: false }); };
import { notbremse } from "./helfer-notbremse.mjs";
await import("./index.js");
notbremse(180_000, "pruef-anleitung");
await new Promise((r) => setTimeout(r, 800));
const BASIS = `http://127.0.0.1:${PORT}`;
let fehler = 0, geprueft = 0;
const melde = (t) => console.log(t);
const ok = (b, t) => { geprueft++; console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; };
function braucht(bedingung, was) {
if (bedingung) return;
console.log(`\nABBRUCH: konnte nicht nachsehen — ${was}`);
console.log("Das ist KEIN Befund am Code. Die Pruefung konnte ihre");
console.log("Voraussetzung nicht herstellen und sagt das, statt zu schweigen.\n");
try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ }
process.exit(2);
}
/* =====================================================================
DIE KACHELLISTE — bereiche.js wirklich ausfuehren
===================================================================== */
function bereicheLaden() {
const quelle = readFileSync(
new URL("../workspace/assets/js/bereiche.js", import.meta.url), "utf8");
const fenster = {};
const tot = () => ({ setAttribute() {}, append() {}, style: { setProperty() {} } });
const umgebung = { window: fenster, URLSearchParams,
document: { createElement: tot, createElementNS: tot } };
umgebung.globalThis = umgebung;
vm.createContext(umgebung);
vm.runInContext(quelle, umgebung, { filename: "bereiche.js" });
return fenster.Bereiche;
}
const B = bereicheLaden();
braucht(B && Array.isArray(B.GRUPPEN) && B.GRUPPEN.length > 0,
"bereiche.js liefert keine Kachelliste — ohne sie ist nichts zu vergleichen");
braucht(typeof B.schluesselVonZiel === "function",
"bereiche.js kennt schluesselVonZiel nicht");
melde("\n=== Der Kachel-Schluessel rechnet richtig ===");
const SCHLUESSEL_PROBEN = [
["uebersicht.html", "uebersicht"],
["bereich.html?b=live", "bereich:live"],
["bereich.html?b=agentur", "bereich:agentur"],
["profil.html", "profil"],
["chat.html?raum=treff", "chat"],
["", ""],
];
for (const [ziel, erwartet] of SCHLUESSEL_PROBEN) {
const ist = B.schluesselVonZiel(ziel);
ok(ist === erwartet, `"${ziel || "(leer)"}" -> "${ist}" (erwartet "${erwartet}")`);
}
/* GEGENPROBE: Die Rechnung muss auch TRENNEN koennen. Lieferte sie fuer
alles dasselbe, waeren die sechs Zeilen oben trotzdem teils gruen. */
ok(B.schluesselVonZiel("bereich.html?b=live")
!== B.schluesselVonZiel("bereich.html?b=technik"),
"Gegenprobe: zwei Bereiche derselben Seite bekommen verschiedene Schluessel");
function kachelnFuer(rolle) {
const nach = new Map();
for (const g of B.GRUPPEN) {
for (const k of (g.bereiche || [])) {
if (Array.isArray(k.rollen) && !k.rollen.includes(rolle)) continue;
const s = B.schluesselVonZiel(k.ziel);
if (s) nach.set(s, k);
}
}
return nach;
}
/* =====================================================================
LEUTE UND ANMELDUNG
===================================================================== */
const { DatabaseSync } = await import("node:sqlite");
const { scryptSync, randomBytes, createHmac } = await import("node:crypto");
const d = new DatabaseSync(process.env.WORKSPACE_DB);
const jetztIso = new Date().toISOString();
const kennSchluessel = randomBytes(32).toString("hex");
d.prepare("INSERT INTO einstellungen (schluessel, wert, geaendert, von)"
+ " VALUES (?,?,?,NULL) ON CONFLICT(schluessel) DO NOTHING")
.run("code_kennung_schluessel", kennSchluessel, jetztIso);
function anlegen(name, rolle, code) {
const salt = randomBytes(16).toString("hex");
const hash = scryptSync(code, salt, 64,
{ N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex");
d.prepare("INSERT INTO personen (name, rolle, code_hash, code_salt, code_n,"
+ " code_kennung, aktiv, erstellt) VALUES (?,?,?,?,?,?,1,?)")
.run(name, rolle, hash, salt, 32768,
createHmac("sha256", kennSchluessel).update(code).digest("hex"), jetztIso);
return d.prepare("SELECT last_insert_rowid() AS id").get().id;
}
const ROLLEN = [["admin", "Filipe"], ["spicy", "Spicy Media"], ["manager", "Patrick"],
["scout", "Kevin"], ["creator", "Luna"], ["modi", "Miss"], ["hand", "VanVan"]];
const CODES = {};
for (const [i, [rolle, name]] of ROLLEN.entries()) {
CODES[rolle] = `CODE-A${String(i).padStart(2, "0")}-000001`;
anlegen(name, rolle, CODES[rolle]);
}
/* Wie viele Zeilen hat die Erstbefuellung angelegt? Direkt in der
Datenbank nachgezaehlt und nicht ueber die Schnittstelle: Sonst
pruefte man die Schnittstelle mit sich selbst. */
const inDb = d.prepare(
"SELECT COUNT(*) n FROM anl_karte WHERE haus = 'agentur' AND rolle = 'creator'").get().n;
d.close();
const { request } = await import("node:http");
const CREW_HOST = "crew.dogfather-universe.com";
function roh(pfad, host, koerper, keks, methode) {
return new Promise((fertig, schief) => {
const daten = koerper ? JSON.stringify(koerper) : null;
const a = request({
host: "127.0.0.1", port: PORT, path: pfad,
method: methode || (daten ? "POST" : "GET"),
headers: {
...(host ? { Host: host } : {}),
Origin: host ? `https://${host}` : BASIS,
...(keks ? { Cookie: keks } : {}),
...(daten ? { "Content-Type": "application/json",
"Content-Length": Buffer.byteLength(daten) } : {}),
},
}, (antwort) => {
let text = "";
antwort.on("data", (t) => { text += t; });
antwort.on("end", () => fertig({ code: antwort.statusCode, kopf: antwort.headers, text }));
});
a.on("error", schief);
if (daten) a.write(daten);
a.end();
});
}
async function anmelden(rolle, host = null) {
const r = await roh("/workspace/api/anmelden", host, { rolle, code: CODES[rolle] });
if (r.code !== 200) return null;
return [].concat(r.kopf["set-cookie"] || []).map((z) => z.split(";")[0]).join("; ") || null;
}
melde("\n=== Alle Rollen kommen herein ===");
const kekse = {};
for (const [rolle] of ROLLEN) {
const haus = (rolle === "modi" || rolle === "hand") ? CREW_HOST : null;
kekse[rolle] = await anmelden(rolle, haus);
ok(!!kekse[rolle], `${rolle}: angemeldet`);
}
braucht(Object.values(kekse).every(Boolean), "nicht alle Rollen konnten sich anmelden");
const holen = async (rolle, host = null) => {
const r = await roh("/workspace/api/anleitung", host, null, kekse[rolle]);
let j = null;
try { j = JSON.parse(r.text); } catch { /* bleibt null */ }
return { code: r.code, j };
};
/* =====================================================================
DIE ERSTBEFUELLUNG
===================================================================== */
melde("\n=== Die Inhalte sind eingespielt ===");
ok(inDb === 18, `Creator hat 18 Karten in der Datenbank (${inDb})`);
const creator = await holen("creator");
ok(creator.code === 200, `Creator bekommt die Anleitung (HTTP ${creator.code})`);
braucht(creator.code === 200 && creator.j, "die Schnittstelle antwortet dem Creator nicht");
ok(creator.j.hat_fassung === true, "Creator hat eine eigene Fassung");
ok(creator.j.anzahl === 18, `18 Karten ueber die Schnittstelle (${creator.j.anzahl})`);
ok((creator.j.saetze || []).length === 3, `drei Saetze „In 60 Sekunden" (${(creator.j.saetze || []).length})`);
ok((creator.j.schritte || []).length === 5, `fuenf Erste Schritte (${(creator.j.schritte || []).length})`);
ok((creator.j.nicht || []).length === 5, `fuenf Zeilen „Nicht deins" (${(creator.j.nicht || []).length})`);
ok((creator.j.takt || []).length === 4, `vier Rhythmus-Zeilen (${(creator.j.takt || []).length})`);
ok((creator.j.fragen || []).length === 4, `vier haeufige Fragen (${(creator.j.fragen || []).length})`);
ok(!!creator.j.leitgedanke && creator.j.leitgedanke.length > 40,
"der Leitgedanke steht da");
/* DIE ZAHLEN DER ABNAHMELISTE — GERECHNET, NICHT ABGESCHRIEBEN.
Der Bauplan nennt 5/6/7. Diese Zeile prueft nicht gegen die Zahl,
sondern gegen die Summe: Zusammen muessen es so viele sein, wie es
Karten gibt, und keine Karte darf eine unbekannte Stufe tragen. */
melde("\n=== Die Stufen ===");
const jeStufe = {};
for (const k of creator.j.karten) jeStufe[k.stufe] = (jeStufe[k.stufe] || 0) + 1;
const bekannt = new Set((creator.j.stufen || []).map((s) => s.schluessel));
ok([...Object.keys(jeStufe)].every((s) => bekannt.has(s)),
`jede Karte traegt eine bekannte Stufe (${Object.keys(jeStufe).join(", ")})`);
ok(Object.values(jeStufe).reduce((a, b) => a + b, 0) === creator.j.karten.length,
"die Stufen zusammen ergeben alle Karten");
ok(jeStufe.muss === 5 && jeStufe.regelmaessig === 6 && jeStufe.bedarf === 7,
`Verteilung 5/6/7 wie im Bauplan (${jeStufe.muss}/${jeStufe.regelmaessig}/${jeStufe.bedarf})`);
/* =====================================================================
DIE ZUORDNUNG — IN BEIDE RICHTUNGEN
===================================================================== */
melde("\n=== Jede Kachel hat eine Karte, jede Karte eine Kachel ===");
const kacheln = kachelnFuer("creator");
/* Die Anleitung selbst bekommt keine Karte. Eine Karte, die die Seite
erklaert, auf der man gerade steht, ist Fuellmaterial. */
kacheln.delete("anleitung");
braucht(kacheln.size > 0, "fuer den Creator findet sich keine einzige Kachel");
const habenKarte = new Set(creator.j.karten.map((k) => k.kachel));
const ohneKarte = [...kacheln.keys()].filter((s) => !habenKarte.has(s));
const ohneKachel = [...habenKarte].filter((s) => !kacheln.has(s));
ok(ohneKarte.length === 0 && kacheln.size > 0,
ohneKarte.length
? `${ohneKarte.length} Kacheln OHNE Karte: ${ohneKarte.join(", ")}`
: `alle ${kacheln.size} Kacheln des Creators haben eine Karte`);
ok(ohneKachel.length === 0 && habenKarte.size > 0,
ohneKachel.length
? `${ohneKachel.length} Karten OHNE Kachel: ${ohneKachel.join(", ")}`
: `alle ${habenKarte.size} Karten zeigen auf eine Kachel, die es gibt`);
/* GEGENPROBE. Beide Zeilen oben koennen "in Ordnung" sagen. Koennen sie
auch "nicht in Ordnung"? Ohne diesen Beweis waere eine leere Menge
auf beiden Seiten gruen -- und genau so sieht ein kaputter Abgleich
aus. */
const erfundeneKachel = new Set([...kacheln.keys(), "gibtesnicht"]);
ok([...erfundeneKachel].filter((s) => !habenKarte.has(s)).length === 1,
"Gegenprobe: eine Kachel ohne Karte wird gefunden");
const erfundeneKarte = new Set([...habenKarte, "auchnicht"]);
ok([...erfundeneKarte].filter((s) => !kacheln.has(s)).length === 1,
"Gegenprobe: eine Karte ohne Kachel wird gefunden");
/* Jede Karte muss alle drei Saetze haben. Eine halb gefuellte Karte
sieht aus wie ein Ladefehler. */
const luecken = creator.j.karten.filter((k) => !k.wozu || !k.tust || !k.merke);
ok(luecken.length === 0,
luecken.length ? `${luecken.length} Karten ohne vollstaendige drei Zeilen: `
+ luecken.map((k) => k.kachel).join(", ")
: "jede Karte hat Wozu, Was du hier tust und Merke");
/* Die Erste-Schritte-Ziele muessen Kacheln dieser Rolle sein -- oder
gar kein Ziel haben ("Installieren" ist ein Knopf, keine Seite).
Ein Sprung auf eine Seite, die ein Creator nicht oeffnen darf, waere
ein Knopf, der auf die Startseite zurueckwirft. */
const schrittFehler = (creator.j.schritte || [])
.filter((s) => s.ziel)
.filter((s) => !kachelnFuer("creator").has(B.schluesselVonZiel(s.ziel)));
ok(schrittFehler.length === 0,
schrittFehler.length
? `Erste Schritte zeigen ins Leere: ${schrittFehler.map((s) => s.ziel).join(", ")}`
: "jeder Erste Schritt mit Ziel fuehrt auf eine Kachel dieser Rolle");
/* =====================================================================
WER DARF, WER NICHT
===================================================================== */
melde("\n=== Die drei Schranken ===");
const admin = await holen("admin");
ok(admin.code === 200 && admin.j?.hat_fassung === false,
`DogFather bekommt keine Fassung, sondern eine Auskunft (HTTP ${admin.code}, `
+ `hat_fassung ${admin.j?.hat_fassung})`);
ok(!!admin.j?.satz && admin.j.satz.length > 30,
"…und die Auskunft sagt ihm, wie er trotzdem hineinsieht");
ok(admin.j?.darf_pflegen === true, "DogFather darf pflegen");
const spicy = await holen("spicy");
ok(spicy.code === 200, `Spicy Media bekommt eine Antwort (HTTP ${spicy.code})`);
ok(spicy.j?.darf_pflegen === true,
"Spicy Media darf pflegen (Entscheidung 06.10.2026, wie bei den Manager-Zielen)");
/* Etappe 1 bringt nur den Creator. Scout, Manager und Spicy haben noch
keine Inhalte -- das ist der Stand der Arbeit und kein Fehler. Es
wird aber AUSGESPROCHEN, nicht uebersprungen. */
melde(` -- Scout, Manager und Spicy Media haben noch keine Inhalte `
+ `(Etappe 3) — Spicy meldet ${spicy.j?.anzahl ?? 0} Karten`);
const creatorPflege = creator.j.darf_pflegen;
ok(creatorPflege === false, "ein Creator darf NICHT pflegen");
const modi = await holen("modi", CREW_HOST);
ok(modi.code === 404,
`ein Modi bekommt die Anleitung nicht (HTTP ${modi.code}, erwartet 404)`);
const hand = await holen("hand", CREW_HOST);
ok(hand.code === 404,
`die rechte Hand bekommt sie auf der Crew-Adresse nicht (HTTP ${hand.code}, erwartet 404)`);
/* Die Seite selbst, nicht nur die Schnittstelle.
=====================================================================
GEPRUEFT WIRD DAS ZIEL, NICHT NUR DIE 302.
Das Haus beantwortet "darfst du nicht" mit einer Umleitung und nicht
mit 403 (workspace.js, bei `darfSeite`) -- ausdruecklich, damit es
auf "nein" nur EINE Antwort gibt und niemand aus dem Unterschied
etwas ablesen kann.
Genau deshalb reicht hier "Status ist 302" nicht: NICHT ANGEMELDET
gibt ebenfalls 302. Eine Pruefung auf die blosse Zahl waere auch
dann gruen, wenn die Sitzung des Modi gar nicht erkannt wuerde --
sie haette dann die Anmeldung gemessen und nicht die Schranke.
Unterscheidbar sind die beiden nur am Ziel:
nicht angemeldet -> /workspace/
angemeldet, aber gesperrt -> /workspace/start.html
===================================================================== */
const seiteCreator = await roh("/workspace/anleitung.html", null, null, kekse.creator);
ok(seiteCreator.code === 200, `anleitung.html oeffnet fuer den Creator (${seiteCreator.code})`);
const seiteModi = await roh("/workspace/anleitung.html", CREW_HOST, null, kekse.modi);
ok(seiteModi.code === 302 && seiteModi.kopf.location === "/workspace/start.html",
`ein angemeldeter Modi wird zur Startseite umgeleitet `
+ `(${seiteModi.code} -> ${seiteModi.kopf.location})`);
const seiteOhne = await roh("/workspace/anleitung.html", null, null, null);
ok(seiteOhne.code === 302 && seiteOhne.kopf.location === "/workspace/",
`ohne Anmeldung zur Anmeldung, nicht zur Startseite `
+ `(${seiteOhne.code} -> ${seiteOhne.kopf.location})`);
/* GEGENPROBE ZU DEN BEIDEN: Sie unterscheiden wirklich. Zeigten beide
Umleitungen auf dasselbe, waeren die Zeilen oben zusammen trotzdem
gruen zu bekommen -- und die Schranke waere ungeprueft. */
ok(seiteModi.kopf.location !== seiteOhne.kopf.location,
"Gegenprobe: gesperrt und nicht angemeldet fuehren an verschiedene Orte");
/* =====================================================================
DIE ERSTBEFUELLUNG DARF NICHTS UEBERSCHREIBEN
=====================================================================
Die wichtigste Zeile in anleitung-tabellen.js: Eingespielt wird nur,
wenn fuer eine Rolle noch gar nichts da ist. Liefe sie bei jedem
Start, waere jede Aenderung aus der Pflege-Ansicht beim naechsten
Neustart weg -- lautlos, und man merkte es Wochen spaeter. */
melde("\n=== Ein zweiter Lauf laesst Aenderungen in Ruhe ===");
const d2 = new DatabaseSync(process.env.WORKSPACE_DB);
d2.prepare("UPDATE anl_karte SET merke = ? WHERE haus='agentur' AND rolle='creator' AND kachel='chat'")
.run("VON HAND GEAENDERT");
const { anleitungTabellen } = await import("./anleitung-tabellen.js");
anleitungTabellen(d2);
const nachher = d2.prepare(
"SELECT merke FROM anl_karte WHERE haus='agentur' AND rolle='creator' AND kachel='chat'").get();
const anzahlNachher = d2.prepare(
"SELECT COUNT(*) n FROM anl_karte WHERE haus='agentur' AND rolle='creator'").get().n;
d2.close();
ok(nachher?.merke === "VON HAND GEAENDERT",
`der geaenderte Text steht noch da ("${(nachher?.merke || "").slice(0, 24)}")`);
ok(anzahlNachher === 18, `und es sind keine Karten dazugekommen (${anzahlNachher})`);
/* =====================================================================
ETAPPE 2 — DIE ERSTEN SCHRITTE ZUM ABHAKEN
===================================================================== */
melde("\n=== Abhaken: der Anfangszustand ===");
const schritte0 = creator.j.schritte || [];
ok(schritte0.every((s) => Number.isInteger(s.id) && s.id > 0),
"jeder Schritt hat eine Nummer");
ok(schritte0.every((s) => s.erledigt === false),
"am Anfang ist kein Schritt abgehakt");
ok(creator.j.fortschritt?.erledigt === 0 && creator.j.fortschritt?.gesamt === 5,
`Fortschritt 0 von 5 (${creator.j.fortschritt?.erledigt} von ${creator.j.fortschritt?.gesamt})`);
ok(creator.j.darf_abhaken === true, "ein Creator darf seine eigenen Schritte abhaken");
const haken = (rolle, id, erledigt, host = null) =>
roh(`/workspace/api/anleitung/schritt/${id}`, host, { erledigt }, kekse[rolle], "PUT");
melde("\n=== Abhaken: setzen, zaehlen, wegnehmen ===");
const ersterId = schritte0[0].id;
const r1 = await haken("creator", ersterId, true);
let j1 = null; try { j1 = JSON.parse(r1.text); } catch { /* bleibt null */ }
ok(r1.code === 200 && j1?.erledigt === true, `Schritt abgehakt (HTTP ${r1.code})`);
ok(j1?.fortschritt?.erledigt === 1, `Fortschritt jetzt 1 von 5 (${j1?.fortschritt?.erledigt})`);
/* ZWEIMAL DASSELBE IST DERSELBE ZUSTAND. Bei wackeligem Mobilfunk
tippt man nach, und das darf nicht doppelt zaehlen. */
const r2 = await haken("creator", ersterId, true);
let j2 = null; try { j2 = JSON.parse(r2.text); } catch { /* bleibt null */ }
ok(r2.code === 200 && j2?.fortschritt?.erledigt === 1,
`zweimal abhaken zaehlt einmal (${j2?.fortschritt?.erledigt})`);
/* Und er haelt ueber einen neuen Abruf -- sonst stuende er nur im
Arbeitsspeicher der Antwort. */
const nachHaken = await holen("creator");
ok(nachHaken.j?.fortschritt?.erledigt === 1,
`der Haken steht auch beim naechsten Laden (${nachHaken.j?.fortschritt?.erledigt} von 5)`);
ok(nachHaken.j?.schritte?.find((s) => s.id === ersterId)?.erledigt === true,
"…und zwar an genau dem Schritt, der angetippt wurde");
const r3 = await haken("creator", ersterId, false);
let j3 = null; try { j3 = JSON.parse(r3.text); } catch { /* bleibt null */ }
ok(r3.code === 200 && j3?.fortschritt?.erledigt === 0,
`der Haken laesst sich wieder wegnehmen (${j3?.fortschritt?.erledigt})`);
melde("\n=== Abhaken: wer darf das nicht ===");
/* Ein Creator darf keinen Schritt abhaken, der es gar nicht ist --
sonst meldete der Fortschritt eines Tages 7 von 5. */
const eineFrage = await (async () => {
const d3 = new DatabaseSync(process.env.WORKSPACE_DB, { readOnly: true });
const z = d3.prepare("SELECT id FROM anl_zeile WHERE art = 'frage' LIMIT 1").get();
d3.close();
return z?.id;
})();
braucht(!!eineFrage, "keine Frage-Zeile gefunden, mit der sich das pruefen liesse");
const rFrage = await haken("creator", eineFrage, true);
ok(rFrage.code === 404,
`eine Frage laesst sich nicht abhaken (HTTP ${rFrage.code}, erwartet 404)`);
/* DogFather hat keine eigene Fassung -- also auch keine Schritte. */
const rDogi = await haken("admin", ersterId, true);
ok(rDogi.code === 404,
`DogFather kann einen Creator-Schritt nicht abhaken (HTTP ${rDogi.code}, erwartet 404)`);
const rModi = await haken("modi", ersterId, true, CREW_HOST);
ok(rModi.code === 404, `ein Modi auch nicht (HTTP ${rModi.code}, erwartet 404)`);
/* Unsinn als Nummer darf nicht durchrutschen. */
const rUnsinn = await roh("/workspace/api/anleitung/schritt/abc", null,
{ erledigt: true }, kekse.creator, "PUT");
ok(rUnsinn.code === 400 || rUnsinn.code === 404,
`keine Nummer -> abgelehnt (HTTP ${rUnsinn.code})`);
/* GEGENPROBE: Die Ablehnungen oben koennten auch davon kommen, dass
der Weg gar nicht existiert. Dass er funktioniert, ist oben bewiesen
-- hier wird es noch einmal unmittelbar daneben gezeigt. */
const rEcht = await haken("creator", schritte0[1].id, true);
ok(rEcht.code === 200,
"Gegenprobe: derselbe Weg nimmt einen gueltigen Schritt an");
await haken("creator", schritte0[1].id, false);
/* =====================================================================
DER HINWEIS „Erste Schritte noch offen"
===================================================================== */
melde("\n=== Der Hinweis ===");
async function hinweise(rolle, host = null) {
const r = await roh("/workspace/api/hinweise", host, null, kekse[rolle]);
let j = null; try { j = JSON.parse(r.text); } catch { /* bleibt null */ }
return { code: r.code, liste: j?.hinweise || [] };
}
const hCreator = await hinweise("creator");
const hSchritte = hCreator.liste.find((h) => h.art === "anl_schritte");
ok(hCreator.code === 200 && !!hSchritte,
`der Creator bekommt den Hinweis (HTTP ${hCreator.code})`);
ok(hSchritte?.anzahl === 5, `…mit allen fuenf offenen Schritten (${hSchritte?.anzahl})`);
ok(hSchritte?.ziel === "anleitung.html", `…und dem Weg zur Anleitung (${hSchritte?.ziel})`);
/* Die Crew darf den Hinweis NICHT bekommen -- die dritte Tuer, durch
die am 15.09.2026 ein Weg fuer die Community gerutscht ist. */
const hModi = await hinweise("modi", CREW_HOST);
/* DIE ANTWORT MUSS ANGEKOMMEN SEIN. Ohne `code === 200` waere diese
Zeile auch dann gruen, wenn die ganze Hinweis-Schnittstelle fuer den
Modi scheitert -- eine leere Liste sieht genauso aus wie "kein
Hinweis fuer ihn dabei". Dieselbe Falle wie am 03.09.2026 beim
Gitea-Abgleich: Eine Pruefung, die ihre Voraussetzung nicht hat,
darf nicht wie Erfolg aussehen. */
ok(hModi.code === 200 && !hModi.liste.some((h) => h.art === "anl_schritte"),
`ein Modi bekommt den Hinweis nicht (HTTP ${hModi.code}, `
+ `${hModi.liste.length} andere Hinweise)`);
const hDogi = await hinweise("admin");
ok(hDogi.code === 200 && !hDogi.liste.some((h) => h.art === "anl_schritte"),
`DogFather bekommt ihn auch nicht — er hat keine Einweisung `
+ `(HTTP ${hDogi.code}, ${hDogi.liste.length} andere Hinweise)`);
/* ---- Und er verschwindet von selbst ---- */
melde("\n=== Alles abgehakt: der Hinweis geht von allein ===");
for (const s of schritte0) await haken("creator", s.id, true);
const fertig = await holen("creator");
ok(fertig.j?.fortschritt?.erledigt === 5,
`alle fuenf abgehakt (${fertig.j?.fortschritt?.erledigt} von ${fertig.j?.fortschritt?.gesamt})`);
const hNachher = await hinweise("creator");
ok(!hNachher.liste.some((h) => h.art === "anl_schritte"),
"der Hinweis ist weg, ohne dass ihn jemand abgeschaltet hat");
/* GEGENPROBE: Er kommt wieder, wenn wieder etwas offen ist. Ohne das
waere „weg" auch dann gruen, wenn der Hinweis gar nicht mehr
entstehen KANN. */
await haken("creator", ersterId, false);
const hWieder = await hinweise("creator");
ok(hWieder.liste.some((h) => h.art === "anl_schritte" && h.anzahl === 1),
"Gegenprobe: ein wieder geoeffneter Schritt bringt ihn zurueck");
/* Aufraeumen, damit die Pruefungen darunter einen sauberen Stand
sehen -- der Haken auf Schritt 2 und die vier von eben. */
for (const s of schritte0) await haken("creator", s.id, false);
/* ---------- Abschluss ------------------------------------------------ */
try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ }
console.log(`\n${fehler ? "FEHLER: " + fehler : "Alles in Ordnung"} — ${geprueft} Prüfungen`);
process.exit(fehler ? 1 : 0);