Files
dogfather-universe/server/pruef-modi-katalog.mjs
T
DogFatherGitandClaude Opus 5 036f30454c Vorlagenbrett: was wartet, steht jetzt ganz oben -- quer ueber alle Kategorien
EIN LOCH, DAS ICH SELBST GEBAUT HABE
------------------------------------
Die Bewerbungen stehen an ihrer Karte, und das ist richtig: Man
entscheidet ueber eine bestimmte Aufgabe, und ihr Text ist die halbe
Auskunft.

Nur steht die Karte in EINER von vierzehn Kategorien. Bewirbt sich
Frida unter "Wachstum" und DogFather oeffnet wie immer
"Chat-Moderation", sieht er nichts -- die Bewerbung ist da, das Brett
ist offen, und trotzdem findet er sie nie.

Die Benachrichtigung von heute frueh hilft, aber sie ist ein Moment:
Wer sie wegwischt, hat keinen zweiten Weg mehr. Ein Brett, auf dem
etwas wartet, muss das selbst sagen koennen.

Jetzt steht ganz oben, ueber den Kategorie-Reitern: "Eine Bewerbung
wartet auf deine Antwort: Marina · Zehn Neue fragen, wie sie
hergefunden haben". Ein Tippen springt in die richtige Kategorie, zur
richtigen Karte, und hebt sie zwei Sekunden hervor.

DER SPRUNG STELLT AUCH DIE STUFE ZURUECK. Ohne das landet man in der
richtigen Kategorie und sieht trotzdem nichts, weil der Stufenfilter
die Karte gerade ausblendet -- eine Reise ins Nichts ist schlimmer als
kein Knopf.

ES DIENT BEIDEN SEITEN, und deshalb steht es nur einmal da: Wer
entscheidet, liest "wartet auf deine Antwort"; wer sich beworben hat,
liest "du hast dich beworben". Der Server schickt ohnehin jedem nur,
was ihn angeht.

