Files
dogfather-universe/server/pruef-modi-katalog.mjs
T
DogFatherGitandClaude Opus 5 40b48e89f1 Bei "An alle" sieht man jetzt, wer sie hat und wer nicht
Filipe: "wenn ich eine aufgabe an alle verteile will ich dass dogfather
und die rechte hand individuel von jedem sehen wer es gemacht hat oder
nicht."

Vier Fehler, die zusammenhingen -- alle gemessen, keiner geraten:

1. "Alle" waehlen, "An alle" druecken, nichts passiert. Die Zeile
   verglich verantwortlich_id !== "alle"; niemand heisst so, also wurde
   jede Aufgabe uebersprungen und die Liste blieb leer. Die Aufgaben
   entstanden, man sah es nur nicht.

2. Der Vermerk an der Karte haette bei "alle" den Stand EINER fremden
   Person gezeigt -- welcher, haengt von der Reihenfolge der Daten ab.
   Jetzt steht dort, wie weit es ist, und darunter namentlich, wer sie
   hat: Offen / Erledigt / ueberfaellig / hat sie nicht. Das Wort steht
   immer dabei, die Farbe ist nur die Abkuerzung.

3. Die "An wen"-Reihe zeigte SECHS Personen, der Server belieferte VIER.
   Rechte und linke Hand gingen leer aus, ohne ein Wort; einzeln
   angeschrieben kam "Das gibt es nicht mehr, lade die Seite neu" zu
   jemandem, den es sehr wohl gibt. Empfaenger sind jetzt Modis UND
   linke Hand (Filipes Regel vom 22.09.), und die Menge steht EINMAL in
   workspace.js -- SQL-Abfrage, Annahme und Browserliste leiten sich
   daraus ab und koennen nicht mehr auseinanderlaufen.

4. Zweimal "An alle" legte alles doppelt an. Der Kommentar im Server
   behauptete das Gegenteil; aktiv war die Sperre nur beim Massenknopf.
   "An alle" fuellt jetzt Luecken. Die bewusste Wiederholung bleibt:
   Steht am Knopf "Nochmal" (weil wirklich alle sie haben), sagt der
   Browser das ausdruecklich, und dann legt der Server neu an.

Geprueft: pruef-modi-katalog 144 statt 133, 0 Fehler -- elf neue
Pruefungen fuer Empfaenger, Luecken und die Gegenprobe, dass ein
gewolltes "Nochmal" sehr wohl anlegt. Dazu mess-alle-einzelsicht.mjs
(eigene Wegwerf-Datenbank, nie die echte) mit Bildern bei 412 und
1280 px.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-24 17:26:35 +02:00

