Files
dogfather-universe/server/pruef-werdegang.mjs
T
DogFatherGitandClaude Opus 5 ea8ceaa539 Das Farbhaus von Team Dogi lag oeffentlich im Netz
Ausgeloest von pruef-modi-wortleck, die seit Tagen rot war: "24
Fundstellen -- der verborgene Zugang waere damit auffindbar." Die
erste Frage war, ob sie recht hat. Gemessen: ja, und schlimmer als
gedacht.

WER DIESE DATEIEN BEKOMMT: Nicht nur Kollegen. `curl` ohne jeden Keks
auf workspace.dogfather-universe.com liefert die Skripte und
Stilvorlagen des Arbeitsplatzes mit HTTP 200 -- fuer jeden im Netz
lesbar.

DARUNTER crew-haus.css, 27 806 Byte, das ganze Farbhaus von Team Dogi
samt seiner Kommentare. In deren Kopf stand seit dem 10.09. woertlich
"Diese Datei wird NUR auf crew.dogfather-universe.com ausgeliefert."
Das war eine Behauptung, keine Tatsache: Die Weiche biegt `haus.css`
auf diesen Namen um, wer den Namen direkt nannte, ging an ihr vorbei.
Ein Kommentar, der eine Absicht beschreibt, erfuellt sie nicht.

Zwei Stellen im Haus haben sich damit widersprochen -- die Datei sagte
"nur auf crew.", pruef-crew-adresse verlangte ausdruecklich das
Gegenteil ("sie ist kein Geheimnis"). Durchgesetzt war die offene
Fassung. Filipe hat am 21.09. entschieden: zusperren. Die alte
Begruendung dagegen war die Sorge, eine Sperre koenne die Pruefung
blind machen -- die ist jetzt beantwortet statt vermieden: Jeder
Eintrag der Tafel wird in BEIDE Richtungen gemessen, und dass die
Umbiegung von `haus.css` auf crew. weiterhin greift, ebenfalls.

DIE ADRESSE WAR DAS EIGENTLICHE GEHEIMNIS, nicht das Wort. Die
Rollennamen stehen auf der Zugangswand von Team Dogi ohnehin offen --
so gewollt. WO sie liegt, stand dagegen im Klartext in gate.css,
gate.js und crew-haus.css, alle drei oeffentlich abrufbar. Jetzt steht
sie in keiner ausgelieferten Datei mehr.

DIE PRUEFUNG SELBST HATTE DREI MAENGEL:

  1. Sie kannte die Mehrzahl nicht. `\bmodi\b` fand "ein Modi", nicht
     "die Modis" -- 23 weitere Stellen, darunter der Satz "Fuer die
     rechte Hand und die Modis gibt es dort den stillen Zugang". Also
     genau die Auskunft, die sie verhindern soll, in der Form, die sie
     nicht sah.
  2. Ihre Ausnahmeliste war abgeschrieben. Sie nannte eine Datei; die
     zweite, die der Server auf crew. begrenzt, haette sie nie
     erfahren. Ausgenommen ist jetzt genau das, was der Server auch
     durchsetzt (GEHOERT_ZU_ADRESSE) -- kein Muster, sondern die
     Durchsetzung selbst.
  3. Sie mahnte 45 Kommentare an. Filipes Entscheidung: in Kommentaren
     ja, in Code und sichtbaren Texten nein. Rot, das immer da ist,
     wird ueberlesen -- und dann faengt sie auch den echten Fall nicht.

Dafuer neu: helfer-ohne-kommentar.mjs, ein Zerleger, der Kommentare
zeichengenau durch Leerzeichen ersetzt, ohne Zeichenketten,
Vorlagen oder Suchmuster zu beschaedigen. KEIN Suchausdruck -- genau
daran ist am 20.09. die Portumstellung gescheitert, mit gueltigem
JavaScript ohne das Feld `port`. Dreizehn Proben in beide Richtungen
beweisen, dass er weder zu viel noch zu wenig wegnimmt; ein Zerleger,
der zu viel nimmt, macht die ganze Pruefung STILL gruen.

Von 45 blieben damit drei echte, alle behoben:
  - daten.modis  -> daten.leute (Feldname in der Auskunft, also Code)
  - zwei Saetze, die Menschen lesen, auf "die Moderation" umgestellt --
    fuer jemanden, der neu im Treff ist, sogar klarer

Nebenbei, beim selben Durchgang gefunden und geschlossen:
  - teamlage.js fiel auf einen Rollennamen zurueck. Nachgemessen: Die
    Stilvorlage kennt zu dieser Karte gar keine solche Regel -- der
    Rueckfall hat nie etwas bewirkt und nur das Wort ausgeliefert.
  - werdegang.js fuehrte die Saetze ueber den Rollengruppen selbst,
    mit den Rollennamen als Schluessel. Der Kommentar daneben
    behauptete sogar, sie seien "abgeleitet, nicht abgeschrieben".
    Sie kommen jetzt vom Server, und pruef-werdegang misst, dass
    jede Gruppe ihren eigenen behaelt.

pruef-modi-wortleck  8/0  (war 5, davon 1 rot)
pruef-crew-adresse 144/0  (war 135)
pruef-werdegang    98 statt 95 Pruefungen, dieselben 3 alten Fehler
pruef-team-ampel 32/0, pruef-team-stufen 28/0, pruef-hilfe 84/0

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-21 13:00:01 +02:00

