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]>
1297 lines
65 KiB
JavaScript
1297 lines
65 KiB
JavaScript
/* =====================================================================
|
||
DIE ANLEITUNG — stimmt sie mit den Kacheln ueberein? (Etappe 1)
|
||
=====================================================================
|
||
|
||
DIE EIGENTLICHE FRAGE DIESER DATEI steht im Bauplan gar nicht; sie
|
||
kam beim Nachmessen dazu, und sie ist der Grund, warum es diese
|
||
Pruefung ueberhaupt gibt:
|
||
|
||
Erklaert die Anleitung genau die Kacheln, die es gibt --
|
||
und zwar JEDE und KEINE DARUEBER HINAUS?
|
||
|
||
Beides kann lautlos kaputtgehen, und beides auf dieselbe Weise: Wer
|
||
eine Kachel hinzufuegt, vergisst die Karte -- dann steht auf der
|
||
Startseite etwas, das die Einweisung nicht kennt. Wer eine Kachel
|
||
entfernt, laesst die Karte stehen -- dann erklaert die Einweisung
|
||
etwas, das es nicht mehr gibt, mit einem Knopf, der ins Leere
|
||
fuehrt.
|
||
|
||
Der Bauplan sieht dafuer nur einen Hinweis in der Pflege-Ansicht vor
|
||
("Fuer diese Kachel fehlt ein Eintrag"). Der hilft nur, wenn jemand
|
||
hinsieht. Diese Pruefung sieht immer hin.
|
||
|
||
---------------------------------------------------------------------
|
||
WOHER DIE KACHELLISTE KOMMT
|
||
|
||
Aus `workspace/assets/js/bereiche.js`, und zwar indem die Datei
|
||
AUSGEFUEHRT wird -- nicht, indem jemand sie mit einem Muster
|
||
durchsucht. Ein Muster waere eine zweite, ungenaue Nachbildung der
|
||
Datei; sie wuerde bei der ersten ungewohnten Schreibweise still
|
||
danebenliegen und dann "alles in Ordnung" melden.
|
||
|
||
Der Server kennt diese Liste nicht (bereicheFuer gibt fuers
|
||
Agenturhaus `null` zurueck). Deshalb muss die Pruefung sie sich
|
||
selbst holen -- genau wie der Browser es tut.
|
||
===================================================================== */
|
||
|
||
import { mkdtempSync, rmSync, readFileSync } from "node:fs";
|
||
import { tmpdir } from "node:os";
|
||
import { join } from "node:path";
|
||
import vm from "node:vm";
|
||
import { eigenerPort } from "./helfer-port.mjs";
|
||
|
||
const PORT = await eigenerPort(import.meta, "pruef-anleitung");
|
||
const ordner = mkdtempSync(join(tmpdir(), "ws-anl-"));
|
||
process.env.WORKSPACE_DB = join(ordner, "workspace.db");
|
||
process.env.PORT = String(PORT);
|
||
process.env.SITE_ACCESS_SECRET = "lokaler-test";
|
||
process.env.SITE_PUBLIC_LAUNCH_AT = "2020-01-01T00:00:00+01:00";
|
||
|
||
const express = (await import("express")).default;
|
||
const ec = express.response.cookie;
|
||
express.response.cookie = function (n, w, o) { return ec.call(this, n, w, { ...(o || {}), secure: false }); };
|
||
import { notbremse } from "./helfer-notbremse.mjs";
|
||
await import("./index.js");
|
||
notbremse(180_000, "pruef-anleitung");
|
||
await new Promise((r) => setTimeout(r, 800));
|
||
|
||
const BASIS = `http://127.0.0.1:${PORT}`;
|
||
let fehler = 0, geprueft = 0;
|
||
const melde = (t) => console.log(t);
|
||
const ok = (b, t) => { geprueft++; console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; };
|
||
function braucht(bedingung, was) {
|
||
if (bedingung) return;
|
||
console.log(`\nABBRUCH: konnte nicht nachsehen — ${was}`);
|
||
console.log("Das ist KEIN Befund am Code. Die Pruefung konnte ihre");
|
||
console.log("Voraussetzung nicht herstellen und sagt das, statt zu schweigen.\n");
|
||
try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ }
|
||
process.exit(2);
|
||
}
|
||
|
||
/* =====================================================================
|
||
DIE KACHELLISTE — bereiche.js wirklich ausfuehren
|
||
===================================================================== */
|
||
function bereicheLaden() {
|
||
const quelle = readFileSync(
|
||
new URL("../workspace/assets/js/bereiche.js", import.meta.url), "utf8");
|
||
const fenster = {};
|
||
const tot = () => ({ setAttribute() {}, append() {}, style: { setProperty() {} } });
|
||
const umgebung = { window: fenster, URLSearchParams,
|
||
document: { createElement: tot, createElementNS: tot } };
|
||
umgebung.globalThis = umgebung;
|
||
vm.createContext(umgebung);
|
||
vm.runInContext(quelle, umgebung, { filename: "bereiche.js" });
|
||
return fenster.Bereiche;
|
||
}
|
||
|
||
const B = bereicheLaden();
|
||
braucht(B && Array.isArray(B.GRUPPEN) && B.GRUPPEN.length > 0,
|
||
"bereiche.js liefert keine Kachelliste — ohne sie ist nichts zu vergleichen");
|
||
braucht(typeof B.schluesselVonZiel === "function",
|
||
"bereiche.js kennt schluesselVonZiel nicht");
|
||
|
||
melde("\n=== Der Kachel-Schluessel rechnet richtig ===");
|
||
const SCHLUESSEL_PROBEN = [
|
||
["uebersicht.html", "uebersicht"],
|
||
["bereich.html?b=live", "bereich:live"],
|
||
["bereich.html?b=agentur", "bereich:agentur"],
|
||
["profil.html", "profil"],
|
||
/* 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);
|