Files
dogfather-universe/server/pruef-bewerbung-aufgaben.mjs
DogFatherGitandClaude Opus 5 ad5e758721 922 px auf einem 844-px-Bildschirm -- die halbe Antwort war zu wenig
VanVan im Support, Meldung #15: „Die Modis koennen die Antworten auf
die Bewerbungen im Bereich eure Aufgaben noch nicht einklappen. Das
wird mit der Zeit unuebersichtlich."

Heute Nacht habe ich dazu „Verstanden" gebaut: Gelesenes rutscht
hinter eine zugeklappte Zeile. Das ist richtig und war trotzdem nur
die halbe Antwort -- denn UNGELESENE stehen absichtlich offen da,
und davon hat ein Modi im Haus gerade neun.

GEMESSEN STATT GESCHAETZT, an seinem echten Bestand nachgebaut, auf
dem Handy (390 x 844 px):

    Band:            922 px   -- hoeher als das ganze Fenster
    Anteil der Seite: 49 %

Das IST ihr Satz. Mein Umbau loeste es erst NACH einem Griff auf
„Alle verstanden"; bis dahin stand die Wand unveraendert da.

GEAENDERT: Hoechstens drei stehen offen, der Rest ist einen Griff
entfernt („und 6 weitere"). Danach:

    Band:            566 px   -- passt in den Bildschirm
    nach einem Griff: 46 px   (zugeklappte Zeile)

WARUM NICHT NULL: Eine Absage, die man aufklappen muss, ist keine
Nachricht mehr -- das war die Begruendung vom 30.09., und sie gilt.
WARUM NICHT ALLE: siehe oben. Drei ist die Zahl, bei der Kopf,
Zeilen und Sammelknopf unter einem Bildschirm bleiben.

DIE NEUESTEN ZUERST -- der Server sortiert nach `entschieden_am
DESC`. Wer nicht aufklappt, hat die juengsten gesehen.

GEPRUEFT -- pruef-bewerbung-aufgaben 178 -> 184 ok

    mit fuenf Ungelesenen stehen drei offen (3)
      und der Rest ist einen Griff entfernt („und 3 weitere")
      das Band passt in den Bildschirm (436 px bei 1000 px)
    aufgeklappt stehen alle da (6, „Weniger zeigen")
    und wieder zu — der Knopf geht in beide Richtungen
    und die Probezeilen sind wieder weg — der Stand ist wie vorher

Die dritte Zeile ist die eigentliche Aussage: „drei Zeilen" waere
eine Zahl, „passt in den Bildschirm" ist der Zweck. Die letzte raeumt
die vier Probezeilen wieder weg -- ohne sie zaehlten die Pruefungen
darunter („1 aeltere Antwort", „2 aeltere Antworten") sechs statt
zwei und wuerden rot, ohne dass etwas kaputt ist.

Der Knopf geht in BEIDE Richtungen, und auch das steht da: Sonst
waere er ein Einwegschalter, und das faellt erst auf, wenn jemand
zurueckklappen will.

AUSSERDEM AN CASPERLINOS ECHTEN NEUN GEMESSEN (Kopie der Datenbank,
eigener Port, nichts im Live-System): neun Antworten, alle
ungelesen; „Verstanden" nimmt genau eine; „Alle verstanden" den
Rest; nachlesbar bleiben alle neun; ein zweiter Druck zaehlt null;
eine fremde Nummer gibt 404.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-03 11:45:03 +02:00

1702 lines
85 KiB
JavaScript

/* =====================================================================
pruef-bewerbung-aufgaben.mjs — sich bewerben statt selbst nehmen
=====================================================================
Filipe, 22.09.2026: „die modis und linke hand sollen da nichts
uebernehmen koennen von aufgaben, ueberhaupt ueberall sollen die
keine aufgaben selber uebernehmen die ihnen nicht zugetragen sind.
ich will dass die sich fuer aufgaben bewerben koennen aber die rechte
hand oder dogfather muessen annehmen oder ablehnen koennen und das
mit einem kommentar als moeglichkeit sogar noch zum hinzufuegen."
Dazu sein Satz vom selben Tag, der NICHT widerspricht, sondern eine
andere Frage beantwortet: „rechte hand linke hand und dogfather sehen
alle aufgaben und koennen alle verteilen."
ZWEI FRAGEN, DIE MAN AUSEINANDERHALTEN MUSS:
verteilen eine Aufgabe an ANDERE geben
entscheiden bestimmen, wer sie am Ende macht
Die linke Hand darf das erste und nicht das zweite. Sie bewirbt sich
wie ein Modi.
WAS HIER GEMESSEN WIRD — und warum gerade das:
(1) DASS DAS VERBOT KEINEN UMWEG HAT. Drei Wege fuehren zu „ich habe
jetzt diese Aufgabe": aus dem Pool uebernehmen, sich beim
Verteilen selbst eintragen, und (neu) eine Bewerbung annehmen.
Alle drei werden geprueft. Ein Verbot mit einem offenen Umweg
ist keins.
(2) DASS DER ERLAUBTE WEG WIRKLICH OFFEN IST. Ein Test, der nur
Verbote misst, ist auch dann gruen, wenn gar nichts mehr geht.
(3) DASS DIE WORTE DER PERSON UND DER KOMMENTAR DER LEITUNG IN ZWEI
FELDERN LANDEN. In dasselbe Feld waere bequemer und wuerde die
Frage mit der Antwort ueberschreiben.
AUFRUF: node server/pruef-bewerbung-aufgaben.mjs
===================================================================== */
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { request as httpAnfrage } from "node:http";
import { eigenerPort } from "./helfer-port.mjs";
import { notbremse } from "./helfer-notbremse.mjs";
import { zertifikatBauen } from "./helfer-openssl.mjs";
const PORT = await eigenerPort(import.meta, "pruef-bewerbung-aufgaben");
const ordner = mkdtempSync(join(tmpdir(), "ws-bew-"));
process.env.WORKSPACE_DB = join(ordner, "workspace.db");
process.env.PORT = String(PORT);
process.env.SITE_ACCESS_SECRET = "lokaler-test";
process.env.SITE_PUBLIC_LAUNCH_AT = "2020-01-01T00:00:00+01:00";
const express = (await import("express")).default;
const ec = express.response.cookie;
express.response.cookie = function (n, w, o) { return ec.call(this, n, w, { ...(o || {}), secure: false }); };
await import("./index.js");
notbremse(240_000, "pruef-bewerbung-aufgaben");
await new Promise((r) => setTimeout(r, 900));
process.on("uncaughtException", (f) => { console.error("ABSTURZ:", f); process.exit(7); });
process.on("unhandledRejection", (f) => { console.error("ABSTURZ (Versprechen):", f); process.exit(7); });
let fehler = 0, geprueft = 0;
const melde = (t) => console.log(t);
const ok = (b, t) => { geprueft++; console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; };
const CREW = "crew.dogfather-universe.com";
/* =====================================================================
1. WER ENTSCHEIDET — die Regel selbst
===================================================================== */
melde("\n=== 1. Wer entscheidet, wer eine Aufgabe macht ===");
const w = await import("./workspace.js");
{
const soll = { admin: true, hand: true, linke: false, modi: false, gast: false };
for (const [rolle, erwartet] of Object.entries(soll)) {
const ist = w.entscheidetUeberAufgaben({ rolle, haus: "crew", id: 1 });
ok(ist === erwartet,
`${rolle}: entscheidet = ${ist} (erwartet ${erwartet})`);
}
/* DIE ZWEITE FRAGE BLEIBT, WIE SIE WAR. Sein Satz „rechte hand linke
hand und dogfather koennen alle verteilen" darf durch diese
Aenderung nicht mit weggehen. */
for (const [rolle, erwartet] of Object.entries(
{ admin: true, hand: true, linke: true, modi: false, gast: false })) {
const ist = w.darfAufgabenVerteilen({ rolle, haus: "crew", id: 1 });
ok(ist === erwartet,
`${rolle}: darf verteilen = ${ist} (erwartet ${erwartet}) — unveraendert`);
}
/* GEGENPROBE: Die zwei Fragen sind wirklich verschieden. Waeren sie
dasselbe, haette die ganze Unterscheidung keinen Sinn. */
const p = { rolle: "linke", haus: "crew", id: 1 };
ok(w.darfAufgabenVerteilen(p) && !w.entscheidetUeberAufgaben(p),
"die linke Hand verteilt, entscheidet aber nicht — genau der Unterschied");
}
/* =====================================================================
2. AM LAUFENDEN SERVER
===================================================================== */
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 idChef = anlegen("Filipe", "admin", "CODE-DOGI-0001");
const idHand = anlegen("Rieke", "hand", "CODE-HAND-0001");
const idLinke = anlegen("Lino", "linke", "CODE-LINK-0001");
const idModi = anlegen("Frida", "modi", "CODE-MODI-0001");
const idModi2 = anlegen("Nele", "modi", "CODE-MODI-0002");
d.close();
function roh(pfad, wie = {}) {
return new Promise((fertig, schief) => {
const daten = wie.body === undefined ? null : Buffer.from(JSON.stringify(wie.body));
const a = httpAnfrage({
host: "127.0.0.1", port: PORT, path: pfad,
method: wie.methode || (daten ? "POST" : "GET"),
headers: {
Host: CREW,
...(daten ? { "Content-Type": "application/json", "Content-Length": daten.length } : {}),
...(wie.keks ? { Cookie: wie.keks } : {}),
Origin: `https://${CREW}`, Referer: `https://${CREW}/workspace/`,
},
}, (antwort) => {
const st = [];
antwort.on("data", (x) => st.push(x));
antwort.on("end", () => {
const text = Buffer.concat(st).toString("utf8");
fertig({ code: antwort.statusCode, text,
json: (() => { try { return JSON.parse(text); } catch { return null; } })() });
});
});
a.on("error", schief);
if (daten) a.write(daten);
a.end();
});
}
async function anmelden(rolle, code) {
const r = await roh("/workspace/api/anmelden", { body: { rolle, code } });
return { keks: [].concat(r.kopfSet || []).join("; "), code: r.code, roh: r };
}
/* Die Kekse kommen aus dem Antwortkopf -- dafuer braucht es ihn. */
function anmeldenMitKeks(rolle, code) {
return new Promise((fertig, schief) => {
const daten = Buffer.from(JSON.stringify({ rolle, code }));
const a = httpAnfrage({
host: "127.0.0.1", port: PORT, path: "/workspace/api/anmelden", method: "POST",
headers: { Host: CREW, "Content-Type": "application/json", "Content-Length": daten.length,
Origin: `https://${CREW}`, Referer: `https://${CREW}/workspace/` },
}, (antwort) => {
const st = [];
antwort.on("data", (x) => st.push(x));
antwort.on("end", () => {
const kekse = [].concat(antwort.headers["set-cookie"] || []);
fertig({ code: antwort.statusCode, keks: kekse.map((c) => c.split(";")[0]).join("; ") });
});
});
a.on("error", schief);
a.write(daten); a.end();
});
}
const chef = await anmeldenMitKeks("admin", "CODE-DOGI-0001");
const hand = await anmeldenMitKeks("hand", "CODE-HAND-0001");
const linke = await anmeldenMitKeks("linke", "CODE-LINK-0001");
const modi = await anmeldenMitKeks("modi", "CODE-MODI-0001");
const modi2 = await anmeldenMitKeks("modi", "CODE-MODI-0002");
melde("");
melde("=== 2. Alle fuenf kommen herein ===");
for (const [wer, s] of [["DogFather", chef], ["rechte Hand", hand],
["linke Hand", linke], ["Frida (Modi)", modi], ["Nele (Modi)", modi2]]) {
ok(s.code === 200 && s.keks.length > 0, `${wer} ist angemeldet (${s.code})`);
}
const aufgabeAnlegen = (keks, titel, zuteilung, verteilart) =>
roh("/workspace/api/aufgaben", {
keks, body: { titel, ...(zuteilung ? { zuteilung, verteilart } : {}) },
});
const liste = (keks) => roh("/workspace/api/aufgaben", { keks });
const finde = async (keks, titel) => {
const r = await liste(keks);
const alle = Array.isArray(r.json) ? r.json : (r.json?.aufgaben || []);
return alle.find((a) => a.titel === titel) || null;
};
/* =====================================================================
3. DER VERBOTENE WEG: selbst nehmen
===================================================================== */
melde("");
melde("=== 3. Aus dem Pool nimmt sich niemand mehr selbst etwas ===");
let poolId = null;
{
const r = await aufgabeAnlegen(chef.keks, "Pool fuer alle",
[idModi, idModi2, idLinke], "pool");
poolId = r.json?.id;
ok(r.code === 201 && Number.isInteger(poolId),
`DogFather legt eine Pool-Aufgabe fuer drei an (${r.code})`);
for (const [wer, s] of [["Frida (Modi)", modi], ["die linke Hand", linke]]) {
const u = await roh(`/workspace/api/aufgaben/${poolId}/uebernehmen`, { methode: "POST", keks: s.keks });
ok(u.code === 403 && u.json?.fehler === "erst_bewerben",
`${wer} kann sie nicht uebernehmen (${u.code}/${u.json?.fehler})`);
/* UND AUCH NICHT DURCH DIE ZWEITE TUER. Beim Pool heisst
„annehmen" dasselbe wie „uebernehmen": Wer zusagt, nimmt sie
den anderen weg. Diese Zeile fehlte im ersten Entwurf, und
genau daran haette das Verbot vorbeigegriffen. */
const an = await roh(`/workspace/api/aufgaben/${poolId}/annehmen`, { methode: "POST", keks: s.keks });
ok(an.code === 403 && an.json?.fehler === "erst_bewerben",
`${wer} kann sie auch nicht einfach annehmen (${an.code}/${an.json?.fehler})`);
}
}
melde("");
melde("=== 3b. Und niemand traegt sich beim Verteilen selbst ein ===");
{
const r = await aufgabeAnlegen(linke.keks, "Die linke Hand fuer sich selbst", [idLinke], "einzeln");
const zu = r.json?.zuteilung;
ok(r.code === 201 && zu?.fehler === "nicht_an_dich_selbst",
`die linke Hand kann sich nicht selbst eintragen (${zu?.fehler})`);
/* GEGENPROBE: An JEMAND ANDEREN darf sie weiterhin verteilen. Sein
Satz „rechte hand linke hand und dogfather koennen alle verteilen"
bleibt damit gueltig. */
const r2 = await aufgabeAnlegen(linke.keks, "Die linke Hand verteilt an Frida", [idModi], "einzeln");
ok(r2.code === 201 && Array.isArray(r2.json?.zuteilung?.gesetzt)
&& r2.json.zuteilung.gesetzt.includes(idModi),
`aber an jemand anderen schon (${JSON.stringify(r2.json?.zuteilung?.gesetzt)})`);
/* Und DogFather darf sich selbst eintragen -- er entscheidet ja. */
const r3 = await aufgabeAnlegen(chef.keks, "DogFather fuer sich selbst", [idChef], "einzeln");
ok(r3.code === 201 && r3.json?.zuteilung?.gesetzt?.includes(idChef),
"DogFather darf sich selbst eintragen — er entscheidet");
}
/* =====================================================================
4. DER ERLAUBTE WEG: sich bewerben
===================================================================== */
melde("");
melde("=== 4. Bewerben ===");
{
const b = await roh(`/workspace/api/aufgaben/${poolId}/bewerben`, {
methode: "POST", keks: modi.keks, body: { text: "Ich habe Freitag Zeit." },
});
ok(b.code === 200 && b.json?.zustand === "beworben",
`Frida bewirbt sich (${b.code}/${b.json?.zustand})`);
const nochmal = await roh(`/workspace/api/aufgaben/${poolId}/bewerben`, {
methode: "POST", keks: modi.keks, body: {} });
ok(nochmal.code === 409 && nochmal.json?.fehler === "schon_beworben",
`ein zweites Mal geht nicht (${nochmal.code}/${nochmal.json?.fehler})`);
const bLinke = await roh(`/workspace/api/aufgaben/${poolId}/bewerben`, {
methode: "POST", keks: linke.keks, body: { text: "Kann ich uebernehmen." } });
ok(bLinke.code === 200, `die linke Hand bewirbt sich auch (${bLinke.code})`);
/* WER ENTSCHEIDET, BEWIRBT SICH NICHT -- er teilt sich zu. Eine
Bewerbung an sich selbst waere ein Umweg mit demselben Ergebnis. */
const bChef = await roh(`/workspace/api/aufgaben/${poolId}/bewerben`, {
methode: "POST", keks: chef.keks, body: {} });
ok(bChef.code === 409 && bChef.json?.fehler === "du_entscheidest_selbst",
`DogFather bewirbt sich nicht (${bChef.code}/${bChef.json?.fehler})`);
const a = await finde(chef.keks, "Pool fuer alle");
ok((a?.bewerbungen || []).length === 2,
`die Aufgabe zeigt ${a?.bewerbungen?.length} Bewerbungen`);
ok((a?.bewerbungen || []).some((z) => z.grund === "Ich habe Freitag Zeit."),
"und Fridas Worte stehen dabei");
}
/* =====================================================================
5. ENTSCHEIDEN — nur die rechte Hand und DogFather
===================================================================== */
melde("");
melde("=== 4b. Wer zuerst Zeit hat, steht zuerst ===");
{
/* Filipe, nachdem er meinen Satz „wer zuerst Zeit hat — genau das
geht ja nicht mehr" gelesen hatte: „falls das nicht mehr geht mach
das es geht wenn es wieder geht ist alles gut."
„Wer zuerst Zeit hat" heisst nicht zwingend „man bedient sich
selbst" -- es kann genauso heissen, dass die ERSTE BEWERBUNG zuerst
drankommt. Damit gilt beides gleichzeitig.
GEPRUEFT WIRD DIE REIHENFOLGE, NICHT DER SATZ. Die Abfrage
sortiert sonst nach NAMEN; nach Alphabet haette nicht der
Schnelle den Vorteil, sondern Frida. Genau deshalb steht hier
eine Messung und kein Blick in den Quelltext. */
const r = await aufgabeAnlegen(chef.keks, "Wer zuerst kommt", [idModi, idModi2], "pool");
const id = r.json?.id;
ok(r.code === 201, `eine neue Pool-Aufgabe (${r.code})`);
/* NELE ZUERST -- und sie steht im Alphabet HINTER Frida. Waere die
Liste nach Namen sortiert, faellt genau das auf. */
const n1 = await roh(`/workspace/api/aufgaben/${id}/bewerben`, {
methode: "POST", keks: modi2.keks, body: { text: "Ich kann sofort." } });
ok(n1.code === 200, `Nele bewirbt sich zuerst (${n1.code})`);
await new Promise((w) => setTimeout(w, 1100));
const n2 = await roh(`/workspace/api/aufgaben/${id}/bewerben`, {
methode: "POST", keks: modi.keks, body: { text: "Ich auch." } });
ok(n2.code === 200, `Frida danach (${n2.code})`);
const a = await finde(chef.keks, "Wer zuerst kommt");
const reihe = (a?.bewerbungen || []).map((z) => z.person_name);
ok(reihe.length === 2, `zwei Bewerbungen liegen vor (${reihe.length})`);
ok(reihe[0] === "Nele",
`Nele steht vorn, weil sie zuerst da war (${reihe.join(" vor ")})`);
/* GEGENPROBE: Nach Namen sortiert stuende Frida vorn. Die Messung
kann den Unterschied also sehen -- ohne diese Zeile waere
„Nele steht vorn" auch bei einer zufaelligen Ordnung mal richtig. */
ok([...reihe].sort()[0] === "Frida",
"nach Alphabet staende Frida vorn — die Reihenfolge ist also wirklich die des Eingangs");
}
melde("");
melde("=== 5. Entscheiden ===");
{
/* Die linke Hand darf nicht -- sie hat sich ja selbst beworben. */
const versuch = await roh(`/workspace/api/aufgaben/${poolId}/bewerbung/annehmen`, {
methode: "POST", keks: linke.keks, body: { person_id: idModi } });
ok(versuch.code === 403,
`die linke Hand entscheidet nicht (${versuch.code}/${versuch.json?.fehler})`);
const modiVersuch = await roh(`/workspace/api/aufgaben/${poolId}/bewerbung/annehmen`, {
methode: "POST", keks: modi.keks, body: { person_id: idModi } });
ok(modiVersuch.code === 403,
`ein Modi auch nicht (${modiVersuch.code})`);
/* Die rechte Hand nimmt Frida an, mit Kommentar. */
const ja = await roh(`/workspace/api/aufgaben/${poolId}/bewerbung/annehmen`, {
methode: "POST", keks: hand.keks,
body: { person_id: idModi, kommentar: "Danke dir, mach das gern." } });
ok(ja.code === 200 && ja.json?.zustand === "angenommen",
`die rechte Hand nimmt Frida an (${ja.code}/${ja.json?.zustand})`);
const a = await finde(chef.keks, "Pool fuer alle");
const fridas = (a?.zuteilung || []).find((z) => z.person_id === idModi);
ok(fridas?.zustand === "angenommen", `Fridas Stand ist "${fridas?.zustand}"`);
/* ZWEI FELDER, NICHT EINS. Der Kommentar der Leitung darf die Worte
der Person nicht ueberschreiben -- sonst weiss hinterher niemand
mehr, worum jemand gebeten hat. */
ok(fridas?.grund === "Ich habe Freitag Zeit.",
`ihre eigenen Worte stehen noch da ("${fridas?.grund}")`);
ok(fridas?.entscheid_text === "Danke dir, mach das gern.",
`und der Kommentar daneben ("${fridas?.entscheid_text}")`);
ok(fridas?.entschieden_von_name === "Rieke",
`mit Namen, wer entschieden hat (${fridas?.entschieden_von_name})`);
/* Die Bewerbung der linken Hand ist damit erledigt -- Pool. */
const linkes = (a?.zuteilung || []).find((z) => z.person_id === idLinke);
ok(linkes?.zustand === "abgelehnt",
`die andere Bewerbung faellt weg, weil Pool (${linkes?.zustand})`);
/* UND IHRE WORTE BLEIBEN STEHEN. Der Hinweis „von jemand anderem
uebernommen" gehoert in das Feld der Leitung, nicht in ihres --
sonst loescht die Antwort die Frage. */
ok(linkes?.grund === "Kann ich uebernehmen.",
`ihre eigenen Worte bleiben ("${linkes?.grund}")`);
ok(/jemand anderem/i.test(linkes?.entscheid_text || ""),
`und der Grund steht daneben ("${linkes?.entscheid_text}")`);
}
melde("");
melde("=== 5b. Ablehnen, auch mit Kommentar ===");
{
const r = await aufgabeAnlegen(chef.keks, "Etwas fuer Nele", [idModi2], "einzeln");
const id = r.json?.id;
/* Nele ist zugeteilt -- sie bewirbt sich nicht, sie hat sie schon. */
const b = await roh(`/workspace/api/aufgaben/${id}/bewerben`, {
methode: "POST", keks: modi2.keks, body: {} });
ok(b.code === 409 && b.json?.fehler === "schon_zugeteilt",
`wer sie schon hat, bewirbt sich nicht (${b.code}/${b.json?.fehler})`);
/* Frida ist nicht zugeteilt und kann sich bewerben. */
const b2 = await roh(`/workspace/api/aufgaben/${id}/bewerben`, {
methode: "POST", keks: modi.keks, body: { text: "Ich kenne mich da aus." } });
ok(b2.code === 200, `Frida bewirbt sich darauf (${b2.code})`);
const nein = await roh(`/workspace/api/aufgaben/${id}/bewerbung/ablehnen`, {
methode: "POST", keks: chef.keks,
body: { person_id: idModi, kommentar: "Nele macht das schon." } });
ok(nein.code === 200 && nein.json?.zustand === "abgelehnt",
`DogFather lehnt ab (${nein.code}/${nein.json?.zustand})`);
const a = await finde(chef.keks, "Etwas fuer Nele");
const fridas = (a?.zuteilung || []).find((z) => z.person_id === idModi);
ok(fridas?.entscheid_text === "Nele macht das schon.",
`mit Kommentar ("${fridas?.entscheid_text}")`);
/* OHNE KOMMENTAR GEHT AUCH -- „als moeglichkeit", nicht als Pflicht. */
const b3 = await roh(`/workspace/api/aufgaben/${id}/bewerben`, {
methode: "POST", keks: modi.keks, body: {} });
ok(b3.code === 200, "Frida bewirbt sich erneut, diesmal ohne Text");
const nein2 = await roh(`/workspace/api/aufgaben/${id}/bewerbung/ablehnen`, {
methode: "POST", keks: chef.keks, body: { person_id: idModi } });
ok(nein2.code === 200, "und wird ohne Kommentar abgelehnt — der ist freiwillig");
}
melde("");
melde("=== 5c. Eine Bewerbung laesst sich zurueckziehen ===");
{
const r = await aufgabeAnlegen(chef.keks, "Noch etwas", null, null);
const id = r.json?.id;
const b = await roh(`/workspace/api/aufgaben/${id}/bewerben`, {
methode: "POST", keks: modi.keks, body: { text: "Mache ich." } });
ok(b.code === 200, `Frida bewirbt sich (${b.code})`);
const zurueck = await roh(`/workspace/api/aufgaben/${id}/bewerbung-zurueck`, {
methode: "POST", keks: modi.keks });
ok(zurueck.code === 200, `und zieht sie zurueck (${zurueck.code})`);
const a = await finde(chef.keks, "Noch etwas");
ok((a?.bewerbungen || []).length === 0,
`danach steht keine mehr da (${a?.bewerbungen?.length})`);
/* GANZ WEG, nicht „zurueckgezogen": keine Zeile, die nur noch sagt,
dass hier mal eine war. */
ok((a?.zuteilung || []).every((z) => z.person_id !== idModi),
"und auch keine Zeile mehr, die davon uebrig ist");
const nochmal = await roh(`/workspace/api/aufgaben/${id}/bewerbung-zurueck`, {
methode: "POST", keks: modi.keks });
ok(nochmal.code === 409, `ein zweites Zurueckziehen geht nicht (${nochmal.code})`);
}
/* =====================================================================
6. AM ECHTEN BILDSCHIRM
=====================================================================
Die Abschnitte davor messen die Wege. Keiner von ihnen haette
gemerkt, wenn der Knopf „Ich bewerbe mich" gar nicht erscheint oder
wenn ein Modi weiterhin „Ich uebernehme das" sieht.
Genau diese Luecke ist heute frueh beim Aufgaben-Formular
aufgefallen: Die Pruefung war gruen und hat den Zustand, in dem der
Fehler steckt, nie hergestellt.
WARUM EIN HTTPS-VORBAU: Team Dogi wohnt auf
crew.dogfather-universe.com, und welche Wand gilt, entscheidet der
Host-Kopf. Chromium setzt den selbst und hebt diese Domain
ausserdem von sich aus auf https.
===================================================================== */
melde("");
melde("=== 6. Am echten Bildschirm ===");
{
const { execFileSync } = await import("node:child_process");
const { createServer: httpsServer } = await import("node:https");
const { readFileSync } = await import("node:fs");
const schluessel = join(ordner, "pruef.key");
const zertifikat = join(ordner, "pruef.crt");
zertifikatBauen(schluessel, zertifikat, CREW);
const HTTPS_PORT = PORT + 1;
const vorbau = httpsServer({
key: readFileSync(schluessel), cert: readFileSync(zertifikat),
}, (von, nach) => {
const weiter = httpAnfrage({
host: "127.0.0.1", port: PORT, path: von.url, method: von.method,
headers: { ...von.headers, host: `${CREW}:${HTTPS_PORT}` },
}, (antwort) => {
nach.writeHead(antwort.statusCode || 502, antwort.headers);
antwort.pipe(nach);
});
weiter.on("error", () => { try { nach.writeHead(502); nach.end(); } catch { /* egal */ } });
von.pipe(weiter);
});
await new Promise((r) => vorbau.listen(HTTPS_PORT, "127.0.0.1", r));
const SEITE = `https://${CREW}:${HTTPS_PORT}`;
const pw = await import("file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs");
const browser = await pw.chromium.launch({
args: [`--host-resolver-rules=MAP ${CREW} 127.0.0.1`, "--ignore-certificate-errors"],
});
/* Eine frische Aufgabe fuer den Bildschirm -- die aus den
Abschnitten davor sind schon entschieden. */
const frisch = await aufgabeAnlegen(chef.keks, "Fuer den Bildschirm",
[idModi, idLinke], "pool");
const frischId = frisch.json?.id;
ok(Number.isInteger(frischId), `eine frische Pool-Aufgabe liegt (${frisch.code})`);
const meckern = [];
async function seiteAls(rolle, code, breite = 1280) {
const ctx = await browser.newContext({ viewport: { width: breite, height: 1000 }, hasTouch: breite <= 860 });
const s2 = await ctx.newPage();
s2.on("pageerror", (e) => meckern.push(`${rolle}: ${String(e).slice(0, 110)}`));
s2.on("console", (m) => {
if (m.type() === "error" && !/favicon|ERR_CERT|net::ERR/i.test(m.text())) {
meckern.push(`${rolle}: ${m.text().slice(0, 110)}`);
}
});
await s2.goto(`${SEITE}/workspace/`, { waitUntil: "networkidle" });
await s2.click(`.rolle[data-rolle="${rolle}"]`);
await s2.fill("#code", code);
await s2.click("button[type=submit]");
await s2.waitForTimeout(900);
await s2.goto(`${SEITE}/workspace/aufgaben.html`, { waitUntil: "networkidle" });
await s2.waitForTimeout(1600);
return { ctx, s: s2 };
}
const knoepfeAn = (s2, titel) => s2.evaluate((t) => {
const karten = [...document.querySelectorAll(".karte, .aufgabe, li, article")];
const k = karten.find((x) => (x.textContent || "").includes(t)
&& x.querySelector(".z-karte"));
if (!k) return null;
return {
knoepfe: [...k.querySelectorAll(".z-karte button")].map((b) => b.textContent.trim()),
/* DIE SCHRITTKNOEPFE DER KARTE (02.10.2026). Sie liegen NEBEN
dem Zuteilungsblock, nicht darin -- „kann sie etwas tun?"
laesst sich am Block allein nicht beantworten. Genau diese
Luecke hat die Pruefung hier am 02.10. rot werden lassen,
und zwar zu Recht: Die zwei Knoepfe im Block waren weg, der
richtige stand daneben und wurde nicht angesehen. */
schritte: [...k.querySelectorAll(".schritt")].map((b) => b.textContent.trim()),
bewerbungen: k.querySelectorAll(".z-bewerbung").length,
text: (k.querySelector(".z-karte")?.textContent || "").replace(/\s+/g, " ").trim().slice(0, 200),
};
}, titel);
try {
/* ---- Ein Modi ------------------------------------------------- */
const m = await seiteAls("modi", "CODE-MODI-0001", 390);
try {
const b = await knoepfeAn(m.s, "Fuer den Bildschirm");
ok(!!b, "der Modi sieht die Aufgabe mit ihrem Zuteilungsblock");
ok(!!b && b.knoepfe.some((t) => /bewerbe mich/i.test(t)),
`und den Knopf „Ich bewerbe mich" (${b?.knoepfe.join(" | ")})`);
ok(!!b && !b.knoepfe.some((t) => /übernehme/i.test(t)),
"und NICHT mehr „Ich übernehme das“");
ok(!!b && !b.knoepfe.some((t) => /^Ablehnen$/i.test(t)),
"und kein „Ablehnen“ — eine Absage auf eine Frage, die niemand gestellt hat");
} finally { await m.ctx.close(); }
/* ---- Die linke Hand: darf verteilen, aber nicht entscheiden --- */
const l = await seiteAls("linke", "CODE-LINK-0001", 1280);
try {
const b = await knoepfeAn(l.s, "Fuer den Bildschirm");
ok(!!b && b.knoepfe.some((t) => /bewerbe mich/i.test(t)),
`die linke Hand sieht denselben Knopf (${b?.knoepfe.join(" | ")})`);
ok(!!b && !b.knoepfe.some((t) => /übernehme/i.test(t)),
"und auch sie nicht „Ich übernehme das“");
} finally { await l.ctx.close(); }
/* ---- Jetzt bewirbt sich der Modi wirklich --------------------- */
const bew = await roh(`/workspace/api/aufgaben/${frischId}/bewerben`, {
methode: "POST", keks: modi.keks, body: { text: "Kann ich machen." } });
ok(bew.code === 200, `Frida bewirbt sich (${bew.code})`);
/* ---- Die rechte Hand sieht die Bewerbung ---------------------- */
const h = await seiteAls("hand", "CODE-HAND-0001", 1280);
try {
const b = await knoepfeAn(h.s, "Fuer den Bildschirm");
ok(!!b && b.bewerbungen === 1,
`die rechte Hand sieht 1 Bewerbung (${b?.bewerbungen})`);
ok(!!b && b.knoepfe.some((t) => /^Annehmen$/i.test(t))
&& b.knoepfe.some((t) => /^Ablehnen$/i.test(t)),
`mit Annehmen und Ablehnen (${b?.knoepfe.join(" | ")})`);
ok(!!b && /Kann ich machen/.test(b.text),
"und Fridas Worte stehen dabei");
} finally { await h.ctx.close(); }
/* ---- Der Modi sieht seinen eigenen Stand ---------------------- */
const m2 = await seiteAls("modi", "CODE-MODI-0001", 390);
try {
const b = await knoepfeAn(m2.s, "Fuer den Bildschirm");
ok(!!b && /beworben/i.test(b.text),
`Frida sieht, dass sie sich beworben hat ("${(b?.text || "").slice(0, 70)}…")`);
ok(!!b && b.knoepfe.some((t) => /zurückziehen/i.test(t)),
`und kann sie zurueckziehen (${b?.knoepfe.join(" | ")})`);
/* GEGENPROBE: Sie sieht KEINE Entscheidungsknoepfe an ihrer
eigenen Bewerbung -- sonst haette sie sich selbst annehmen
koennen, und das ganze Verbot waere ein Knopf weiter offen. */
ok(!!b && b.bewerbungen === 0,
`und keinen Block zum Entscheiden (${b?.bewerbungen})`);
} finally { await m2.ctx.close(); }
/* =================================================================
UND JETZT WIRD ANGENOMMEN -- VanVans Meldung (30.09.2026)
„Wenn ein Modi sich auf eine Aufgabe beworben hat und die von uns
angenommen wurde, dann steht beim Modi unter der Aufgabe immer
noch ich bewerbe mich."
DIESER SCHRITT HAT HIER GEFEHLT. Die Pruefung ging den ganzen
Weg bis zur Bewerbung und hoerte dann auf. Serverseitig ist das
Annehmen weiter oben geprueft („Fridas Stand ist angenommen");
was der Mensch danach auf seinem Brett LIEST, hat nie jemand
nachgesehen.
================================================================= */
const annahme = await roh(`/workspace/api/aufgaben/${frischId}/bewerbung/annehmen`, {
methode: "POST", keks: hand.keks,
body: { person_id: idModi, kommentar: "Gern, mach das." } });
ok(annahme.code === 200 && annahme.json?.zustand === "angenommen",
`die rechte Hand nimmt Fridas Bewerbung an (${annahme.code}/${annahme.json?.zustand})`);
const m3 = await seiteAls("modi", "CODE-MODI-0001", 390);
try {
const b = await knoepfeAn(m3.s, "Fuer den Bildschirm");
ok(!!b, "Frida sieht die Aufgabe weiterhin");
/* DER BEFUND. Ein Knopf, der nach der Zusage weiter „Ich
bewerbe mich" sagt, behauptet, es sei nichts entschieden --
und wer ihn drueckt, bekommt eine Fehlermeldung auf etwas,
das laengst geklappt hat. */
ok(!!b && !b.knoepfe.some((t) => /bewerbe mich/i.test(t)),
`und NICHT mehr „Ich bewerbe mich" (${b?.knoepfe.join(" | ")})`);
ok(!!b && !b.knoepfe.some((t) => /zurückziehen/i.test(t)),
"auch nichts mehr zum Zurueckziehen -- es gibt keine offene Bewerbung mehr");
/* UND DAS EIGENTLICHE: Steht da, dass sie die Aufgabe HAT?
„Kein falscher Knopf" allein waere eine leere Karte. */
ok(!!b && /(du machst|gehört dir|angenommen|übernommen|deine aufgabe)/i.test(b.text),
`sondern dass sie die Aufgabe hat ("${(b?.text || "").slice(0, 90)}…")`);
} finally { await m3.ctx.close(); }
/* =================================================================
DERSELBE WEG, ABER UEBER DAS VORLAGENBRETT -- VanVans Fall
Der Abschnitt darueber geht ueber das Aufgabenbrett
(`/api/aufgaben/:id/bewerben`). Dort war alles in Ordnung.
VanVans Weg ist der andere: Sie bewirbt sich auf eine VORLAGE.
Wird die angenommen, entsteht eine NEUE Aufgabe -- und die
bekam bis zum 30.09.2026 keine Zeile in `aufgaben_zuteilung`.
WAS DAS BEDEUTET HAT. Das Brett liest die Zuteilung, nicht
`verantwortlich_id`. Ohne Zeile sagt es „niemand zugeteilt" und
bietet an, sich auf die eigene Aufgabe zu bewerben. Und weil
„Ich fange an" und „Fertig" an derselben Zeile haengen, fehlten
sie: eine Karte, auf der man das Falsche tun kann und das
Richtige nicht.
Gemessen an den echten Daten hatten 8 von 11 Aufgaben mit einem
Verantwortlichen keine Zuteilungszeile.
================================================================= */
const vorl = await roh("/workspace/api/vorlagen", { keks: modi.keks });
/* DIE VORLAGEN STEHEN UNTER `katalog.aufgaben`. Beim ersten
Anlauf habe ich auf `gruppen` geraten und einen leeren Treffer
bekommen -- die Pruefung meldete daraufhin „konnte nicht
nachsehen", und das war die richtige Antwort auf eine falsche
Frage. */
/* DIE LETZTE UND NICHT DIE ERSTE. Ein spaeterer Abschnitt (Zeile
~999) bewirbt sich ebenfalls, und zwar auf die ERSTE Vorlage.
Nahm ich sie auch, bekam er 409 („liegt schon bei dir") -- und
zehn Pruefungen wurden rot, ohne dass am Programm etwas falsch
war. Ein Messfehler, der wie ein Befund aussieht, und einer,
den ich selbst eingebaut hatte. */
const katalogAufgaben = vorl.json?.katalog?.aufgaben || [];
const v = katalogAufgaben.at(-1);
if (!v) {
ok(false, "KONNTE NICHT NACHSEHEN: keine Vorlage gefunden "
+ `(${Object.keys(vorl.json || {}).join(", ")})`);
} else {
const vb = await roh("/workspace/api/vorlagen/bewerben", {
methode: "POST", keks: modi.keks,
body: { vorlage: v.schluessel, text: "Das mache ich gern." } });
ok(vb.code === 201 || vb.code === 200,
`Frida bewirbt sich auf die Vorlage „${v.titel}" (${vb.code})`);
const offen2 = await roh("/workspace/api/vorlagen/bewerbungen", { keks: hand.keks });
const bw = (offen2.json?.bewerbungen || []).find((x) => x.vorlage === v.schluessel);
ok(!!bw, `die rechte Hand sieht die Bewerbung (${offen2.json?.bewerbungen?.length})`);
const vann = await roh("/workspace/api/vorlagen/bewerbung/annehmen", {
methode: "POST", keks: hand.keks,
body: { id: bw?.id, kommentar: "Gern, mach das." } });
ok(vann.code === 200, `und nimmt sie an (${vann.code})`);
/* ---- DER KERN: was steht jetzt auf Fridas Brett? ---------- */
const nach = await roh("/workspace/api/aufgaben", { keks: modi.keks });
const liste = nach.json?.aufgaben || nach.json?.liste || [];
const neue = liste.find((x) => x.vorlage === v.schluessel);
ok(!!neue, `die Aufgabe steht auf ihrem Brett (${liste.length} insgesamt)`);
ok(!!neue && !!neue.meine_zuteilung,
`und sie hat eine Zuteilungszeile (${JSON.stringify(neue?.meine_zuteilung?.zustand)})`);
ok(neue?.meine_zuteilung?.zustand === "angenommen",
`im Zustand „angenommen" -- sie hat darum gebeten und sie bekommen `
+ `(${neue?.meine_zuteilung?.zustand})`);
ok(neue?.ich_beworben === false,
`und es liegt keine offene Bewerbung mehr vor (${neue?.ich_beworben})`);
const m4 = await seiteAls("modi", "CODE-MODI-0001", 390);
try {
const b = await knoepfeAn(m4.s, v.titel);
ok(!!b, "Frida sieht die neue Aufgabe mit ihrem Zuteilungsblock");
/* VANVANS BEFUND. Ein Knopf, der nach der Zusage weiter „Ich
bewerbe mich" sagt, behauptet, es sei nichts entschieden. */
ok(!!b && !b.knoepfe.some((t) => /bewerbe mich/i.test(t)),
`und NICHT „Ich bewerbe mich" (${b?.knoepfe.join(" | ")})`);
/* UND DIE ANDERE HAELFTE: Sie kann auch wirklich etwas tun.
„Kein falscher Knopf" allein waere eine Karte ohne Griff.
==== DER GRIFF IST SEIT DEM 02.10.2026 EIN ANDERER ========
Hier stand `„Ich fange an"`. Den Knopf gibt es nicht mehr --
VanVan hat gemeldet, dass er nur den halben Zustand setzte:
„Wenn man auf ich fange an drückt steht dort in Bearbeitung
… Die Aufgabe bleibt aber im status offen stehen."
Die Absicht dieser Zeile bleibt woertlich dieselbe: Kann
sie etwas tun? Nur ist es jetzt der STATUSKNOPF, der auch
bewegt, was alle anderen lesen. Dass eine Modi ihn
ueberhaupt bekommt, ist ebenfalls neu -- vorher sah sie
keinen einzigen (gemessen in pruef-zuteilung). */
ok(!!b && b.schritte.some((t) => /starten|Freigabe|erledigt/i.test(t)),
`sondern den Statusknopf (${b?.schritte.join(" | ") || "KEINER"})`);
ok(!!b && !b.knoepfe.some((t) => /fange an/i.test(t))
&& !b.knoepfe.some((t) => /^Fertig$/i.test(t)),
`und die zwei halben Knoepfe sind weg (${b?.knoepfe.join(" | ") || "keine"})`);
} finally { await m4.ctx.close(); }
/* ===============================================================
UND JETZT EINE ABSAGE -- VanVans vierte Meldung (30.09.2026)
„dann bekommt der Modi keine Benachrichtigung darüber, dass
die Aufgabe abgelehnt wurde und sieht somit auch eine
eventuelle Begründung nicht.
Außerdem haben auch wir nirgendwo eine Übersicht, bei wem wir
welche Aufgaben schon abgelehnt haben."
Bei einer ZUSAGE entsteht eine Aufgabe, und an ihr steht die
Antwort. Bei einer ABSAGE entsteht nichts -- die Begruendung
wird in `vorlagen_bewerbungen.entscheid_text` geschrieben und
danach von niemandem mehr ausgeliefert:
`vorlagenBewerbungenFuer` fragt `WHERE zustand = 'beworben'`.
Der Satz wird also verlangt, geschrieben und weggesperrt.
=============================================================== */
const v2 = katalogAufgaben.at(-2);
const vb2 = await roh("/workspace/api/vorlagen/bewerben", {
methode: "POST", keks: modi.keks,
body: { vorlage: v2.schluessel, text: "Die würde ich auch gern machen." } });
ok(vb2.code === 201 || vb2.code === 200,
`Frida bewirbt sich auf „${v2.titel}" (${vb2.code})`);
const offen3 = await roh("/workspace/api/vorlagen/bewerbungen", { keks: hand.keks });
const bw2 = (offen3.json?.bewerbungen || []).find((x) => x.vorlage === v2.schluessel);
const GRUND = "Diese Woche nicht — du hast schon drei offene.";
const vab = await roh("/workspace/api/vorlagen/bewerbung/ablehnen", {
methode: "POST", keks: hand.keks, body: { id: bw2?.id, kommentar: GRUND } });
ok(vab.code === 200, `die rechte Hand lehnt ab, mit Begruendung (${vab.code})`);
/* ---- 1. SIEHT FRIDA DIE ANTWORT? ------------------------- */
const beiFrida = await roh("/workspace/api/vorlagen", { keks: modi.keks });
/* `antworten` UND NICHT `bewerbungen`. Das alte Feld heisst im
Browser „wartet auf Antwort" -- beantwortete Zeilen darin
haetten bei einer Absage genau das angezeigt. */
const ihre = beiFrida.json?.antworten || [];
const abgelehnt = ihre.find((x) => x.vorlage === v2.schluessel);
ok(!!abgelehnt,
`Frida sieht ihre beantwortete Bewerbung (${ihre.length} insgesamt)`);
ok(abgelehnt?.zustand === "abgelehnt",
`und zwar als abgelehnt (${abgelehnt?.zustand})`);
ok(abgelehnt?.entscheid_text === GRUND,
`mit der Begruendung, die geschrieben wurde (${JSON.stringify(abgelehnt?.entscheid_text)})`);
/* ---- 2. SIEHT DIE LEITUNG, WAS SIE ABGELEHNT HAT? -------- */
const beiHand = await roh("/workspace/api/vorlagen", { keks: hand.keks });
const absagen = beiHand.json?.absagen;
const verlauf = absagen?.liste || [];
ok(verlauf.length >= 1,
`die Leitung sieht die Absagen der letzten ${absagen?.seit_tagen} Tage `
+ `(${verlauf.length})`);
const eintrag = verlauf.find((x) => x.kennung === v2.schluessel);
ok(!!eintrag && eintrag.person_name === "Frida",
`mit Namen und Aufgabe (${eintrag?.person_name} / ${eintrag?.titel})`);
ok(eintrag?.entscheid_text === GRUND,
`und der eigenen Begruendung (${JSON.stringify(eintrag?.entscheid_text)})`);
/* VANVANS EIGENTLICHE FRAGE: „Falls sich jemand immer wieder auf
Aufgaben bewirbt die immer wieder abgelehnt werden, wäre das
sinnvoll." Eine Liste beantwortet das nicht, eine Zahl je
Mensch schon. */
const frida = (absagen?.je_mensch || []).find((x) => x.name === "Frida");
ok(!!frida && frida.anzahl >= 1,
`und wie oft es je Mensch war (Frida: ${frida?.anzahl})`);
/* BEIDE WEGE STEHEN DRIN. Weiter oben hat DogFather zwei
Bewerbungen auf AUFGABEN abgelehnt. Eine Uebersicht, die nur
die Vorlagen zeigt, waere eine Auskunft, die stimmt und
taeuscht. */
ok(verlauf.some((x) => x.quelle === "aufgabe")
&& verlauf.some((x) => x.quelle === "vorlage"),
`aus beiden Wegen (${[...new Set(verlauf.map((x) => x.quelle))].join(", ")})`);
/* GEGENPROBE: Frida selbst bekommt die Uebersicht NICHT -- sie
entscheidet nicht, und eine Liste der Absagen an andere geht
sie nichts an. */
ok(beiFrida.json?.absagen === null,
`ein Modi bekommt die Uebersicht nicht (${JSON.stringify(beiFrida.json?.absagen)})`);
/* ---- UND AM ECHTEN BILDSCHIRM -----------------------------
„Der Server liefert es" waere ein gruener Haken ueber etwas,
das niemand sieht. Das Vorlagenbrett steht auf der
Aufgabenseite -- also aufmachen und nachlesen. */
const liesBrett = (seite) => seite.evaluate(() => {
const a = document.querySelector(".vorlagenbrett__antwort");
const u = document.querySelector(".vorlagenbrett__absagen");
return {
antwort: a ? (a.innerText || "").replace(/\s+/g, " ").trim().slice(0, 220) : null,
zeilen: a ? a.querySelectorAll(".vorlagenbrett__antwort-zeile").length : 0,
absagen: u ? (u.innerText || "").replace(/\s+/g, " ").trim().slice(0, 220) : null,
zugeklappt: u ? !u.open : null,
leute: u ? [...u.querySelectorAll(".vorlagenbrett__absagen-mensch")]
.map((x) => x.textContent.trim()) : [],
};
});
/* AUF „EURE AUFGABEN" UND NICHT AUF DEM AUFGABENBRETT. Dort
bewirbt man sich, dort steht der Vorlagenkatalog
(`zweig: 'team'` in entwicklung.js). Das Aufgabenbrett zeigt
den anderen Zweig (`creator`, den Aufbauplan) -- beim ersten
Anlauf habe ich dort gemessen und nichts gefunden. Ein
Messfehler, der wie ein Befund aussieht. */
const m5 = await seiteAls("modi", "CODE-MODI-0001", 390);
try {
await m5.s.goto(`${SEITE}/workspace/entwicklung.html`, { waitUntil: "networkidle" });
await m5.s.waitForTimeout(1800);
const w = await liesBrett(m5.s);
ok(w.zeilen >= 1, `Frida sieht die Antwort auf ihrem Brett (${w.zeilen} Zeilen)`);
/* OHNE RUECKSICHT AUF GROSS UND KLEIN. `innerText` liefert den
Text SO, WIE ER DASTEHT -- und das Schild traegt
`text-transform: uppercase`. Die Zeile suchte „Diesmal
nicht" und fand „DIESMAL NICHT": rot, obwohl alles stimmte.
`textContent` haette den Rohtext gegeben, aber `innerText`
ist hier richtig: Gemessen wird, was ein Mensch liest. */
ok(/diesmal nicht/i.test(w.antwort || ""),
`und dass es diesmal nichts wurde („${(w.antwort || "").slice(0, 60)}…")`);
ok(!!w.antwort && w.antwort.includes("schon drei offene"),
"mitsamt der Begruendung");
/* GEGENPROBE: Die Uebersicht der Absagen sieht sie NICHT. */
ok(w.absagen === null,
`und die Uebersicht der Absagen nicht (${JSON.stringify(w.absagen)})`);
/* UND EIN BILD DAVON -- am Handy, denn dort liest ein Modi. */
const kasten2 = await m5.s.$(".vorlagenbrett__antwort");
if (kasten2) {
await kasten2.screenshot({ path: "server/pruef-bewerbung-antwort.png" });
console.log(" -- Bild: server/pruef-bewerbung-antwort.png");
}
/* ==== „VERSTANDEN" RAEUMT AUF (03.10.2026) ==============
VanVan im Support, Meldung #15: „Die Modis können die
Antworten auf die Bewerbungen im Bereich eure Aufgaben noch
nicht einklappen. Das wird mit der Zeit unübersichtlich."
GEMESSEN WIRD DER GANZE WEG, nicht nur die Route: Knopf
druecken, Zeile rutscht in die zugeklappte Gruppe, Gruppe
laesst sich oeffnen, Text ist noch da -- und nach dem
Neuladen ist sie immer noch gelesen. Der letzte Punkt ist
der eigentliche: Haette ich es im Browserspeicher gemerkt,
waere dieselbe Antwort auf dem naechsten Geraet wieder neu. */
const stand = () => m5.s.evaluate(() => {
const neuB = document.querySelector('.vorlagenbrett__antwort:not([data-alt])');
const altB = document.querySelector('.vorlagenbrett__antwort[data-alt="ja"]');
return {
neuZeilen: neuB ? neuB.querySelectorAll(".vorlagenbrett__antwort-zeile").length : 0,
altKopf: altB
? (altB.querySelector(".vorlagenbrett__antwort-kopf")?.innerText || "").trim()
: null,
altZeilen: altB ? altB.querySelectorAll(".vorlagenbrett__antwort-zeile").length : 0,
knopf: !!document.querySelector(".vorlagenbrett__antwort-weg"),
};
});
const vorher = await stand();
ok(vorher.neuZeilen >= 1 && vorher.knopf === true,
`die ungelesene Antwort steht offen da, mit „Verstanden" (${vorher.neuZeilen})`);
/* ==== HOECHSTENS DREI AUF EINMAL (03.10.2026) =============
GEMESSEN AM ECHTEN BESTAND, nicht geschaetzt: Ein Modi im
Haus hat NEUN beantwortete Bewerbungen liegen, alle
ungelesen. Auf dem Handy war das Band damit 922 px hoch --
bei 844 px Fenster, also hoeher als der ganze Bildschirm
und 49 % der Seite. Das ist woertlich VanVans Satz: „Das
wird mit der Zeit unuebersichtlich."
Mit dem Deckel sind es 566 px, und ein Griff auf „Alle
verstanden" macht daraus 46 px.
DIE ZAHL DREI GEHOERT GEPRUEFT, nicht nur in den
Quelltext. Deshalb bekommt Frida hier vier weitere
beantwortete Bewerbungen -- erst ab vier greift der
Deckel, und ohne ihn waere diese Pruefung gruen, ohne
etwas gesehen zu haben. */
{
const dx = new DatabaseSync(process.env.WORKSPACE_DB);
const nun = new Date().toISOString();
for (let i = 0; i < 4; i++) {
dx.prepare(`INSERT INTO vorlagen_bewerbungen
(vorlage, kategorie, person_id, text, zustand, entscheid_text,
entschieden_von, entschieden_am, erstellt)
VALUES (?,?,?,?,?,?,?,?,?)`)
.run(`deckel-probe-${i}`, "chat", idModi, "Die auch gern.",
"angenommen", null, idHand, nun, nun);
}
dx.close();
}
await m5.s.reload({ waitUntil: "networkidle" });
await m5.s.waitForTimeout(1800);
const gedeckelt = await m5.s.evaluate(() => {
const band = document.querySelector('.vorlagenbrett__antwort:not([data-alt])');
return {
zeilen: band ? band.querySelectorAll(".vorlagenbrett__antwort-zeile").length : 0,
hoch: band ? Math.round(band.getBoundingClientRect().height) : 0,
knopf: band
? (band.querySelector(".vorlagenbrett__antwort-kopf")?.innerText || "")
.replace(/\s+/g, " ").trim()
: "",
fenster: window.innerHeight,
};
});
ok(gedeckelt.zeilen === 3,
`mit fuenf Ungelesenen stehen drei offen (${gedeckelt.zeilen})`);
ok(/und \d+ weitere/.test(gedeckelt.knopf),
` und der Rest ist einen Griff entfernt („${gedeckelt.knopf}")`);
/* DER EIGENTLICHE PUNKT IST DIE HOEHE. „Drei Zeilen" waere
eine Zahl; dass das Band unter den Bildschirm passt, ist
die Aussage. */
ok(gedeckelt.hoch > 0 && gedeckelt.hoch < gedeckelt.fenster,
` das Band passt in den Bildschirm (${gedeckelt.hoch} px bei `
+ `${gedeckelt.fenster} px)`);
/* AUFKLAPPEN ZEIGT ALLE -- wer nachsehen will, kann. */
await m5.s.click(".vorlagenbrett__antwort-kopf");
await m5.s.waitForTimeout(900);
const aufgeklappt = await m5.s.evaluate(() => {
const band = document.querySelector('.vorlagenbrett__antwort:not([data-alt])');
return {
zeilen: band ? band.querySelectorAll(".vorlagenbrett__antwort-zeile").length : 0,
knopf: band
? (band.querySelector(".vorlagenbrett__antwort-kopf")?.innerText || "")
.replace(/\s+/g, " ").trim()
: "",
};
});
ok(aufgeklappt.zeilen > 3 && /Weniger/.test(aufgeklappt.knopf),
`aufgeklappt stehen alle da (${aufgeklappt.zeilen}, „${aufgeklappt.knopf}")`);
/* UND WIEDER ZU -- der Knopf geht in beide Richtungen. Ohne
diese Zeile koennte er ein Einwegschalter sein. */
await m5.s.click(".vorlagenbrett__antwort-kopf");
await m5.s.waitForTimeout(900);
ok(await m5.s.evaluate(() => document.querySelectorAll(
'.vorlagenbrett__antwort:not([data-alt]) .vorlagenbrett__antwort-zeile').length) === 3,
"und wieder zu — der Knopf geht in beide Richtungen");
/* DIE VIER PROBEZEILEN WIEDER WEG. Die Zeilen weiter unten
zaehlen „1 aeltere Antwort" und „2 aeltere Antworten" --
mit den Proben waeren es sechs, und sie wuerden rot, ohne
dass etwas kaputt ist. Eine Pruefung, die den Stand fuer
die naechste verschiebt, ist schlimmer als keine. */
{
const dx = new DatabaseSync(process.env.WORKSPACE_DB);
dx.prepare("DELETE FROM vorlagen_bewerbungen WHERE vorlage LIKE 'deckel-probe-%'")
.run();
dx.close();
}
await m5.s.reload({ waitUntil: "networkidle" });
await m5.s.waitForTimeout(1800);
ok(await m5.s.evaluate(() => document.querySelectorAll(
'.vorlagenbrett__antwort:not([data-alt]) .vorlagenbrett__antwort-zeile').length) === 2,
"und die Probezeilen sind wieder weg — der Stand ist wie vorher");
ok(vorher.altKopf === null,
`und es gibt noch keine aeltere Gruppe (${vorher.altKopf})`);
await m5.s.click(".vorlagenbrett__antwort-weg");
await m5.s.waitForTimeout(1200);
const danach = await stand();
/* EINE WENIGER, NICHT KEINE. Mein erster Anlauf erwartete hier
null -- Frida hat aber ZWEI Antworten (eine Zusage von
weiter oben und diese Absage), und „Verstanden" gilt je
Zeile. Die Pruefung hatte recht, die Erwartung war falsch;
dass der Knopf nur seine eigene Zeile nimmt, ist genau das
gewollte Verhalten und steht jetzt hier als Aussage. */
ok(danach.neuZeilen === vorher.neuZeilen - 1,
`nach „Verstanden" ist genau diese eine Zeile weg `
+ `(${vorher.neuZeilen} → ${danach.neuZeilen})`);
/* SIE IST NICHT WEG, SIE IST NUR ZU. Eine Absage samt
Begruendung zu loeschen, weil jemand sie einmal gelesen
hat, waere das Gegenteil von dem, was am 30.09. gebaut
wurde. */
ok(/1 ältere Antwort/.test(danach.altKopf || ""),
`sondern hinter einer zugeklappten Zeile („${danach.altKopf}")`);
ok(danach.altZeilen === 0,
` und zugeklappt wird sie gar nicht erst gebaut (${danach.altZeilen})`);
await m5.s.click(".vorlagenbrett__antwort-kopf");
await m5.s.waitForTimeout(800);
const auf = await m5.s.evaluate(() => {
const altB = document.querySelector('.vorlagenbrett__antwort[data-alt="ja"]');
return {
zeilen: altB ? altB.querySelectorAll(".vorlagenbrett__antwort-zeile").length : 0,
text: altB ? (altB.innerText || "").replace(/\s+/g, " ").trim() : "",
};
});
ok(auf.zeilen === 1, `aufgeklappt steht sie wieder da (${auf.zeilen})`);
ok(/schon drei offene/.test(auf.text),
"mitsamt der Begruendung — nachlesen geht weiter");
/* ---- UND DER KNOPF FUER ALLE AUF EINMAL ----------------
Wer nach dem Urlaub sieben Antworten vorfindet, soll nicht
siebenmal tippen. Er steht erst ab zwei da -- bei einer
waere er dasselbe wie der daneben, nur laenger. */
await m5.s.click(".vorlagenbrett__antwort-kopf"); /* wieder zu */
await m5.s.waitForTimeout(500);
const vorAlle = await m5.s.evaluate(() =>
!!document.querySelector(".vorlagenbrett__antwort-alle"));
if (vorAlle) {
await m5.s.click(".vorlagenbrett__antwort-alle");
await m5.s.waitForTimeout(1200);
} else {
/* Nur noch eine offen -- dann nimmt sie ihr eigener Knopf. */
await m5.s.click(".vorlagenbrett__antwort-weg");
await m5.s.waitForTimeout(1200);
}
const leer = await stand();
ok(leer.neuZeilen === 0,
`danach steht oben gar nichts mehr (${leer.neuZeilen})`);
ok(/2 ältere Antworten/.test(leer.altKopf || ""),
`und beide stehen in der zugeklappten Gruppe („${leer.altKopf}")`);
/* UND DER SERVER HAT ES SICH GEMERKT. */
await m5.s.reload({ waitUntil: "networkidle" });
await m5.s.waitForTimeout(1800);
const nachLaden = await stand();
ok(nachLaden.neuZeilen === 0 && /2 ältere Antworten/.test(nachLaden.altKopf || ""),
`nach dem Neuladen gelten sie weiter als gelesen („${nachLaden.altKopf}")`);
/* UND SIE IST WIEDER ZU. Das Aufklappen ist eine Frage des
Augenblicks, kein Zustand, den man mitschleppt. */
ok(nachLaden.altZeilen === 0,
`und wieder zugeklappt (${nachLaden.altZeilen} Zeilen)`);
} finally { await m5.ctx.close(); }
/* GEGENPROBE AM SERVER: Eine fremde Antwort laesst sich nicht
als gelesen melden -- und eine, auf die noch niemand
geantwortet hat, auch nicht. Ohne diese beiden waere
„gesehen" ein Knopf, mit dem man in fremden Zeilen schreibt. */
{
const fremd = await roh("/workspace/api/vorlagen/bewerbung/gesehen", {
methode: "POST", keks: hand.keks, body: { id: bw2?.id } });
ok(fremd.code === 404,
`eine fremde Antwort kann niemand als gelesen melden (${fremd.code})`);
const erfunden = await roh("/workspace/api/vorlagen/bewerbung/gesehen", {
methode: "POST", keks: modi.keks, body: { id: 999999 } });
ok(erfunden.code === 404, `eine erfundene Nummer auch nicht (${erfunden.code})`);
/* UND ZWEIMAL „VERSTANDEN" AENDERT NICHTS MEHR. */
const nochmal = await roh("/workspace/api/vorlagen/bewerbung/gesehen", {
methode: "POST", keks: modi.keks, body: { id: bw2?.id } });
ok(nochmal.code === 200 && nochmal.json?.wieviele === 0,
`ein zweites „Verstanden" zaehlt nicht noch einmal (${nochmal.json?.wieviele})`);
}
const h2 = await seiteAls("hand", "CODE-HAND-0001", 1280);
try {
await h2.s.goto(`${SEITE}/workspace/entwicklung.html`, { waitUntil: "networkidle" });
await h2.s.waitForTimeout(1800);
/* DAS BRETT AUFKLAPPEN. Die Uebersicht ist ein
Nachschlagewerk und liegt im Koerper des Vorlagenbretts --
der wird zugeklappt gar nicht erst gebaut. Die ANTWORT auf
die eigene Bewerbung steht dagegen davor: Sie ist eine
Nachricht, und eine Nachricht, die man aufklappen muss,
ist keine. */
await h2.s.evaluate(() => {
const k = document.querySelector("#vorlagenbrett .vorlagenbrett__kopf");
if (k && k.getAttribute("aria-expanded") !== "true") k.click();
});
await h2.s.waitForTimeout(1200);
const w = await liesBrett(h2.s);
ok(!!w.absagen, `die rechte Hand sieht die Uebersicht („${(w.absagen || "").slice(0, 50)}…")`);
/* ZUGEKLAPPT. Eine Liste von Absagen, die immer offen steht,
ist eine Sammlung von Nein-Sagen ueber Menschen, mit denen
man morgen wieder arbeitet. */
ok(w.zugeklappt === true, `und zwar zugeklappt (${w.zugeklappt})`);
ok(w.leute.some((t) => /Frida/.test(t)),
`mit der Zahl je Mensch (${w.leute.join(" | ")})`);
/* EIN BILD VON DEM, WAS NEU IST. Zahlen sagen, dass etwas da
ist; ob es auch auszuhalten ist, sieht man nur. Aufgeklappt
-- zugeklappt waere ein Bild von einer Zeile. */
await h2.s.evaluate(() => {
const u = document.querySelector(".vorlagenbrett__absagen");
if (u) u.open = true;
});
await h2.s.waitForTimeout(300);
const kasten = await h2.s.$("#vorlagenbrett");
if (kasten) {
await kasten.screenshot({ path: "server/pruef-bewerbung-absagen.png" });
console.log(" -- Bild: server/pruef-bewerbung-absagen.png");
}
} finally { await h2.ctx.close(); }
}
/* =================================================================
EINE BEWERBUNG AUF EINE ERLEDIGTE AUFGABE IST KEINE (01.10.2026)
Beim Durchsehen der echten Daten gefunden: Zwei Bewerbungen
standen auf „beworben" an Aufgaben, die laengst durch waren.
Bei der Person stand weiter „wartet auf Antwort", bei der
Leitung eine Entscheidung, die es nicht mehr gibt.
DAS MUSTER GIBT ES IM HAUS SCHON -- `uebernahmeAbschliessen`
raeumt genauso auf, wenn jemand anderes eine Pool-Aufgabe
bekommt. Derselbe Fall, ein anderer Ausloeser.
================================================================= */
console.log("");
console.log("=== Bewerbungen auf durchgelaufene Aufgaben ===");
{
/* UEBER DIE LISTE, nicht ueber die Einzelaufgabe. Der erste
Anlauf las `/api/aufgaben/:id` und bekam `undefined` -- die
Antwort dort hat eine andere Form. Die Liste ist in dieser
Datei erprobt, und „erprobt" schlaegt „naheliegend". */
const stand = async (id, wer) => {
const a = await roh("/workspace/api/aufgaben", { keks: hand.keks });
const auf = (a.json?.aufgaben || a.json?.liste || []).find((x) => x.id === id);
return (auf?.zuteilung || []).find((x) => x.person_id === wer) || null;
};
/* ---- Weg 1: die Aufgabe wird ERLEDIGT ---------------------- */
const e1 = await roh("/workspace/api/aufgaben", {
methode: "POST", keks: hand.keks,
body: { titel: "Kurz die Liste sortieren", prioritaet: "niedrig" } });
const eId = e1.json?.aufgabe?.id ?? e1.json?.id;
const b1 = await roh(`/workspace/api/aufgaben/${eId}/bewerben`, {
methode: "POST", keks: modi.keks, body: { text: "Kann ich machen." } });
ok(b1.code === 200, `Frida bewirbt sich (${b1.code})`);
ok((await stand(eId, idModi))?.zustand === "beworben",
"und steht auf „beworben\"");
const fertig = await roh(`/workspace/api/aufgaben/${eId}`, {
methode: "PATCH", keks: hand.keks, body: { status: "erledigt" } });
ok(fertig.code === 200, `die rechte Hand erledigt sie (${fertig.code})`);
const nach1 = await stand(eId, idModi);
ok(nach1?.zustand !== "beworben",
`danach wartet Frida nicht mehr (${nach1?.zustand})`);
ok(/inzwischen erledigt/.test(nach1?.entscheid_text || ""),
`und liest, warum (${JSON.stringify(nach1?.entscheid_text)})`);
/* NIEMAND HAT ENTSCHIEDEN. Dadurch faellt die Zeile aus der
Absagen-Uebersicht heraus -- es ist keine Absage an sie. */
ok(!nach1?.entschieden_von,
`niemand hat ihr abgesagt (entschieden_von=${JSON.stringify(nach1?.entschieden_von)})`);
/* ---- Weg 2: die Aufgabe wird ABGEBROCHEN ------------------- */
const e2 = await roh("/workspace/api/aufgaben", {
methode: "POST", keks: hand.keks,
body: { titel: "Etwas, das sich erledigt hat", prioritaet: "niedrig" } });
const aId = e2.json?.aufgabe?.id ?? e2.json?.id;
await roh(`/workspace/api/aufgaben/${aId}/bewerben`, {
methode: "POST", keks: modi.keks, body: { text: "Mach ich." } });
const ab = await roh(`/workspace/api/aufgaben/${aId}/abbrechen`, {
methode: "POST", keks: chef.keks,
body: { grund: "Brauchen wir doch nicht." } });
const nach2 = await stand(aId, idModi);
ok(ab.code === 200 && nach2?.zustand !== "beworben",
`beim Abbrechen ebenso (${ab.code}, ${nach2?.zustand})`);
/* DER SATZ IST EIN ANDERER -- „abgebrochen" ist nicht
„erledigt", und wer gewartet hat, soll den Unterschied
lesen koennen. */
ok(/abgebrochen/.test(nach2?.entscheid_text || ""),
`mit dem passenden Satz (${JSON.stringify(nach2?.entscheid_text)})`);
/* ---- GEGENPROBE: eine OFFENE Aufgabe behaelt ihre Bewerbung.
Ohne sie hiesse „faellt weg" womoeglich nur, dass jede
Bewerbung wegfaellt. */
const e3 = await roh("/workspace/api/aufgaben", {
methode: "POST", keks: hand.keks,
body: { titel: "Laeuft noch", prioritaet: "niedrig" } });
const oId = e3.json?.aufgabe?.id ?? e3.json?.id;
await roh(`/workspace/api/aufgaben/${oId}/bewerben`, {
methode: "POST", keks: modi.keks, body: { text: "Gern." } });
const nach3 = await stand(oId, idModi);
ok(nach3?.zustand === "beworben",
`eine offene Aufgabe behaelt ihre Bewerbung (${nach3?.zustand})`);
}
/* =================================================================
DAUERHAFTE AUFGABEN (30.09.2026)
VanVan im Support: „Man kann bei den Aufgaben, wenn man sie
verteilt, ob selbst erstellt oder über die Vorlage noch nicht
festlegen, dass die Aufgabe dauerhaft sein soll und somit nicht
vom Modi in den Status erledigt gesetzt werden kann."
================================================================= */
console.log("");
console.log("=== Dauerhafte Aufgaben ===");
{
/* ORTSZEIT UND NICHT UTC. Meine erste Fassung rechnete
`toISOString().slice(0,10)` -- und `pruef-struktur` hat sie
noch am selben Abend gefunden: Zwischen 00:00 und 02:00 liegt
der UTC-Tag noch auf gestern, das Datum waere um einen Tag
daneben, und die Pruefung schluege nachts falsch an. Genau
die Sorte Zeitbombe, vor der die Hausregel warnt. */
const { tagLokal } = await import("./helfer-tag.mjs");
const morgen = tagLokal(1);
const neu = await roh("/workspace/api/aufgaben", {
methode: "POST", keks: hand.keks,
body: {
titel: "Neue im Chat begruessen", prioritaet: "mittel",
frist: morgen, dauerhaft: true,
zuteilung: [idModi], verteilart: "einzeln",
} });
const dId = neu.json?.aufgabe?.id ?? neu.json?.id;
ok(neu.code === 201 || neu.code === 200,
`die rechte Hand legt eine dauerhafte Aufgabe an (${neu.code})`);
const lesen = async (keks) => {
const l = await roh("/workspace/api/aufgaben", { keks });
return (l.json?.aufgaben || l.json?.liste || []).find((x) => x.id === dId);
};
const a1 = await lesen(hand.keks);
ok(!!a1 && !!a1.dauerhaft, `sie ist als dauerhaft gespeichert (${a1?.dauerhaft})`);
/* DIE FRIST IST WEG, und das ist der Punkt: Eine dauerhafte
Aufgabe mit Frist waere ab morgen fuer immer ueberfaellig. */
ok(a1?.frist === null || a1?.frist === undefined,
`und hat KEINE Frist mehr, obwohl eine mitgeschickt wurde (${JSON.stringify(a1?.frist)})`);
/* DER KERN DER MELDUNG. */
await roh(`/workspace/api/aufgaben/${dId}/annehmen`,
{ methode: "POST", keks: modi.keks });
/* UEBER DEN STATUS, nicht ueber `/mein-stand` (03.10.2026).
Den zweiten Weg gibt es nicht mehr: Er setzte den
Zuteilungszustand, ohne den Aufgabenstatus mitzuziehen --
VanVans Meldung #8. Geprueft wird jetzt die Tuer, die es
noch gibt. */
const fertig = await roh(`/workspace/api/aufgaben/${dId}`, {
methode: "PATCH", keks: modi.keks, body: { status: "erledigt" } });
ok(fertig.code === 409 && fertig.json?.fehler === "dauerhafte_aufgabe",
`Frida kann sie nicht abhaken (${fertig.code}/${fertig.json?.fehler})`);
/* GEGENPROBE 1: „Ich fange an" geht weiterhin. Auch eine
stehende Aufgabe hat einen Anfang -- gesperrt ist nur das
Abhaken, nicht die Arbeit. */
const anfang = await roh(`/workspace/api/aufgaben/${dId}`, {
methode: "PATCH", keks: modi.keks, body: { status: "arbeit" } });
ok(anfang.code === 200, `anfangen geht trotzdem (${anfang.code})`);
/* GEGENPROBE 2: Bei einer GEWOEHNLICHEN Aufgabe geht das Abhaken.
Ohne sie hiesse „409" womoeglich nur, dass niemand je etwas
abhaken kann. */
const normal = await roh("/workspace/api/aufgaben", {
methode: "POST", keks: hand.keks,
body: { titel: "Einmal die Liste durchsehen", prioritaet: "niedrig",
zuteilung: [idModi], verteilart: "einzeln" } });
const nId = normal.json?.aufgabe?.id ?? normal.json?.id;
await roh(`/workspace/api/aufgaben/${nId}/annehmen`,
{ methode: "POST", keks: modi.keks });
const ok2 = await roh(`/workspace/api/aufgaben/${nId}`, {
methode: "PATCH", keks: modi.keks, body: { status: "erledigt" } });
ok(ok2.code === 200,
`eine gewoehnliche Aufgabe haekt sie sehr wohl ab (${ok2.code})`);
/* GEGENPROBE 3: EIN MODI LEGT GAR KEINE AUFGABEN AN.
Gemessen, nicht angenommen: Beim ersten Anlauf stand hier
„ein Modi legt sich selbst eine Aufgabe an" -- der Server
antwortete 403. `darfAufgabenAnlegen` laesst ihn nicht, und
das ist aelter als diese Aenderung. Die Zeile prueft jetzt,
was wirklich gilt. */
const selbst = await roh("/workspace/api/aufgaben", {
methode: "POST", keks: modi.keks,
body: { titel: "Meine eigene Notiz", prioritaet: "niedrig", dauerhaft: true } });
ok(selbst.code === 403,
`ein Modi legt ueberhaupt keine Aufgaben an (${selbst.code})`);
/* GEGENPROBE 4: Und er macht auch keine bestehende dauerhaft --
noch nimmt er es zurueck. Das ist der Kern: Koennte er es,
waere die ganze Sperre ein Knopf weiter offen. */
const zurueck = await roh(`/workspace/api/aufgaben/${dId}`, {
methode: "PATCH", keks: modi.keks, body: { dauerhaft: false } });
const a2 = await lesen(hand.keks);
ok(!!a2?.dauerhaft,
`und der Zugeteilte nimmt „dauerhaft" nicht zurueck `
+ `(HTTP ${zurueck.code}, danach ${a2?.dauerhaft})`);
/* UND DIE LEITUNG KANN SIE BEENDEN. Eine Pflicht, die niemand
mehr beenden kann, waere eine Falle statt einer Regel. */
const beenden = await roh(`/workspace/api/aufgaben/${dId}`, {
methode: "PATCH", keks: hand.keks, body: { dauerhaft: false } });
const a3 = await lesen(hand.keks);
ok(beenden.code === 200 && !a3?.dauerhaft,
`die rechte Hand beendet sie (${beenden.code}, danach ${a3?.dauerhaft})`);
const jetztFertig = await roh(`/workspace/api/aufgaben/${dId}`, {
methode: "PATCH", keks: modi.keks, body: { status: "erledigt" } });
ok(jetztFertig.code === 200,
`und danach laesst sie sich abhaken (${jetztFertig.code})`);
}
/* =================================================================
7. AUFGABEN DORT ANLEGEN, WO DIE PERSON STEHT (A5 und B6)
=================================================================
Filipe: „dieser buttion da soll nicht einen zu der seite
aufgaben fuehren sondern da in dieser seite die aufgaben
erstellen und vergeben koennen. plus man soll die aufgaben hier
in dieser seite auch sehen." Und zum Aufgabenbrett: „dieser
button kann da jetzt doch endlich verschwinden."
DER ORT WIRD GEMESSEN, NICHT ANGENOMMEN: Auf der Agenturadresse
gibt es die Entwicklungsseite nicht, und vier Rollen dort
duerfen sehr wohl Aufgaben anlegen. Der Knopf verschwindet also
genau dort, wo es einen anderen Ort gibt -- und nur dort.
================================================================= */
const e = await seiteAls("admin", "CODE-DOGI-0001", 1180);
try {
/* B6: auf dem Brett ist der Knopf weg. */
const b6 = await e.s.evaluate(() => {
const k2 = document.getElementById("neu-oeffnen");
return { da: !!k2, versteckt: k2 ? k2.hidden : null,
ort: window.__ich?.aufgaben_anlegen_auf };
});
ok(b6.ort === "entwicklung",
`der Server nennt die Entwicklungsseite als Ort (${b6.ort})`);
ok(b6.da && b6.versteckt === true,
`und der Knopf auf dem Aufgabenbrett ist weg (${b6.versteckt})`);
/* A5: auf der Entwicklungsseite geht es jetzt. */
await e.s.goto(`${SEITE}/workspace/entwicklung.html`, { waitUntil: "networkidle" });
await e.s.waitForTimeout(1800);
const vorher = await e.s.evaluate(() => {
const k2 = document.getElementById("verteilen-knopf");
return { istLink: k2?.tagName === "A", aus: k2?.disabled,
aufgabenBlock: !document.getElementById("e-aufgaben-block")?.hidden };
});
ok(vorher.istLink === false,
"der Knopf fuehrt nicht mehr auf eine andere Seite (kein Link mehr)");
/* HIER STAND `vorher.aus === true` (bis 22.09.2026 abends).
---------------------------------------------------------------
Die Pruefung hat damit MEINEN Entwurf bestaetigt, nicht die
Sache: Der Knopf war abgeschaltet, solange niemand gewaehlt
war, und daneben stand „Waehl links jemanden aus".
Gemessen steht die Auswahl aber gar nicht links, sondern
206 px WEITER UNTEN, bei derselben linken Kante (130 px). Wer
der Anweisung folgte, sah eine leere Flaeche neben einem
toten Knopf.
Filipe: „weil gerade hängt das und weiß nicht wieso."
Das ist die unangenehmste Sorte gruener Haken: Er lief, er war
gruen, und er hat den Fehler festgeschrieben. Jetzt wird das
Gegenteil verlangt -- der Knopf ist IMMER benutzbar, und die
Auswahl steht im Formular. */
ok(vorher.aus === false,
"und er ist benutzbar, auch ohne vorherige Auswahl — die Frage"
+ " nach der Person wird im Formular selbst gefragt");
ok(vorher.aufgabenBlock === false,
"ohne Person steht auch keine Aufgabenliste da");
await e.s.click("#personen .e-person");
await e.s.waitForTimeout(1400);
const nachher = await e.s.evaluate(() => ({
knopf: document.getElementById("verteilen-knopf")?.textContent.trim(),
aus: document.getElementById("verteilen-knopf")?.disabled,
block: !document.getElementById("e-aufgaben-block")?.hidden,
titel: document.getElementById("e-aufgaben-titel")?.textContent.trim(),
}));
ok(nachher.aus === false && /Aufgabe für/.test(nachher.knopf || ""),
`nach der Auswahl heisst er „${nachher.knopf}"`);
ok(nachher.block === true && /Aufgaben von/.test(nachher.titel || ""),
`und die Aufgabenliste steht da („${nachher.titel}")`);
await e.s.click("#verteilen-knopf");
await e.s.waitForTimeout(500);
const form = await e.s.evaluate(() => ({
offen: !document.getElementById("e-neu")?.hidden,
felder: document.querySelectorAll("#e-neu input, #e-neu textarea").length,
}));
ok(form.offen === true && form.felder >= 3,
`das Formular oeffnet sich auf derselben Seite (${form.felder} Felder)`);
const titel = "Auf der Entwicklungsseite angelegt";
await e.s.fill("#e-titel", titel);
await e.s.click("#e-neu-speichern");
await e.s.waitForTimeout(1800);
const fertig = await e.s.evaluate((t) => ({
zu: document.getElementById("e-neu")?.hidden,
steht: [...document.querySelectorAll(".e-aufgabe__titel")].some((x) => x.textContent === t),
gut: document.getElementById("fehler")?.dataset.gut,
}), titel);
ok(fertig.zu === true, "nach dem Anlegen ist das Formular zu");
ok(fertig.steht === true, "und die Aufgabe steht sofort in der Liste darunter");
/* EINE BESTAETIGUNG DARF NICHT AUSSEHEN WIE EIN FEHLER. Auf dem
ersten Bildschirmfoto stand sie in Warnrot -- `melde` schreibt
immer in denselben Absatz, und der heisst `.fehler`. */
ok(fertig.gut === "ja",
`und die Bestaetigung ist als gute Nachricht gekennzeichnet (${fertig.gut})`);
/* GEGENPROBE: Die Aufgabe ist wirklich da, nicht nur im Bild. */
const ausDerListe = await finde(chef.keks, titel);
ok(!!ausDerListe, "sie steht auch wirklich in der Aufgabenliste des Servers");
/* =================================================================
6b. VERTEILEN OHNE VORHERIGE AUSWAHL -- und als Bewerbung
Filipe am 22.09.2026 abends: „dogfather, die rechte hand und
die linke hand sollen immer noch aufgaben werden teilen
können plus die option dass die modis sich für aufgaben
bewerben können. weil gerade hängt das und weiß nicht wieso."
Der Weg, der vorher tot war: Seite aufmachen, auf „Aufgabe
anlegen" druecken -- ohne unten jemanden angeklickt zu haben.
Frueher passierte dabei NICHTS.
Und im selben Formular muss die Bewerbung zu finden sein.
Vorher steckte sie hinter „Noch jemanden dazunehmen" und
erschien erst, wenn man dort jemanden ankreuzte -- eine
Moeglichkeit, die man nicht findet, gibt es nicht.
================================================================= */
await e.s.goto(`${SEITE}/workspace/entwicklung.html`, { waitUntil: "networkidle" });
await e.s.waitForTimeout(1800);
await e.s.click("#verteilen-knopf");
await e.s.waitForTimeout(600);
const direkt = await e.s.evaluate(() => ({
offen: !document.getElementById("e-neu")?.hidden,
zurWahl: document.querySelectorAll("#e-fuer-leute input[type=checkbox]").length,
artSichtbar: document.getElementById("e-art")
? !document.getElementById("e-art").hidden : null,
/* Die alte, zugeklappte zweite Liste darf es nicht mehr geben --
sonst stehen wieder zwei Bedienungen fuer dieselbe Frage da. */
alteZweiteListe: !!document.getElementById("e-mehr-auf"),
}));
ok(direkt.offen === true,
"das Formular geht auch ohne vorherige Auswahl auf — genau der Weg, der haengen blieb");
ok(direkt.zurWahl >= 2,
`und die Frage nach der Person steht darin, mit ${direkt.zurWahl} Personen zur Wahl`);
ok(direkt.alteZweiteListe === false,
"die zweite, zugeklappte Personenliste gibt es nicht mehr");
ok(direkt.artSichtbar === false,
"bei niemandem oder einer Person wird nicht nach der Verteilart gefragt");
/* Zwei anhaken -> jetzt MUSS die Frage kommen, und die
Bewerbung muss eine der Antworten sein. */
const angehakt = await e.s.evaluate(() => {
const k = [...document.querySelectorAll("#e-fuer-leute input[type=checkbox]")];
k[0].click(); k[1].click();
return k.length;
});
await e.s.waitForTimeout(500);
const zuZweit = await e.s.evaluate(() => ({
artSichtbar: !document.getElementById("e-art").hidden,
antworten: [...document.querySelectorAll('#e-art input[name="e-verteilart"]')]
.map((x) => x.value),
knopf: document.getElementById("verteilen-knopf")?.textContent.trim(),
}));
ok(angehakt >= 2 && zuZweit.artSichtbar === true,
"bei zwei Personen wird gefragt, wie es laufen soll");
ok(zuZweit.antworten.includes("pool"),
`und der Pool-Weg steht als Antwort da (${zuZweit.antworten.join(", ")})`);
ok(/2 Personen/.test(zuZweit.knopf || ""),
`der Knopf zaehlt mit („${zuZweit.knopf}")`);
const poolTitel = "Pool von der Entwicklungsseite";
await e.s.check('#e-art input[value="pool"]');
await e.s.fill("#e-titel", poolTitel);
await e.s.click("#e-neu-speichern");
await e.s.waitForTimeout(1800);
const poolFertig = await e.s.evaluate(() => ({
zu: document.getElementById("e-neu")?.hidden,
meldung: document.getElementById("fehler")?.textContent.trim(),
gut: document.getElementById("fehler")?.dataset.gut,
}));
ok(poolFertig.zu === true && poolFertig.gut === "ja",
`angelegt und als gute Nachricht gemeldet (${poolFertig.gut})`);
/* DIE MELDUNG DARF NICHT „steht jetzt bei ihm" SAGEN. Bei einem
Pool sitzt noch niemand daran -- das ist der ganze Punkt. */
ok(/bewirbt|bereit/i.test(poolFertig.meldung || "")
&& !/bei ihm/.test(poolFertig.meldung || ""),
`und die Meldung sagt, dass eine Bewerbung kommt („${poolFertig.meldung}")`);
const poolAufgabe = await finde(chef.keks, poolTitel);
ok(!!poolAufgabe, "die Pool-Aufgabe steht in der Liste des Servers");
ok(poolAufgabe?.verteilart === "pool",
`und sie ist wirklich ein Pool (${poolAufgabe?.verteilart})`);
ok((poolAufgabe?.zuteilung || []).length === 2,
`mit zwei Leuten darin (${(poolAufgabe?.zuteilung || []).length})`);
} finally { await e.ctx.close(); }
/* ===================================================================
6c. ALLE DREI, DIE VERTEILEN DUERFEN -- am Bildschirm
Filipe: „dogfather, die rechte hand und die linke hand sollen
immer noch aufgaben werden teilen können."
Der Server sagt das seit dem 22.09. ueber `darfAufgabenVerteilen`,
und Abschnitt 1 misst das dort auch. Hier geht es um die andere
Haelfte: ob es auf der SEITE ankommt. Genau diese Luecke war der
Fehler -- die Regel stimmte, der Knopf war trotzdem tot.
DIE DREI STEHEN IN EINER LISTE, und die Zahl der geprueften
Rollen steht in der Bedingung: Eine leere Schleife darf nicht
„in Ordnung" heissen.
=================================================================== */
const verteiler = [
["admin", "CODE-DOGI-0001", "DogFather"],
["hand", "CODE-HAND-0001", "die rechte Hand"],
["linke", "CODE-LINK-0001", "die linke Hand"],
];
let konnten = 0;
for (const [rolle, code, wer] of verteiler) {
const v = await seiteAls(rolle, code, 1180);
try {
await v.s.goto(`${SEITE}/workspace/entwicklung.html`, { waitUntil: "networkidle" });
await v.s.waitForTimeout(1700);
const stand = await v.s.evaluate(() => ({
band: document.getElementById("verteilen-band")
? !document.getElementById("verteilen-band").hidden : null,
aus: document.getElementById("verteilen-knopf")?.disabled,
darf: window.__ich?.darf_verteilen,
}));
if (stand.band === true && stand.aus === false && stand.darf === true) {
/* Nicht nur hinsehen -- einmal aufmachen. Ein Knopf, der
nicht abgeschaltet ist, muss noch lange nichts tun. */
await v.s.click("#verteilen-knopf");
await v.s.waitForTimeout(500);
const auf = await v.s.evaluate(() => ({
offen: !document.getElementById("e-neu")?.hidden,
leute: document.querySelectorAll("#e-fuer-leute input[type=checkbox]").length,
}));
if (auf.offen && auf.leute > 0) konnten++;
}
ok(stand.band === true && stand.aus === false,
`${wer} sieht das Verteilband und kann es bedienen`
+ ` (sichtbar=${stand.band}, aus=${stand.aus})`);
} finally { await v.ctx.close(); }
}
ok(konnten === verteiler.length,
`und alle ${verteiler.length} bekommen das Formular mit der Personenliste auf (${konnten})`);
/* GEGENPROBE: Ein Modi darf es NICHT. Ohne diese Zeile waere nicht
bewiesen, dass die drei oben etwas Besonderes koennen -- eine
Pruefung, die bei jedem gruen wird, misst keine Grenze. */
{
const m = await seiteAls("modi", "CODE-MODI-0001", 1180);
try {
await m.s.goto(`${SEITE}/workspace/entwicklung.html`, { waitUntil: "networkidle" });
await m.s.waitForTimeout(1700);
const stand = await m.s.evaluate(() => ({
band: document.getElementById("verteilen-band")
? !document.getElementById("verteilen-band").hidden : null,
darf: window.__ich?.darf_verteilen,
}));
ok(stand.band === false && stand.darf === false,
`Gegenprobe: ein Modi sieht das Verteilband nicht`
+ ` (sichtbar=${stand.band}, darf=${stand.darf})`);
} finally { await m.ctx.close(); }
}
/* =================================================================
DIE SPALTE „BEWERBUNGEN" IM AUFGABENBRETT (25.09.2026)
=================================================================
Filipe: „ich will in dieser seite auch eine eigene kategorie fuer
die aufgaben wo die modis sich selbst bewerben. ich will dass man
die getrennt sieht. mach es richtig uebersichtlich."
BISHER STANDEN BEWERBUNGEN NUR AUF DEM VORLAGENBRETT, an der
jeweiligen Karte. Wer morgens das Aufgabenbrett oeffnete, sah
nicht, dass jemand auf eine Antwort wartet.
DREI DINGE WERDEN GEMESSEN, und das dritte ist das, was ohne
Pruefung irgendwann kippt:
1. Wer entscheidet, sieht die Spalte MIT den zwei Knoepfen.
2. Der Modi sieht seine eigene -- ohne Knoepfe, mit dem Satz
„wartet auf Antwort". Ein Knopf, der eine Absage holt,
waere schlimmer als keiner.
3. Ohne Bewerbung gibt es die Spalte GAR NICHT. Eine leere
Spalte stuende 360 Tage im Jahr im Weg.
================================================================= */
{
/* ERST DER ZUSTAND OHNE: Ohne diese Messung waere „die Spalte ist
da" auch dann gruen, wenn sie immer da waere. */
const vorher = await seiteAls("admin", "CODE-DOGI-0001", 1400);
let ohne = null;
try {
await vorher.s.goto(`${SEITE}/workspace/aufgaben.html`, { waitUntil: "networkidle" });
await vorher.s.waitForTimeout(1700);
ohne = await vorher.s.evaluate(() =>
document.querySelectorAll(".spalte--bewerbung").length);
} finally { await vorher.ctx.close(); }
ok(ohne === 0, `ohne Bewerbung gibt es die Spalte nicht (${ohne})`);
/* JETZT BEWIRBT SICH EIN MODI -- ueber die echte Schnittstelle
der Seite, damit Sitzung und Herkunftspruefung mitlaufen. */
const m = await seiteAls("modi", "CODE-MODI-0001", 1400);
let beworben = null, beiModi = null;
try {
await m.s.goto(`${SEITE}/workspace/entwicklung.html`, { waitUntil: "networkidle" });
await m.s.waitForTimeout(1500);
beworben = await m.s.evaluate(async () => {
const k = await fetch("/workspace/api/vorlagen", { credentials: "same-origin" });
const d = await k.json();
const v = (d.katalog?.aufgaben || [])[0];
if (!v) return { fehler: "kein Katalog" };
const a = await fetch("/workspace/api/vorlagen/bewerben", {
method: "POST", credentials: "same-origin",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ vorlage: v.schluessel, text: "ich habe Freitag Zeit" }),
});
return { code: a.status, titel: v.titel };
});
/* UND DER MODI SIEHT SEINE EIGENE -- auf dem Aufgabenbrett. */
await m.s.goto(`${SEITE}/workspace/aufgaben.html`, { waitUntil: "networkidle" });
await m.s.waitForTimeout(1800);
beiModi = await m.s.evaluate(() => {
const sp = document.querySelector(".spalte--bewerbung");
if (!sp) return { spalte: false };
return {
spalte: true,
karten: sp.querySelectorAll(".karte--bewerbung").length,
knoepfe: sp.querySelectorAll(".karte--bewerbung button").length,
warte: !!sp.querySelector(".karte__warte"),
titel: sp.querySelector(".karte__titel")?.textContent || "",
wort: sp.querySelector(".karte__bwort")?.textContent || "",
};
});
} finally { await m.ctx.close(); }
ok(beworben?.code === 201,
`ein Modi bewirbt sich (HTTP ${beworben?.code} ${beworben?.fehler || ""})`);
ok(beiModi?.spalte === true && beiModi.karten === 1,
`er sieht sie im Aufgabenbrett in einer eigenen Spalte (${beiModi?.karten})`);
ok(beiModi?.titel === beworben?.titel,
`mit dem TITEL der Aufgabe, nicht dem Schluessel ("${beiModi?.titel}")`);
ok((beiModi?.wort || "").includes("Freitag"),
`und mit seinem eigenen Satz dazu ("${beiModi?.wort}")`);
ok(beiModi?.knoepfe === 0 && beiModi?.warte === true,
`Gegenprobe: er bekommt KEINE Entscheidungsknoepfe (${beiModi?.knoepfe}), `
+ `sondern „wartet auf Antwort" (${beiModi?.warte})`);
/* UND DER, DER ENTSCHEIDET. */
const e2 = await seiteAls("admin", "CODE-DOGI-0001", 1400);
let beiDogi = null;
try {
await e2.s.goto(`${SEITE}/workspace/aufgaben.html`, { waitUntil: "networkidle" });
await e2.s.waitForTimeout(1800);
beiDogi = await e2.s.evaluate(() => {
const sp = document.querySelector(".spalte--bewerbung");
if (!sp) return { spalte: false };
return {
spalte: true,
karten: sp.querySelectorAll(".karte--bewerbung").length,
knoepfe: [...sp.querySelectorAll(".karte--bewerbung button")]
.map((b) => b.textContent.trim()),
/* Die Spalte muss die ERSTE sein: Was auf eine Antwort
wartet, gehoert an die Stelle, die man zuerst ansieht. */
erste: document.querySelector("#brett .spalte")?.classList
.contains("spalte--bewerbung"),
wer: sp.querySelector(".karte__wer")?.textContent || "",
};
});
} finally { await e2.ctx.close(); }
ok(beiDogi?.spalte === true && beiDogi.karten === 1,
`DogFather sieht dieselbe Bewerbung (${beiDogi?.karten})`);
ok(beiDogi?.erste === true,
`und die Spalte steht ganz vorn (${beiDogi?.erste})`);
ok(JSON.stringify(beiDogi?.knoepfe) === JSON.stringify(["Annehmen", "Ablehnen"]),
`mit beiden Knoepfen (${JSON.stringify(beiDogi?.knoepfe)})`);
ok((beiDogi?.wer || "").includes("möchte das übernehmen"),
`und dem Namen dessen, der wartet ("${beiDogi?.wer}")`);
}
ok(meckern.length === 0, meckern.length
? `der Browser meldet: ${meckern.slice(0, 3).join(" | ")}`
: "kein Absturz und keine Fehlermeldung im Browser");
} finally {
await browser.close();
await new Promise((r) => vorbau.close(r));
}
}
/* =====================================================================
ERGEBNIS
===================================================================== */
melde("");
if (geprueft < 70) {
console.log(` FEHL nur ${geprueft} Pruefungen gelaufen -- erwartet waren 70+`);
fehler++;
}
melde(fehler === 0
? `Alles in Ordnung. ${geprueft} Pruefungen, 0 Fehler.`
: `${fehler} FEHLER bei ${geprueft} Pruefungen.`);
melde("");
try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ }
process.exit(fehler ? 1 : 0);