UND WIEDER: ERST STAND DIE ABSICHT NUR IM KOMMENTAR
----------------------------------------------------
Im Kommentar stand "ES STEHT GANZ OBEN, ueber den Kategorie-Reitern".
Der Code haengte es darunter -- gesehen auf dem Bildschirmfoto, nicht
beim Lesen. Das ist heute das zweite Mal (nach "gleiche Mittel,
gleiche Staerke" bei den Handkarten). Ein Kommentar, der eine Absicht
beschreibt, erfuellt sie nicht; ich schreibe sie offenbar gern auf,
bevor ich sie baue.

VORHER GEMESSEN, NICHT VERMUTET
-------------------------------
Rundgang als Modi, 390 px, ueber alle 27 Kacheln der Startseite -- die
Liste aus den Kacheln GELESEN, nicht aufgeschrieben. Ergebnis: kein
Querscrollen, keine zu kleinen Ziele, kein Text auf Text, keine
Konsolenfehler. 27 von 27 sauber.

Dabei sah ich im Chat einen magentafarbenen Kasten ohne Beschriftung
und hielt ihn fuer kaputt. Nachgemessen mit echtem Finger
(hasTouch): 44x44-Knopf, 36x36-Farbprobe, quadratisch -- und am Laptop
30x30 / 22x22, ebenfalls quadratisch. Der schmale Balken entstand nur
in meinem Messaufbau (390 px OHNE Beruehrung), also in einer Lage, die
kein Geraet hat. Kein Fehler, und ich habe nichts "repariert", was
nicht kaputt war.

GEPRUEFT
--------
pruef-modi-katalog: 125 Pruefungen, 0 Fehler (vorher 116).

Gemessen wird genau der Fall, um den es geht: eine Bewerbung in einer
Kategorie, die gerade NICHT gewaehlt ist. Dazu der Sprung (landet auf
der richtigen Karte, und dort stehen Annehmen und Ablehnen), die
Daumengroesse (40 px) und der Wortlaut.

MIT GEGENPROBE, und die ist der Kern: Wartet nichts, steht auch nichts
da. Ohne sie bewiese alles darueber nur, dass das Band immer dasteht
-- und ein Hinweis, der immer da ist, wird ueberlesen.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-23 11:32:25 +02:00

1131 lines
57 KiB
JavaScript

/* =====================================================================
DER AUFGABENKATALOG AUS TEIL 2 DES ANFORDERUNGSDOKUMENTS
Kapitel 14: "Alle Aufgaben dieses Katalogs koennen 1:1 als Vorlagen in
die App importiert werden -- inklusive Kategorie, empfohlener Rolle
und Frequenz. So ist das Aufgaben-Board ab dem ersten Tag vollstaendig
befuellt, statt leer zu starten."
GEPRUEFT WIRD NICHT NUR, DASS ER DA IST. Ein Katalog, den man ansehen,
aber nicht uebernehmen kann, ist eine Speisekarte ohne Kueche. Und ein
uebernommener Eintrag, bei dem die Kategorie unterwegs verlorengeht,
ist die Haelfte wert -- dann sortiert man 60-mal von Hand ein, was im
Dokument bereits danebensteht.
DAZU DIE VERBORGENHEIT: Wer den Katalog nicht bekommt, darf auch nicht
erfahren, dass es ihn gibt. Eine Absage muss deshalb wortgleich mit
der fuer eine erfundene Art sein.
===================================================================== */
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
const ordner = mkdtempSync(join(tmpdir(), "ws-kat2-"));
process.env.WORKSPACE_DB = join(ordner, "workspace.db");
/* DER PORT MUSS MEINER SEIN (nachgeruestet 19.09.2026).
Ohne diese Zeile startet der eigene Server bei belegtem Port
STILL nicht -- und alles Folgende misst gegen einen fremden
Stand. Das ist der dritte Ausgang: belegt ist etwas anderes
als kaputt. */
const PORT = await eigenerPort(import.meta, "pruef-modi-katalog");
process.env.PORT = `${PORT}`;
process.env.SITE_ACCESS_SECRET = "lokaler-test";
process.env.SITE_ACCESS_CODE_DOGI = "DOGI-TEST-1";
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";
import { ersatzschreibungen } from "./helfer-deutsch.mjs";
import { fileURLToPath } from "node:url";
import { eigenerPort } from "./helfer-port.mjs";
await import("./index.js");
notbremse(180_000, "pruef-modi-katalog");
await new Promise((r) => setTimeout(r, 700));
const BASIS = `http://127.0.0.1:${PORT}`;
let fehler = 0;
let geprueft = 0;
const ok = (b, t) => { geprueft++; console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; };
let offen = 0;
const unklar = (t) => { offen++; console.log(" -- " + t); };
/* ---------- Personen ------------------------------------------------- */
const { DatabaseSync } = await import("node:sqlite");
const { scryptSync, randomBytes, createHmac } = await import("node:crypto");
const d = new DatabaseSync(process.env.WORKSPACE_DB);
const jetzt = new Date().toISOString();
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);
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", schluessel).update(code).digest("hex"), jetzt);
return d.prepare("SELECT last_insert_rowid() AS id").get().id;
}
const idDogi = anlegen("Filipe", "admin", "CODE-DOGI-0001");
const idMan = anlegen("Schulle", "manager", "CODE-MAN-0001");
const idCreat = anlegen("Tili", "creator", "CODE-ANNA-0001");
const idModi = anlegen("Marina", "modi", "CODE-MODI-0001");
/* DIE BEIDEN HAENDE (23.09.2026). Ohne sie laesst sich die Grenze, die
Filipe gezogen hat, gar nicht messen: "nur dogfather und die rechte
hand sollen annehmen oder ablehnen koennen". Die linke Hand darf
verteilen -- und genau deshalb ist sie die schaerfste Gegenprobe:
Wer nur "darf verteilen" prueft, laesst sie mitentscheiden. */
const idHand = anlegen("Rieke", "hand", "CODE-HAND-0001");
const idLinke = anlegen("Funny", "linke", "CODE-LINK-0001");
void idHand; void idLinke;
d.close();
void idDogi;
void idMan;
async function anmelden(rolle, code) {
const a = await fetch(BASIS + "/workspace/api/anmelden", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ rolle, code }),
});
const keks = (a.headers.getSetCookie?.() || []).map((z) => z.split(";")[0]).join("; ");
return { status: a.status, keks };
}
async function hole(keks, pfad, aufruf = {}) {
const a = await fetch(BASIS + pfad, {
...aufruf, headers: { ...(aufruf.headers || {}), cookie: keks },
});
return { status: a.status, daten: await a.json().catch(() => null) };
}
const jsonKopf = { "Content-Type": "application/json" };
const modiAn = await anmelden("creator", "CODE-MODI-0001");
const dogiAn = await anmelden("admin", "CODE-DOGI-0001");
const manAn = await anmelden("manager", "CODE-MAN-0001");
const creatAn = await anmelden("creator", "CODE-ANNA-0001");
/* Ueber den stillen Zugang -- welche Kachel man anfasst, ist
gleichgueltig, der Code allein entscheidet (siehe die Anmelderoute). */
const handAn = await anmelden("creator", "CODE-HAND-0001");
const linkeAn = await anmelden("creator", "CODE-LINK-0001");
/* ---------- 1. Ist er vollstaendig da? ------------------------------- */
console.log("\n=== 1. Der Katalog, wie ihn das Dokument beschreibt ===\n");
let katalog = null;
{
const a = await hole(modiAn.keks, "/workspace/api/vorlagen");
katalog = a.daten?.katalog || null;
/* SEIT DEM 16.09.2026 NACH KATEGORIE UND STUFE.
Vorher standen hier neun PHASEN mit 60 Eintraegen -- und
nachgezaehlt gehoerten 39 davon DogFather und der rechten Hand.
Es war ein Aufbauplan, kein Arbeitskatalog. Der Aufbauplan gibt es
weiterhin, unter `aufbauplan`; hier steht jetzt, was ein Mensch im
Team TUT.
ZAHLEN FEST HINGESCHRIEBEN, wie vorher: Eine Zahl, die sich selbst
nachzaehlt, merkt nicht, wenn etwas herausfaellt. */
ok(katalog && katalog.gruppen.length === 14,
`${katalog?.gruppen?.length ?? 0} Kategorien (erwartet 14)`);
ok(katalog && katalog.stufen.length === 3,
`${katalog?.stufen?.length ?? 0} Stufen (erwartet 3)`);
ok(katalog && katalog.aufgaben.length === 101,
`${katalog?.aufgaben?.length ?? 0} Aufgaben (erwartet 101)`);
/* JEDE KATEGORIE MUSS BELEGT SEIN. Vorher hatten vier von vierzehn
eine Aufgabe oder keine (Events 1, Wachstum 1, Branding 1,
Sonstiges 0) -- eine Kategorie ohne Inhalt ist ein leeres
Versprechen im Auswahlfeld. */
const je = {};
for (const v of katalog?.aufgaben || []) je[v.gruppe] = (je[v.gruppe] || 0) + 1;
const leer = (katalog?.gruppen || []).filter((g) => !je[g.schluessel]);
ok(leer.length === 0,
`jede Kategorie hat Aufgaben${leer.length ? ": leer sind " + leer.map((g) => g.name).join(", ") : ""}`);
ok(Math.min(...Object.values(je)) >= 3,
`auch die kleinste hat mindestens drei (${Math.min(...Object.values(je))})`);
/* UND JEDE STUFE. Eine Stufe ohne Aufgaben waere ein Knopf, der eine
leere Liste oeffnet. */
const jeStufe = {};
for (const v of katalog?.aufgaben || []) jeStufe[v.stufe] = (jeStufe[v.stufe] || 0) + 1;
ok((katalog?.stufen || []).every((s) => jeStufe[s.schluessel] > 0),
`jede Stufe ist belegt (${Object.entries(jeStufe).map(([k, n]) => `${k}:${n}`).join(" ")})`);
ok((katalog?.aufgaben || []).every((v) => v.titel && v.text && v.gruppe && v.stufe && v.tage),
"jede Aufgabe hat Titel, Text, Kategorie, Stufe und Frist");
/* DER TEXT SAGT WARUM, nicht was. Ein Text, der kuerzer ist als der
Titel, ist eine Wiederholung -- und die haette man sich sparen
koennen. */
const duenn = (katalog?.aufgaben || []).filter((v) => v.text.length < v.titel.length + 20);
ok(duenn.length === 0,
`jeder Text begruendet${duenn.length ? ": zu duenn bei " + duenn[0].schluessel : ""}`);
ok(new Set((katalog?.aufgaben || []).map((v) => v.schluessel)).size
=== (katalog?.aufgaben || []).length, "keine Kennung doppelt");
/* DER AUFBAUPLAN IST NICHT VERLORENGEGANGEN. */
const plan = a.daten?.aufbauplan;
ok(plan && plan.aufgaben.length === 60 && plan.gruppen.length === 9,
`der Aufbauplan steht weiter zur Verfuegung (${plan?.aufgaben?.length ?? 0} in `
+ `${plan?.gruppen?.length ?? 0} Etappen)`);
}
/* Jede Kategorie im Katalog muss es auch wirklich geben -- sonst nimmt
der Server sie beim Uebernehmen an und lehnt sie beim Aendern ab. */
{
const a = await hole(modiAn.keks, "/workspace/api/aufgaben");
const gueltig = new Set((a.daten?.kategorien || []).map((k) => k.wert));
/* Die Kategorie heisst im ausgelieferten Katalog `gruppe` -- sie ist
zugleich die Gruppe, nach der die Oberflaeche sortiert. Ein
zweiter Name fuer dasselbe waere die Gelegenheit, dass beide
auseinanderlaufen. */
const unbekannt = [...new Set((katalog?.aufgaben || []).map((v) => v.gruppe))]
.filter((k) => !gueltig.has(k));
ok(gueltig.size > 0 && unbekannt.length === 0,
unbekannt.length
? `unbekannte Kategorie(n) im Katalog: ${unbekannt.join(", ")}`
: `alle ${gueltig.size} Kategorien passen zum Katalog`);
}
/* ---------- 2. Wer bekommt ihn nicht? -------------------------------- */
console.log("\n=== 2. Und wer ihn nicht bekommt ===\n");
for (const [name, an] of [["Manager", manAn], ["Creator", creatAn]]) {
const a = await hole(an.keks, "/workspace/api/vorlagen");
ok(a.status === 200 && a.daten?.katalog === null,
`${name}: kein Katalog (${JSON.stringify(a.daten?.katalog)})`);
/* Und der ANDERE Katalog ist bei ihnen sehr wohl da -- sonst waere
oben auch gruen, wenn die ganze Antwort leer kaeme. */
ok(a.daten?.aufgaben && Object.keys(a.daten.aufgaben).length > 0,
`${name}: der Creator-Katalog dagegen schon`);
}
{
const a = await hole(modiAn.keks, "/workspace/api/vorlagen");
ok(a.daten?.aufgaben === null,
"und der Modi bekommt umgekehrt den Creator-Katalog nicht");
}
/* ---------- 3. Ein Modi kann sich nur BEWERBEN ------------------------
Filipe, 23.09.2026: "die modis sollen bei all diesen voschlaegen
auch nur bewerben koennen. die aufgaben aus der vorlage, da sollen
die modis sich nur bewerben koennen und nur dogfather und die rechte
hand sollen annehmen oder ablehnen koennen, mit einem text als
notiz."
BIS HEUTE STAND HIER DAS GEGENTEIL: "uebernommen (200)", "und er
selbst ist verantwortlich". Diese Pruefung ist beim Umbau ROT
geworden -- richtig so, sie hat die Aenderung bemerkt.
DREI DINGE WERDEN GEMESSEN, und das dritte ist das, was Filipe
ausdruecklich wollte:
1. Der Modi kommt an die alte Tuer nicht mehr heran.
2. Die Bewerbung kommt an -- und legt NOCH KEINE Aufgabe an.
3. Erst die Zusage macht daraus Arbeit, mit Notiz daran.
--------------------------------------------------------------------- */
console.log("\n=== 3. Ein Modi kann sich nur bewerben ===\n");
let bewerbungsNr = null, bewerbungsVorlage = null;
{
const nr = katalog.aufgaben.findIndex((v) => v.gruppe === "chat");
const vorlage = katalog.aufgaben[nr];
bewerbungsVorlage = vorlage;
/* ---- 1. Die alte Tuer ist zu -------------------------------------- */
const alt = await hole(modiAn.keks, "/workspace/api/vorlagen/uebernehmen", {
method: "POST", headers: jsonKopf,
body: JSON.stringify({ art: "katalog", nr, gruppe: "chat" }),
});
ok(alt.status === 403, `selbst uebernehmen geht nicht mehr (${alt.status})`);
/* DER SATZ IST EIN WEGWEISER, KEINE ABSAGE. Wer "Das darfst du
nicht" liest, haelt sich fuer ausgeschlossen -- es gibt aber einen
Weg, er fuehrt nur ueber jemanden. */
ok(alt.daten?.fehler === "erst_bewerben",
` und sagt, was stattdessen geht (${alt.daten?.fehler})`);
const keine = await hole(modiAn.keks, "/workspace/api/aufgaben");
const gibtsNicht = (keine.daten?.aufgaben || [])
.some((x) => x.vorlage === vorlage.schluessel);
ok(!gibtsNicht, " und es liegt nichts auf seinem Brett");
/* ---- 2. Die Bewerbung ---------------------------------------------- */
const b = await hole(modiAn.keks, "/workspace/api/vorlagen/bewerben", {
method: "POST", headers: jsonKopf,
body: JSON.stringify({ vorlage: vorlage.schluessel, text: "ich habe Freitag Zeit" }),
});
ok(b.status === 201, `die Bewerbung kommt an (${b.status})`);
/* DAS IST DER KERN DER BAUWEISE: Bis jemand ja sagt, entsteht keine
Arbeit. Waere die Aufgabe schon da, stuenden nach zwoelf Absagen
zwoelf herrenlose Aufgaben auf dem Brett -- und das Ablehnen
muesste Aufgaben LOESCHEN, um das wieder einzufangen. */
const nochNicht = await hole(modiAn.keks, "/workspace/api/aufgaben");
ok(!(nochNicht.daten?.aufgaben || []).some((x) => x.vorlage === vorlage.schluessel),
" und legt noch KEINE Aufgabe an — das tut erst die Zusage");
const meine = await hole(modiAn.keks, "/workspace/api/vorlagen");
const meineB = (meine.daten?.bewerbungen || [])
.find((x) => x.vorlage === vorlage.schluessel);
ok(!!meineB, "er sieht seine eigene Bewerbung");
ok(meineB?.selbst === true && meineB?.text === "ich habe Freitag Zeit",
` samt seinem Wort dazu ("${meineB?.text}")`);
ok(meine.daten?.darf_entscheiden === false,
"und der Server sagt ihm, dass er nicht entscheidet");
bewerbungsNr = meineB?.id ?? null;
/* Zweimal dieselbe Bitte ist eine Bitte. */
const doppelt = await hole(modiAn.keks, "/workspace/api/vorlagen/bewerben", {
method: "POST", headers: jsonKopf,
body: JSON.stringify({ vorlage: vorlage.schluessel }),
});
ok(doppelt.status === 409 && doppelt.daten?.fehler === "schon_beworben",
`zweimal dieselbe Bewerbung wird abgewiesen (${doppelt.status})`);
}
/* ---------- 3b. Wer entscheidet -- und wer nicht ----------------------
DIE LINKE HAND IST HIER DIE SCHAERFSTE PROBE. Sie darf verteilen und
gehoert sonst ueberall dazu; Filipe hat bei der Entscheidung aber
genau zwei genannt. Wer statt "darf entscheiden" nur "darf
verteilen" prueft, laesst sie durch -- und niemand merkt es, weil
alles funktioniert.
--------------------------------------------------------------------- */
console.log("\n=== 3b. Wer über die Bewerbung entscheidet ===\n");
{
ok(Number.isInteger(bewerbungsNr), `die Bewerbung hat eine Nummer (${bewerbungsNr})`);
const alsLinke = await hole(linkeAn.keks, "/workspace/api/vorlagen");
ok(alsLinke.daten?.darf_verteilen === true,
"die linke Hand darf verteilen (sie bekommt den Übernehmen-Weg)");
ok(alsLinke.daten?.darf_entscheiden === false,
" aber NICHT entscheiden — woertlich nach Filipes Aufzaehlung");
const versuch = await hole(linkeAn.keks, "/workspace/api/vorlagen/bewerbung/annehmen", {
method: "POST", headers: jsonKopf,
body: JSON.stringify({ id: bewerbungsNr, kommentar: "mach ich" }),
});
ok(versuch.status === 403,
` und am Knopf vorbei geht es auch nicht (${versuch.status})`);
/* GEGENPROBE: Der Modi selbst erst recht nicht -- sonst naehme er
seine eigene Bewerbung an. */
const selbst = await hole(modiAn.keks, "/workspace/api/vorlagen/bewerbung/annehmen", {
method: "POST", headers: jsonKopf,
body: JSON.stringify({ id: bewerbungsNr }),
});
ok(selbst.status === 403, `und der Bewerber selbst schon gar nicht (${selbst.status})`);
/* Die rechte Hand sieht sie -- mit Namen, denn sie entscheidet ueber
einen Menschen und nicht ueber einen Listeneintrag. */
const alsHand = await hole(handAn.keks, "/workspace/api/vorlagen");
const sieht = (alsHand.daten?.bewerbungen || []).find((x) => x.id === bewerbungsNr);
ok(alsHand.daten?.darf_entscheiden === true, "die rechte Hand entscheidet");
ok(!!sieht && sieht.person_name === "Marina",
` und sieht, wer sich beworben hat (${sieht?.person_name})`);
ok(sieht?.selbst === false, " als fremde Bewerbung, nicht als eigene");
}
/* ---------- 3c. Die Zusage macht daraus Arbeit ------------------------ */
console.log("\n=== 3c. Annehmen und ablehnen — mit Notiz ===\n");
{
const a = await hole(handAn.keks, "/workspace/api/vorlagen/bewerbung/annehmen", {
method: "POST", headers: jsonKopf,
body: JSON.stringify({ id: bewerbungsNr, kommentar: "danke dir, mach das gern" }),
});
ok(a.status === 200 && a.daten?.zustand === "angenommen",
`die rechte Hand sagt zu (${a.status})`);
ok(Number.isInteger(a.daten?.aufgabe_id),
` und JETZT entsteht die Aufgabe (#${a.daten?.aufgabe_id})`);
const brett = await hole(modiAn.keks, "/workspace/api/aufgaben");
const neu = (brett.daten?.aufgaben || [])
.find((x) => x.vorlage === bewerbungsVorlage.schluessel);
ok(!!neu, "sie liegt auf seinem Brett");
/* SIE MUSS AUSSEHEN WIE EINE VERTEILTE. Ein zweiter Weg zum Anlegen
waere ein zweiter Satz Regeln -- deshalb geht die Zusage durch
dieselbe Funktion wie das Uebernehmen. */
ok(neu?.kategorie === bewerbungsVorlage.gruppe,
`mit ihrer Kategorie (${neu?.kategorie} / erwartet ${bewerbungsVorlage.gruppe})`);
ok(!!neu?.frist, `und mit Frist (${neu?.frist})`);
ok(neu?.verantwortlich_id === idModi, "und der Bewerber ist verantwortlich");
/* Die Bewerbung ist damit vom Tisch -- sonst entschiede gleich noch
jemand ein zweites Mal darueber. */
const danach = await hole(handAn.keks, "/workspace/api/vorlagen");
ok(!(danach.daten?.bewerbungen || []).some((x) => x.id === bewerbungsNr),
"und sie wartet nicht mehr auf eine Antwort");
const nochmal = await hole(handAn.keks, "/workspace/api/vorlagen/bewerbung/annehmen", {
method: "POST", headers: jsonKopf,
body: JSON.stringify({ id: bewerbungsNr }),
});
ok(nochmal.status === 409 && nochmal.daten?.fehler === "keine_bewerbung",
`zweimal entscheiden geht nicht (${nochmal.status})`);
/* ---- UND DIE ABSAGE, mit Notiz ------------------------------------ */
const nr2 = katalog.aufgaben.findIndex((v) => v.gruppe === "events");
const v2 = katalog.aufgaben[nr2];
await hole(modiAn.keks, "/workspace/api/vorlagen/bewerben", {
method: "POST", headers: jsonKopf,
body: JSON.stringify({ vorlage: v2.schluessel }),
});
const offen2 = (await hole(handAn.keks, "/workspace/api/vorlagen"))
.daten?.bewerbungen?.find((x) => x.vorlage === v2.schluessel);
ok(!!offen2, "eine zweite Bewerbung liegt vor");
ok(offen2?.text === null,
" auch ohne Wort dazu — der Text ist freiwillig, wie bei den Aufgaben");
const ab = await hole(handAn.keks, "/workspace/api/vorlagen/bewerbung/ablehnen", {
method: "POST", headers: jsonKopf,
body: JSON.stringify({ id: offen2.id, kommentar: "das macht diesmal jemand anders" }),
});
ok(ab.status === 200 && ab.daten?.zustand === "abgelehnt",
`und wird abgelehnt (${ab.status})`);
ok(ab.daten?.aufgabe_id === null,
" ohne dass Arbeit entsteht, die niemand bestellt hat");
/* DIE NOTIZ BLEIBT STEHEN -- das ist der Teil, den Filipe
ausdruecklich genannt hat ("mit einem text als notiz"). Gelesen
wird direkt aus der Datenbank: Ein Feld, das der Server zwar
annimmt, aber nirgends speichert, saehe von aussen genauso aus. */
const { DatabaseSync: DB2 } = await import("node:sqlite");
const d2 = new DB2(process.env.WORKSPACE_DB, { readOnly: true });
const zeilen = d2.prepare(`SELECT zustand, entscheid_text, entschieden_von, aufgabe_id
FROM vorlagen_bewerbungen ORDER BY id`).all();
d2.close();
const zu = zeilen.find((z) => z.zustand === "angenommen");
const weg = zeilen.find((z) => z.zustand === "abgelehnt");
ok(zu?.entscheid_text === "danke dir, mach das gern",
`die Notiz der Zusage steht in der Datenbank ("${zu?.entscheid_text}")`);
ok(weg?.entscheid_text === "das macht diesmal jemand anders",
`und die der Absage auch ("${weg?.entscheid_text}")`);
ok(zu?.entschieden_von === idHand && weg?.entschieden_von === idHand,
"bei beiden steht, WER entschieden hat");
ok(Number.isInteger(zu?.aufgabe_id) && weg?.aufgabe_id === null,
"und nur an der Zusage haengt eine Aufgabe");
/* ---- Nach einer Absage darf man es wieder versuchen ---------------- */
const nochmalBewerben = await hole(modiAn.keks, "/workspace/api/vorlagen/bewerben", {
method: "POST", headers: jsonKopf,
body: JSON.stringify({ vorlage: v2.schluessel, text: "doch noch Zeit" }),
});
ok(nochmalBewerben.status === 201,
`nach einer Absage kann man sich wieder bewerben (${nochmalBewerben.status})`);
/* GEGENPROBE zur Sperre oben: Waere der Index ueber ALLE Zustaende
eindeutig, kaeme hier 409 -- und eine Absage waere ein Bann. */
const wiederOffen = (await hole(handAn.keks, "/workspace/api/vorlagen"))
.daten?.bewerbungen?.filter((x) => x.vorlage === v2.schluessel).length;
ok(wiederOffen === 1, `und sie wartet wieder auf Antwort (${wiederOffen})`);
/* Und auf etwas, das man schon hat, bewirbt man sich nicht. */
const doppelt = await hole(modiAn.keks, "/workspace/api/vorlagen/bewerben", {
method: "POST", headers: jsonKopf,
body: JSON.stringify({ vorlage: bewerbungsVorlage.schluessel }),
});
ok(doppelt.status === 409 && doppelt.daten?.fehler === "liegt_schon_bei_dir",
`auf etwas, das schon auf dem Brett liegt, bewirbt man sich nicht (${doppelt.status})`);
}
/* ---------- 4. DogFather traegt fuer jemanden ein -------------------- */
console.log("\n=== 4. DogFather trägt für einen Modi ein ===\n");
{
const nr = katalog.aufgaben.findIndex((v) => v.gruppe === "analyse");
const a = await hole(dogiAn.keks, "/workspace/api/vorlagen/uebernehmen", {
method: "POST", headers: jsonKopf,
body: JSON.stringify({ art: "katalog", nr, gruppe: "analyse", person_id: idModi }),
});
ok(a.status === 200 && a.daten?.angelegt === 1, `für den Modi geht es (${a.status})`);
/* Aber nicht an einen Creator -- sonst laege eine Aufgabe mit
Modi-Kategorie an jemandem, der die Kategorien gar nicht kennt, und
die Trennung waere von innen aufgeweicht. */
const b = await hole(dogiAn.keks, "/workspace/api/vorlagen/uebernehmen", {
method: "POST", headers: jsonKopf,
body: JSON.stringify({ art: "katalog", nr, gruppe: "monat", person_id: idCreat }),
});
ok(b.status === 404, `an einen Creator dagegen nicht (${b.status})`);
/* Und ohne Person gar nicht -- eine Aufgabe ohne Adressaten ist ein
Zettel. */
const c = await hole(dogiAn.keks, "/workspace/api/vorlagen/uebernehmen", {
method: "POST", headers: jsonKopf,
body: JSON.stringify({ art: "katalog", nr, gruppe: "monat" }),
});
ok(c.status === 400 && /Person/i.test(c.daten?.fehler || ""),
`ohne Person wird gefragt (${c.status} ${c.daten?.fehler || ""})`);
}
/* ---------- 5. Der heikelste Fall ------------------------------------ */
console.log("\n=== 5. Ein Manager versucht es trotzdem ===\n");
{
const a = await hole(manAn.keks, "/workspace/api/vorlagen/uebernehmen", {
method: "POST", headers: jsonKopf,
body: JSON.stringify({ art: "katalog", nr: 0, gruppe: "aufbau", person_id: idModi }),
});
const b = await hole(manAn.keks, "/workspace/api/vorlagen/uebernehmen", {
method: "POST", headers: jsonKopf,
body: JSON.stringify({ art: "gibtesnicht", nr: 0 }),
});
/* WORTGLEICH. Ein eigener Fehlertext waere die Auskunft, dass es die
Art gibt -- und damit, dass es den Katalog gibt. */
ok(a.status === b.status && JSON.stringify(a.daten) === JSON.stringify(b.daten),
`klingt wie eine erfundene Art (${a.status} ${JSON.stringify(a.daten)})`);
}
/* ---------- 6. Im Browser -------------------------------------------- */
console.log("\n=== 6. Das Brett im echten Browser ===\n");
{
const { chromium } = await import(
"file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs");
const browser = await chromium.launch();
try {
const kontext = await browser.newContext({ viewport: { width: 1280, height: 1000 } });
const seite = await kontext.newPage();
const konsole = [];
seite.on("console", (e) => { if (e.type() === "error") konsole.push(e.text()); });
seite.on("pageerror", (e) => konsole.push("Absturz: " + e.message));
await seite.goto(BASIS + "/workspace/", { waitUntil: "domcontentloaded" });
await seite.click('.rolle[data-rolle="creator"]');
await seite.fill("#code", "CODE-MODI-0001");
await seite.click("button[type=submit]");
await seite.waitForURL("**/start.html", { timeout: 15000 });
await seite.goto(BASIS + "/workspace/aufgaben.html", { waitUntil: "networkidle" });
await seite.waitForTimeout(1200);
/* Aufklappen, falls es zugeklappt startet. */
const zu = await seite.evaluate(() =>
document.getElementById("vorlagenbrett")?.dataset.auf !== "ja");
if (zu) {
await seite.click(".vorlagenbrett__kopf").catch(() => {});
await seite.waitForTimeout(700);
}
const bild = await seite.evaluate(() => ({
sichtbar: !document.getElementById("vorlagenbrett")?.hidden,
/* ZWEI REIHEN SEIT DEM 16.09.2026: oben die Kategorien, darunter
die Stufen. Getrennt gezaehlt -- eine gemeinsame Zahl haette
nicht gesagt, WELCHE der beiden fehlt. */
/* UEBER DIE BESCHRIFTUNG UNTERSCHIEDEN, nicht ueber eine
Hilfsklasse (16.09.2026). Seit die Stufenreihe genauso aussieht
wie die Kategorienreihe -- so wollte Filipe es -- gibt es im
Aussehen keinen Unterschied mehr. Den gibt es aber im SINN, und
der steht ohnehin da: in dem Wort, das ein Vorleseprogramm
ansagt. Eine Klasse nur zum Wiederfinden waere eine zweite
Wahrheit ueber dieselbe Sache. */
kategorien: [...document.querySelectorAll(
'[aria-label="Kategorie wählen"] .stufenleiter__name')]
.map((z) => z.textContent.trim()),
stufen: [...document.querySelectorAll(
'[aria-label="Stufe wählen"] .stufenleiter__name')]
.map((z) => z.textContent.trim()),
karten: document.querySelectorAll(".vorlagenbrett__karte").length,
/* WIE VIELE ZEILEN BRAUCHT DIE FRIST? Gemessen ueber einen
Bereich um den TEXT, nicht ueber das Element.
ERST FALSCH GEMESSEN, und die Gegenprobe hat es verraten:
`element.getClientRects()` liefert hier IMMER genau ein
Rechteck, weil ein Flex-Kind zu einem Block wird -- die
Pruefung blieb gruen, auch mit dem Fehler wieder eingebaut.
Ein Bereich ueber den Textknoten dagegen gibt pro Zeilenkasten
ein Rechteck, und genau das ist die Frage: Steht "in 2 Tagen"
auf einer Zeile oder als "in / 2 / Tagen" untereinander? */
fristZeilen: [...document.querySelectorAll(".vorlagenbrett__frist")]
.map((z) => {
const b = document.createRange();
b.selectNodeContents(z);
return b.getClientRects().length;
}),
/* Und wie viele Karten tragen ueberhaupt den DRITTEN Eintrag, an
dem es eng wurde. Ohne diese Zahl wuerde die Zeile darueber
auch dann gruen, wenn der enge Fall gar nicht auf dem Schirm
war. */
engeKarten: document.querySelectorAll(".vorlagenbrett__schon").length,
text: document.body.innerText,
kopf: (document.querySelector(".kopfleiste .marke__text") || {}).textContent || "",
}));
ok(bild.sichtbar, "das Brett steht da");
ok(bild.kategorien.length === 14,
`mit vierzehn Kategorien (${bild.kategorien.join(" · ")})`);
/* DREI STUFEN PLUS "Alle" -- der vierte Knopf ist die Ruecknahme.
Ohne ihn kaeme man aus einer Stufe nicht wieder heraus, ohne die
Kategorie zu wechseln. */
ok(bild.stufen.length === 4 && bild.stufen[0] === "Alle",
`und drei Stufen daneben (${bild.stufen.join(" · ")})`);
ok(bild.karten > 0, `und ${bild.karten} Karten in der offenen Etappe`);
/* Der Katalog des ANDEREN darf nirgends durchschlagen. */
ok(!/vier Bereichen und vier Stufen/.test(bild.text),
"kein Wort vom Creator-Katalog");
/* DIE KOPFLEISTE. Auf jeder Seite steht dort fest "Creator
Workspace" -- fuer jemanden, der moderiert statt einen Kanal
aufzubauen, ist das schlicht der falsche Name. Aufgefallen auf
dem Bildschirmfoto, wie schon die Zierzeile auf der Startseite. */
ok(bild.kopf.includes("Team") && !bild.kopf.includes("Creator"),
`die Kopfleiste nennt seinen Betrieb ("${bild.kopf.trim()}")`);
/* Und der Seitenname dahinter steht noch da -- ihn beim Austausch
zu verlieren waere lautlos passiert. */
ok(/Aufgaben/.test(bild.kopf), "der Seitenname dahinter blieb erhalten");
/* DIE FUSSZEILE DER KARTEN (16.09.2026, auf dem Bildschirmfoto
gefunden). Bei einer schon uebernommenen Aufgabe stehen dort
drei Dinge statt zwei, und "Frist: in 2 Tagen" brach mitten im
Wort auf drei Zeilen um. Die Karten sind alle gleich breit --
eine einzige gequetschte reicht, um den Fehler zu zeigen. */
ok(bild.engeKarten > 0,
`${bild.engeKarten} Karten tragen den dritten Fusseintrag (der enge Fall)`);
const zuViel = bild.fristZeilen.filter((n) => n !== 1).length;
ok(bild.fristZeilen.length > 0 && zuViel === 0,
`jede der ${bild.fristZeilen.length} Fristangaben steht auf einer Zeile`
+ (zuViel ? ` -- ${zuViel} umgebrochen` : ""));
/* UMLAUTE AUF DEM FERTIGEN BILDSCHIRM (16.09.2026).
Am 16.09. stand unter jeder Kategorie eine Erklaerung in ASCII:
"Was im Livechat passiert, waehrend gesendet wird. Loeschen,
stummschalten, begruessen". Alles gruen, niemand hat es gemerkt.
HIER und nicht im Quelltext, weil hier der Unterschied sichtbar
wird, auf den es ankommt: `innerText` ist, was ein Mensch liest.
Feldnamen, Stilklassen und Adressen -- die ASCII sein MUESSEN --
stehen nicht drin. Im Quelltext derselben Skripte schlaegt
dieselbe Liste 71 Mal an, und kein einziges Mal zu Recht.
Die Kataloge und die festen Seitentexte prueft
pruef-deutsche-texte, ohne Browser. */
const ersatz = ersatzschreibungen(bild.text);
ok(bild.text.length > 400 && ersatz.length === 0,
ersatz.length
? `Ersatzschreibungen auf dem Brett: ${ersatz.join(", ")}`
: `der Text auf dem Brett traegt Umlaute (${bild.text.length} Zeichen geprueft)`);
/* GEGENPROBE, sonst hiesse "nichts gefunden" nur, dass nichts
gesucht wurde -- etwa wenn `innerText` leer bliebe. */
ok(ersatzschreibungen(`${bild.text} waehrend`).length === 1,
"und ein eingeschmuggeltes waehrend wuerde auffallen");
/* ==== AM BILDSCHIRM: BEWERBEN STATT UEBERNEHMEN (23.09.2026) ===
Filipe: „die modis sollen bei all diesen voschlaegen auch nur
bewerben koennen."
ALLES BISHERIGE HAT DEN SERVER GEPRUEFT. Hier geht es um die
andere Haelfte: Steht der richtige Knopf da? Ein Modi, der
„Uebernehmen" liest und dann eine Absage bekommt, haelt die
Seite fuer kaputt. */
const knoepfe = await seite.evaluate(() =>
[...document.querySelectorAll(".vorlagenbrett__karte .knopf")]
.map((k) => k.textContent.trim()));
/* DIE ZAHL GEHOERT IN DIE BEDINGUNG. Bei null Karten waere
`every()` wahr, ohne einen Knopf angesehen zu haben -- genau der
Fehler, der am 01.09. dreimal gruen dastand. */
ok(knoepfe.length >= 10 && knoepfe.every((t) => t === "Bewerben"),
`alle ${knoepfe.length} Knoepfe heissen "Bewerben" (${[...new Set(knoepfe)].join(", ")})`);
/* GEGENPROBE: „Uebernehmen" kommt nirgends mehr vor -- weder als
Knopf noch als „Alle 12 uebernehmen" darunter. */
const uebernahme = await seite.evaluate(() =>
[...document.querySelectorAll("#vorlagenbrett button")]
.filter((k) => /übernehmen/i.test(k.textContent)).length);
ok(uebernahme === 0,
`Gegenprobe: kein einziger Uebernehmen-Knopf mehr (${uebernahme})`);
/* Die schon beantragte Karte sagt, dass sie wartet -- und WER
antwortet. Sonst wartet man auf niemanden.
ERST ZUR RICHTIGEN KATEGORIE. Die offene Bewerbung liegt bei
„Events", das Brett startet bei der ersten Kategorie. Ohne
diesen Klick maesse die Pruefung eine Karte, die gar nicht auf
dem Bildschirm steht -- und waere rot, ohne dass etwas kaputt
ist. (Beim Bauen genau so passiert.) */
await seite.evaluate(() => {
const reihe = document.querySelector('.vorlagenbrett .stufenleiter');
const k = [...(reihe?.querySelectorAll('button') || [])]
.find((b) => /Events/i.test(b.textContent));
if (k) k.click();
});
await seite.waitForTimeout(700);
const wartet = await seite.evaluate(() => {
const w = document.querySelector(".vorlagenbrett__warte");
const karte = w?.closest(".vorlagenbrett__karte");
return w ? {
satz: w.textContent.trim(),
zurueck: !![...karte.querySelectorAll("button")]
.find((b) => /zurückziehen/i.test(b.textContent)),
} : null;
});
ok(!!wartet, "die Karte, auf die er sich beworben hat, sagt es");
ok(/rechte hand/i.test(wartet?.satz || ""),
` und nennt, wer antwortet ("${wartet?.satz}")`);
ok(wartet?.zurueck === true, " mit einem Weg zurueck");
ok(konsole.length === 0,
konsole.length ? `Fehler in der Konsole: ${konsole[0]}` : "keine Fehler in der Konsole");
/* DER PFAD GEHOERT AN DIESE DATEI, nicht ans Arbeitsverzeichnis.
Vorher stand hier ein blosser Dateiname: Das Bild landete dort,
von wo aus gestartet wurde, waehrend die Zeile darunter
"server/..." meldete. Man sucht es dann an der falschen Stelle
-- oder findet ein altes und haelt es fuer das neue. */
const bildPfad = fileURLToPath(new URL("pruef-modi-katalog.png", import.meta.url));
await seite.screenshot({ path: bildPfad, fullPage: false });
console.log(" -- Bildschirmfoto: server/pruef-modi-katalog.png");
await kontext.close();
} finally {
await browser.close();
}
}
/* =======================================================================
6b. AN WEN? — die Auswahl steht dort, wo entschieden wird (16.09.2026)
Filipe: „perfektionniere die kategorien wo ich aufgaben an die modis
verteile und so."
Bis heute kam die Person aus dem Formular GANZ OBEN auf der Seite:
dort wählen, herunterscrollen, „Übernehmen" drücken. Wer das nicht
weiß, bekommt „Bitte zuerst eine Person wählen" und sucht.
Und man sah beim Vergeben nicht, wer schon wie viel offen hat. Zu
wenig Zeit ist der meistgenannte Grund, warum Moderatoren aufhören;
die Person, die verteilt, ist die einzige, die das verhindern kann.
Dafür muss die Zahl dort stehen, wo entschieden wird — nicht auf
einer Auswertung, die man hinterher aufruft.
======================================================================= */
console.log("");
console.log("=== 6b. An wen die Aufgabe geht ===");
console.log("");
{
const { chromium } = await import(
"file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs");
const browser = await chromium.launch();
try {
const kontext = await browser.newContext({ viewport: { width: 1280, height: 1100 } });
const seite = await kontext.newPage();
const konsole = [];
seite.on("console", (e) => { if (e.type() === "error") konsole.push(e.text()); });
seite.on("pageerror", (e) => konsole.push("Absturz: " + e.message));
await seite.goto(BASIS + "/workspace/", { waitUntil: "domcontentloaded" });
await seite.click('.rolle[data-rolle="admin"]');
await seite.fill("#code", "CODE-DOGI-0001");
await seite.click("button[type=submit]");
await seite.waitForURL("**/start.html", { timeout: 15000 });
await seite.goto(BASIS + "/workspace/aufgaben.html", { waitUntil: "networkidle" });
await seite.waitForTimeout(1400);
const zu = await seite.evaluate(() =>
document.getElementById("vorlagenbrett")?.dataset.auf !== "ja");
if (zu) {
await seite.click(".vorlagenbrett__kopf").catch(() => {});
await seite.waitForTimeout(800);
}
const wahl = await seite.evaluate(() => {
const reihen = [...document.querySelectorAll(".stufenleiter")];
const letzte = document.querySelector('[aria-label="Person wählen"]');
const zahl = /^[0-9]+$/;
return {
reihen: reihen.length,
namen: [...(letzte?.querySelectorAll(".stufenleiter__name") || [])]
.map((z) => z.textContent.trim()),
zahlenOk: [...(letzte?.querySelectorAll(".stufenleiter__zahl") || [])]
.every((z) => zahl.test(z.textContent.trim())),
zahlen: [...(letzte?.querySelectorAll(".stufenleiter__zahl") || [])]
.map((z) => z.textContent.trim()),
/* Der Satz steht seit dem 16.09.2026 in `stufenleiter__hilfe`
-- derselbe Baustein wie beim anderen Brett. Vorher war es
`vorlagenbrett__stand`, und das ist die Zeile fuer Zahlen. */
satz: [...document.querySelectorAll(".stufenleiter__hilfe")]
.map((z) => z.textContent.trim())
.find((t) => t.includes("Formular oben") || t.includes("gehen an")) || "",
};
});
/* ZWEI FEINE REIHEN: die Stufen und die Personen. Eine einzelne
Zahl haette nicht gesagt, welche der beiden fehlt. */
/* DREI REIHEN: Kategorie, Stufe, Person. Die ersten beiden sehen
jetzt aus wie drueben beim anderen Brett. */
ok(wahl.reihen === 3, "drei Auswahlreihen (Kategorie, Stufe, Person): " + wahl.reihen);
ok(wahl.namen.includes("Marina"),
"der Modi steht zur Auswahl (" + wahl.namen.join(", ") + ")");
ok(!wahl.namen.includes("Tili") && !wahl.namen.includes("Schulle"),
"ein Creator und eine Managerin nicht — sie arbeiten im anderen Haus");
ok(wahl.zahlenOk && wahl.zahlen.length === wahl.namen.length,
"neben jedem Namen steht, wie viel offen ist (" + wahl.zahlen.join(", ") + ")");
ok(wahl.satz.includes("Formular oben"),
"und solange niemand gewählt ist, sagt die Seite es");
/* JETZT WIRKLICH DRUECKEN -- und nachsehen, ob die Aufgabe bei
DIESEM Menschen landet. Ein Knopf, den niemand betaetigt hat,
ist kein geprueter Knopf. */
await seite.evaluate(() => {
const reihen = [...document.querySelectorAll(".stufenleiter")];
const letzte = document.querySelector('[aria-label="Person wählen"]');
[...letzte.querySelectorAll(".stufenleiter__knopf")]
.find((k) => k.textContent.includes("Marina"))?.click();
});
await seite.waitForTimeout(600);
const nachWahl = await seite.evaluate(() =>
[...document.querySelectorAll(".stufenleiter__hilfe")]
.map((z) => z.textContent.trim()).find((t) => t.includes("gehen an")) || "");
ok(nachWahl.includes("Marina"), "nach dem Klick steht es da: " + nachWahl);
const vorher = await seite.evaluate(() => document.querySelectorAll(".karte").length);
await seite.evaluate(() => {
[...document.querySelectorAll(".vorlagenbrett__karte")][0]
?.querySelector("button")?.click();
});
await seite.waitForTimeout(1800);
const danach = await seite.evaluate(() => ({
karten: document.querySelectorAll(".karte").length,
fuerMarina: [...document.querySelectorAll(".karte")]
.filter((k) => k.textContent.includes("Marina")).length,
}));
ok(danach.karten === vorher + 1,
"die Aufgabe liegt auf dem Brett (" + vorher + " -> " + danach.karten + ")");
ok(danach.fuerMarina > 0, "und zwar bei Marina (" + danach.fuerMarina + ")");
/* ==== DIE BEWERBUNG AM BILDSCHIRM ENTSCHEIDEN (23.09.2026) ======
Filipe: „nur dogfather und die rechte hand sollen annehmen oder
ablehnen koennen, mit einem text als notiz."
DER SERVERTEIL IST OBEN GEPRUEFT. Hier geht es um das, was ein
Mensch tut: Sieht er die Bewerbung, steht der Name dabei, und
bekommt er ein Feld fuer seine Notiz?
ZUR RICHTIGEN KATEGORIE -- die offene Bewerbung liegt bei
„Events". Ohne den Klick maesse die Pruefung eine Karte, die gar
nicht auf dem Bildschirm steht. */
await seite.evaluate(() => {
const reihe = document.querySelector(".vorlagenbrett .stufenleiter");
const k = [...(reihe?.querySelectorAll("button") || [])]
.find((b) => /Events/i.test(b.textContent));
if (k) k.click();
});
await seite.waitForTimeout(800);
const bw = await seite.evaluate(() => {
const reihe = document.querySelector(".vorlagenbrett__bewerbung");
if (!reihe) return null;
const karte = reihe.closest(".vorlagenbrett__karte");
return {
name: reihe.querySelector(".vorlagenbrett__bname")?.textContent.trim() || "",
wort: reihe.querySelector(".vorlagenbrett__bwort")?.textContent.trim() || "",
schild: karte.querySelector(".vorlagenbrett__bschild")?.textContent.trim() || "",
knoepfe: [...reihe.querySelectorAll("button")].map((b) => b.textContent.trim()),
auffaellig: karte.dataset.beworben === "ja",
titel: karte.querySelector(".vorlagenbrett__titel")?.textContent.trim() || "",
};
});
ok(!!bw, "DogFather sieht die Bewerbung an der Karte");
ok(bw?.name === "Marina", `mit dem Namen dessen, der sie geschrieben hat (${bw?.name})`);
/* MAN ENTSCHEIDET UEBER EINEN MENSCHEN, nicht ueber einen
Listeneintrag -- deshalb stehen beide Knoepfe an SEINER Zeile. */
ok(JSON.stringify(bw?.knoepfe) === JSON.stringify(["Annehmen", "Ablehnen"]),
`und beiden Wegen daneben (${(bw?.knoepfe || []).join(", ")})`);
ok(bw?.wort.includes("doch noch Zeit"),
`sein Wort dazu steht dabei (${bw?.wort})`);
/* Eine Karte mit Bewerbung ist der einzige Grund, dieses Brett
gerade zu oeffnen -- sie darf auffallen. */
ok(bw?.auffaellig === true, "und die Karte faellt auf");
/* ---- Jetzt wirklich druecken, samt Notiz ---------------------- */
const vorherK = await seite.evaluate(() => document.querySelectorAll(".karte").length);
await seite.evaluate(() => {
[...document.querySelectorAll(".vorlagenbrett__bewerbung button")]
.find((b) => b.textContent.trim() === "Annehmen")?.click();
});
await seite.waitForTimeout(600);
/* DER KASTEN MUSS BEANTWORTET WERDEN. Beim ersten Anlauf einer
aehnlichen Pruefung habe ich ihn stehen lassen und dann gemeldet,
der Weg sei kaputt -- er war es nie, ich hatte nur nicht geklickt. */
const kastenDa = await seite.evaluate(() =>
!!document.querySelector("dialog.nachfrage[open]"));
ok(kastenDa, "es kommt ein Kasten für die Notiz — kein stilles Durchdrücken");
const feldDa = await seite.evaluate(() => {
const f = document.getElementById("nachfrage-grund");
return !!f && !f.closest("[hidden]");
});
ok(feldDa, " mit einem Feld für den Text — „mit einem text als notiz“");
await seite.fill("#nachfrage-grund", "danke dir, mach das gern");
await seite.click('dialog.nachfrage [data-teil="ja"]');
await seite.waitForTimeout(2200);
const nachher = await seite.evaluate(() => ({
karten: document.querySelectorAll(".karte").length,
nochBeworben: document.querySelectorAll(".vorlagenbrett__bewerbung").length,
}));
ok(nachher.karten === vorherK + 1,
`die Aufgabe entsteht mit der Zusage (${vorherK} -> ${nachher.karten})`);
/* UND DIE BEWERBUNG IST VOM TISCH. Bliebe sie stehen, entschiede
gleich noch jemand ein zweites Mal ueber dieselbe Bitte. */
ok(nachher.nochBeworben === 0,
`und die Bewerbung wartet nicht mehr (${nachher.nochBeworben})`);
/* ==== UND WAS IN EINER ANDEREN KATEGORIE WARTET? (23.09.2026) ===
DAS WAR EIN LOCH, das ich selbst gebaut hatte. Die Bewerbung
steht an ihrer Karte -- und die Karte steht in EINER von
vierzehn Kategorien. Wer eine andere offen hat, sieht nichts:
Die Bewerbung ist da, das Brett ist offen, und trotzdem findet
er sie nie.
Geprueft wird genau dieser Fall: Eine Bewerbung in einer
Kategorie, die gerade NICHT gewaehlt ist. Ohne das Band waere
hier nichts zu sehen -- und die Pruefung war es, die das
beweisen muss, nicht mein Zutrauen. */
const fern = katalog.aufgaben.find((v) => v.gruppe === "wachstum");
ok(!!fern, `es gibt eine Vorlage in einer anderen Kategorie (${fern?.gruppe})`);
await hole(modiAn.keks, "/workspace/api/vorlagen/bewerben", {
method: "POST", headers: jsonKopf,
body: JSON.stringify({ vorlage: fern.schluessel, text: "das kann ich" }),
});
/* Zurueck auf eine ANDERE Kategorie -- die Ausgangslage. */
await seite.evaluate(() => {
const reihe = document.querySelector(".vorlagenbrett .stufenleiter");
const k = [...(reihe?.querySelectorAll("button") || [])]
.find((b) => /Chat-Moderation/i.test(b.textContent));
if (k) k.click();
});
await seite.reload({ waitUntil: "networkidle" });
await seite.waitForTimeout(1600);
const zu2 = await seite.evaluate(() =>
document.getElementById("vorlagenbrett")?.dataset.auf !== "ja");
if (zu2) {
await seite.click(".vorlagenbrett__kopf").catch(() => {});
await seite.waitForTimeout(800);
}
const band = await seite.evaluate(() => {
const b = document.querySelector(".vorlagenbrett__warten");
if (!b) return null;
const knoepfe = [...b.querySelectorAll(".vorlagenbrett__warten-knopf")];
const offen = document.querySelector('.stufenleiter__knopf[aria-pressed="true"]');
return {
schild: b.querySelector(".vorlagenbrett__warten-schild")?.textContent.trim() || "",
knoepfe: knoepfe.map((k) => k.textContent.trim()),
/* Steht die Karte dazu gerade ueberhaupt auf dem Brett? */
karteDa: !!document.querySelector(".vorlagenbrett__karte"),
offeneKategorie: offen?.textContent.trim() || "",
hoch: Math.round(knoepfe[0]?.getBoundingClientRect().height || 0),
};
});
ok(!!band, "das Band sagt, dass etwas wartet — auch aus einer anderen Kategorie");
ok(/wartet|warten/.test(band?.schild || ""),
`mit einem Satz, der sagt was zu tun ist ("${band?.schild}")`);
ok((band?.knoepfe || []).some((t) => /Marina/.test(t)),
`und nennt, wer sich beworben hat (${(band?.knoepfe || []).join(" | ")})`);
/* AM DAUMEN TREFFBAR -- 40 px ist die Grenze im Haus fuer alles,
was in einer Reihe steht. */
ok((band?.hoch || 0) >= 40, `der Knopf ist ${band?.hoch} px hoch`);
/* DER SPRUNG MUSS AUCH ANKOMMEN. Ein Knopf, der in die richtige
Kategorie fuehrt und dort nichts zeigt, ist schlimmer als
keiner -- genau das passiert, wenn der Stufenfilter stehen
bleibt. */
await seite.evaluate(() => {
document.querySelector(".vorlagenbrett__warten-knopf")?.click();
});
await seite.waitForTimeout(1400);
const nachSprung = await seite.evaluate(() => {
const hervor = document.querySelector('.vorlagenbrett__karte[data-hervor="ja"]');
return {
vorlage: hervor?.dataset.vorlage || "",
titel: hervor?.querySelector(".vorlagenbrett__titel")?.textContent.trim() || "",
bewerbungDa: !!hervor?.querySelector(".vorlagenbrett__bewerbung"),
};
});
ok(nachSprung.vorlage === fern.schluessel,
`der Sprung landet auf der richtigen Karte (${nachSprung.titel})`);
ok(nachSprung.bewerbungDa,
"und dort steht die Bewerbung mit Annehmen und Ablehnen");
/* GEGENPROBE: Wenn nichts mehr wartet, ist das Band WEG.
Ohne sie bewiesen die Zeilen darueber nur, dass das Band immer
dasteht -- und ein Hinweis, der immer da ist, wird ueberlesen.
Dieselbe Hausregel wie bei der Warnung, die immer kommt. */
const offeneAlle = (await hole(handAn.keks, "/workspace/api/vorlagen"))
.daten?.bewerbungen || [];
for (const b3 of offeneAlle) {
await hole(handAn.keks, "/workspace/api/vorlagen/bewerbung/ablehnen", {
method: "POST", headers: jsonKopf,
body: JSON.stringify({ id: b3.id, kommentar: "Aufraeumen der Pruefung" }),
});
}
ok(offeneAlle.length > 0,
`${offeneAlle.length} offene Bewerbung(en) werden fuer die Gegenprobe beantwortet`);
await seite.reload({ waitUntil: "networkidle" });
await seite.waitForTimeout(1600);
const zu3 = await seite.evaluate(() =>
document.getElementById("vorlagenbrett")?.dataset.auf !== "ja");
if (zu3) {
await seite.click(".vorlagenbrett__kopf").catch(() => {});
await seite.waitForTimeout(800);
}
const ohneBand = await seite.evaluate(() =>
document.querySelectorAll(".vorlagenbrett__warten").length);
ok(ohneBand === 0,
`Gegenprobe: wartet nichts, steht auch nichts da (${ohneBand})`);
ok(konsole.length === 0,
konsole.length ? "Fehler in der Konsole: " + konsole[0].slice(0, 80)
: "keine Fehler in der Konsole");
} finally { await browser.close(); }
}
/* =======================================================================
6c. EINE BEWERBUNG, DIE NIEMAND SIEHT, IST KEINE (23.09.2026)
=======================================================================
Beide Bewerbungswege waren gebaut und beide funktionierten -- und
beide waren STUMM. Nachgemessen vor dem Bauen: `benachrichtige` kam
weder in workspace-zuteilung.js noch in workspace-vorlagen.js ein
einziges Mal vor.
Wer sich bewirbt, wartet. Warten ohne Rueckmeldung fuehlt sich nach
zwei Tagen an wie "interessiert keinen" -- und genau das soll eine
Bewerbung verhindern.
GEPRUEFT WIRD, WAS SICH OHNE PUSH-DIENST PRUEFEN LAESST, und das ist
fast alles: WER etwas erfaehrt (abgeleitet, nicht aufgezaehlt), WAS
darin steht (reine Funktionen), und DASS es zwei getrennte Schalter
gibt.
======================================================================= */
console.log("\n=== 6c. Erfaehrt es auch jemand? ===\n");
{
const melder = await import("./workspace-bewerbung-melden.js");
const { ARTEN } = await import("./workspace-push.js");
/* ---- DIE SCHALTER --------------------------------------------- */
const neu2 = ARTEN.find((a) => a.schluessel === "bewerbung_neu");
const antwort = ARTEN.find((a) => a.schluessel === "bewerbung_antwort");
ok(!!neu2 && !!antwort,
"beide Arten stehen in den Benachrichtigungs-Einstellungen");
ok(neu2?.vorgabe === true && antwort?.vorgabe === true,
"und sind von sich aus an");
/* ZWEI SCHALTER, ZWEI ENTSCHEIDUNGEN. Wer an einem lebhaften Tag
viele Bitten bekommt, will die stumm stellen koennen -- die
Antwort auf die EIGENE Bewerbung will niemand stumm stellen. Eine
gemeinsame Art hiesse: beides zusammen abschalten oder beides
zusammen ertragen. */
ok(neu2?.schluessel !== antwort?.schluessel,
"getrennt voneinander — die eine trifft den, der antwortet, die andere den, der wartet");
/* ---- WER ES ERFAEHRT ------------------------------------------- */
const wer = melder.entscheiderFuer({ id: idModi, name: "Marina" });
const rollen = wer.map((x) => x.rolle).sort();
ok(wer.length > 0, `die Bewerbung eines Modis erreicht ${wer.length} Leute`);
ok(rollen.includes("admin") && rollen.includes("hand"),
`DogFather und die rechte Hand sind dabei (${rollen.join(", ")})`);
/* DIE LINKE HAND NICHT -- sie darf verteilen, aber nicht ueber
Bewerbungen entscheiden. Eine Meldung ueber etwas, das man nicht
beantworten kann, ist Laerm. */
ok(!rollen.includes("linke"),
"die linke Hand nicht — sie entscheidet hier nicht mit");
/* UND DIE AGENTUR ERST RECHT NICHT. Ohne die Haeuser-Bedingung
erfuehre ein Manager ueber eine Push-Nachricht, dass es Team Dogi
ueberhaupt gibt. */
ok(!rollen.includes("manager") && !rollen.includes("creator"),
`niemand aus dem anderen Haus (${rollen.join(", ") || "niemand"})`);
/* Und der Bewerber nicht sich selbst. */
ok(!wer.some((x) => x.id === idModi), "und der Bewerber nicht sich selbst");
/* GEGENPROBE: Die Liste darf nicht einfach "alle Aktiven" sein --
sonst waere jede Zeile oben zufaellig richtig. */
const alleAktiven = (await import("node:sqlite")).DatabaseSync;
const d3 = new alleAktiven(process.env.WORKSPACE_DB, { readOnly: true });
const anzahl = d3.prepare("SELECT COUNT(*) AS n FROM personen WHERE aktiv = 1").get().n;
d3.close();
ok(wer.length < anzahl,
`Gegenprobe: es sind nicht einfach alle (${wer.length} von ${anzahl} Aktiven)`);
/* ---- WAS DRINSTEHT --------------------------------------------- */
const b1 = melder.bewerbungText({
name: "Marina", titel: "Die Regeln des Rudels einmal ganz lesen",
wort: "ich habe Freitag Zeit" });
ok(b1.titel === "Marina bewirbt sich", `der Titel nennt den Menschen (${b1.titel})`);
ok(b1.text.includes("Regeln des Rudels"), "und die Zeile darunter die Aufgabe");
/* DAS WORT IST OFT DIE GANZE AUSKUNFT. Wer es schon auf dem
Sperrbildschirm liest, kann entscheiden, ohne zu oeffnen. */
ok(b1.text.includes("ich habe Freitag Zeit"),
`samt dem Wort des Bewerbers (${b1.text})`);
const b2 = melder.bewerbungText({ name: "Marina", titel: "Kurz", wort: "" });
ok(!b2.text.includes("„"), `ohne Wort bleibt es sauber (${b2.text})`);
ok(b2.text === "Kurz", "und nur die Aufgabe steht da");
/* NAMENLOS DARF NICHT "undefined bewirbt sich" ergeben. */
const b3 = melder.bewerbungText({ name: "", titel: "X", wort: "" });
ok(b3.titel === "Jemand bewirbt sich", `ohne Namen steht ein Wort da (${b3.titel})`);
const a1 = melder.antwortText({ titel: "Die Regeln", ja: true, notiz: "mach das gern" });
const a2 = melder.antwortText({ titel: "Die Regeln", ja: false, notiz: "diesmal jemand anders" });
/* DAS ERGEBNIS STEHT IM TITEL -- man soll es lesen koennen, ohne zu
oeffnen. Auch die gute Nachricht. */
ok(/angenommen/.test(a1.titel), `die Zusage sagt es im Titel (${a1.titel})`);
ok(/nicht/.test(a2.titel) && a2.titel !== a1.titel,
`die Absage auch, und anders (${a2.titel})`);
/* DIE NOTIZ IST DER GRUND, WARUM FILIPE SIE VERLANGT HAT ("mit
einem text als notiz"). Sie hier wegzulassen hiesse, sie an genau
der Stelle zu verstecken, an der man sie liest. */
ok(a1.text.includes("mach das gern") && a2.text.includes("diesmal jemand anders"),
"und die Notiz steht in beiden");
/* LANGES WIRD GEKUERZT, ABER NICHT MITTEN IM WORT.
DIE ERSTE FASSUNG DIESER ZEILE WAR FALSCH und hat mich das
Richtige gelehrt: Sie nahm einen 68 Zeichen langen Titel und
erwartete eine Kuerzung bei 50 -- die gilt aber nur, wenn eine
Notiz danebensteht und Platz wegnimmt. Ohne Notiz darf der Titel
die ganzen 90 haben. Die Pruefung war rot, das Programm richtig.
Gemessen wird jetzt mit etwas, das JEDE Grenze reisst. */
const langerSatz = "Eine ganze Sendung neben einem Neuen sitzen und im Zweifel "
+ "eingreifen und danach noch drei Zeilen aufschreiben, damit am naechsten "
+ "Tag jemand weiss, was eigentlich los war";
const lang = melder.antwortText({ titel: langerSatz, ja: true, notiz: "" });
ok(lang.text.length < langerSatz.length && /…$/.test(lang.text),
`zu Langes endet mit Auslassung (${lang.text})`);
/* KEIN SCHNITT MITTEN IM WORT -- ein "eingrei…" liest sich wie ein
Fehler, nicht wie eine Kuerzung. */
ok(/\s…$/.test(lang.text) || /[a-zA-Zäöüß]{3,}\s?…$/.test(lang.text),
`und nicht mitten im Wort (${lang.text.slice(-24)})`);
/* GEGENPROBE: Kurzes bleibt unangetastet -- sonst bewiese die Zeile
darueber nur, dass immer gekuerzt wird. */
const kurzerSatz = melder.antwortText({ titel: "Kurzer Titel", ja: true, notiz: "" });
ok(kurzerSatz.text === "Kurzer Titel",
`Gegenprobe: Kurzes bleibt unangetastet (${kurzerSatz.text})`);
}
/* ---------- 7. Gegenprobe -------------------------------------------- */
console.log("\n=== 7. Gegenprobe: kann das hier ueberhaupt scheitern? ===\n");
{
/* Eine Vorlagennummer, die es nicht gibt. Kaeme dafuer ein 200,
wuerden die Haken oben nichts beweisen -- dann sagt der Server zu
allem ja. */
/* MIT DEM ZUGANG VON DOGFATHER, nicht mit dem des Modis: Seit dem
23.09. bekommt ein Modi auf dieser Route ohnehin 403 -- die
Gegenprobe haette also ab da nur noch bewiesen, dass die
Rechtepruefung greift, und nichts mehr ueber erfundene Nummern
gesagt. Genau so verliert eine Pruefung still ihren Sinn. */
const a = await hole(dogiAn.keks, "/workspace/api/vorlagen/uebernehmen", {
method: "POST", headers: jsonKopf,
body: JSON.stringify({ art: "katalog", nr: 9999, gruppe: "aufbau", person_id: idModi }),
});
ok(a.status === 400, `eine erfundene Vorlagennummer wird abgelehnt (${a.status})`);
const b = await hole(dogiAn.keks, "/workspace/api/vorlagen/uebernehmen", {
method: "POST", headers: jsonKopf,
body: JSON.stringify({ art: "katalog_alle", gruppe: "gibtesnicht", person_id: idModi }),
});
ok(b.status === 400, `und eine erfundene Etappe ebenso (${b.status})`);
/* Und eine erfundene Bewerbungsnummer. */
const c = await hole(handAn.keks, "/workspace/api/vorlagen/bewerbung/annehmen", {
method: "POST", headers: jsonKopf,
body: JSON.stringify({ id: 999999 }),
});
ok(c.status === 409, `eine erfundene Bewerbung ebenso (${c.status})`);
const e = await hole(modiAn.keks, "/workspace/api/vorlagen/bewerben", {
method: "POST", headers: jsonKopf,
body: JSON.stringify({ vorlage: "gibt-es-nicht" }),
});
ok(e.status === 400, `und eine erfundene Vorlage in der Bewerbung (${e.status})`);
}
console.log(`\n${fehler ? "FEHLGESCHLAGEN" : "BESTANDEN"} -- `
+ `${geprueft} geprueft, ${fehler} Fehler, ${offen} nicht nachsehbar`);
try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ }
process.exit(fehler ? 1 : 0);