Files
dogfather-universe/server/pruef-anleitung.mjs
T
DogFatherGitandClaude Opus 5 a7b5775268 Die Rechtetafel siebt die Karten jetzt wirklich -- und „ab Dabei" steht dran
ZWEI SACHEN, EINE DAVON EIN ECHTER BEFUND.

1) DIE KARTEN KAMEN AN DER RECHTETAFEL VORBEI
Der Bauplan sagt in Kapitel 04 und noch einmal in der Abnahme:
"Leite die sichtbaren Karten aus derselben Rechte-Tafel ab, die
rechte.html verwendet. Eine Rolle sieht nur Karten zu Seiten, die sie
oeffnen darf; pruefe das SERVERSEITIG."

Ich habe dafuer eine Pruefung geschrieben, die die Seite ueber die
ECHTE Schnittstelle sperrt (nicht mit einem UPDATE in der Datenbank --
nur so laeuft tafelLaden() mit). Sie wurde sofort rot: Nach dem Sperren
von bereich.html fuer einen Modi lieferte der Server weiterhin ALLE
ELF Bereichs-Karten.

Im Browser fiel das nicht auf, und das ist der heikle Teil: Die Seite
verbindet jede Karte mit einer Kachel, und die Kachelliste ist
gefiltert -- die Karten verschwanden also auf dem Bildschirm. Die
TEXTE gingen trotzdem hinaus. Eine Oberflaeche, die etwas nicht
anzeigt, ist keine Schranke; genau deshalb steht im Bauplan
"serverseitig".

Jetzt siebt `darfKarte()` an der Quelle. Der Pfad wird aus dem
Schluessel GERECHNET (`bereich:highlight` -> /workspace/bereich.html),
nicht aus einer Zuordnungsliste -- das ist die Umkehrung von
`schluesselVonZiel`, und eine gepflegte Tabelle daneben waere die, die
beim naechsten neuen Brett fehlt. Entschieden wird mit `darfSeite`
selbst; hier wird nichts davon nachgebaut.

Gegenprobe in derselben Pruefung: zuruecksperren -> die Karten sind
wieder da (29 von 29). Und eine Zeile dazwischen, die zaehlt, dass NUR
sie verschwunden sind -- waere mit der Sperre die halbe Anleitung
weggefallen, waere die erste Zeile auch gruen gewesen.

2) „AB DABEI" UND „AB STAMM" AN DER KARTE (Kapitel 04)
"Kann ein Community-Mitglied etwas wegen seiner Stufe noch nicht,
steht das an der Karte."

Auch das abgeleitet: aus TREFF_SCHREIBEN, derselben Tafel, die das
Schreiben ENTSCHEIDET. `null` darin heisst "von aussen schreibt hier
niemand" -- das ist keine Stufe, die man erreicht, und bekommt
deshalb keine Marke. "ab Stamm" an einem Anschlagbrett waere ein
Versprechen, das nie eingeloest wird.

Als Wort, nicht als Schloss-Symbol: Ein Schloss sagt "du darfst
nicht" und laesst offen, ob jemals. "ab Dabei" sagt, dass es kommt --
und wann, steht mit Datum in der Karte "Deine Stufe" darueber. Ruhig
gestaltet, nicht rot: Es ist keine Sperre, sondern eine Auskunft ueber
die Zeit.

GEPRUEFT (pruef-anleitung 166 -> 173)
Die wichtigste der neuen Zeilen ist die, dass die Marke nach dem
Aufstieg VERSCHWINDET. Eine Marke, die bleibt, ist schlimmer als
keine: Sie sagt jemandem, er duerfe etwas nicht, das er laengst darf,
und er probiert es gar nicht erst. Gemessen an drei Karten mit drei
verschiedenen Antworten (Chat: Dabei, Highlights: Stamm,
Anschlagbrett: gar nichts) -- waere die Rechnung grob falsch, traefe
sie alle drei gleich. Dazu die Gegenprobe, dass eine Teamrolle keine
einzige solche Marke bekommt.

Mitgelaufen: pruef-rechtetafel 19, pruef-rechte-umstellen 56,
pruef-css-klassen, pruef-tippziele 13 -- alle gruen. Die
Abnahmezahlen aus Kapitel 13 (14/29/29/34 mit ihrer Aufteilung) sind
nach dem neuen Sieb unveraendert; das ist der Beleg, dass es im
Normalfall nichts wegnimmt.