661 lines
32 KiB
JavaScript

/* =====================================================================
ENTWICKLUNG — die Auswertung je Person (19.09.2026)
Filipe: „dan perfektionierst du eine neue wo diagramme texte und so
zu jedem modi gemacht wird und diese kategorie kannst du entwicklung
nenen."
---------------------------------------------------------------------
WAS HIER GEPRÜFT WIRD — UND WARUM GENAU DAS
Eine Seite, die Diagramme über MENSCHEN zeigt, kann auf drei Arten
falsch sein, und nur eine davon ist ein Programmfehler:
1. SIE ZEIGT ZU VIEL. Der Anlass eines „da hakt es“ ist für ein
Gespräch aufgeschrieben. Landete er in der Spur, wäre jede
längst geklärte Beobachtung auf Dauer lesbar. Block 5 („Wie
geht es dir damit?“) darf überhaupt nicht hierherkommen.
2. SIE BEWERTET. Punktzahl, Prozentwert, Rangliste, Vergleich
zwischen zwei Menschen — nichts davon darf entstehen, auch
nicht versehentlich über einen Umweg.
3. SIE BEHAUPTET EINEN VERLAUF, DEN ES NICHT GIBT. Ein Diagramm
über einen einzigen Zeitpunkt ist kein Diagramm. Dann muss die
Seite das SAGEN — der dritte Ausgang.
ZU JEDER PRÜFUNG, DIE „ALLES IN ORDNUNG“ SAGEN KANN, GEHÖRT EINE
GEGENPROBE, die beweist, dass sie auch „nicht in Ordnung“ sagen
kann. Eine Prüfung, die immer bestätigt, bestätigt nichts.
Aufrufen mit: node server/pruef-werdegang.mjs
===================================================================== */
import { mkdtempSync, rmSync, readFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { request as httpAnfrage } from "node:http";
import { eigenerPort } from "./helfer-port.mjs";
const PORT = await eigenerPort(import.meta, "pruef-werdegang");
const ordner = mkdtempSync(join(tmpdir(), "ws-weg-"));
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-werdegang");
await new Promise((r) => setTimeout(r, 700));
process.on("uncaughtException", (f) => { console.error("ABSTURZ:", f); process.exit(7); });
process.on("unhandledRejection", (f) => { console.error("ABSTURZ (Versprechen):", f); process.exit(7); });
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++; };
const CREW = "crew.dogfather-universe.com";
const { DatabaseSync } = await import("node:sqlite");
const { scryptSync, randomBytes, createHmac } = await import("node:crypto");
const W = await import("./workspace-werdegang.js");
const kat = await import("./workspace-entwicklungs-punkte.js");
const rechte = await import("./rechte.js");
const ent = await import("./workspace-entwicklung.js");
const oeffne = () => new DatabaseSync(process.env.WORKSPACE_DB);
const jetzt = new Date().toISOString();
/* ABGELEITET, NICHT ABGESCHRIEBEN. Jede feste Zahl in einer Prüfung ist
eine Rechnung von gestern -- am 17.09. wurden aus sechs Fragen zwölf
und vier Prüfungen waren rot, ohne dass etwas kaputt war. */
const PUNKTE = kat.ENTWICKLUNG_FREMD;
const SELBST = kat.ENTWICKLUNG_SELBST;
let idDogi, idHand, idModi, idCreator;
{
const d = oeffne();
const schluessel = randomBytes(32).toString("hex");
d.prepare("INSERT INTO einstellungen (schluessel, wert, geaendert, von)"
+ " VALUES (?,?,?,NULL) ON CONFLICT(schluessel) DO NOTHING")
.run("code_kennung_schluessel", schluessel, jetzt);
const 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", schluessel).update(code).digest("hex"), jetzt);
return d.prepare("SELECT last_insert_rowid() AS id").get().id;
};
idDogi = anlegen("Filipe", "admin", "CODE-DOGI-0001");
idHand = anlegen("Rieke", "hand", "CODE-HAND-0001");
idModi = anlegen("Diene", "modi", "CODE-MODI-0001");
idCreator = anlegen("Tili", "creator", "CODE-CREA-0001");
d.close();
}
function ruf(pfad, host, methode, kopf = {}, koerper = null) {
return new Promise((fertig, schief) => {
const a = httpAnfrage({ host: "127.0.0.1", port: PORT, path: pfad, method: methode,
headers: { Host: host, ...(koerper ? { "Content-Type": "application/json" } : {}), ...kopf } },
(antwort) => {
let text = "";
antwort.on("data", (s) => { text += s; });
antwort.on("end", () => fertig({ code: antwort.statusCode, text, kopf: antwort.headers }));
});
a.on("error", schief);
if (koerper) a.write(JSON.stringify(koerper));
a.end();
});
}
const json = (a) => { try { return JSON.parse(a.text); } catch { return {}; } };
async function anmelden(rolle, code, host) {
const r = await ruf("/workspace/api/anmelden", host, "POST", {}, { rolle, code });
return [].concat(r.kopf?.["set-cookie"] || []).map((c) => c.split(";")[0]).join("; ");
}
const kDogi = await anmelden("admin", "CODE-DOGI-0001", CREW);
const kHand = await anmelden("hand", "CODE-HAND-0001", CREW);
const kModi = await anmelden("modi", "CODE-MODI-0001", CREW);
ok(!!kDogi && !!kHand && !!kModi, "DogFather, rechte Hand und ein Modi sind angemeldet");
/* =======================================================================
1. DIE KACHEL HEISST „ENTWICKLUNG“ UND FÜHRT AUCH DORTHIN
Der ganze Auftrag hängt an einer Umbenennung: Die alte Kachel heißt
seit heute früh „Eure Aufgaben“, damit der Name frei wird. Wenn die
neue Kachel auf die alte Seite zeigt, ist genau nichts gewonnen.
======================================================================= */
melde("");
melde("=== 1. Die Kachel ===");
{
const ich = json(await ruf("/workspace/api/ich", CREW, "GET", { cookie: kDogi }));
const alle = [...(ich.bereiche || []), ...(ich.bereiche_zusatz || [])];
const neu = alle.find((k) => k.name === "Entwicklung");
ok(!!neu, "eine Kachel „Entwicklung“ steht bei DogFather");
ok(neu?.ziel === "werdegang.html",
`und führt auf die Auswertung (${neu?.ziel})`);
/* GEGENPROBE. Ohne sie bewiese die Zeile darüber nichts: Der Katalog
muss weiterhin erreichbar sein, unter seinem neuen Namen. */
const alt = alle.find((k) => k.ziel === "entwicklung.html");
ok(alt?.name === "Eure Aufgaben",
`der Katalog heißt weiter „${alt?.name}“ und liegt woanders`);
ok(neu?.ziel !== alt?.ziel, "beide Kacheln zeigen auf VERSCHIEDENE Seiten");
/* ZWEI KACHELN, ZWEI FARBEN. Gleiche Farbe hieße, dass man sie auf
der Startseite verwechselt -- und sie stehen nebeneinander. */
ok(neu?.ton && alt?.ton && neu.ton !== alt.ton,
`verschiedene Farbtöne (${alt?.ton} / ${neu?.ton})`);
const css = readFileSync(
new URL("../workspace/assets/css/start.css", import.meta.url), "utf8");
ok(new RegExp(`\\[data-ton="${neu?.ton}"\\]`).test(css),
`Ton ${neu?.ton} ist in start.css wirklich definiert`);
/* UND BEI DER RECHTEN HAND AUCH. Sie arbeitet an denselben Dingen --
eine Kachel nur bei DogFather wäre die Über-/Unterordnung, die es
in diesem Haus nicht gibt. */
const beiHand = json(await ruf("/workspace/api/ich", CREW, "GET", { cookie: kHand }));
ok([...(beiHand.bereiche || []), ...(beiHand.bereiche_zusatz || [])]
.some((k) => k.ziel === "werdegang.html"),
"die rechte Hand hat dieselbe Kachel");
}
/* =======================================================================
2. WER HINEINDARF
======================================================================= */
melde("");
melde("=== 2. Wer hineindarf ===");
{
const tafel = rechte.tafelJetzt();
const erlaubt = tafel["/workspace/werdegang.html"] || [];
ok(erlaubt.length > 0, "die Seite steht in der Rechtetafel");
ok(erlaubt.includes("admin") && erlaubt.includes("hand"),
"DogFather und die rechte Hand dürfen sie öffnen");
ok(erlaubt.includes("modi"),
"ein Modi auch — er sieht dort seine eigene Bewegung");
/* GEGENPROBE: Die Agentur hat gar keine Entwicklungskarte. Eine Seite,
die man öffnen kann und die dann leer bleibt, sieht aus wie ein
Fehler -- und ist einer. */
ok(!erlaubt.includes("creator") && !erlaubt.includes("gast"),
"die Agentur und die Community nicht");
ok(json(await ruf("/workspace/api/werdegang/liste", CREW, "GET", { cookie: kDogi })).liste !== undefined,
"DogFather bekommt die Liste");
const beiModi = await ruf("/workspace/api/werdegang/liste", CREW, "GET", { cookie: kModi });
ok(beiModi.code === 404,
`ein Modi bekommt sie nicht (${beiModi.code} — nicht 403: wer „kein Zugriff“ liest, fragt als Nächstes, wer denn dann)`);
const fremdeKarte = await ruf(`/workspace/api/werdegang/person/${idHand}`, CREW, "GET", { cookie: kModi });
ok(fremdeKarte.code === 404, `und auch keine fremde Karte (${fremdeKarte.code})`);
const ohne = await ruf("/workspace/api/werdegang/liste", CREW, "GET", {});
ok(ohne.code === 401, `ohne Anmeldung gar nichts (${ohne.code})`);
}
/* =======================================================================
3. DIE SPUR — SIE MERKT SICH, WAS DIE TABELLE VERGISST
======================================================================= */
melde("");
melde("=== 3. Die Spur ===");
{
const p1 = PUNKTE[0].schluessel, p2 = PUNKTE[1].schluessel;
const a = await ruf(`/workspace/api/entwicklung/person/${idModi}/${p1}`, CREW, "PUT",
{ cookie: kDogi }, { stufe: "waechst" });
ok(a.code === 200, "eine Einschätzung lässt sich setzen");
const b = await ruf(`/workspace/api/entwicklung/person/${idModi}/${p1}`, CREW, "PUT",
{ cookie: kDogi }, { stufe: "laeuft" });
ok(b.code === 200, "und ändern");
const d = oeffne();
const spur = d.prepare(
"SELECT * FROM entwicklung_lauf WHERE person_id = ? ORDER BY id").all(idModi);
ok(spur.length === 2, `beide Schritte stehen in der Spur (${spur.length})`);
ok(spur[0].vorher === null && spur[0].stufe === "waechst",
"der erste hat kein „vorher“ — er war der erste");
ok(spur[1].vorher === "waechst" && spur[1].stufe === "laeuft",
"der zweite weiß, was vorher dastand (waechst → laeuft)");
ok(spur.every((z) => z.nachgetragen === 0),
"beide sind beobachtet, nicht nachgetragen");
/* ==== DIE WICHTIGSTE PRÜFUNG DER GANZEN DATEI =====================
KEIN ANLASS IN DER SPUR. Neben einem „da hakt es“ steht ein Satz,
woran man es gemerkt hat. Eine Chronik dieser Sätze wäre das
Belastendste, was dieses Haus speichern kann.
GEPRÜFT WIRD DIE TABELLE SELBST, nicht die Ausgabe: Eine Spalte,
die es gibt, wird eines Tages gefüllt.
============================================================== */
const spalten = d.prepare("PRAGMA table_info(entwicklung_lauf)").all().map((z) => z.name);
ok(!spalten.includes("anlass"),
`die Spur hat gar keine Spalte für den Anlass (${spalten.join(", ")})`);
/* Und der Anlass steht trotzdem dort, wo er hingehört -- sonst wäre
die Zeile oben nur deshalb grün, weil die Funktion gar nichts tut. */
await ruf(`/workspace/api/entwicklung/person/${idModi}/${p2}`, CREW, "PUT",
{ cookie: kDogi }, { stufe: "hakt", anlass: "Am Dienstag im Stream, zweimal nacheinander." });
const stand = d.prepare(
"SELECT anlass FROM entwicklung_stand WHERE person_id = ? AND schluessel = ?")
.get(idModi, p2);
ok(/Dienstag/.test(stand?.anlass || ""),
"im Katalog steht er weiterhin — dort gehört er hin");
const inSpur = d.prepare(
"SELECT COUNT(*) AS n FROM entwicklung_lauf WHERE person_id = ? AND schluessel = ?")
.get(idModi, p2).n;
ok(inSpur === 1, "die Spur kennt den Punkt, nicht den Satz dazu");
/* BLOCK 5 DARF NIE HIERHERKOMMEN. „Wie geht es dir damit?“ beantwortet
nur die Person selbst; diese Antworten haben eine eigene Tabelle,
die niemand sonst liest. */
const selbst = SELBST[0].schluessel;
await ruf(`/workspace/api/entwicklung/mich/${selbst}`, CREW, "PUT",
{ cookie: kModi }, { stufe: "hakt" });
const inSpur2 = d.prepare(
"SELECT COUNT(*) AS n FROM entwicklung_lauf WHERE schluessel = ?").get(selbst).n;
ok(inSpur2 === 0, "eine Selbstauskunft aus Block 5 landet NICHT in der Spur");
/* Gegenprobe: sie ist trotzdem gespeichert worden. */
const selbstStand = d.prepare(
"SELECT stufe FROM entwicklung_stand WHERE person_id = ? AND schluessel = ?")
.get(idModi, selbst);
ok(selbstStand?.stufe === "hakt",
"sie steht aber sehr wohl im Katalog — die Zeile darüber ist keine Ausrede");
d.close();
}
/* =======================================================================
4. DIE ÜBERGÄNGE HEISSEN RICHTIG
Reine Rechnung, ohne Server -- und mit Gegenprobe: Wer nur prüft,
dass „vor“ herauskommt, wenn „vor“ gemeint ist, hätte auch mit einer
Funktion recht, die IMMER „vor“ sagt.
======================================================================= */
melde("");
melde("=== 4. Die Übergänge ===");
{
ok(W.artVon(null, "laeuft", 0) === "neu", "zum ersten Mal gesetzt → neu");
ok(W.artVon("hakt", "waechst", 0) === "vor", "hakt → wächst ist ein Schritt nach vorn");
ok(W.artVon("waechst", "laeuft", 0) === "vor", "wächst → läuft auch");
ok(W.artVon("laeuft", "hakt", 0) === "zurueck", "läuft → hakt ist einer zurück");
ok(W.artVon("unklar", "laeuft", 0) === "geklaert",
"aus „kann ich nicht sagen“ wird etwas → geklärt, nicht „vor“");
ok(W.artVon("laeuft", "unklar", 0) === "offen",
"und umgekehrt → offen, nicht „zurück“");
ok(W.artVon("laeuft", "laeuft", 0) === "gleich", "derselbe Wert ist keine Bewegung");
ok(W.artVon(null, "laeuft", 1) === "anfang",
"die Startaufnahme ist der Nullpunkt, nicht ein Ereignis");
/* GEGENPROBE zur Rangfolge: „kann ich nicht sagen“ hat KEINEN Rang.
Bekäme sie einen, würde „ich habe es nicht gesehen“ irgendwann wie
ein halbes „da hakt es“ gelesen. */
ok(W.artVon("unklar", "hakt", 0) === "geklaert",
"auch unklar → hakt ist geklärt und kein Rückschritt");
}
/* =======================================================================
5. DIE MONATSSPALTEN — UND DIE FALLE AM MONATSENDE
======================================================================= */
melde("");
melde("=== 5. Die Monatsspalten ===");
{
const s = W.monatsspalten();
ok(s.length === W.MONATE, `${s.length} Spalten`);
ok(s.join(",") === [...s].sort().join(","), "älteste zuerst");
/* DIE FALLE: Wer am 31. März einen Monat abzieht, landet in
JavaScript wieder im März -- den 31. Februar gibt es nicht, und
das Datum rollt über. Eine Spalte fiele aus, und zwar still. */
const ende = W.monatsspalten(6, new Date("2026-03-31T12:00:00Z"));
ok(ende.join(" ") === "2025-10 2025-11 2025-12 2026-01 2026-02 2026-03",
`am 31. März stimmen alle sechs Monate (${ende.join(" ")})`);
ok(new Set(ende).size === 6, "kein Monat doppelt, keiner übersprungen");
/* UND DIE ZWEITE FALLE: toISOString rechnet in UTC. Zwischen
Mitternacht und zwei Uhr am Monatsersten liefert das den VORMONAT.
Gerechnet wird deshalb in Europe/Berlin. */
ok(W.monatVon("2026-04-01T00:30:00+02:00") === "2026-04",
"am 1. April um 00:30 Ortszeit ist es April, nicht März");
ok(W.monatVon("2026-03-31T23:30:00+02:00") === "2026-03",
"und am 31. März um 23:30 noch März");
ok(W.monatVon("kaputt") === null, "ein unlesbares Datum ergibt null, nicht „jetzt“");
}
/* =======================================================================
6. DIE AUSWERTUNG — UND DER DRITTE AUSGANG
======================================================================= */
melde("");
melde("=== 6. Die Auswertung ===");
{
const d = json(await ruf(`/workspace/api/werdegang/person/${idModi}`, CREW, "GET", { cookie: kDogi }));
ok(d.person?.name === "Diene", "die Karte gehört der richtigen Person");
ok(d.gesetzt === 2, `zwei Punkte sind beantwortet (${d.gesetzt})`);
ok(d.gesamt === PUNKTE.length,
`von ${PUNKTE.length} im Katalog (${d.gesamt}) — gezählt, nicht abgeschrieben`);
/* DER DRITTE AUSGANG. Alles ist heute passiert, also gibt es genau
EINEN Monat mit Inhalt. Die Seite muss das SAGEN statt eine Linie
zwischen einem Wert und dem Rand zu zeichnen. */
ok(d.verlauf_moeglich === false,
"mit einem einzigen Monat wird kein Verlauf behauptet");
ok(d.monate_mit_inhalt === 1 && d.verlauf_ab === W.VERLAUF_AB,
`und die Antwort sagt, warum (${d.monate_mit_inhalt} von ${d.verlauf_ab} nötigen Monaten)`);
/* KEINE NOTE. Nirgends im ganzen Datenpaket darf eine Punktzahl, ein
Prozentwert oder ein Durchschnitt stehen. Geprüft am ROHTEXT --
ein Feld, das ich hier namentlich abfrage, müsste ich kennen; ein
neues, das jemand später hinzufügt, fiele durch. */
const roh = JSON.stringify(d).toLowerCase();
ok(!/"(punktzahl|score|prozent|durchschnitt|note|rang|platz)"/.test(roh),
"kein Feld heißt Punktzahl, Prozent, Durchschnitt, Note oder Rang");
ok(!/anlass/.test(roh), "und der Anlass kommt nicht mit");
ok(!roh.includes("dienstag"),
"auch nicht sein Inhalt — die Zeile darüber ist keine Wortklauberei");
/* KEIN FREMDER NAME. Auf einer Karte steht genau ein Mensch. */
ok(!roh.includes("rieke") && !roh.includes("tili"),
"auf der Karte steht kein anderer Name");
ok(typeof d.satz === "string" && d.satz.length > 20,
"ein Satz fasst die Karte zusammen");
ok(Array.isArray(d.texte), "und es gibt Texte dazu");
const arten = d.texte.map((t) => t.art);
ok(arten.includes("offen"),
"darunter der Hinweis, worüber die Auswertung schweigt");
/* DIE REIHENFOLGE IST DIE AUSSAGE: Was sich bewegt hat und was trägt,
steht VOR dem, was liegt. Rückmeldung, die auf Defizite zeigt,
senkt die Leistung messbar. */
const iVor = arten.indexOf("vor"), iLiegt = arten.indexOf("liegt");
ok(iLiegt === -1 || iVor === -1 || iVor < iLiegt,
"und was sich bewegt hat steht vor dem, was liegt");
}
/* =======================================================================
7. ZWEI MONATE — JETZT ENTSTEHT EIN VERLAUF
GEGENPROBE zu Abschnitt 6: Ohne sie wäre „kein Verlauf möglich“ nur
deshalb grün, weil die Funktion vielleicht NIE einen Verlauf sieht.
======================================================================= */
melde("");
melde("=== 7. Zwei Monate ===");
{
/* Eine Zeile mit einem Datum aus dem Vormonat -- von Hand in die
Spur, weil man die Uhr nicht vorstellen kann. Der Weg dorthin ist
derselbe, den die Anwendung benutzt. */
const d = oeffne();
const vormonat = new Date();
vormonat.setDate(15);
vormonat.setMonth(vormonat.getMonth() - 1);
const p3 = PUNKTE[2].schluessel;
d.prepare(`INSERT INTO entwicklung_lauf
(person_id, schluessel, von_id, stufe, vorher, nachgetragen, am)
VALUES (?,?,?,?,NULL,0,?)`)
.run(idModi, p3, idDogi, "hakt", vormonat.toISOString());
d.prepare(`INSERT INTO entwicklung_stand
(person_id, schluessel, von_id, stufe, anlass, am)
VALUES (?,?,?,?,NULL,?)`)
.run(idModi, p3, idDogi, "hakt", vormonat.toISOString());
d.close();
const k = json(await ruf(`/workspace/api/werdegang/person/${idModi}`, CREW, "GET", { cookie: kDogi }));
ok(k.verlauf_moeglich === true, "mit zwei Monaten gibt es einen Verlauf");
ok(k.spalten.length === W.MONATE, `${k.spalten.length} Monatsspalten`);
ok(k.streifen.length === 3, `drei Punkte im Streifen (${k.streifen.length})`);
/* FORTGESCHRIEBEN, NICHT LÜCKENHAFT: Was im Vormonat galt, gilt auch
diesen Monat -- niemand hat es geändert. Eine leere Zelle hieße
„keine Einschätzung“, und das wäre falsch. */
const zeile = k.streifen.find((z) => z.schluessel === p3);
const letzte = zeile.zellen.at(-1);
ok(letzte.stufe === "hakt",
"eine Einschätzung aus dem Vormonat gilt auch diesen Monat weiter");
ok(zeile.zellen.filter((c) => c.stufe).length === 2,
"davor steht nichts — vor der ersten Einschätzung gab es keine");
/* DIE BEWEGUNG ZÄHLT DAS RICHTIGE. */
const summe = (f) => k.bewegung.reduce((s, m) => s + m[f], 0);
ok(summe("neu") === 3, `drei Punkte wurden zum ersten Mal gesetzt (${summe("neu")})`);
ok(summe("vor") === 1, `einer ging nach vorn (${summe("vor")})`);
ok(summe("zurueck") === 0, `keiner zurück (${summe("zurueck")})`);
}
/* =======================================================================
8. DIE STARTAUFNAHME ZÄHLT NICHT ALS BEWEGUNG
Der Tag, an dem die Spur entstand, war kein Tag, an dem 43 Dinge
gleichzeitig passiert sind.
======================================================================= */
melde("");
melde("=== 8. Die Startaufnahme ===");
{
const d = oeffne();
const p4 = PUNKTE[3].schluessel;
const alt = new Date(); alt.setDate(15); alt.setMonth(alt.getMonth() - 2);
d.prepare(`INSERT INTO entwicklung_lauf
(person_id, schluessel, von_id, stufe, vorher, nachgetragen, am)
VALUES (?,?,?,?,NULL,1,?)`)
.run(idHand, p4, idDogi, "laeuft", alt.toISOString());
d.prepare(`INSERT INTO entwicklung_stand
(person_id, schluessel, von_id, stufe, anlass, am)
VALUES (?,?,?,?,NULL,?)`)
.run(idHand, p4, idDogi, "laeuft", alt.toISOString());
d.close();
const k = json(await ruf(`/workspace/api/werdegang/person/${idHand}`, CREW, "GET", { cookie: kDogi }));
const summe = (f) => k.bewegung.reduce((s, m) => s + m[f], 0);
ok(summe("neu") + summe("vor") + summe("zurueck") + summe("geklaert") === 0,
"eine nachgetragene Zeile ist keine Bewegung");
/* Gegenprobe: sie ist trotzdem da -- im Streifen. Sonst wäre die
Zeile oben nur deshalb grün, weil gar nichts gespeichert wurde. */
ok(k.streifen.length === 1 && k.streifen[0].zellen.some((c) => c.stufe === "laeuft"),
"im Streifen steht sie sehr wohl");
ok(/nichts bewegt/.test(k.satz),
`und der Satz sagt es ehrlich: ${(k.satz || "").slice(0, 60)}…`);
}
/* =======================================================================
9. DIE EIGENE SICHT — OHNE NAMEN, OHNE ANLASS, OHNE STREIFEN
======================================================================= */
melde("");
melde("=== 9. Die eigene Sicht ===");
{
const m = json(await ruf("/workspace/api/werdegang/mich", CREW, "GET", { cookie: kModi }));
ok(m.hat_karte === true, "ein Modi hat eine eigene Karte");
ok(Array.isArray(m.bewegung) && m.bewegung.length === W.MONATE,
"und sieht seine Bewegung über dieselben Monate");
ok(m.streifen === undefined,
"aber KEINEN Streifen — welcher Punkt wackelte, sagt ihm nur ein Mensch");
const roh = JSON.stringify(m).toLowerCase();
ok(!roh.includes("filipe") && !roh.includes("rieke"),
"kein Name eines Beurteilers");
ok(!roh.includes("dienstag"), "kein Anlasstext");
ok(/frag/i.test(m.hinweis || ""),
"stattdessen der Hinweis, danach zu fragen");
/* Seine eigene Antwort aus Block 5 darf hier nicht auftauchen -- sie
hat er über SICH gegeben, nicht jemand über ihn. */
ok(m.bewegung.reduce((s, x) => s + x.neu, 0) === 3,
"gezählt werden nur die Einschätzungen anderer");
/* GEGENPROBE: DogFather hat keine Karte -- und bekommt einen Satz
statt einer leeren Seite. */
const beiDogi = json(await ruf("/workspace/api/werdegang/mich", CREW, "GET", { cookie: kDogi }));
ok(beiDogi.hat_karte === false, "für DogFather wird keine Karte geführt");
}
/* =======================================================================
10. DIE LISTE VERGLEICHT NIEMANDEN
======================================================================= */
melde("");
melde("=== 10. Die Liste ===");
{
const l = json(await ruf("/workspace/api/werdegang/liste", CREW, "GET", { cookie: kDogi }));
ok(l.liste.length === 2, `zwei Karten — die rechte Hand und der Modi (${l.liste.length})`);
/* GEGENPROBE: Ein Creator steht NICHT drin. Für die Agentur wird
keine Entwicklungskarte geführt. */
ok(!l.liste.some((p) => p.name === "Tili"), "ein Creator steht nicht darin");
ok(!l.liste.some((p) => p.name === "Filipe"), "DogFather auch nicht");
/* NACH NAMEN SORTIERT, NICHT NACH AKTIVITÄT. Eine Liste, die die mit
der meisten Bewegung nach oben stellt, ist eine Rangliste mit einem
anderen Namen. */
const namen = l.liste.map((p) => p.name);
ok(namen.join(",") === [...namen].sort((a, b) => a.localeCompare(b, "de")).join(","),
`alphabetisch (${namen.join(", ")})`);
ok(/kein vergleich/i.test(l.hinweis || ""),
"und die Seite sagt selbst, dass sie niemanden vergleicht");
/* DER SATZ UEBER EINER GRUPPE KOMMT VOM SERVER (neu am 21.09.2026).
Er stand bis dahin als Liste in werdegang.js -- also in einer
Datei, die jeder im Netz ohne Anmeldung herunterladen kann, mit
den Rollennamen als Schluessel. Verschoben wurde er, weil
Rollennamen der Community dort nicht hingehoeren.
WARUM DAS HIER GEMESSEN WIRD: Beim Verschieben kann man eine
Erklaerung leicht ganz verlieren -- die Oberflaeche zeigt dann
einfach keine Zeile mehr, ohne Fehlermeldung. Eine Gruppe mit
Ueberschrift und ohne Erklaerung sieht nicht kaputt aus, sie sieht
nur aermer aus. Genau solche Verluste faellt niemandem auf. */
const mitSatz = l.liste.filter((p) => p.rolle_satz && p.rolle_satz.length > 10);
ok(mitSatz.length === l.liste.length,
`jede Karte bringt den Satz ihrer Gruppe mit (${mitSatz.length} von ${l.liste.length})`);
/* GEGENPROBE: Er darf nicht fuer alle derselbe sein -- sonst haette
das Verschieben aus zwei Saetzen einen gemacht, und die Pruefung
darueber waere trotzdem gruen. */
const verschieden = new Set(l.liste.map((p) => p.rolle_satz)).size;
ok(verschieden === new Set(l.liste.map((p) => p.rolle)).size,
`und jede Rolle hat ihren eigenen (${verschieden} Saetze auf `
+ `${new Set(l.liste.map((p) => p.rolle)).size} Rollen)`);
/* UND DIE OBERFLAECHE DARF IHN NICHT MEHR SELBST KENNEN. Bliebe die
alte Liste dort stehen, waere nichts gewonnen -- der Rollenname
stuende weiter in einer ausgelieferten Datei, und beide Fassungen
wuerden auseinanderlaufen. */
const { readFileSync } = await import("node:fs");
const vorne = readFileSync(
new URL("../workspace/assets/js/werdegang.js", import.meta.url), "utf8");
ok(!/GRUPPENSATZ\s*=/.test(vorne),
"und die Oberflaeche fuehrt keine eigene Liste mehr");
}
/* =======================================================================
11. IM BROWSER
Der Teil, den keine Schnittstellenprüfung ersetzt: Zeichnet die Seite
wirklich etwas, und bleibt die Konsole still?
UND DER NEBENBEFUND VOM 19.09.: Bis heute bekamen elf Seiten von
Team Dogi KEINE Kachelfarbe, weil `zuSeite()` nur die Agenturliste
kennt. Hier wird nachgemessen, dass diese Seite ihre bekommt.
======================================================================= */
melde("");
melde("=== 11. Im Browser ===");
{
const { chromium } = await import(
"file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs");
const browser = await chromium.launch();
const kontext = await browser.newContext({ viewport: { width: 1280, height: 1000 } });
const seite = await kontext.newPage();
const konsole = [];
seite.on("console", (m) => { if (m.type() === "error" && !/favicon|ERR_/i.test(m.text())) konsole.push(m.text()); });
seite.on("pageerror", (e) => konsole.push("Absturz: " + e.message));
await kontext.addCookies(kDogi.split("; ").filter(Boolean).map((teil) => {
const i = teil.indexOf("=");
return { name: teil.slice(0, i), value: teil.slice(i + 1), domain: "127.0.0.1", path: "/" };
}));
await seite.goto(`http://127.0.0.1:${PORT}/workspace/werdegang.html`, { waitUntil: "networkidle" });
await seite.waitForTimeout(900);
const liste = await seite.evaluate(() => ({
karten: document.querySelectorAll('.w-karte').length,
hinweis: document.getElementById('liste-hinweis')?.textContent || '',
ton: document.body.dataset.ton || null,
buehne: document.body.dataset.buehne || null,
zeichen: !!document.querySelector('.kopf-zeichen'),
}));
ok(liste.karten === 2, `zwei Karten stehen auf der Seite (${liste.karten})`);
ok(/kein vergleich/i.test(liste.hinweis), "der Hinweis steht sichtbar darüber");
ok(liste.ton === "40",
`die Seite trägt die Farbe ihrer Kachel (data-ton=${liste.ton}) — das ging bis heute nur auf den Agenturseiten`);
ok(liste.buehne === "wald", `und ihre Bühne (${liste.buehne})`);
ok(liste.zeichen, "das Zeichen der Kachel steht im Kopf");
await seite.locator('.w-karte').first().click();
await seite.waitForTimeout(700);
const karte = await seite.evaluate(() => ({
name: document.getElementById('karte-name')?.textContent || '',
balken: document.querySelectorAll('.w-bewegung__balken').length,
zellen: document.querySelectorAll('.w-streifen__zelle').length,
zeichenInZellen: [...document.querySelectorAll('.w-streifen__zelle')]
.every((z) => (z.textContent || '').trim().length > 0),
legende: document.querySelectorAll('.w-legende__punkt').length,
texte: document.querySelectorAll('.w-text').length,
breiter: document.documentElement.scrollWidth > document.documentElement.clientWidth,
}));
ok(/Diene|Rieke/.test(karte.name), `eine Karte öffnet sich (${karte.name})`);
ok(karte.zellen > 0, `der Streifen ist gezeichnet (${karte.zellen} Zellen)`);
/* DIE FARBE IST NIE DIE AUSKUNFT. Jede Zelle trägt ihr Zeichen als
Text -- wer Farben schlecht unterscheidet, liest dasselbe. */
ok(karte.zeichenInZellen, "jede Zelle trägt ihr Zeichen, nicht nur ihre Farbe");
ok(karte.legende === 2, `unter dem Diagramm steht eine Legende (${karte.legende} Einträge)`);
ok(karte.texte > 0, `und die Texte dazu (${karte.texte})`);
ok(!karte.breiter, "nichts steht über den rechten Rand hinaus");
/* HANDY. Zwölf Seiten hatten am 06.09. genau hier ihren Fehler: ein
Knopf lag über einem anderen, weil eine feste Schwelle nicht mehr
passte. */
await seite.setViewportSize({ width: 412, height: 915 });
await seite.waitForTimeout(500);
const handy = await seite.evaluate(() => ({
breiter: document.documentElement.scrollWidth > document.documentElement.clientWidth + 1,
balkenSichtbar: [...document.querySelectorAll('.w-bewegung__balken')]
.every((b) => b.getBoundingClientRect().width >= 8),
}));
ok(!handy.breiter, "auf 412 px steht nichts über den Rand hinaus");
ok(handy.balkenSichtbar, "und die Balken sind noch breit genug, um sie zu sehen");
if (process.env.SCHIRM) {
await seite.setViewportSize({ width: 1280, height: 1100 });
await seite.waitForTimeout(400);
await seite.screenshot({
path: "C:/Users/qciga/Documents/Obelix/DogiHompage/pruef-werdegang.png",
fullPage: true,
});
}
ok(konsole.length === 0,
konsole.length ? "Fehler im Browser: " + konsole[0].slice(0, 120) : "keine Fehler im Browser");
await browser.close();
}
/* =======================================================================
12. DIE BEIDEN ROLLENLISTEN DÜRFEN NICHT AUSEINANDERLAUFEN
======================================================================= */
melde("");
melde("=== 12. Was zusammengehört ===");
{
const hier = W.KARTEN_ROLLEN.join(",");
const quelle = readFileSync(
new URL("./workspace-entwicklung.js", import.meta.url), "utf8");
const dort = (quelle.match(/const KARTEN_ROLLEN = \[([^\]]+)\]/) || [])[1] || "";
const sauber = dort.replace(/["\s]/g, "");
ok(sauber === hier,
`dieselben Rollen in beiden Dateien (${hier} / ${sauber})`);
}
/* ------------------------------------------------------------------- */
melde("");
melde(`${geprueft} Pruefungen, ${fehler} Fehler`);
melde(fehler ? "NICHT IN ORDNUNG" : "ALLES IN ORDNUNG");
try { rmSync(ordner, { recursive: true, force: true }); } catch { /* Aufraeumrest */ }
process.exit(fehler ? 1 : 0);