1379 lines
70 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 }),
});
/* STRENGER ALS VORHER (24.09.2026). Hier stand nur `status === 404`.
Das war zugleich die Antwort auf „die Person gibt es nicht" -- und
auf dem Bildschirm stand deshalb „Das gibt es nicht mehr, lade die
Seite neu" zu jemandem, den es sehr wohl gibt. Ein Neuladen half
nie. Seit heute trennt der Server die beiden Faelle, und geprueft
wird das WORT, nicht nur die Nummer: Ein Zahlendreher woanders
koennte sonst denselben Haken setzen. */
ok(b.status === 400 && b.daten?.fehler === "rolle_ohne_katalog",
`an einen Creator dagegen nicht (${b.status} ${b.daten?.fehler || ""})`);
/* 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 });
/* DER KATALOG STEHT SEIT DEM 23.09.2026 AUF "EURE AUFGABEN".
Filipe: "das soll auch bitte nicht in der kategorie aufgaben
sondern eure aufgaben sein bitte." */
await seite.goto(BASIS + "/workspace/entwicklung.html", { waitUntil: "networkidle" });
await seite.waitForTimeout(1600);
/* 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 });
/* Siehe oben: der Katalog ist umgezogen. */
await seite.goto(BASIS + "/workspace/entwicklung.html", { waitUntil: "networkidle" });
await seite.waitForTimeout(1800);
const zu = await seite.evaluate(() =>
document.getElementById("vorlagenbrett")?.dataset.auf !== "ja");
if (zu) {
await seite.click(".vorlagenbrett__kopf").catch(() => {});
await seite.waitForTimeout(800);
}
/* ==== AN WEN? DIE FRAGE IST JETZT SCHON BEANTWORTET ============
Bis zum 23.09.2026 brachte der Katalog eine EIGENE Personenreihe
mit -- eine dritte Knopfreihe unter zwei anderen. Sie war noetig,
weil "Aufgaben" keine Personenwahl hat.
Auf "Eure Aufgaben" gibt es sie: die Kacheln unter "Wen gehst du
durch?". Zwei Auswahlen fuer dieselbe Frage waeren die
Gelegenheit, oben den einen und unten den anderen zu waehlen --
und dann stuenden zwei Antworten auf einem Bildschirm.
GEPRUEFT WIRD DESHALB DIE VERBINDUNG: Was die Seite oben sagt,
muss der Katalog unten meinen. */
const ohne = await seite.evaluate(() => {
const reihe = document.querySelector('#vorlagenbrett [aria-label="Person wählen"]');
return {
knopf: document.querySelector(".vorlagenbrett__karte .knopf")?.textContent.trim(),
satz: [...document.querySelectorAll("#vorlagenbrett .stufenleiter__hilfe")]
.map((z) => z.textContent.trim()).join(" | "),
hin: !!document.querySelector("#vorlagenbrett .knopf-still"),
reihen: document.querySelectorAll("#vorlagenbrett .stufenleiter").length,
namen: [...(reihe?.querySelectorAll(".stufenleiter__name") || [])]
.map((z) => z.textContent.trim()),
zahlen: [...(reihe?.querySelectorAll(".stufenleiter__zahl") || [])].length,
};
});
/* DREI REIHEN: Kategorie, Stufe, Person.
Hier stand zwischenzeitlich "zwei Auswahlreihen statt drei" --
weil ich die Personenreihe beim Umzug entfernt hatte. Das war
nicht verlangt; sie ist wieder da. Was sich geaendert hat, ist
nur, WAS sie setzt: dieselbe Person wie die Kacheln oben.
Gemessen wird das ein paar Zeilen weiter unten. */
ok(ohne.reihen === 3,
"drei Auswahlreihen (Kategorie, Stufe, Person): " + ohne.reihen);
ok(ohne.namen.includes("Marina"),
"der Modi steht zur Auswahl (" + ohne.namen.join(", ") + ")");
ok(!ohne.namen.includes("Tili") && !ohne.namen.includes("Schulle"),
"ein Creator und eine Managerin nicht — sie arbeiten im anderen Haus");
ok(ohne.zahlen === ohne.namen.length && ohne.namen.length > 0,
"neben jedem Namen steht, wie viel offen ist (" + ohne.zahlen + ")");
ok(ohne.knopf === "Übernehmen",
'solange niemand gewaehlt ist, heisst der Knopf schlicht "Übernehmen" ('
+ ohne.knopf + ")");
ok(/niemand gewählt/.test(ohne.satz), "und ein Satz sagt genau das");
/* KEINE WEGBESCHREIBUNG, SONDERN EIN WEG. "Waehle oben jemanden"
waere falsch -- die Kacheln stehen weiter UNTEN. Der Knopf
weiss, wo sie sind, auch nach dem naechsten Umbau. */
ok(ohne.hin, "mit einem Knopf, der zur Personenwahl fuehrt");
/* JETZT WIRKLICH WAEHLEN -- und zwar IN DER REIHE, nicht an der
Kachel. Genau das ist die Frage seit dem 23.09.2026: Die Reihe
hat keine eigene Auswahl mehr, sie setzt dieselbe Person wie die
Kacheln oben. Ein Tipp hier muss also dort ankommen. */
await seite.evaluate(() => {
const reihe = document.querySelector('#vorlagenbrett [aria-label="Person wählen"]');
[...(reihe?.querySelectorAll(".stufenleiter__knopf") || [])]
.find((k) => k.textContent.includes("Marina"))?.click();
});
await seite.waitForTimeout(2600);
/* DIE KACHEL OBEN MUSS ES WISSEN. Ohne diese Zeile bewiese der
Rest nur, dass der Katalog mit sich selbst einig ist. */
const obenAn = await seite.evaluate(() =>
[...document.querySelectorAll("#personen .e-person")]
.filter((k) => k.getAttribute("aria-pressed") === "true")
.map((k) => k.dataset.name || k.textContent.trim().split("\n")[0]));
ok(obenAn.some((t) => t.includes("Marina")),
"ein Tipp in der Reihe waehlt die Person auch oben (" + (obenAn.join(", ") || "keine") + ")");
const nachWahl = await seite.evaluate(() => {
const karten = [...document.querySelectorAll(".vorlagenbrett__karte")];
const frisch = karten.find((k) => k.dataset.schon !== "ja");
const schon = karten.find((k) => k.dataset.schon === "ja");
return {
satz: [...document.querySelectorAll("#vorlagenbrett .stufenleiter__hilfe")]
.map((z) => z.textContent.trim()).find((t) => t.includes("geht an")) || "",
/* ZWEI VERSCHIEDENE KARTEN, ZWEI VERSCHIEDENE KNOEPFE -- und
die erste im Feld ist nicht zwangslaeufig eine frische.
Die erste Fassung nahm schlicht "die erste Karte" und
meldete einen Fehler, weil dort "Nochmal" stand: genau das
richtige Wort fuer eine, die schon liegt. */
frisch: frisch?.querySelector(".knopf")?.textContent.trim() || null,
schon: schon?.querySelector(".knopf")?.textContent.trim() || null,
liste: document.getElementById("e-aufgaben-titel")?.textContent || "",
};
});
ok(nachWahl.satz.includes("Marina"), "nach dem Klick steht es da: " + nachWahl.satz);
/* DER KNOPF NENNT DEN NAMEN. "Uebernehmen" allein laesst offen,
wer sie bekommt -- und die Antwort steht weit oben. */
ok(nachWahl.frisch === "An Marina",
'eine frische Karte traegt ihren Namen ("' + nachWahl.frisch + '")');
/* Und eine, die sie schon hat, sagt das statt des Namens -- sonst
saehen beide gleich aus und man holte sie zweimal. */
ok(nachWahl.schon === null || nachWahl.schon === "Nochmal",
'eine schon uebernommene sagt "Nochmal" ('
+ (nachWahl.schon === null ? "keine im Feld" : nachWahl.schon) + ")");
ok(/Marina/.test(nachWahl.liste),
'die Aufgabenliste darunter gehoert ihr ("' + nachWahl.liste + '")');
/* GEZAEHLT WIRD IN IHRER LISTE, NICHT AUF DEM BRETT.
Auf "Aufgaben" stand unter dem Katalog das Brett, und die
Pruefung zaehlte dort die Karten. Hier steht darunter die
Aufgabenliste der gewaehlten Person -- und die ist die bessere
Antwort auf die Frage, die der Abschnitt stellt: Landet die
Aufgabe bei DIESEM Menschen? Auf dem Brett musste man das aus
dem Namen auf der Karte herauslesen; hier ist es die Liste
selbst, die ihren Namen traegt. */
const zaehleListe = () =>
document.querySelectorAll("#e-aufgaben-liste .e-aufgabe").length;
const vorher = await seite.evaluate(zaehleListe);
const welche = await seite.evaluate(() =>
document.querySelector(".vorlagenbrett__karte .vorlagenbrett__titel")
?.textContent.trim() || "");
await seite.evaluate(() => {
[...document.querySelectorAll(".vorlagenbrett__karte")][0]
?.querySelector("button")?.click();
});
await seite.waitForTimeout(2600);
const danach = await seite.evaluate((titel) => ({
liste: document.querySelectorAll("#e-aufgaben-liste .e-aufgabe").length,
steht: [...document.querySelectorAll("#e-aufgaben-liste .e-aufgabe")]
.some((a) => a.textContent.includes(titel)),
ueberschrift: document.getElementById("e-aufgaben-titel")?.textContent || "",
markiert: [...document.querySelectorAll(".vorlagenbrett__karte")]
.filter((k) => k.dataset.schon === "ja").length,
}), welche);
ok(danach.liste === vorher + 1,
"die Aufgabe liegt in ihrer Liste (" + vorher + " -> " + danach.liste + ")");
ok(danach.steht, 'und zwar genau die uebernommene ("' + welche + '")');
ok(/Marina/.test(danach.ueberschrift),
"die Liste ist ihre (" + danach.ueberschrift.trim() + ")");
/* UND DER KATALOG WEISS ES. Ohne diese Zeile koennte man dieselbe
Vorlage gleich noch einmal holen, ohne Hinweis -- genau das war
der Grund fuer die Markierung. */
ok(danach.markiert >= 1,
"der Katalog markiert sie als uebernommen (" + danach.markiert + ")");
/* ==== 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 ---------------------- */
/* Auch hier: die Liste der gewaehlten Person, nicht das Brett.
Marina ist oben gewaehlt, und ihre Bewerbung wird gleich
angenommen -- die Aufgabe muss also bei ihr auftauchen. */
const vorherK = await seite.evaluate(() =>
document.querySelectorAll("#e-aufgaben-liste .e-aufgabe").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("#e-aufgaben-liste .e-aufgabe").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})`);
}
/* ---------- 8. Wer aus dem Katalog etwas bekommen kann --------------- *
*
* ER STEHT GANZ HINTEN, OBWOHL ER INHALTLICH ZU 4 GEHOERT.
*
* Grund: Er legt Aufgaben an, und Abschnitt 6 sieht im Browser nach,
* wie viele Karten noch einen "Bewerben"-Knopf tragen -- eine Karte,
* die man schon hat, traegt keinen. Stand er vorn, fiel diese Zahl von
* zehn auf neun, und eine Pruefung wurde rot, an der nichts kaputt war.
*
* Die Schwelle dort (knoepfe.length >= 10) ist eine feste Zahl und
* damit eine Rechnung von gestern -- wer dem Modi kuenftig Aufgaben
* gibt, bringt sie wieder zum Kippen. Das hier ist die kleine Loesung
* (nicht stoeren); die grosse waere, die Zahl dort abzuleiten statt sie
* zu setzen. Wer das anfasst, darf diesen Abschnitt zurueckschieben.
*/
/* =====================================================================
Filipe, 24.09.2026: „wenn ich eine aufgabe an alle verteile will ich
dass dogfather und die rechte hand individuel von jedem sehen wer es
gemacht hat oder nicht."
WARUM DIESER ABSCHNITT NEU IST. „An alle" gab es seit dem Vormittag,
und geprueft war davon genau ein Fall: dass eine erfundene Gruppe
abgelehnt wird. Der Weg, der tatsaechlich benutzt wird -- an alle
verteilen und nachsehen, wer sie hat --, lief ungeprueft.
UND DABEI LAG ER FALSCH: Die „An wen"-Reihe im Browser zeigte sechs
Personen (rechte Hand, linke Hand, vier Modis), der Server nahm aber
nur Modis an. „An alle 6" legte vier Aufgaben an, und die beiden
Haende gingen leer aus -- ohne ein Wort. Das ist kein Randfall: Es
ist der Normalfall dieses Knopfes.
GEPRUEFT WIRD IN BEIDE RICHTUNGEN. Wer etwas bekommen soll, bekommt
es (200); wer nicht, bekommt eine Absage MIT GRUND (400). Eine
Pruefung, die nur das Gelingen kennt, kann nicht scheitern -- und
bestaetigt damit nichts.
===================================================================== */
console.log("\n=== 8. Wer aus dem Katalog etwas bekommen kann ===\n");
{
/* VORLAGEN, DIE IN DIESEM LAUF NOCH NIEMAND HAT -- gefragt, nicht
getippt. Im ersten Anlauf stand hier `gruppe === "monat"`; diese
Gruppe gibt es gar nicht (sie stammte aus einer Absage-Pruefung,
in der der Name nie geprueft wurde), und `frei[0]` war undefined.
Eine abgeschriebene Kategorie ist dieselbe Falle wie eine feste
Zahl. So gefragt bleibt die Pruefung richtig, auch wenn der
Katalog morgen andere Gruppen hat. */
/* EIN EIGENER, LESENDER GRIFF. Der von ganz oben ist seit Zeile 87
zu -- weiter oben machen es die Bewerbungen mit `d2` schon so. Ein
offen gelassener Griff waere schlimmer: Die Pruefung wuerde am
Ende nicht mehr enden. */
const { DatabaseSync: DB4 } = await import("node:sqlite");
const d4 = new DB4(process.env.WORKSPACE_DB, { readOnly: true });
const belegt = new Set(d4.prepare(
"SELECT DISTINCT vorlage AS v FROM aufgaben WHERE vorlage IS NOT NULL")
.all().map((x) => x.v));
d4.close();
const frei = katalog.aufgaben
.map((v, i) => ({ ...v, nr: i }))
.filter((v) => !belegt.has(v.schluessel));
ok(frei.length >= 2,
`es gibt genug unbenutzte Vorlagen zum Pruefen (${frei.length})`);
/* --- Die linke Hand GEHOERT DAZU (neu seit 24.09.2026) ------------
Filipes Regel vom 22.09.: „die modis und linke hand sollen ... sich
fuer aufgaben bewerben koennen aber die rechte hand oder dogfather
muessen annehmen oder ablehnen". Also ist sie Empfaengerin. */
const l = await hole(dogiAn.keks, "/workspace/api/vorlagen/uebernehmen", {
method: "POST", headers: jsonKopf,
body: JSON.stringify({ art: "katalog", nr: frei[0].nr, gruppe: frei[0].gruppe, person_id: idLinke }),
});
ok(l.status === 200 && l.daten?.angelegt === 1,
`an die linke Hand geht es (${l.status})`);
/* --- Die rechte Hand NICHT. Sie verteilt; stuende sie in ihrer
eigenen Verteilliste, waere „an alle" auch „an mich selbst". */
const h = await hole(dogiAn.keks, "/workspace/api/vorlagen/uebernehmen", {
method: "POST", headers: jsonKopf,
body: JSON.stringify({ art: "katalog", nr: frei[0].nr, gruppe: frei[0].gruppe, person_id: idHand }),
});
ok(h.status === 400 && h.daten?.fehler === "rolle_ohne_katalog",
`an die rechte Hand dagegen nicht (${h.status} ${h.daten?.fehler || ""})`);
/* --- Und jetzt „an alle" ------------------------------------------
Im Haus dieses Laufs gibt es genau EINEN Modi und EINE linke Hand.
„Alle" muss also ZWEI Aufgaben anlegen -- nicht eine (dann waere
die linke Hand wieder vergessen) und nicht drei (dann haette die
rechte Hand eine bekommen). Die Zahl allein reicht aber nicht:
Drei falsche Leute waeren auch drei. Deshalb wird danach
nachgesehen, WER sie wirklich hat. */
const alle = await hole(dogiAn.keks, "/workspace/api/vorlagen/uebernehmen", {
method: "POST", headers: jsonKopf,
body: JSON.stringify({ art: "katalog", nr: frei[1].nr, gruppe: frei[1].gruppe, person_id: "alle" }),
});
ok(alle.status === 200 && alle.daten?.angelegt === 2,
`„an alle" legt bei beiden Empfaengern an (${alle.status}, ${alle.daten?.angelegt})`);
const d5 = new DB4(process.env.WORKSPACE_DB, { readOnly: true });
const hats = new Set(d5.prepare(
"SELECT verantwortlich_id AS w FROM aufgaben WHERE vorlage = ?")
.all(frei[1].schluessel).map((x) => Number(x.w)));
ok(hats.has(idModi) && hats.has(idLinke),
`und zwar beim Modi UND bei der linken Hand (${[...hats].join(", ")})`);
ok(!hats.has(idHand) && !hats.has(idCreat),
"und bei niemandem sonst");
d5.close();
/* --- "AN ALLE" FUELLT LUECKEN, ES DOPPELT NICHT -------------------
Filipe, 24.09.2026: „Nur die 2 Fehlenden bekommen sie."
Derselbe Druck ein zweites Mal: Beide haben sie schon, also darf
KEINE dazukommen -- und der Server muss es SAGEN, sonst sieht ein
Knopf, der nichts tut, wie ein Fehler aus. Am 22.09. sind aus zwei
Klicks 112 Aufgaben geworden, weil niemand nachgezaehlt hat. */
const wieder = await hole(dogiAn.keks, "/workspace/api/vorlagen/uebernehmen", {
method: "POST", headers: jsonKopf,
body: JSON.stringify({ art: "katalog", nr: frei[1].nr, gruppe: frei[1].gruppe, person_id: "alle" }),
});
const zaehle = (schluessel) => {
const dz = new DB4(process.env.WORKSPACE_DB, { readOnly: true });
const n = dz.prepare(
"SELECT COUNT(*) AS n FROM aufgaben WHERE vorlage = ?").get(schluessel)?.n;
dz.close();
return Number(n);
};
ok(zaehle(frei[1].schluessel) === 2,
`ein zweiter Druck legt nichts dazu (${zaehle(frei[1].schluessel)} statt 2)`);
ok(wieder.daten?.uebersprungen === 2,
`und er sagt, dass er zwei uebersprungen hat (${wieder.daten?.uebersprungen})`);
/* --- ABER DIE GEGENPROBE: gemeint ist gemeint ---------------------
Eine Sperre, die sich nicht bewusst umgehen laesst, verbietet die
Wochenaufgabe. Steht am Knopf "Nochmal" (weil wirklich alle sie
haben), schickt der Browser `nochmal: true` -- und DANN muss die
Zahl steigen. Ohne diese Probe koennte die Sperre auch schlicht
alles blockieren und saehe von aussen genauso richtig aus. */
const absicht = await hole(dogiAn.keks, "/workspace/api/vorlagen/uebernehmen", {
method: "POST", headers: jsonKopf,
body: JSON.stringify({
art: "katalog", nr: frei[1].nr, gruppe: frei[1].gruppe,
person_id: "alle", nochmal: true,
}),
});
ok(absicht.status === 200 && zaehle(frei[1].schluessel) === 4,
`ein gewolltes "Nochmal" legt sehr wohl an (${zaehle(frei[1].schluessel)} statt 4)`);
/* --- Und eine LUECKE wird wirklich gefuellt ------------------------
Der eigentliche Fall: Einer hat sie, der andere nicht. Dann darf
genau EINE entstehen -- nicht null (dann waere die Sperre zu
scharf) und nicht zwei (dann doppelt sie). `frei[0]` liegt seit
oben bei der linken Hand, beim Modi nicht. */
const luecke = await hole(dogiAn.keks, "/workspace/api/vorlagen/uebernehmen", {
method: "POST", headers: jsonKopf,
body: JSON.stringify({ art: "katalog", nr: frei[0].nr, gruppe: frei[0].gruppe, person_id: "alle" }),
});
ok(luecke.daten?.angelegt === 1 && luecke.daten?.uebersprungen === 1,
`eine Luecke wird gefuellt, der Rest nicht gedoppelt `
+ `(${luecke.daten?.angelegt} neu, ${luecke.daten?.uebersprungen} uebersprungen)`);
ok(zaehle(frei[0].schluessel) === 2,
`und danach hat sie jeder genau einmal (${zaehle(frei[0].schluessel)})`);
}
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);