Im Browser nachgemessen (390 px, echte Anmeldung): Community sieht
vier Marken (Rudel-Chat, Wunschliste, Mitmachen „ab Dabei",
Highlights „ab Stamm"), die rechte Hand keine einzige.

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

1297 lines
65 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* =====================================================================
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"],
/* ZWEI KACHELN AUF EINER SEITE (06.10.2026). Hier stand "chat" --
und damit bekamen "Chat" (das Team) und "Rudel-Chat" (die
Community) denselben Schluessel. Auf der Agenturadresse fiel das
nie auf, dort gibt es nur eine Chat-Kachel. Die Crew hat beide,
und der Bauplan gibt ihnen verschiedene Einordnungen -- fuer die
rechte Hand ist der Team-Chat Muss, der Rudel-Chat nur bei
Bedarf. Ohne eigenen Schluessel waere das nicht abbildbar. */
["chat.html?raum=treff", "chat:treff"],
["chat.html", "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"],
/* Seit dem 06.10.2026 hat auch die Community eine Fassung -- und
sie ist die einzige mit "Drei Dinge vorweg" und der Stufenkarte.
Ohne sie in dieser Liste bliebe genau der Teil ungeprueft, der
neu ist. */
["gast", "Diene"], ["linke", "Funny"]];
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) {
/* DIE ALTERSBESTAETIGUNG IST EIN EIGENER SCHRITT und betrifft nur
den Gast. Ohne sie antwortet der Server mit 400, und die Pruefung
meldete "kommt nicht herein" -- ein Befund, der dem Testaufbau
gehoert und nicht dem Haus. Mitzuschicken schadet den anderen
Rollen nicht: Sie sehen das Feld gar nicht an. */
const r = await roh("/workspace/api/anmelden", host,
{ rolle, code: CODES[rolle], alter_ok: true });
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 = ["modi", "hand", "gast", "linke"].includes(rolle) ? 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));
/* Dieselbe Menge weiter unten fuer alle vier Rollen -- der Server
nennt die Stufen, diese Datei kennt keine eigene Liste. */
const STUFEN_DA = bekannt;
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");
/* ==== DIE CREW HAT JETZT IHRE EIGENE ANLEITUNG (06.10.2026) ========
Hier stand das Gegenteil: "ein Modi bekommt die Anleitung nicht
(erwartet 404)". Das war bis heute richtig -- die Anleitung gab es
nur im Agenturhaus. Mit dem Bauplan "Einweisung je Rolle" hat auch
Team Dogi vier Fassungen bekommen, und die Zeilen sind mitgewandert
statt stehenzubleiben.
DIE TRENNUNG BLEIBT TROTZDEM, und sie wird hier gemessen: Auf der
Crew-Adresse kommen CREW-Inhalte, nicht die der Agentur. Eine
Anleitung, die auf beiden Adressen dasselbe zeigt, waere der
Rueckschritt hinter die Haustrennung vom 24.09. */
const modi = await holen("modi", CREW_HOST);
ok(modi.code === 200 && (modi.j?.karten?.length ?? 0) === 29,
`ein Modi bekommt seine Anleitung (HTTP ${modi.code}, ${modi.j?.karten?.length ?? 0} Karten)`);
const hand = await holen("hand", CREW_HOST);
ok(hand.code === 200 && (hand.j?.karten?.length ?? 0) === 34,
`die rechte Hand auf der Crew-Adresse ebenso (HTTP ${hand.code}, `
+ `${hand.j?.karten?.length ?? 0} Karten)`);
/* GEGENPROBE ZUR HAUSTRENNUNG: Die Crew-Fassung darf keine
Agenturkachel enthalten. "leistung" gibt es nur dort. */
ok(!(modi.j?.karten || []).some((k) => k.kachel === "leistung"),
"und keine einzige Agenturkachel ist dabei");
/* ==== "DREI DINGE VORWEG" UND "DEINE STUFE" (Bauplan Kapitel 04) ====
Beides gibt es NUR fuer die Community. Gemessen wird deshalb in
beide Richtungen: Der Gast bekommt es, der Modi nicht. Nur "der
Gast bekommt es" waere auch dann gruen, wenn es alle bekaemen --
und dann stuende bei einer rechten Hand eine Karte "Du bist gerade:
Neu", die fuer sie keinen Sinn ergibt. */
const gast = await holen("gast", CREW_HOST);
ok(gast.code === 200 && (gast.j?.karten?.length ?? 0) === 14,
`die Community bekommt ihre Anleitung (HTTP ${gast.code}, `
+ `${gast.j?.karten?.length ?? 0} Karten)`);
ok((gast.j?.vorweg || []).length === 3,
`mit "Drei Dinge vorweg" (${(gast.j?.vorweg || []).length} Punkte)`);
ok(/Code/.test((gast.j?.vorweg || [])[0]?.titel || ""),
`und der erste ist der eigene Code ("${(gast.j?.vorweg || [])[0]?.titel || "-"}")`);
ok(!!gast.j?.stufe && (gast.j.stufe.reihen || []).length === 3,
`dazu die Stufenkarte mit drei Stufen (${gast.j?.stufe?.name || "keine"})`);
/* Die Zahlen des Bauplans stehen nicht im Text der Anleitung, sondern
kommen aus workspace-treff.js -- derselben Stelle, die die Stufe
auch BERECHNET. Hier wird nachgesehen, dass sie wirklich ankommen. */
ok(/7 Tagen/.test((gast.j?.stufe?.reihen || [])[1]?.ab || ""),
`„Dabei" nennt die echte Frist (${(gast.j?.stufe?.reihen || [])[1]?.ab || "-"})`);
ok((gast.j?.vorweg || []).length === 3 && (modi.j?.vorweg || []).length === 0,
`Gegenprobe: ein Modi bekommt kein "Vorweg" (${(modi.j?.vorweg || []).length})`);
ok(modi.j?.stufe === null || modi.j?.stufe === undefined,
`Gegenprobe: und keine Stufenkarte (${JSON.stringify(modi.j?.stufe)})`);
/* 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})`);
/* ==== DIE SEITE OEFFNET JETZT AUCH FUER DIE CREW (06.10.2026) ======
Hier stand "wird zur Startseite umgeleitet". Das war richtig,
solange es die Anleitung nur im Agenturhaus gab. Seit dem Bauplan
"Einweisung je Rolle" hat Team Dogi vier eigene Fassungen, und die
Kachel fuehrt dorthin -- eine Umleitung waere jetzt die Sackgasse,
die es laut Filipes Regel vom 19.09. nicht geben darf. */
const seiteModi = await roh("/workspace/anleitung.html", CREW_HOST, null, kekse.modi);
ok(seiteModi.code === 200,
`anleitung.html oeffnet auch fuer den Modi auf der Crew-Adresse (${seiteModi.code})`);
/* Und die alte Adresse fuehrt weiter dorthin -- der Bauplan verlangt
ausdruecklich, dass alte Verweise funktionieren. */
const seiteAlt = await roh("/workspace/willkommen.html", CREW_HOST, null, kekse.modi);
ok(seiteAlt.code === 200 && /anleitung\.html/.test(seiteAlt.text || ""),
`willkommen.html leitet auf die Anleitung weiter (${seiteAlt.code}`
+ `${seiteAlt.kopf?.location ? " -> " + seiteAlt.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})`);
/* =====================================================================
ALLE VIER FASSUNGEN — JEDE GEGEN IHRE EIGENEN KACHELN
=====================================================================
Oben ist der Creator Zeile fuer Zeile geprueft. Hier laufen alle vier
durch dieselbe Zuordnung, und die ZAHLEN WERDEN ABGELEITET, nicht
abgeschrieben: Wie viele Karten eine Rolle haben muss, sagt ihre
Kachelliste -- nicht die Abnahmeliste im Bauplan.
Der Bauplan nennt 18/21/22/23. Die stimmen heute. Am Tag, an dem
eine Kachel dazukommt, waere eine feste Zahl hier eine Luege, die
erst auffaellt, wenn jemand sie nachzaehlt. Also zaehlt die
Pruefung selbst -- und meldet die Zahl, damit man sie lesen kann.
===================================================================== */
melde("\n=== Jede Rolle erklaert genau ihre eigenen Kacheln ===");
const FASSUNGEN_HIER = ["creator", "scout", "manager", "spicy"];
for (const rolle of FASSUNGEN_HIER) {
const a = await holen(rolle);
if (a.code !== 200 || !a.j?.hat_fassung) {
ok(false, `${rolle.padEnd(8)} hat keine Fassung (HTTP ${a.code})`);
continue;
}
const soll = kachelnFuer(rolle);
soll.delete("anleitung"); // die Seite erklaert sich nicht selbst
const ist = new Set(a.j.karten.map((k) => k.kachel));
const fehlt = [...soll.keys()].filter((x) => !ist.has(x));
const zuviel = [...ist].filter((x) => !soll.has(x));
ok(soll.size > 0 && fehlt.length === 0 && zuviel.length === 0,
fehlt.length || zuviel.length
? `${rolle.padEnd(8)} stimmt nicht -- ohne Karte: ${fehlt.join(", ") || "nichts"}; `
+ `ohne Kachel: ${zuviel.join(", ") || "nichts"}`
: `${rolle.padEnd(8)} ${soll.size} Kacheln, ${ist.size} Karten, eins zu eins`);
/* Jede Karte vollstaendig. Eine halbe Karte sieht aus wie ein
Ladefehler, und zwar auf jeder der vier Seiten gleich. */
const halb = a.j.karten.filter((k) => !k.wozu || !k.tust || !k.merke);
ok(halb.length === 0,
halb.length ? `${rolle.padEnd(8)} ${halb.length} unvollstaendige Karten: `
+ halb.map((k) => k.kachel).join(", ")
: `${rolle.padEnd(8)} jede Karte hat alle drei Zeilen`);
/* Die Stufen -- gezaehlt, nicht vorgegeben. */
const je = {};
for (const k of a.j.karten) je[k.stufe] = (je[k.stufe] || 0) + 1;
const summe = Object.values(je).reduce((x, y) => x + y, 0);
ok(summe === a.j.karten.length
&& Object.keys(je).every((x) => STUFEN_DA.has(x)),
`${rolle.padEnd(8)} Stufen ${je.muss || 0}/${je.regelmaessig || 0}/${je.bedarf || 0} `
+ `(zusammen ${summe} von ${a.j.karten.length})`);
/* Die Ersten Schritte duerfen nur auf Kacheln dieser Rolle zeigen --
oder auf gar nichts ("Installieren", "Kopfleiste" sind Knoepfe in
der Kopfleiste, keine Seiten). */
const alleKacheln = kachelnFuer(rolle);
const ins_leere = (a.j.schritte || []).filter((x) => x.ziel)
.filter((x) => !alleKacheln.has(B.schluesselVonZiel(x.ziel)));
ok((a.j.schritte || []).length === 5 && ins_leere.length === 0,
ins_leere.length
? `${rolle.padEnd(8)} Schritte ins Leere: ${ins_leere.map((x) => x.ziel).join(", ")}`
: `${rolle.padEnd(8)} fuenf Erste Schritte, alle mit gueltigem Ziel`);
/* Und der Rest der Fassung ist da. Eine Rolle ohne Rhythmus oder
ohne "Nicht deins" waere eine halbe Einweisung. */
ok(!!a.j.leitgedanke && (a.j.saetze || []).length === 3
&& (a.j.nicht || []).length >= 3 && (a.j.takt || []).length >= 3
&& (a.j.fragen || []).length >= 3,
`${rolle.padEnd(8)} Leitgedanke, 3 Saetze, ${(a.j.nicht || []).length} x Nicht deins, `
+ `${(a.j.takt || []).length} Takt-Zeilen, ${(a.j.fragen || []).length} Fragen`);
}
/* GEGENPROBE ZUR SCHLEIFE: Sie darf nicht leer durchgelaufen sein.
Vier Rollen, und jede musste wirklich etwas liefern. */
ok(FASSUNGEN_HIER.length === 4, `vier Fassungen durchgegangen`);
/* Und: Die Fassungen muessen sich UNTERSCHEIDEN. Lieferte der Server
allen dieselbe Liste, waeren alle Zeilen oben trotzdem gruen --
solange die Kachellisten zufaellig passten. */
{
const koepfe = [];
for (const rolle of FASSUNGEN_HIER) {
const a = await holen(rolle);
koepfe.push(a.j?.leitgedanke || "");
}
ok(new Set(koepfe).size === 4,
`Gegenprobe: vier verschiedene Leitgedanken (${new Set(koepfe).size} verschiedene)`);
}
/* =====================================================================
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. */
/* UMGEDREHT AM 06.10.2026: Der Modi HAT jetzt Erste Schritte, also
gehoert der Hinweis auch zu ihm. Vorher gab es fuer ihn nichts
einzuweisen, und die Abwesenheit war richtig. */
ok(hModi.code === 200 && hModi.liste.some((h) => h.art === "anl_schritte"),
`ein Modi bekommt den Hinweis jetzt auch (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);
/* =====================================================================
ETAPPE 4 — DIE PFLEGE
===================================================================== */
melde("\n=== Pflege: wer darf ===");
const pput = (rolle, pfad, rumpf, host = null) =>
roh(pfad, host, rumpf, kekse[rolle], "PUT");
const eineKarte = (await holen("creator")).j.karten.find((k) => k.kachel === "chat");
braucht(!!eineKarte?.id, "keine Karte zum Bearbeiten gefunden");
const pCreator = await pput("creator", `/workspace/api/anleitung/karte/${eineKarte.id}`,
{ stufe: "bedarf", wozu: "x", tust: "y", merke: "z" });
ok(pCreator.code === 403, `ein Creator darf nicht pflegen (HTTP ${pCreator.code}, erwartet 403)`);
const pScout = await pput("scout", `/workspace/api/anleitung/karte/${eineKarte.id}`,
{ stufe: "bedarf", wozu: "x", tust: "y", merke: "z" });
ok(pScout.code === 403, `ein Scout auch nicht (HTTP ${pScout.code}, erwartet 403)`);
const pModi = await pput("modi", `/workspace/api/anleitung/karte/${eineKarte.id}`,
{ stufe: "bedarf", wozu: "x", tust: "y", merke: "z" }, CREW_HOST);
/* 403 STATT 404 (seit 06.10.2026): Die Seite gibt es auf der
Crew-Adresse jetzt -- pflegen darf ein Modi sie trotzdem nicht. */
ok(pModi.code === 403, `ein Modi darf nicht pflegen (HTTP ${pModi.code}, erwartet 403)`);
melde("\n=== Pflege: Spicy aendert eine Stufe, und sie gilt sofort ===");
const vorher = eineKarte.stufe;
const pSpicy = await pput("spicy", `/workspace/api/anleitung/karte/${eineKarte.id}`,
{ stufe: "bedarf", wozu: "Nachrichten mit deinem Team.",
tust: "Mit „Neu“ ein Gespräch beginnen.", merke: "GEAENDERT VON SPICY" });
ok(pSpicy.code === 200, `Spicy Media darf (HTTP ${pSpicy.code})`);
const nachSpicy = await holen("creator");
const chatNeu = nachSpicy.j.karten.find((k) => k.kachel === "chat");
ok(chatNeu?.stufe === "bedarf",
`die Stufe steht sofort in der Creator-Fassung (${vorher} -> ${chatNeu?.stufe})`);
ok(chatNeu?.merke === "GEAENDERT VON SPICY", "…und der Text auch");
/* DIE MARKE "NEU". Der Creator hat die Seite gerade geladen -- damit
ist sein Besuch juenger als die Aenderung davor, aber AELTER als die
von eben. Die Reihenfolge ist der ganze Beweis. */
ok(chatNeu?.neu === true, "die geaenderte Karte traegt die Marke „Neu“");
const nochmal = await holen("creator");
ok(nochmal.j.karten.find((k) => k.kachel === "chat")?.neu === false,
"…und beim zweiten Hinsehen nicht mehr");
/* Zurueck auf Anfang, damit die Zuordnungspruefung oben weiter stimmt. */
await pput("spicy", `/workspace/api/anleitung/karte/${eineKarte.id}`,
{ stufe: vorher, wozu: "Nachrichten mit deinem Team.",
tust: "Mit „Neu“ ein Gespräch beginnen.",
merke: "Fehler an der Seite gehören in Support, nicht in den Chat." });
melde("\n=== Pflege: was nicht durchgehen darf ===");
const pUnsinn = await pput("admin", `/workspace/api/anleitung/karte/${eineKarte.id}`,
{ stufe: "gibtesnicht", wozu: "", tust: "", merke: "" });
ok(pUnsinn.code === 400, `eine erfundene Stufe wird abgelehnt (HTTP ${pUnsinn.code})`);
const pWeg = await pput("admin", "/workspace/api/anleitung/karte/999999",
{ stufe: "muss", wozu: "", tust: "", merke: "" });
ok(pWeg.code === 404, `eine Karte, die es nicht gibt (HTTP ${pWeg.code})`);
melde("\n=== Pflege: fehlenden Eintrag anlegen und wieder entfernen ===");
/* Erst loeschen, dann ist er "fehlend" -- genau der Fall, den der
Bauplan "Fuer diese Kachel fehlt ein Eintrag" nennt. */
const weg = await roh(`/workspace/api/anleitung/karte/${eineKarte.id}`, null, null,
kekse.admin, "DELETE");
ok(weg.code === 200, `DogFather kann eine Karte entfernen (HTTP ${weg.code})`);
const ohne = await holen("creator");
ok(ohne.j.karten.length === 17 && !ohne.j.karten.some((k) => k.kachel === "chat"),
`danach 17 Karten, die Chat-Karte fehlt (${ohne.j.karten.length})`);
const neu1 = await roh("/workspace/api/anleitung/karte", null,
{ rolle: "creator", kachel: "chat", stufe: "muss",
wozu: "Nachrichten mit deinem Team.", tust: "Mit „Neu“ ein Gespräch beginnen.",
merke: "Fehler an der Seite gehören in Support, nicht in den Chat." },
kekse.admin, "POST");
ok(neu1.code === 200, `…und wieder anlegen (HTTP ${neu1.code})`);
const neu2 = await roh("/workspace/api/anleitung/karte", null,
{ rolle: "creator", kachel: "chat", stufe: "muss", wozu: "", tust: "", merke: "" },
kekse.admin, "POST");
ok(neu2.code === 409,
`ein zweites Mal nicht -- eine Kachel hat genau eine Karte (HTTP ${neu2.code})`);
const zurueck = await holen("creator");
ok(zurueck.j.karten.length === 18,
`wieder 18 Karten (${zurueck.j.karten.length})`);
melde("\n=== Pflege: eine Zeile aendern ===");
const einSchritt = zurueck.j.schritte[0];
const zAend = await pput("admin", `/workspace/api/anleitung/zeile/${einSchritt.id}`,
{ titel: einSchritt.titel, text: "GEAENDERTER TEXT", ziel: einSchritt.ziel });
ok(zAend.code === 200, `eine Listenzeile laesst sich aendern (HTTP ${zAend.code})`);
const nachZeile = await holen("creator");
ok(nachZeile.j.schritte[0]?.text === "GEAENDERTER TEXT", "…und der Text steht da");
await pput("admin", `/workspace/api/anleitung/zeile/${einSchritt.id}`,
{ titel: einSchritt.titel, text: einSchritt.text, ziel: einSchritt.ziel });
melde("\n=== Pflege: wer ist wie weit ===");
const standR = await roh("/workspace/api/anleitung/stand", null, null, kekse.admin);
let standJ = null; try { standJ = JSON.parse(standR.text); } catch { /* bleibt null */ }
ok(standR.code === 200 && Array.isArray(standJ?.leute),
`DogFather sieht den Stand (HTTP ${standR.code}, ${standJ?.leute?.length ?? 0} Leute)`);
ok((standJ?.leute || []).some((p) => p.rolle === "creator" && p.gesamt === 5),
"…und der Creator steht mit fuenf Schritten drin");
const standC = await roh("/workspace/api/anleitung/stand", null, null, kekse.creator);
ok(standC.code === 403, `ein Creator sieht ihn nicht (HTTP ${standC.code}, erwartet 403)`);
/* =====================================================================
ETAPPE 5 — DIE SUCHE
===================================================================== */
melde("\n=== Suche ===");
async function suchen(rolle, wort, host = null) {
const r = await roh(`/workspace/api/suche?q=${encodeURIComponent(wort)}`, host, null, kekse[rolle]);
let j = null; try { j = JSON.parse(r.text); } catch { /* bleibt null */ }
const g = (j?.gruppen || []).find((x) => x.titel === "Anleitung");
return { code: r.code, treffer: g?.treffer || [], gruppen: (j?.gruppen || []).length };
}
/* Ein Wort, das NUR in der Creator-Fassung steht. */
const sCreator = await suchen("creator", "Jemand schaut noch drauf");
ok(sCreator.code === 200 && sCreator.treffer.length > 0,
`der Creator findet seine eigene Karte (${sCreator.treffer.length} Treffer)`);
/* DIE ZAHL GEHOERT IN DIE BEDINGUNG. `every()` liefert auf einer LEEREN
Liste `true` -- diese Zeile war beim ersten Lauf gruen, ohne einen
einzigen Treffer angesehen zu haben. Genau der Fehler vom 01.09.2026
im Shop, nur mit anderen Namen. */
ok(sCreator.treffer.length > 0
&& sCreator.treffer.every((t) => t.ziel && !t.ziel.includes("undefined")),
`…und jeder der ${sCreator.treffer.length} Treffer hat ein Ziel `
+ `(${sCreator.treffer.map((t) => t.ziel).join(", ") || "keine"})`);
/* Ein Wort, das NUR in der Manager-Fassung steht. */
const sFremd = await suchen("creator", "Zuteilen, freigeben");
ok(sFremd.treffer.length === 0,
`der Creator findet die Manager-Karte NICHT (${sFremd.treffer.length} Treffer)`);
const sManager = await suchen("manager", "Zuteilen, freigeben");
ok(sManager.treffer.length > 0,
`Gegenprobe: der Manager findet sie sehr wohl (${sManager.treffer.length} Treffer)`);
const sModi = await suchen("modi", "Freigabe heißt", CREW_HOST);
ok(sModi.code === 200 && sModi.treffer.length === 0,
`ein Modi findet nichts davon (HTTP ${sModi.code}, ${sModi.treffer.length} Treffer)`);
/* Die Adresse im Treffer muss den Zusatz hinter dem Fragezeichen
tragen -- sonst landen fuenf Karten auf derselben Seite. */
const sBereich = await suchen("creator", "Ruhig statt Drama");
ok(sBereich.treffer.some((t) => t.ziel === "bereich.html?b=community"),
`ein Bereichs-Treffer traegt seinen Zusatz (${sBereich.treffer.map((t) => t.ziel).join(", ")})`);
/* =====================================================================
ETAPPE 6 — BEGRUESSUNG UND RUNDGANG
===================================================================== */
melde("\n=== Einweisung beim ersten Anmelden ===");
async function einweisung(rolle, host = null) {
const r = await roh("/workspace/api/anleitung/einweisung", host, null, kekse[rolle]);
let j = null; try { j = JSON.parse(r.text); } catch { /* bleibt null */ }
return { code: r.code, j };
}
const e1 = await einweisung("creator");
ok(e1.code === 200 && e1.j?.zeigen === true,
`beim ersten Mal wird sie gezeigt (HTTP ${e1.code}, zeigen ${e1.j?.zeigen})`);
ok(Array.isArray(e1.j?.muss) && e1.j.muss.length === 5,
`…mit den fuenf Muss-Kacheln (${e1.j?.muss?.length})`);
const gesehen = await roh("/workspace/api/anleitung/einweisung/gesehen", null, {}, kekse.creator,
"POST");
ok(gesehen.code === 200, `„gesehen“ laesst sich merken (HTTP ${gesehen.code})`);
const e2 = await einweisung("creator");
ok(e2.j?.zeigen === false, "beim zweiten Mal nicht mehr");
/* DIE CREW BEKOMMT 200 UND NICHT 404. Das Skript laeuft auf JEDER
Startseite; ein Fehlschlag waere eine rote Zeile in der Konsole bei
jedem Aufruf -- und die verdeckt die naechste, die echt ist. */
/* Seit dem 06.10. hat der Modi eine Fassung -- also soll die
Einweisung bei ihm auch ANGEBOTEN werden. Vorher war die ruhige
Absage richtig, weil es nichts einzuweisen gab. */
const eModi = await einweisung("modi", CREW_HOST);
ok(eModi.code === 200 && eModi.j?.zeigen === true,
`ein Modi bekommt seine Einweisung angeboten (HTTP ${eModi.code}, zeigen ${eModi.j?.zeigen})`);
const eDogi = await einweisung("admin");
ok(eDogi.code === 200 && eDogi.j?.zeigen === false,
`DogFather auch -- er braucht keine Einweisung (zeigen ${eDogi.j?.zeigen})`);
/* DER ROLLENWECHSEL. Der Bauplan verlangt, dass die Einweisung dann
einmal neu startet. Gebaut ist das NICHT als Sonderregel, sondern
ueber den Schluessel (Person, Rolle) -- die neue Rolle hat schlicht
noch keine Zeile. Genau das wird hier nachgemessen. */
{
const d4 = new DatabaseSync(process.env.WORKSPACE_DB);
d4.prepare("UPDATE personen SET rolle = 'scout' WHERE rolle = 'creator'").run();
d4.close();
const neuAn = await anmelden("scout");
const alt = kekse.scout;
kekse.scout = neuAn || alt;
const eNach = await einweisung("scout");
kekse.scout = alt;
ok(eNach.j?.zeigen === true,
`nach einem Rollenwechsel startet sie einmal neu (zeigen ${eNach.j?.zeigen})`);
const d5 = new DatabaseSync(process.env.WORKSPACE_DB);
d5.prepare("UPDATE personen SET rolle = 'creator' WHERE name = 'Luna'").run();
d5.close();
}
/* =====================================================================
DREI SCHRITTE FUER DIE COMMUNITY, FUENF FUERS TEAM
---------------------------------------------------------------------
Bauplan Kapitel 10: "Community drei Schritte ... Team-Rollen fuenf
Schritte (zusaetzlich 'Deine Aufgaben' und die Gruppen ...)".
Gemessen wird das in ZWEI Haelften, weil die Entscheidung an zwei
Stellen liegt:
1. Der Server sagt Ja/Nein ("team"). Das ist die Stelle, an der
die Rolle vorkommt -- `rundgang.js` kennt absichtlich keinen
einzigen Rollennamen, sonst muesste jede neue Rolle dort
nachgetragen werden und waere sonst stillschweigend Team.
2. Der Browser siebt damit die Stationen.
Die zweite Haelfte wird hier NICHT nachgebildet, sondern
AUSGEFUEHRT: Stationsliste und Siebausdruck werden aus der echten
Datei geholt und laufen zweimal -- einmal mit team=true, einmal mit
team=false. Ein Muster ("steht da istTeam?") waere gruen, sobald das
Wort irgendwo in einem Kommentar steht; der Ausdruck selbst kann
nicht luegen. Und der zweite Lauf ist gleich die Gegenprobe: Faellt
das Sieb weg, kommen beide Male fuenf heraus, und beide Zeilen
schlagen an. */
melde("\n=== Rundgang: drei Schritte / fuenf Schritte ===");
{
const erwartet = { gast: false, modi: true, linke: true, hand: true };
for (const [rolle, soll] of Object.entries(erwartet)) {
const e = await einweisung(rolle, CREW_HOST);
ok(e.j?.team === soll,
`${rolle}: Server meldet team=${soll} (bekommen ${e.j?.team})`);
}
/* Das Agenturhaus arbeitet -- dort ist jede Rolle eine Teamrolle.
Diese Zeile ist die Gegenrichtung zum Gast: Sie beweist, dass das
Feld nicht einfach immer dasselbe antwortet. */
const eCre = await einweisung("creator");
ok(eCre.j?.team === true, `creator: Server meldet team=true (${eCre.j?.team})`);
const quelle = readFileSync(
new URL("../workspace/assets/js/rundgang.js", import.meta.url), "utf8");
/* Die Stationsliste aus der echten Datei, als Ausdruck ausgefuehrt. */
const anfang = quelle.indexOf("const STATIONEN = [");
const ende = quelle.indexOf("\n ];", anfang);
const liste = anfang >= 0 && ende > anfang
? vm.runInNewContext(quelle.slice(anfang + "const STATIONEN = ".length, ende + 4))
: null;
ok(Array.isArray(liste) && liste.length === 5,
`fuenf Stationen in rundgang.js (${liste?.length})`);
ok(Array.isArray(liste) && liste.filter((s) => s.nurTeam).length === 2,
`davon zwei nur fuers Team (${liste?.filter((s) => s.nurTeam).length})`);
/* Der Siebausdruck, ebenfalls aus der echten Datei. Faellt er weg,
ist das KEIN Ueberspringen, sondern ein Fehlschlag: Die Aussage
"die Community bekommt drei" waere dann unbewiesen, und genau das
ist der Zustand, den diese Pruefung verhindern soll. */
const m = quelle.match(/\.filter\(\(s\) => \((s\.nurTeam[^\n]*?)\)\)/);
ok(!!m, `der Siebausdruck steht in rundgang.js${m ? "" : " — NICHT gefunden"}`);
if (m && Array.isArray(liste)) {
const sieb = (istTeam) =>
liste.filter(vm.runInNewContext(`(s) => (${m[1]})`, { istTeam })).length;
ok(sieb(false) === 3, `Community: drei Stationen (${sieb(false)})`);
ok(sieb(true) === 5, `Teamrolle: fuenf Stationen (${sieb(true)})`);
} else {
ok(false, "Community/Team-Zaehlung nicht durchfuehrbar — Siebausdruck fehlt");
ok(false, "Community/Team-Zaehlung nicht durchfuehrbar — Siebausdruck fehlt");
}
/* KEIN ROLLENNAME IN rundgang.js. Der Grund steht im Kopf der
Datei; diese Zeile haelt ihn fest, damit die naechste Rolle nicht
per Hand nachgetragen werden muss. */
const nurCode = quelle
.replace(/\/\*[\s\S]*?\*\//g, " ")
.replace(/(^|[^:])\/\/[^\n]*/g, "$1 ");
const gefunden = ["'gast'", '"gast"', "'hand'", "'modi'", "'linke'", "'creator'"]
.filter((w) => nurCode.includes(w));
ok(gefunden.length === 0,
`kein Rollenname im Code von rundgang.js${gefunden.length ? " — " + gefunden.join(", ") : ""}`);
}
/* =====================================================================
WO DIE KACHEL STEHT (Bauplan Kapitel 03)
---------------------------------------------------------------------
„Community: bleibt die erste, breite Kachel in der Gruppe
‚Community'. Modi, Linke Hand, Rechte Hand: zusaetzlich als erste
Kachel in ‚Fuer dich', weil sie bei ihnen sonst weit unten steht."
Gebaut ist das als VERSCHIEBEN statt Verdoppeln (der Grund steht in
workspace.js an anleitungNachVorne) -- deshalb prueft die letzte
Zeile jeder Rolle auch, dass es GENAU EINE gibt. Zwei gleich
benannte Wege zum selben Ort waeren hier der wahrscheinlichste
Rueckfall.
Gemessen wird ueber /api/ich, also ueber genau das Feld, aus dem die
Startseite ihre Gruppen baut -- nicht ueber die Liste im Quelltext.
Und die Gruppennummer gehoert mit in die Bedingung: „steht in Fuer
dich" waere auch wahr, wenn „Fuer dich" eines Tages die letzte
Gruppe ist, und dann steht sie wieder weit unten. */
melde("\n=== Die Kachel steht da, wo der Bauplan sie hinstellt ===");
{
const ichFuer = async (rolle, host) => {
const r = await roh("/workspace/api/ich", host, null, kekse[rolle]);
try { return JSON.parse(r.text); } catch { return null; }
};
const lage = (liste) => {
const gruppen = [];
for (const k of liste) if (!gruppen.includes(k.gruppe)) gruppen.push(k.gruppe);
const treffer = liste.filter((k) => k.ziel === "anleitung.html");
const k = treffer[0] || null;
const inGruppe = k ? liste.filter((x) => x.gruppe === k.gruppe) : [];
return {
anzahl: treffer.length, gruppe: k?.gruppe || null, gross: !!k?.gross,
nummer: k ? gruppen.indexOf(k.gruppe) + 1 : 0, gruppen: gruppen.length,
erste: inGruppe[0]?.ziel === "anleitung.html",
vorCommunity: k ? gruppen.indexOf(k.gruppe) < gruppen.indexOf("Community") : false,
};
};
const jGast = await ichFuer("gast", CREW_HOST);
const g = Array.isArray(jGast?.bereiche) ? lage(jGast.bereiche) : null;
ok(g && g.anzahl === 1 && g.gruppe === "Community" && g.nummer === 1
&& g.erste && g.gross,
`Community: erste und breite Kachel der ersten Gruppe `
+ `(${g?.anzahl}x, ${g?.gruppe}, Gruppe ${g?.nummer}/${g?.gruppen}, `
+ `erste ${g?.erste}, breit ${g?.gross})`);
for (const rolle of ["modi", "linke", "hand"]) {
const j = await ichFuer(rolle, CREW_HOST);
const l = Array.isArray(j?.bereiche) ? lage(j.bereiche) : null;
ok(l && l.anzahl === 1 && l.gruppe === "Für dich" && l.erste && l.vorCommunity,
`${rolle}: erste Kachel in „Für dich“, vor „Community“ `
+ `(${l?.anzahl}x, ${l?.gruppe}, Gruppe ${l?.nummer}/${l?.gruppen}, `
+ `erste ${l?.erste}, vor Community ${l?.vorCommunity})`);
}
/* DAS ANDERE HAUS BLEIBT UNBERUEHRT -- und das ist hier mehr als
eine Hoeflichkeit: Die Umsortierung sitzt in `bereicheFuer`, also
in der Funktion, die BEIDE Haeuser benutzen. Fuer die Agentur
muss sie `null` liefern ("der Browser hat die Liste"); gaebe sie
dort eine Liste zurueck, bekaeme die Agenturseite ihre Kacheln
ploetzlich vom Server, und niemand haette es bemerkt, weil
dieselben Kacheln herauskaemen. */
const jCre = await ichFuer("creator", null);
ok(jCre?.bereiche === null,
`die Agentur holt ihre Kacheln weiter aus dem Browser (bereiche ${
jCre?.bereiche === null ? "null" : typeof jCre?.bereiche})`);
}
/* =====================================================================
„NEU FUER DICH" BEIM AUFSTIEG (Bauplan Kapitel 10, Etappe 6)
---------------------------------------------------------------------
„Erreicht ein Community-Mitglied die Stufe Dabei oder Stamm, zeige
auf der Startseite EINMAL die Karte ‚Neu fuer dich' mit Sprung zur
passenden Karte."
Die vier Zeilen in der Mitte sind der eigentliche Punkt: Der
Schluessel ist (Person, STUFE) und nicht (Person). Wer von Neu auf
Dabei steigt, bekommt die Karte, und Wochen spaeter beim Sprung auf
Stamm noch einmal -- mit anderem Inhalt. Eine einzelne Spalte „schon
gesehen" haette den zweiten Aufstieg verschluckt, und das waere
niemandem aufgefallen: Es fehlt ja nur etwas.
DIE STUFE WIRD NICHT GESETZT, SIE WIRD GERECHNET. Deshalb wird hier
der Beitrittstag verschoben und es werden echte Beitraege angelegt
-- genau die zwei Dinge, aus denen `stufeVon` sie ableitet. Ein
„UPDATE personen SET stufe = 'stamm'" gaebe es gar nicht, und ein
Test, der eine Spalte setzt, die es nicht gibt, waere gruen ohne
etwas zu beweisen (siehe die Hausregel vom 01.09.2026). */
melde("\n=== „Neu für dich“ beim Aufstieg ===");
{
const aufstieg = async (rolle, host = null) => {
const r = await roh("/workspace/api/anleitung/aufstieg", host, null, kekse[rolle]);
let j = null; try { j = JSON.parse(r.text); } catch { /* bleibt null */ }
return { code: r.code, j };
};
const merken = (rolle, host = null) => roh(
"/workspace/api/anleitung/aufstieg/gesehen", host, {}, kekse[rolle], "POST");
const ziele = (j) => (j?.kacheln || []).map((k) => k.ziel).join(", ");
const gastId = (() => {
const d = new DatabaseSync(process.env.WORKSPACE_DB);
const z = d.prepare("SELECT id FROM personen WHERE rolle = 'gast'").get();
d.close();
return z?.id || 0;
})();
braucht(!!gastId, "kein Mitglied in der Probe-Datenbank");
const beitritt = (tageHer) => {
const d = new DatabaseSync(process.env.WORKSPACE_DB);
d.prepare("UPDATE personen SET erstellt = ? WHERE id = ?")
.run(new Date(Date.now() - tageHer * 86400_000).toISOString(), gastId);
d.close();
};
/* ---- Stufe „Neu": nichts zu feiern ------------------------------- */
const aNeu = await aufstieg("gast", CREW_HOST);
ok(aNeu.code === 200 && aNeu.j?.zeigen === false,
`frisch dabei: keine Karte (HTTP ${aNeu.code}, zeigen ${aNeu.j?.zeigen})`);
/* ---- Stufe „Dabei" ------------------------------------------------ */
beitritt(10);
const aDabei = await aufstieg("gast", CREW_HOST);
ok(aDabei.j?.zeigen === true && aDabei.j?.stufe === "dabei",
`nach ${10} Tagen kommt sie (zeigen ${aDabei.j?.zeigen}, Stufe ${aDabei.j?.stufe})`);
/* ABGELEITET AUS TREFF_SCHREIBEN, nicht aufgeschrieben: „Dabei"
schaltet Rudel-Chat, Wunschliste und Mitmachen frei. Steht hier
eines Tages etwas anderes, hat jemand die Tafel geaendert -- und
genau dann soll diese Zeile anschlagen. */
const dabeiZiele = (aDabei.j?.kacheln || []).map((k) => k.ziel);
ok(dabeiZiele.length === 3
&& dabeiZiele.includes("chat.html?raum=treff")
&& dabeiZiele.includes("bereich.html?b=wunsch")
&& dabeiZiele.includes("bereich.html?b=mitmachen"),
`…mit den drei frisch freigeschalteten Wegen (${ziele(aDabei.j)})`);
const m1 = await merken("gast", CREW_HOST);
const aWieder = await aufstieg("gast", CREW_HOST);
ok(m1.code === 200 && aWieder.j?.zeigen === false,
`gemerkt — sie kommt kein zweites Mal (HTTP ${m1.code}, zeigen ${aWieder.j?.zeigen})`);
/* ---- Stufe „Stamm": dieselbe Person, neue Karte -------------------- */
beitritt(40);
const aOhneBeitraege = await aufstieg("gast", CREW_HOST);
ok(aOhneBeitraege.j?.zeigen === false,
`40 Tage ohne eigene Beiträge bleiben „Dabei“ (zeigen ${aOhneBeitraege.j?.zeigen})`);
{
const d = new DatabaseSync(process.env.WORKSPACE_DB);
const satz = d.prepare(
`INSERT INTO eintraege (bereich, art, titel, datum, erstellt, erstellt_von)
VALUES ('highlight','clip',?,?,?,?)`);
const heute = new Date().toISOString();
for (let i = 1; i <= 3; i++) satz.run(`Probe ${i}`, heute.slice(0, 10), heute, gastId);
d.close();
}
const aStamm = await aufstieg("gast", CREW_HOST);
ok(aStamm.j?.zeigen === true && aStamm.j?.stufe === "stamm",
`der ZWEITE Aufstieg kommt eigenständig (zeigen ${aStamm.j?.zeigen}, `
+ `Stufe ${aStamm.j?.stufe})`);
ok((aStamm.j?.kacheln || []).length === 1
&& aStamm.j.kacheln[0].ziel === "bereich.html?b=highlight",
`…und zeigt auf die Highlights (${ziele(aStamm.j)})`);
/* ---- Gegenprobe: eine Teamrolle hat gar keine Mitglieder-Stufe ---- */
const aHand = await aufstieg("hand", CREW_HOST);
ok(aHand.code === 200 && aHand.j?.zeigen === false,
`eine Teamrolle bekommt nie eine (HTTP ${aHand.code}, zeigen ${aHand.j?.zeigen})`);
}
/* =====================================================================
DIE ABNAHME-CHECKLISTE DES BAUPLANS (Kapitel 13)
---------------------------------------------------------------------
Woertlich:
Community: 14 Karten — 5 Muss, 4 Regelmaessig, 5 Bei Bedarf,
„Nicht deins" mit 5 Punkten.
Modi: 29 Karten — 7 / 7 / 15, „Nicht deins" 5.
Linke Hand: 29 Karten — 7 / 7 / 15, „Nicht deins" 5.
Rechte Hand: 34 Karten — 8 / 7 / 19, „Nicht deins" 4.
DIE GESAMTZAHLEN STANDEN SCHON WEITER OBEN — die AUFTEILUNG nicht.
Das ist der Unterschied, auf den es ankommt: Verrutscht eine Kachel
von „Muss" nach „Bei Bedarf", bleibt die Summe gleich, und der
Fehler ist genau der, den die ganze Seite verhindern soll — jemand
bekommt beim ersten Anmelden das Falsche zuerst gezeigt.
GEMESSEN WIRD AN DER SCHNITTSTELLE, NICHT AN DER TEXTDATEI. Die
Einordnung ist seit Etappe 4 in der Pflege aenderbar; die
Erstbefuellung ist nur der Anfangszustand. Wer die Textdatei fragt,
fragt eine Quelle, die nach der ersten Aenderung nicht mehr gilt.
DIE STUFENSCHLUESSEL WERDEN NICHT ABGESCHRIEBEN, sondern aus
STUFEN_SCHLUESSEL genommen — kommt eine vierte Einordnung dazu,
faellt diese Pruefung auf, statt sie stillschweigend zu uebergehen. */
melde("\n=== Die Abnahme-Checkliste (Bauplan Kapitel 13) ===");
{
const { STUFEN_SCHLUESSEL } = await import("./anleitung-tabellen.js");
const SOLL = {
gast: { karten: 14, auf: [5, 4, 5], nicht: 5 },
modi: { karten: 29, auf: [7, 7, 15], nicht: 5 },
linke: { karten: 29, auf: [7, 7, 15], nicht: 5 },
hand: { karten: 34, auf: [8, 7, 19], nicht: 4 },
};
ok(STUFEN_SCHLUESSEL.length === 3,
`drei Einordnungen, wie der Bauplan sie kennt (${STUFEN_SCHLUESSEL.join(", ")})`);
for (const [rolle, soll] of Object.entries(SOLL)) {
const j = (await holen(rolle, CREW_HOST)).j;
const karten = j?.karten || [];
const auf = STUFEN_SCHLUESSEL.map((st) => karten.filter((k) => k.stufe === st).length);
ok(karten.length === soll.karten
&& auf.every((n, i) => n === soll.auf[i])
&& (j?.nicht || []).length === soll.nicht,
`${rolle}: ${soll.karten} Karten (${soll.auf.join("/")}) und `
+ `${soll.nicht}x „Nicht deins“ — bekommen: ${karten.length} `
+ `(${auf.join("/")}) und ${(j?.nicht || []).length}`);
}
}
/* =====================================================================
„AB DABEI" UND „AB STAMM" AN DER KARTE (Bauplan Kapitel 04)
---------------------------------------------------------------------
„Kann ein Community-Mitglied etwas wegen seiner Stufe noch nicht,
steht das an der Karte."
DIE WICHTIGE ZEILE IST DIE DRITTE: Nach dem Aufstieg muss die Marke
VERSCHWINDEN. Eine Marke, die bleibt, ist schlimmer als keine --
sie sagt jemandem, er duerfe etwas nicht, das er laengst darf, und
er probiert es gar nicht erst.
GEMESSEN WIRD AN DREI KARTEN MIT DREI VERSCHIEDENEN ANTWORTEN, und
das ist Absicht: Der Rudel-Chat braucht „Dabei", die Highlights
brauchen „Stamm", und das Anschlagbrett braucht gar nichts (dort
schreibt von aussen NIEMAND, auf keiner Stufe). Waere die Rechnung
grob falsch, traefe sie alle drei gleich. */
melde("\n=== „ab Dabei“ und „ab Stamm“ an der Karte ===");
{
const markeVon = (j, kachel) =>
(j?.karten || []).find((k) => k.kachel === kachel)?.erst_ab ?? null;
const gastId = (() => {
const d = new DatabaseSync(process.env.WORKSPACE_DB);
const z = d.prepare("SELECT id FROM personen WHERE rolle = 'gast'").get();
d.close(); return z?.id || 0;
})();
const beitritt = (tageHer) => {
const d = new DatabaseSync(process.env.WORKSPACE_DB);
d.prepare("UPDATE personen SET erstellt = ? WHERE id = ?")
.run(new Date(Date.now() - tageHer * 86400_000).toISOString(), gastId);
d.close();
};
beitritt(0);
const neuJ = (await holen("gast", CREW_HOST)).j;
ok(markeVon(neuJ, "chat:treff") === "Dabei"
&& markeVon(neuJ, "bereich:highlight") === "Stamm"
&& markeVon(neuJ, "bereich:anschlag") === null,
`Stufe Neu: Chat „ab ${markeVon(neuJ, "chat:treff")}“, Highlights `
+ `„ab ${markeVon(neuJ, "bereich:highlight")}“, Anschlagbrett `
+ `${markeVon(neuJ, "bereich:anschlag") || "ohne Marke"}`);
beitritt(10);
const dabeiJ = (await holen("gast", CREW_HOST)).j;
ok(markeVon(dabeiJ, "chat:treff") === null
&& markeVon(dabeiJ, "bereich:highlight") === "Stamm",
`Stufe Dabei: die Chat-Marke ist weg (${markeVon(dabeiJ, "chat:treff") || "weg"}), `
+ `die der Highlights bleibt (${markeVon(dabeiJ, "bereich:highlight")})`);
/* Eine Teamrolle hat keine Mitglieder-Stufe -- an ihren Karten darf
nie eine solche Marke stehen. Ohne diese Zeile waere die Rechnung
auch dann gruen, wenn sie die Stufe gar nicht beachtete. */
const handJ = (await holen("hand", CREW_HOST)).j;
const beimTeam = (handJ?.karten || []).filter((k) => k.erst_ab).length;
ok(beimTeam === 0,
`eine Teamrolle bekommt keine einzige Stufen-Marke (${beimTeam})`);
beitritt(0);
}
/* =====================================================================
EINE SEITE SPERREN — UND DIE KARTE IST WEG (Bauplan Kapitel 13)
---------------------------------------------------------------------
„In ‚Wer sieht was' eine Seite sperren: Die Karte verschwindet bei
dieser Rolle."
DAS IST DER PUNKT, AN DEM DIE ANLEITUNG GEFAEHRLICH WERDEN KANN.
Eine Karte ist eine Einladung mit einem Knopf „Hingehen". Bleibt sie
stehen, nachdem die Seite gesperrt wurde, schickt die Einweisung
jemanden gegen eine Tuer — und erzaehlt ihm vorher noch, was dahinter
zu tun waere. Der Bauplan verlangt deshalb ausdruecklich, dass die
Karten aus DERSELBEN Tafel kommen wie die Tuer.
GESPERRT WIRD UEBER DIE ECHTE SCHNITTSTELLE, nicht mit einem
UPDATE in der Datenbank: Nur so laeuft auch `tafelLaden()` mit, und
genau dort koennte die Kette reissen. Danach wird die Sperre wieder
aufgehoben und nachgemessen, dass die Karte zurueckkommt — sonst
wuesste man nicht, ob sie verschwunden ist oder nur nie da war. */
melde("\n=== Seite sperren, Karte weg (Abnahme Kapitel 13) ===");
{
const PFAD = "/workspace/bereich.html";
const stellen = (erlaubt) => roh("/workspace/api/rechte/feld", CREW_HOST,
{ pfad: PFAD, rolle: "modi", erlaubt }, kekse.admin, "PUT");
const vorher = (await holen("modi", CREW_HOST)).j?.karten || [];
const bereicheVorher = vorher.filter((k) => k.kachel.startsWith("bereich:")).length;
ok(bereicheVorher > 0,
`vorher erklaert die Anleitung dem Modi ${bereicheVorher} Bereichs-Karten`);
const zu = await stellen(false);
const gesperrt = (await holen("modi", CREW_HOST)).j?.karten || [];
const bereicheGesperrt = gesperrt.filter((k) => k.kachel.startsWith("bereich:")).length;
ok(zu.code === 200 && bereicheGesperrt === 0,
`nach dem Sperren sind sie weg (HTTP ${zu.code}, noch ${bereicheGesperrt})`);
/* UND NUR SIE. Waere mit der Sperre die halbe Anleitung
verschwunden, waere die Zeile darueber auch gruen. */
ok(gesperrt.length === vorher.length - bereicheVorher,
`und nur sie (${vorher.length} - ${bereicheVorher} = ${gesperrt.length})`);
const auf = await stellen(true);
const zurueck = (await holen("modi", CREW_HOST)).j?.karten || [];
ok(auf.code === 200 && zurueck.length === vorher.length,
`Gegenprobe: zurueckgestellt sind sie wieder da (${zurueck.length} von `
+ `${vorher.length})`);
}
/* ---------- 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);