/* ===================================================================== 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)); /* 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"); 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})`); /* ===================================================================== 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. */ 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); /* ===================================================================== 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); ok(pModi.code === 404, `ein Modi bekommt die Pflege gar nicht (HTTP ${pModi.code}, erwartet 404)`); 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. */ const eModi = await einweisung("modi", CREW_HOST); ok(eModi.code === 200 && eModi.j?.zeigen === false, `ein Modi bekommt eine ruhige Absage (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(); } /* ---------- 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);