/* EINE GRUPPE ODER EINEN KANAL AUFLÖSEN — FÜR ALLE (11.09.2026) Filipe: "wenn ein gruppenchat oder kanal geöffnet wird, die person die ihn öffnet soll auch das recht haben das zu löschen und so dass es dan für jeden gelöscht ist. aber nur die person die es gründet." --------------------------------------------------------------------- WARUM DIESE DATEI EXISTIERT Es gibt jetzt ZWEI Löschknöpfe nebeneinander, und sie tun etwas sehr Verschiedenes: DELETE /raeume/:id -> Wegräumen. Nur bei mir. Umkehrbar in dem Sinn, dass die anderen alles haben. DELETE /raeume/:id/ganz -> Auflösen. Für alle. Endgültig. Eine Verwechslung der beiden ist der teuerste Fehler, den dieser Chat machen kann -- ein ganzer Verlauf ist weg, bei allen, ohne Sicherung. Deshalb prüft diese Datei nicht nur, dass das Auflösen funktioniert, sondern vor allem, was es NICHT tut und wer es NICHT darf. GEMESSEN WIRD AM BESTAND, NICHT AN DER ANTWORT. Ein "ok: true" sagt nur, dass der Server nicht gestolpert ist. Ob der Raum wirklich weg ist, steht in der Datenbank -- und ob der Anhang weg ist, auf der Platte. Beides wird hier nachgesehen. Aufruf: node server/pruef-chat-aufloesen.mjs ===================================================================== */ import { mkdtempSync, rmSync, existsSync, readdirSync } from "node:fs"; import { tmpdir } from "node:os"; import { join, dirname } from "node:path"; import { fileURLToPath } from "node:url"; const HIER_BILD = dirname(fileURLToPath(import.meta.url)); const ordner = mkdtempSync(join(tmpdir(), "ws-aufloesen-")); process.env.WORKSPACE_DB = join(ordner, "workspace.db"); const { eigenerPort } = await import("./helfer-port.mjs"); const PORT = await eigenerPort(import.meta, "die Aufloesen-Pruefung"); process.env.PORT = `${PORT}`; process.env.SITE_ACCESS_SECRET = "lokaler-test"; process.env.SITE_PUBLIC_LAUNCH_AT = "2020-01-01T00:00:00+01:00"; const BASIS = `http://127.0.0.1:${PORT}`; await import("./index.js"); /* Eigene Zuhoerer neben denen aus index.js -- sonst wird ein Absturz dieser Datei nur notiert und der Lauf haengt (siehe die ausfuehrliche Begruendung in pruef-chat-kanaele.mjs). */ process.on("uncaughtException", (f) => { console.error(""); console.error("ABBRUCH durch einen Absturz:", f?.stack || f); process.exit(1); }); process.on("unhandledRejection", (f) => { console.error(""); console.error("ABBRUCH durch ein unbehandeltes Versprechen:", f?.stack || f); process.exit(1); }); const notbremse = setTimeout(() => { console.error(""); console.error("ABBRUCH: Die Pruefung laeuft seit 120 s."); process.exit(1); }, 120000); notbremse.unref?.(); let fehler = 0; let geprueft = 0; const ok = (b, t) => { geprueft++; console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; }; /* ---- Bestand ---------------------------------------------------------- */ const { DatabaseSync } = await import("node:sqlite"); const { scryptSync, randomBytes } = await import("node:crypto"); await fetch(BASIS + "/workspace/api/anmelden", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ rolle: "admin", code: "x" }) }).catch(() => {}); for (let i = 0; i < 60; i++) { try { const p = new DatabaseSync(process.env.WORKSPACE_DB); try { p.prepare("SELECT 1 FROM personen LIMIT 1").get(); break; } finally { p.close(); } } catch { await new Promise((r) => setTimeout(r, 250)); } } const d = new DatabaseSync(process.env.WORKSPACE_DB); const jetzt = new Date().toISOString(); 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, aktiv, erstellt) VALUES (?,?,?,?,?,1,?)") .run(name, rolle, hash, salt, 32768, jetzt); return d.prepare("SELECT last_insert_rowid() AS id").get().id; } anlegen("Filipe", "admin", "CODE-DOGI-0001"); const idHand = anlegen("Vanessa", "hand", "CODE-HAND-0001"); const idEins = anlegen("Ayla", "modi", "CODE-TEAM-0001"); const idZwei = anlegen("Ben", "modi", "CODE-TEAM-0002"); const idDrei = anlegen("Cem", "modi", "CODE-TEAM-0003"); /* Anmeldung an der Crew-Wand: Modis und die rechte Hand kommen an der Agenturwand nicht herein, und `fetch` laesst einen selbst gesetzten Host-Kopf fallen -- deshalb node:http. */ const { request: httpAnfrage } = await import("node:http"); function anmelden(rolle, code, wand = "crew.dogfather-universe.com") { return new Promise((fertig) => { const rumpf = JSON.stringify({ rolle, code }); const a = httpAnfrage({ host: "127.0.0.1", port: PORT, path: "/workspace/api/anmelden", method: "POST", headers: { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(rumpf), Host: wand, }, }, (antwort) => { antwort.resume(); antwort.on("end", () => fertig({ ok: antwort.statusCode === 200, keks: (antwort.headers["set-cookie"] || []).map((k) => k.split(";")[0]).join("; "), })); }); a.on("error", () => fertig({ ok: false, keks: "" })); a.end(rumpf); }); } const rufe = (keks, weg, koerper, methode = "GET") => fetch(BASIS + weg, { method: methode, headers: { "Content-Type": "application/json", Cookie: keks, Origin: BASIS }, body: koerper === undefined ? undefined : JSON.stringify(koerper), }); const holen = async (keks, weg) => (await rufe(keks, weg)).json(); const dogi = await anmelden("admin", "CODE-DOGI-0001"); const hand = await anmelden("hand", "CODE-HAND-0001"); const eins = await anmelden("modi", "CODE-TEAM-0001"); const zwei = await anmelden("modi", "CODE-TEAM-0002"); const drei = await anmelden("modi", "CODE-TEAM-0003"); ok([dogi, hand, eins, zwei, drei].every((x) => x.ok && x.keks), "alle fuenf sind angemeldet"); const raumVon = (keks, id) => holen(keks, "/workspace/api/chat/raeume") .then((a) => (a.raeume || []).find((r) => r.id === id) || null); const stehtDa = (id) => !!d.prepare("SELECT 1 FROM chat_raeume WHERE id = ?").get(id); /* ======================================================================= 1. Eine Gruppe, gegruendet von Ayla ======================================================================= */ console.log(""); console.log("=== Wer darf aufloesen? ==="); const gruppe = await (await rufe(eins.keks, "/workspace/api/chat/gruppe", { mit: [idZwei, idDrei], name: "Clipping Mo-Fr" }, "POST")).json(); ok(Number.isInteger(gruppe.raum_id), `Ayla legt eine Gruppe an (#${gruppe.raum_id})`); /* Zwei Nachrichten hinein, damit es auch etwas zu verlieren gibt. */ for (const [k, t] of [[eins, "Moin"], [zwei, "Bin dabei"]]) { await rufe(k.keks, `/workspace/api/chat/raeume/${gruppe.raum_id}/nachrichten`, { text: t }, "POST"); } const wieViele = d.prepare( "SELECT COUNT(*) AS n FROM chat_nachrichten WHERE raum_id = ?").get(gruppe.raum_id).n; ok(wieViele === 2, `zwei Nachrichten stehen darin (${wieViele})`); /* ---- Das Recht steht in der Liste, nicht im Browser ------------------- */ { const beiAyla = await raumVon(eins.keks, gruppe.raum_id); const beiBen = await raumVon(zwei.keks, gruppe.raum_id); ok(beiAyla?.darf_aufloesen === true, "Ayla bekommt das Recht mitgeliefert"); ok(beiBen?.darf_aufloesen === false, "Ben nicht -- er ist dabei, aber er hat sie nicht angelegt"); } /* ---- Und wer es nicht hat, kommt auch nicht durch --------------------- */ { const a = await rufe(zwei.keks, `/workspace/api/chat/raeume/${gruppe.raum_id}/ganz`, undefined, "DELETE"); const d2 = await a.json().catch(() => ({})); ok(a.status === 403 && d2.fehler === "nur_gruender", `Ben wird abgewiesen (HTTP ${a.status}, ${d2.fehler})`); ok(stehtDa(gruppe.raum_id), "und die Gruppe steht noch"); } { /* Die rechte Hand darf ALLES sehen -- aufloesen darf sie trotzdem nicht. Das ist der Fall, den man beim Bauen am ehesten aufweicht ("sie ist ja die Leitung"), und genau deshalb steht er hier. */ const a = await rufe(hand.keks, `/workspace/api/chat/raeume/${gruppe.raum_id}/ganz`, undefined, "DELETE"); ok(a.status === 403 || a.status === 404, `auch die rechte Hand nicht (HTTP ${a.status})`); ok(stehtDa(gruppe.raum_id), "und die Gruppe steht immer noch"); } /* ======================================================================= 2. Wegraeumen ist NICHT aufloesen ======================================================================= */ console.log(""); console.log("=== Der Unterschied zum Knopf daneben ==="); { const a = await rufe(zwei.keks, `/workspace/api/chat/raeume/${gruppe.raum_id}`, undefined, "DELETE"); ok(a.status === 200, `Ben raeumt sie bei sich weg (HTTP ${a.status})`); ok(stehtDa(gruppe.raum_id), "der Raum bleibt in der Datenbank"); const beiBen = await raumVon(zwei.keks, gruppe.raum_id); ok(beiBen === null, "bei Ben steht sie nicht mehr in der Liste"); const beiCem = await raumVon(drei.keks, gruppe.raum_id); ok(beiCem !== null, "bei Cem dagegen schon -- niemand nimmt einem anderen etwas weg"); const uebrig = d.prepare( "SELECT COUNT(*) AS n FROM chat_nachrichten WHERE raum_id = ?").get(gruppe.raum_id).n; ok(uebrig === 2, `und beide Nachrichten sind noch da (${uebrig})`); } /* ======================================================================= 3. Ein Zweier-Gespraech laesst sich gar nicht aufloesen ======================================================================= */ console.log(""); console.log("=== Zweier-Gespraeche bleiben unantastbar ==="); { const direkt = await (await rufe(eins.keks, "/workspace/api/chat/direkt", { mit: idZwei }, "POST")).json(); ok(Number.isInteger(direkt.raum_id), `Ayla schreibt Ben direkt (#${direkt.raum_id})`); const liste = await raumVon(eins.keks, direkt.raum_id); ok(liste?.darf_aufloesen === false, "auch die Anlegerin bekommt dort kein Aufloesen-Recht"); const a = await rufe(eins.keks, `/workspace/api/chat/raeume/${direkt.raum_id}/ganz`, undefined, "DELETE"); const d2 = await a.json().catch(() => ({})); ok(a.status === 403 && d2.fehler === "nur_gruppe_oder_kanal", `und der Weg weist sie ab (HTTP ${a.status}, ${d2.fehler})`); ok(stehtDa(direkt.raum_id), "das Gespraech steht weiterhin"); } /* ======================================================================= 4. Fremde kommen nicht einmal in die Naehe ======================================================================= */ console.log(""); console.log("=== Wer nicht dabei ist ==="); { /* Cem ist in der Gruppe, Ayla hat sie gegruendet. Ein VIERTER, der nicht dabei ist, muss 404 bekommen -- nicht 403. 403 waere die Auskunft, dass es diesen Raum gibt. */ const vier = anlegen("Dilan", "modi", "CODE-TEAM-0004"); const vierK = await anmelden("modi", "CODE-TEAM-0004"); ok(vierK.ok && Number.isInteger(vier), "ein Vierter ist angemeldet"); const a = await rufe(vierK.keks, `/workspace/api/chat/raeume/${gruppe.raum_id}/ganz`, undefined, "DELETE"); ok(a.status === 404, `er bekommt 404, nicht 403 (HTTP ${a.status})`); } /* ======================================================================= 5. Und jetzt wirklich: die Gruenderin loest auf ======================================================================= */ console.log(""); console.log("=== Aufloesen, gemessen am Bestand ==="); { const vorher = { raum: stehtDa(gruppe.raum_id), nachrichten: d.prepare("SELECT COUNT(*) AS n FROM chat_nachrichten WHERE raum_id = ?") .get(gruppe.raum_id).n, leute: d.prepare("SELECT COUNT(*) AS n FROM chat_teilnehmer WHERE raum_id = ?") .get(gruppe.raum_id).n, }; ok(vorher.raum && vorher.nachrichten === 2 && vorher.leute === 3, `vorher: Raum da, ${vorher.nachrichten} Nachrichten, ${vorher.leute} Beteiligte`); const a = await rufe(eins.keks, `/workspace/api/chat/raeume/${gruppe.raum_id}/ganz`, undefined, "DELETE"); const antwort = await a.json().catch(() => ({})); ok(a.status === 200 && antwort.ok === true, `Ayla loest auf (HTTP ${a.status})`); ok(antwort.nachrichten === 2 && antwort.beteiligte === 3, `die Antwort nennt, was wegfiel (${antwort.nachrichten} Nachrichten, ` + `${antwort.beteiligte} Beteiligte)`); ok(!stehtDa(gruppe.raum_id), "der Raum ist aus der Datenbank verschwunden"); const restN = d.prepare("SELECT COUNT(*) AS n FROM chat_nachrichten WHERE raum_id = ?") .get(gruppe.raum_id).n; const restT = d.prepare("SELECT COUNT(*) AS n FROM chat_teilnehmer WHERE raum_id = ?") .get(gruppe.raum_id).n; ok(restN === 0, `keine Nachricht blieb liegen (${restN})`); ok(restT === 0, `keine Teilnehmerzeile blieb liegen (${restT})`); /* UND BEI DEN ANDEREN. Das ist der eigentliche Satz aus Filipes Wunsch: "so dass es dan fuer jeden geloescht ist." */ const beiCem = await raumVon(drei.keks, gruppe.raum_id); ok(beiCem === null, "bei Cem ist sie ebenfalls weg -- fuer jeden"); /* GEGENPROBE: Ein zweiter Versuch findet nichts mehr. Ohne sie koennte der Weg beim zweiten Aufruf stillschweigend "ok" sagen. */ const nochmal = await rufe(eins.keks, `/workspace/api/chat/raeume/${gruppe.raum_id}/ganz`, undefined, "DELETE"); ok(nochmal.status === 404, `ein zweiter Versuch findet nichts (HTTP ${nochmal.status})`); } /* ======================================================================= 6. Ein Kanal -- und seine Zustaendigkeit wird wieder frei ======================================================================= */ console.log(""); console.log("=== Kanaele ==="); { const frei = await holen(hand.keks, "/workspace/api/chat/raeume"); const kat = (frei.kanal_frei || [])[0]?.wert; ok(!!kat, `es gibt eine freie Zustaendigkeit (${kat || "keine"})`); const kanal = await (await rufe(hand.keks, "/workspace/api/chat/kanal", { kategorie: kat }, "POST")).json(); ok(Number.isInteger(kanal.raum_id), `die rechte Hand legt den Kanal an (#${kanal.raum_id})`); /* DogFather kommt in jeden Kanal -- aufloesen darf er trotzdem nicht, denn angelegt hat ihn die rechte Hand. */ const beiDogi = await raumVon(dogi.keks, kanal.raum_id); ok(beiDogi !== null, "DogFather sieht den Kanal"); ok(beiDogi?.darf_aufloesen === false, "und darf ihn NICHT aufloesen -- er hat ihn nicht angelegt"); const versuch = await rufe(dogi.keks, `/workspace/api/chat/raeume/${kanal.raum_id}/ganz`, undefined, "DELETE"); ok(versuch.status === 403, `der Weg bestaetigt das (HTTP ${versuch.status})`); const beiHand = await raumVon(hand.keks, kanal.raum_id); ok(beiHand?.darf_aufloesen === true, "die Gruenderin dagegen darf"); const weg = await rufe(hand.keks, `/workspace/api/chat/raeume/${kanal.raum_id}/ganz`, undefined, "DELETE"); ok(weg.status === 200, `sie loest ihn auf (HTTP ${weg.status})`); ok(!stehtDa(kanal.raum_id), "der Kanal ist weg"); /* Und die Zustaendigkeit steht wieder zur Verfuegung. Ohne diese Zeile koennte der eindeutige Index sie dauerhaft blockieren -- ein Kanal, den es nicht gibt und den man nicht anlegen kann. */ const danach = await holen(hand.keks, "/workspace/api/chat/raeume"); ok((danach.kanal_frei || []).some((k) => k.wert === kat), `die Zustaendigkeit "${kat}" ist wieder frei`); const neu = await (await rufe(hand.keks, "/workspace/api/chat/kanal", { kategorie: kat }, "POST")).json(); ok(Number.isInteger(neu.raum_id), `und laesst sich neu anlegen (#${neu.raum_id})`); } /* ======================================================================= 7. Anhaenge verschwinden von der Platte ======================================================================= */ console.log(""); console.log("=== Anhaenge ==="); { const g = await (await rufe(eins.keks, "/workspace/api/chat/gruppe", { mit: [idZwei], name: "Mit Anhang" }, "POST")).json(); ok(Number.isInteger(g.raum_id), `eine Gruppe mit Anhang (#${g.raum_id})`); /* Ein winziges PNG, damit es wirklich durch die Bildpruefung geht. */ const png = Buffer.from( "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk" + "YPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==", "base64"); /* ROH, NICHT ALS FORMULAR. Der Weg nimmt den Dateiinhalt direkt entgegen (express.raw) und erkennt den Typ an den ersten Bytes -- ein multipart-Umschlag kommt dort als Unsinn an und wird mit 415 abgewiesen. Beim ersten Anlauf stand hier FormData, und die Pruefung mass daraufhin ein Loeschen von nichts. */ const hoch = await fetch(BASIS + `/workspace/api/chat/raeume/${g.raum_id}/anhang`, { method: "POST", headers: { Cookie: eins.keks, Origin: BASIS, "Content-Type": "application/octet-stream", "x-dateiname": "punkt.png", }, body: png }); ok(hoch.status === 200 || hoch.status === 201, `der Anhang ist oben (HTTP ${hoch.status})`); const datei = d.prepare( `SELECT anhang_datei FROM chat_nachrichten WHERE raum_id = ? AND anhang_datei IS NOT NULL`).get(g.raum_id)?.anhang_datei; ok(!!datei, `die Datei steht in der Tabelle (${datei || "keine"})`); const { DATEN_ORDNER } = await import("./workspace.js"); const pfad = join(DATEN_ORDNER, "chat-anhaenge", datei || "-"); ok(existsSync(pfad), "und liegt auf der Platte"); const weg = await rufe(eins.keks, `/workspace/api/chat/raeume/${g.raum_id}/ganz`, undefined, "DELETE"); ok(weg.status === 200, `die Gruppe wird aufgeloest (HTTP ${weg.status})`); ok(!existsSync(pfad), "und die Datei ist von der Platte verschwunden"); /* GEGENPROBE ZUM PFAD: Der Ordner selbst bleibt stehen. Ein `existsSync` auf einen Pfad, den es nie gab, waere sonst auch "weg" -- und die Zeile darueber bewiese nichts. */ ok(existsSync(join(DATEN_ORDNER, "chat-anhaenge")), "der Anhang-Ordner selbst steht noch (die Pruefung misst die Datei, nicht den Ordner)"); } /* ======================================================================= 7b. REAKTIONEN — auf die Nachricht, ohne zu antworten (14.09.2026) ======================================================================= Filipe: "ich will dass man auf die nachrichten auch reagieren kann mit einem emoji, die nachricht selbst ohne zu antworten." GEMESSEN WIRD DER BESTAND, nicht die Antwort. Ein "ok" sagt nur, dass der Server nicht gestolpert ist; ob die Reaktion wirklich steht (und beim zweiten Klick wirklich weg ist), steht in der Datenbank. ======================================================================= */ console.log(""); console.log("=== Reaktionen ==="); { const g = await (await rufe(eins.keks, "/workspace/api/chat/gruppe", { mit: [idZwei, idDrei], name: "Reaktionsrunde" }, "POST")).json(); const n = await (await rufe(eins.keks, `/workspace/api/chat/raeume/${g.raum_id}/nachrichten`, { text: "Moin" }, "POST")).json(); const nid = n.nachricht?.id; ok(Number.isInteger(nid), `eine Nachricht zum Reagieren (#${nid})`); const auswahl = await holen(eins.keks, "/workspace/api/chat/reaktionen/auswahl"); ok(Array.isArray(auswahl.zeichen) && auswahl.zeichen.length >= 4, `die Auswahl kommt vom Server (${(auswahl.zeichen || []).length} Zeichen)`); const daumen = auswahl.zeichen[0]; const herz = auswahl.zeichen[1]; const zaehle = () => d.prepare( "SELECT COUNT(*) AS n FROM chat_reaktionen WHERE nachricht_id = ?").get(nid).n; /* ---- KATALOG UND FAVORITEN (14.09.2026) ------------------------- Filipe: "paar als vorschlaege mit der moeglichkeit andere auszuwaehlen und als favoriten zu speichern." DREI LISTEN, DREI FRAGEN -- und sie werden gern verwechselt: Katalog (was gibt es?), Vorschlag (was steht vorn?), Favoriten (was hat DIESER Mensch gewaehlt?). Geprueft wird, dass sie zusammenhaengen, ohne dasselbe zu sein. */ const aus1 = await holen(eins.keks, "/workspace/api/chat/reaktionen/auswahl"); ok(Array.isArray(aus1.katalog) && aus1.katalog.length >= 4, `der Katalog hat ${(aus1.katalog || []).length} Gruppen`); const alleZeichen = (aus1.katalog || []).flatMap((g) => g.zeichen.map((z) => z[0])); ok(alleZeichen.length >= 50, `und ${alleZeichen.length} Zeichen insgesamt`); ok(new Set(alleZeichen).size === alleZeichen.length, "keines davon doppelt -- ein doppeltes waere in der Tafel still"); ok((aus1.katalog || []).every((g) => g.zeichen.every(([, w]) => typeof w === "string" && w.length > 2)), "jedes Zeichen hat deutsche Suchwoerter"); ok(aus1.eigene === false && aus1.zeichen.length === 6, `ohne eigene Favoriten stehen die sechs Vorgaben vorn (${aus1.zeichen.length})`); ok(aus1.zeichen.every((z) => alleZeichen.includes(z)), "und jeder Vorschlag steht auch im Katalog"); /* ---- Einen Favoriten merken ------------------------------------- */ const exot = alleZeichen.find((z) => !aus1.zeichen.includes(z)); const f1 = await (await rufe(eins.keks, "/workspace/api/chat/reaktionen/favorit", { zeichen: exot }, "PUT")).json(); ok(f1.favoriten?.length === 1 && f1.favoriten[0] === exot, `ein Zeichen wird gemerkt (${f1.favoriten?.join(" ")})`); ok(f1.zeichen?.length === 1, "und steht ab jetzt ALLEIN vorn -- Favoriten ersetzen die Vorgabe"); /* ---- Sie bleiben, auch nach dem Neuladen ------------------------- */ const aus2 = await holen(eins.keks, "/workspace/api/chat/reaktionen/auswahl"); ok(aus2.eigene === true && aus2.favoriten[0] === exot, "beim naechsten Aufruf sind sie noch da"); /* ---- Und sie gehoeren EINEM Menschen ----------------------------- */ const ausBen = await holen(zwei.keks, "/workspace/api/chat/reaktionen/auswahl"); ok(ausBen.eigene === false, "Ben hat davon nichts -- Favoriten sind persoenlich"); /* ---- Zweimal dasselbe nimmt es weg ------------------------------- */ const f2 = await (await rufe(eins.keks, "/workspace/api/chat/reaktionen/favorit", { zeichen: exot }, "PUT")).json(); ok(f2.favoriten?.length === 0 && f2.gesetzt === false, "noch einmal darauf nimmt den Favoriten wieder weg"); const aus3 = await holen(eins.keks, "/workspace/api/chat/reaktionen/auswahl"); ok(aus3.eigene === false && aus3.zeichen.length === 6, "und die Vorgabe steht wieder vorn"); /* ---- Die Grenze sagt Bescheid, statt still zu schlucken ---------- */ const acht = alleZeichen.slice(0, aus1.favoriten_max); for (const z of acht) { await rufe(eins.keks, "/workspace/api/chat/reaktionen/favorit", { zeichen: z }, "PUT"); } const voll = await rufe(eins.keks, "/workspace/api/chat/reaktionen/favorit", { zeichen: alleZeichen[aus1.favoriten_max] }, "PUT"); const vollD = await voll.json(); ok(voll.status === 409 && /Favoriten/.test(vollD.fehler || ""), `der neunte wird abgelehnt und begruendet (HTTP ${voll.status})`); ok((vollD.favoriten || []).length === aus1.favoriten_max, `die acht bleiben unangetastet (${(vollD.favoriten || []).length})`); /* GEGENPROBE: Nach dem Wegnehmen eines Favoriten geht wieder einer hinein. Ohne sie hiesse das oben nur, dass ab acht gar nichts mehr geht. */ await rufe(eins.keks, "/workspace/api/chat/reaktionen/favorit", { zeichen: acht[0] }, "PUT"); const platzFrei = await rufe(eins.keks, "/workspace/api/chat/reaktionen/favorit", { zeichen: alleZeichen[aus1.favoriten_max] }, "PUT"); ok(platzFrei.status === 200, `nach dem Wegnehmen passt wieder einer (HTTP ${platzFrei.status})`); /* ---- Ein Favorit ist keine Erlaubnis ----------------------------- */ const erfunden = await rufe(eins.keks, "/workspace/api/chat/reaktionen/favorit", { zeichen: "nicht-im-katalog" }, "PUT"); ok(erfunden.status === 400, `ein Zeichen ausserhalb des Katalogs wird nicht gemerkt (HTTP ${erfunden.status})`); /* ---- Der ganze Katalog ist auch zum Reagieren erlaubt ------------ */ const weit = alleZeichen[alleZeichen.length - 1]; const gr = await (await rufe(eins.keks, "/workspace/api/chat/gruppe", { mit: [idZwei], name: "Katalogtest" }, "POST")).json(); const nn = await (await rufe(eins.keks, `/workspace/api/chat/raeume/${gr.raum_id}/nachrichten`, { text: "x" }, "POST")).json(); const mitExot = await rufe(eins.keks, `/workspace/api/chat/nachrichten/${nn.nachricht.id}/reaktion`, { zeichen: weit }, "PUT"); ok(mitExot.status === 200, `auch das letzte Zeichen des Katalogs laesst sich geben (${weit})`); /* ---- Setzen ---------------------------------------------------- */ const a1 = await (await rufe(zwei.keks, `/workspace/api/chat/nachrichten/${nid}/reaktion`, { zeichen: daumen }, "PUT")).json(); ok(zaehle() === 1, `Ben setzt ein Zeichen -- es steht in der Datenbank (${zaehle()})`); ok(a1.reaktionen?.[0]?.anzahl === 1 && a1.reaktionen?.[0]?.selbst === true, "die Antwort nennt Anzahl und dass es seines ist"); /* ---- Derselbe Mensch, ZWEITES Zeichen -- das geht --------------- */ await rufe(zwei.keks, `/workspace/api/chat/nachrichten/${nid}/reaktion`, { zeichen: herz }, "PUT"); ok(zaehle() === 2, `dasselbe Mensch darf ein ZWEITES Zeichen geben (${zaehle()})`); /* ---- Derselbe Mensch, DASSELBE Zeichen -- nimmt zurueck --------- */ const a3 = await (await rufe(zwei.keks, `/workspace/api/chat/nachrichten/${nid}/reaktion`, { zeichen: daumen }, "PUT")).json(); ok(zaehle() === 1, `zweimal dasselbe nimmt es wieder weg (${zaehle()})`); ok(a3.gesetzt === false, "und die Antwort sagt, dass es weg ist"); /* ---- Mehrere Menschen, dasselbe Zeichen ------------------------- */ await rufe(drei.keks, `/workspace/api/chat/nachrichten/${nid}/reaktion`, { zeichen: herz }, "PUT"); const stand = await (await rufe(eins.keks, `/workspace/api/chat/nachrichten/${nid}/reaktion`, { zeichen: herz }, "PUT")).json(); const herzEintrag = (stand.reaktionen || []).find((r) => r.zeichen === herz); ok(herzEintrag?.anzahl === 3, `drei Menschen, ein Zeichen, Anzahl 3 (${herzEintrag?.anzahl})`); ok(Array.isArray(herzEintrag?.wer) && herzEintrag.wer.length === 3, `und die Namen stehen dabei (${(herzEintrag?.wer || []).join(", ")})`); /* ---- Was NICHT geht --------------------------------------------- */ const fremd = await rufe(hand.keks, `/workspace/api/chat/nachrichten/${nid}/reaktion`, { zeichen: daumen }, "PUT"); ok(fremd.status === 404, `wer nicht im Raum ist, kommt nicht heran (HTTP ${fremd.status})`); const unsinn = await rufe(zwei.keks, `/workspace/api/chat/nachrichten/${nid}/reaktion`, { zeichen: "" }, "PUT"); ok(unsinn.status === 400, `ein erfundenes Zeichen wird abgewiesen (HTTP ${unsinn.status})`); ok(zaehle() === 3, `und aendert nichts am Bestand (${zaehle()})`); /* ---- Eine GELOESCHTE Nachricht ---------------------------------- Hier stand 409 („zurueckgenommen"). Seit dem 22.09.2026 abends wird wirklich geloescht -- Filipe: „Geloeschte Nachrichten verschwinden vollstaendig." Die Zeile gibt es dann nicht mehr, und die richtige Antwort ist 404: nicht gefunden. Die Frage bleibt dieselbe und ist sogar schaerfer: Niemand darf auf etwas reagieren, das es nicht gibt -- und der Bestand darf sich dabei nicht aendern. */ const vorherBestand = zaehle(); const weg = await (await rufe(eins.keks, `/workspace/api/chat/raeume/${g.raum_id}/nachrichten`, { text: "Weg gleich" }, "POST")).json(); await rufe(eins.keks, `/workspace/api/chat/nachrichten/${weg.nachricht.id}`, undefined, "DELETE"); const drauf = await rufe(zwei.keks, `/workspace/api/chat/nachrichten/${weg.nachricht.id}/reaktion`, { zeichen: daumen }, "PUT"); ok(drauf.status === 404, `auf eine geloeschte Nachricht geht es nicht -- es gibt sie nicht mehr (HTTP ${drauf.status})`); ok(zaehle() === vorherBestand, `und der Bestand bleibt unveraendert (${vorherBestand})`); /* ---- Und sie ueberleben das Neuladen ----------------------------- */ const verlauf = await holen(zwei.keks, `/workspace/api/chat/raeume/${g.raum_id}/nachrichten`); const wieder = (verlauf.nachrichten || []).find((x) => x.id === nid); ok(Array.isArray(wieder?.reaktionen) && wieder.reaktionen.length === 1, `beim Neuladen sind sie wieder da (${(wieder?.reaktionen || []).length} Sorte)`); ok(wieder?.reaktionen?.[0]?.selbst === true, "und Ben sieht, dass eines davon seines ist"); /* GEGENPROBE, DIE WIRKLICH UNTERSCHEIDET. Der erste Entwurf pruefte "bei Cem ist ueberall selbst=true" -- und das war trivial wahr, weil es zu dem Zeitpunkt nur EINE Sorte gab. Eine Angabe, die nie `false` sein kann, sagt nichts. Also bekommt Ayla ein drittes Zeichen, das Cem NICHT gegeben hat. Jetzt muss bei Cem eines auf `true` und eines auf `false` stehen -- in derselben Antwort. */ const lachen = auswahl.zeichen[2]; await rufe(eins.keks, `/workspace/api/chat/nachrichten/${nid}/reaktion`, { zeichen: lachen }, "PUT"); const beiDrei = await holen(drei.keks, `/workspace/api/chat/raeume/${g.raum_id}/nachrichten`); const seins = (beiDrei.nachrichten || []).find((x) => x.id === nid); const meins = (seins?.reaktionen || []).find((r) => r.zeichen === herz); const fremdes = (seins?.reaktionen || []).find((r) => r.zeichen === lachen); ok(meins?.selbst === true && fremdes?.selbst === false, `bei Cem: sein Herz ist "selbst", Aylas Lachen nicht ` + `(${meins?.selbst} / ${fremdes?.selbst})`); /* ---- Die Nachricht loeschen nimmt die Reaktionen mit ------------- */ const vorherAnzahl = zaehle(); ok(vorherAnzahl > 0, `vorher stehen ${vorherAnzahl} Reaktionen da`); d.prepare("DELETE FROM chat_nachrichten WHERE id = ?").run(nid); ok(zaehle() === 0, `mit der Nachricht verschwinden die Reaktionen (${zaehle()} uebrig)`); } /* ======================================================================= 8. Und wie es auf dem Bildschirm aussieht ======================================================================= Filipe: "ich will eine bessere erklaerung dafuer bitte." -- gemeint war der Umschalter "Gespraech | Kanal" im Neu-Fenster. Daneben stand ein Satz ueber das ANTIPPEN von Personen, also ueber den naechsten Schritt, nicht ueber die Wahl, die gerade ansteht. EIN TEXT, DEN NIEMAND PRUEFT, VERSCHWINDET beim naechsten Umbau, und es faellt keinem auf, weil ein fehlender Satz nichts kaputtmacht. DIE ANMELDUNG LAEUFT UEBER DEN KEKS, NICHT UEBER DAS FORMULAR: Die Crew-Rollen kommen an der Agenturwand nicht herein, und ein Browser laesst den Host-Kopf nicht setzen (Chrome verbietet es). Der Keks aus der Anmeldung oben gilt fuer 127.0.0.1 -- also wird er direkt in den Browser gelegt. Das umgeht keine Regel, es umgeht ein Werkzeug-Hindernis. */ console.log(""); console.log("=== Das Neu-Fenster ==="); { const pw = await import("file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs"); const browser = await pw.chromium.launch(); const ctx = await browser.newContext({ viewport: { width: 1280, height: 900 } }); const [kname, kwert] = hand.keks.split("="); await ctx.addCookies([{ name: kname, value: kwert, domain: "127.0.0.1", path: "/" }]); const seite = await ctx.newPage(); const skriptfehler = []; seite.on("pageerror", (e) => skriptfehler.push(String(e).slice(0, 140))); await seite.goto(BASIS + "/workspace/chat.html", { waitUntil: "networkidle" }); await seite.waitForTimeout(1400); ok(skriptfehler.length === 0, `kein Skriptfehler (${skriptfehler.join(" | ") || "keiner"})`); await seite.click("#neu-oeffnen"); await seite.waitForSelector("#neu-dialog[open]", { timeout: 5000 }); await seite.waitForTimeout(400); const lage = await seite.evaluate(() => { const e = document.getElementById("neu-erklaerung"); const u = document.getElementById("neu-unter"); return { umschalter: document.getElementById("neu-art")?.hidden === false, /* Ohne Leerraum-Muster: Ein regulaerer Ausdruck stand hier mit einem verschluckten Backslash da ("/s+/g") und hat jedes "s" durch ein Leerzeichen ersetzt -- aus "Gespraech" wurde "Ge praech", und die Pruefung fand das Wort nicht mehr. Zum Suchen von Woertern braucht es keine Normalisierung. */ erklaerung: e && !e.hidden ? e.textContent.trim() : "", unter: u ? u.textContent.trim() : "", }; }); ok(lage.umschalter === true, "die rechte Hand sieht den Umschalter"); ok(lage.erklaerung.length > 60, `und darunter eine Erklaerung (${lage.erklaerung.length} Zeichen)`); /* Sie muss BEIDE Woerter erklaeren -- eine Erklaerung, die nur eines der beiden nennt, laesst genau die Frage offen, die Filipe hatte. */ ok(/Gespräch/.test(lage.erklaerung) && /Kanal/.test(lage.erklaerung), "sie nennt beide Arten"); /* Und sie muss den UNTERSCHIED benennen, nicht zweimal dasselbe: Leute gegen Thema. */ ok(/Leute/.test(lage.erklaerung) && /Thema/.test(lage.erklaerung), "und sagt, worin sie sich unterscheiden (Leute / Thema)"); ok(/einmal/.test(lage.erklaerung), "dass es einen Kanal je Zustaendigkeit nur einmal gibt"); ok(!/antippen/i.test(lage.erklaerung), "sie erklaert die WAHL, nicht den naechsten Schritt"); /* Umschalten auf Kanal: Der Satz darunter wechselt mit. */ await seite.click('#neu-art [data-art="kanal"]'); await seite.waitForTimeout(250); const unterKanal = await seite.$eval("#neu-unter", (e) => e.textContent.trim()); ok(unterKanal !== lage.unter && /Zuständigkeit/.test(unterKanal), `im Kanal-Modus steht etwas anderes darunter ("${unterKanal.slice(0, 48)}…")`); if (process.env.SCHIRM) { await seite.screenshot({ path: join(HIER_BILD, "pruef-chat-neu.png") }); } await seite.click("#neu-zu"); await seite.waitForTimeout(250); /* ---- REAGIEREN AUF DEM BILDSCHIRM (14.09.2026) ------------------ Das Recht und der Weg sind oben geprueft. Ob die Oberflaeche beides verbindet, ist eine eigene Frage -- genau daran ist am 11.09. der Aufloesen-Knopf gescheitert: Recht richtig, Knopf unsichtbar. */ const rRaum = await (await rufe(hand.keks, "/workspace/api/chat/gruppe", { mit: [idEins], name: "Reagieren sichtbar" }, "POST")).json(); await rufe(hand.keks, `/workspace/api/chat/raeume/${rRaum.raum_id}/nachrichten`, { text: "Reagier mal drauf" }, "POST"); /* UND EINE VON JEMAND ANDEREM. Ohne sie gibt es in diesem Raum nur eigene Nachrichten -- und genau daran ist die Prüfung am 15.09. vorbeigelaufen: Bei einer eigenen Nachricht hat das Raster nur eine Spalte, die Tafel bekam die volle Breite, alles war grün. Kaputt war es bei jeder FREMDEN, wo der Kreis mit den Initialen eine 30-px-Spalte aufmacht. */ await rufe(eins.keks, `/workspace/api/chat/raeume/${rRaum.raum_id}/nachrichten`, { text: "Und hier steht was von Ayla" }, "POST"); await seite.goto(BASIS + `/workspace/chat.html?raum=${rRaum.raum_id}`, { waitUntil: "networkidle" }); await seite.waitForTimeout(1500); const knopfDa = await seite.$$eval(".chat-nachricht__reag-knopf", (b) => b.length); ok(knopfDa > 0, `der Knopf "reagieren" steht an der Nachricht (${knopfDa})`); await seite.click(".chat-nachricht__reag-knopf"); await seite.waitForTimeout(600); const tafel = await seite.$$eval(".reagieren-tafel__zeichen", (b) => b.map((x) => x.textContent.trim())); ok(tafel.length >= 4, `die Auswahl klappt auf (${tafel.length} Zeichen)`); /* ---- DIE TAFEL: Vorschlaege, Suche, Katalog, Favoriten ---------- (14.09.2026) Filipe: "soll viel besser und geiler sein und man soll auch auswaehlen koennen." Gemessen wird, was auf dem Bildschirm PASSIERT -- nicht, dass es Elemente gibt. Eine Tafel, die aufklappt und deren Suche nichts tut, haette alle Anwesenheitspruefungen bestanden. */ const teile = await seite.evaluate(() => ({ schnell: document.querySelectorAll(".reagieren-tafel__schnell .reagieren-tafel__zeichen").length, gruppen: document.querySelectorAll(".reagieren-tafel__gruppe").length, katalog: document.querySelectorAll(".reagieren-tafel__katalog .reagieren-tafel__zeichen").length, suche: !!document.querySelector(".reagieren-tafel__suche"), stern: !!document.querySelector(".reagieren-tafel__stern"), })); ok(teile.schnell === 6, `sechs Vorschlaege stehen oben (${teile.schnell})`); ok(teile.gruppen >= 4 && teile.katalog >= 50, `darunter der Katalog: ${teile.gruppen} Gruppen, ${teile.katalog} Zeichen`); ok(teile.suche && teile.stern, "Suchfeld und Favoriten-Schalter sind da"); /* ===== UND SIE MUSS AUCH AUSSEHEN WIE EINE TAFEL (15.09.2026) ====== Filipe, mit einem Bildschirmfoto: „was ist das den für eine scheisse verbesser das sofort." Darauf: ein dreissig Pixel schmaler Streifen, sechs Zeichen untereinander, ein Rollbalken daneben. ALLE PRÜFUNGEN OBEN WAREN DABEI GRÜN. Sie fragen, ob es die Elemente gibt und ob die Suche sucht -- beides stimmte. Wie breit die Tafel ist, hat nie jemand gemessen. Genau die Sorte Prüfung, die bestätigt und nichts beweist. Gemessen wird deshalb die GEOMETRIE: Ist sie so breit, wie eine Karte sein muss? Und stehen die sechs Vorschläge NEBENeinander -- also auf derselben Höhe? */ const masse = await seite.evaluate(() => { const t = document.querySelector(".reagieren-tafel"); if (!t) return null; const k = t.getBoundingClientRect(); const z = [...document.querySelectorAll( ".reagieren-tafel__schnell .reagieren-tafel__zeichen")].map((x) => Math.round( x.getBoundingClientRect().top)); const n = t.closest(".chat-nachricht"); return { breite: Math.round(k.width), hoehe: Math.round(k.height), reihen: new Set(z).size, zeichen: z.length, selbst: n?.dataset.selbst || "nein", spalten: n ? getComputedStyle(n).gridTemplateColumns : "", zelle: getComputedStyle(t).gridColumnStart }; }); ok(!!masse, "die Tafel liegt im Bild"); ok(masse.selbst === "ja", "hier haengt sie an einer EIGENEN Nachricht"); ok(masse.breite >= 280, `sie ist eine Karte, kein Streifen (${masse.breite} px breit)`); /* Sechs Zeichen auf EINER Zeile. Zwei Zeilen wären kein Fehler -- dreissig Pixel Breite sind einer, und dort stünden sechs. */ ok(masse.reihen === 1, `die ${masse.zeichen} Vorschläge stehen nebeneinander (${masse.reihen} Zeile)`); ok(masse.hoehe < masse.breite * 1.6, `und sie ist breiter als hoch (${masse.breite} x ${masse.hoehe} px)`); /* ===== UND JETZT DER FALL, DER WIRKLICH KAPUTT WAR ================== Alles oben wurde an einer EIGENEN Nachricht gemessen. Dort hat das Raster nur eine Spalte, und die Tafel bekam die volle Breite -- deshalb war diese Prüfung gruen, waehrend es bei jeder FREMDEN Nachricht dreissig Pixel waren. Eine Prüfung, die nur den Fall misst, in dem es ohnehin funktioniert, ist keine Prüfung. */ await seite.evaluate(() => document.querySelector(".reagieren-tafel")?.remove()); const fremdeDa = await seite.evaluate(() => { const n = [...document.querySelectorAll('.chat-nachricht:not([data-selbst="ja"])')] .find((x) => x.querySelector(".chat-nachricht__reag-knopf")); if (!n) return false; n.querySelector(".chat-nachricht__reag-knopf").click(); return true; }); /* KEIN ÜBERSPRINGEN: Findet sich keine fremde Nachricht, ist die Prüfung nicht durchführbar -- und das ist ein Fehler, kein stilles Weiterlaufen. */ ok(fremdeDa, "es gibt eine Nachricht von jemand anderem zum Messen"); await seite.waitForTimeout(500); const fremdeTafel = await seite.evaluate(() => { const t = document.querySelector(".reagieren-tafel"); if (!t) return null; const k = t.getBoundingClientRect(); const z = [...document.querySelectorAll( ".reagieren-tafel__schnell .reagieren-tafel__zeichen")].map((x) => Math.round( x.getBoundingClientRect().top)); const n = t.closest(".chat-nachricht"); const kreis = n?.querySelector(".chat-nachricht__zeichen")?.getBoundingClientRect(); return { breite: Math.round(k.width), reihen: new Set(z).size, selbst: n?.dataset.selbst || "nein", /* Steht sie NEBEN dem Kreis oder darunter in seiner Spalte? */ nebenDemKreis: kreis ? Math.round(k.left - kreis.left) : null }; }); ok(fremdeTafel?.selbst === "nein", "die Tafel haengt jetzt an einer fremden Nachricht"); ok(fremdeTafel.breite >= 280, `auch dort ist sie eine Karte (${fremdeTafel.breite} px statt der 30 von vorher)`); ok(fremdeTafel.reihen === 1, `und die Vorschlaege stehen nebeneinander (${fremdeTafel.reihen} Zeile)`); ok(fremdeTafel.nebenDemKreis >= 30, `sie steht in der Spalte der Blase, nicht in der des Kreises (+${fremdeTafel.nebenDemKreis} px)`); if (process.env.SCHIRM) { await seite.locator(".chat-nachricht").last().screenshot( { path: join(HIER_BILD, "pruef-chat-tafel.png") }); } /* ---- Die Suche muss SUCHEN --------------------------------------- */ await seite.fill(".reagieren-tafel__suche", "feuer"); await seite.waitForTimeout(400); const gefunden = await seite.evaluate(() => ({ zahl: document.querySelectorAll(".reagieren-tafel__katalog .reagieren-tafel__zeichen").length, erstes: document.querySelector(".reagieren-tafel__katalog .reagieren-tafel__zeichen")?.textContent, })); ok(gefunden.zahl >= 1 && gefunden.zahl < teile.katalog, `"feuer" grenzt ein: ${gefunden.zahl} statt ${teile.katalog}`); ok(gefunden.erstes === "\u{1F525}", `und findet das Richtige (${gefunden.erstes})`); /* Ein Wort, das es nicht gibt, sagt das -- statt eine leere Flaeche zu zeigen, die wie ein Fehler aussieht. */ await seite.fill(".reagieren-tafel__suche", "zwiebelkuchen"); await seite.waitForTimeout(400); const leer = await seite.evaluate(() => document.querySelector(".reagieren-tafel__leer")?.textContent || ""); ok(leer.includes("Nichts zu"), `ein Fehlgriff wird gesagt ("${leer.slice(0, 40)}")`); await seite.fill(".reagieren-tafel__suche", ""); await seite.waitForTimeout(400); /* ---- Der Favoriten-Modus ----------------------------------------- */ await seite.click(".reagieren-tafel__stern"); await seite.waitForTimeout(300); const anModus = await seite.evaluate(() => ({ merken: document.querySelector(".reagieren-tafel")?.dataset.merken, hinweis: document.querySelector(".reagieren-tafel__hinweis")?.textContent || "", })); ok(anModus.merken === "ja", "der Stern schaltet den Merk-Modus ein"); ok(anModus.hinweis.includes("merkt"), `und sagt, was ein Antippen jetzt tut ("${anModus.hinweis.slice(0, 44)}…")`); /* Ein Zeichen im Merk-Modus antippen REAGIERT NICHT, es merkt. Das ist der Unterschied, der schiefgehen kann -- und er wird hier an der Zahl der Reaktionen gemessen, nicht an der Absicht. */ const vorher = await seite.$$eval(".reaktionen__stueck", (b) => b.length); await seite.click(".reagieren-tafel__katalog .reagieren-tafel__zeichen"); await seite.waitForTimeout(700); const nachher = await seite.evaluate(() => ({ reaktionen: document.querySelectorAll(".reaktionen__stueck").length, gemerkt: document.querySelectorAll('.reagieren-tafel__zeichen[data-favorit="ja"]').length, offen: !!document.querySelector(".reagieren-tafel"), hinweis: document.querySelector(".reagieren-tafel__hinweis")?.textContent || "", })); ok(nachher.reaktionen === vorher, `im Merk-Modus entsteht KEINE Reaktion (${vorher} -> ${nachher.reaktionen})`); ok(nachher.gemerkt >= 1, `sondern ein Favorit (${nachher.gemerkt} markiert)`); ok(nachher.offen, "und die Tafel bleibt offen -- sonst achtmal aufmachen"); ok(nachher.hinweis.includes("gemerkt"), `der Hinweis zaehlt mit ("${nachher.hinweis.slice(0, 34)}…")`); if (process.env.SCHIRM) { await seite.locator(".chat-nachricht").last().screenshot( { path: join(HIER_BILD, "pruef-chat-favoriten.png") }); } /* Und der Favorit steht danach VORN -- ohne die Tafel neu zu oeffnen. */ const vornDrin = await seite.evaluate(() => { const merk = [...document.querySelectorAll('.reagieren-tafel__katalog .reagieren-tafel__zeichen[data-favorit="ja"]')] .map((b) => b.dataset.zeichen); const vorn = [...document.querySelectorAll(".reagieren-tafel__schnell .reagieren-tafel__zeichen")] .map((b) => b.dataset.zeichen); return merk.every((z) => vorn.includes(z)) && vorn.length > 0; }); ok(vornDrin, "der frisch gemerkte steht sofort in der Vorschlagszeile"); /* Modus wieder aus, dann wirklich reagieren. */ await seite.click(".reagieren-tafel__stern"); await seite.waitForTimeout(250); const tafelLage = await seite.evaluate(() => { const t = document.querySelector(".reagieren-tafel"); const z = document.querySelector(".reagieren-tafel__zeichen"); const k = (e) => { if (!e) return null; const r = e.getBoundingClientRect(); const st = getComputedStyle(e); return { b: Math.round(r.width), h: Math.round(r.height), d: st.display, sicht: st.visibility, o: st.opacity, eltern: e.parentElement?.className }; }; return { tafel: k(t), zeichen: k(z) }; }); ok(tafelLage.zeichen && tafelLage.zeichen.b > 0 && tafelLage.zeichen.h > 0, `die Zeichen haben eine Groesse (${JSON.stringify(tafelLage)})`); if (process.env.SCHIRM) { await seite.locator(".chat-nachricht").last().screenshot( { path: join(HIER_BILD, "pruef-chat-auswahl.png") }); } await seite.click(".reagieren-tafel__zeichen"); await seite.waitForTimeout(900); const stueck = await seite.evaluate(() => { const b = document.querySelector(".reaktionen__stueck"); return b ? { text: b.textContent.trim(), selbst: b.dataset.selbst, titel: b.title } : null; }); ok(!!stueck, `unter der Nachricht steht die Reaktion ("${stueck?.text || "nichts"}")`); ok(stueck?.selbst === "ja", "und sie ist als die eigene gekennzeichnet"); ok((stueck?.titel || "").includes("Vanessa"), `im Titel steht, wer reagiert hat ("${(stueck?.titel || "").slice(0, 40)}…")`); ok((await seite.$$(".reagieren-tafel")).length === 0, "die Auswahl schliesst sich nach dem Klick von selbst"); if (process.env.SCHIRM) { await seite.locator(".chat-nachricht").last().screenshot( { path: join(HIER_BILD, "pruef-chat-reaktion.png") }); } /* ZWEITER KLICK NIMMT SIE ZURUECK -- an derselben Stelle, wo sie steht. Ohne diese Zeile hiesse alles oben nur, dass man etwas hinzufuegen kann. */ await seite.click(".reaktionen__stueck"); await seite.waitForTimeout(900); ok((await seite.$$(".reaktionen__stueck")).length === 0, "ein Klick darauf nimmt sie wieder weg"); /* ---- UND DER KNOPF SELBST ----------------------------------------- Bis hierher ist nur das RECHT geprueft (darf_aufloesen) und der Weg dahinter. Ob die Oberflaeche beides verbindet, war unbewiesen -- und genau dort sitzt der Fehler, den man am ehesten macht: ein Knopf, der immer dasteht, oder einer, der nie erscheint. Beide Faelle in einem Raum zu messen geht nicht; es braucht zwei. Deshalb legt die rechte Hand hier eine eigene Gruppe an (sie darf) und oeffnet danach ein Zweier-Gespraech (sie darf nicht). */ const eigene = await (await rufe(hand.keks, "/workspace/api/chat/gruppe", { mit: [idEins, idDrei], name: "Leitungsrunde" }, "POST")).json(); ok(Number.isInteger(eigene.raum_id), `die rechte Hand legt eine eigene Gruppe an (#${eigene.raum_id})`); await seite.goto(BASIS + `/workspace/chat.html?raum=${eigene.raum_id}`, { waitUntil: "networkidle" }); await seite.waitForTimeout(1500); const beiEigener = await seite.evaluate(() => ({ aufloesen: document.getElementById("raum-aufloesen")?.hidden, wegraeumen: document.getElementById("gespraech-weg")?.hidden, titel: document.getElementById("verlauf-titel")?.textContent.trim(), })); ok(beiEigener.titel === "Leitungsrunde", `die Gruppe ist offen ("${beiEigener.titel}")`); ok(beiEigener.aufloesen === false, "in ihrer eigenen Gruppe steht der Aufloesen-Knopf da"); ok(beiEigener.wegraeumen === false, "und der Wegraeumen-Knopf ebenfalls -- beide Wege bleiben offen"); if (process.env.SCHIRM) { await seite.locator(".chat__kopf--verlauf").screenshot( { path: join(HIER_BILD, "pruef-chat-kopf.png") }); } const fremd = await (await rufe(hand.keks, "/workspace/api/chat/direkt", { mit: idEins }, "POST")).json(); await seite.goto(BASIS + `/workspace/chat.html?raum=${fremd.raum_id}`, { waitUntil: "networkidle" }); await seite.waitForTimeout(1500); const beiDirekt = await seite.evaluate(() => ({ aufloesen: document.getElementById("raum-aufloesen")?.hidden, wegraeumen: document.getElementById("gespraech-weg")?.hidden, })); ok(beiDirekt.aufloesen === true, "im Zweier-Gespraech ist er weg"); ok(beiDirekt.wegraeumen === false, "der Wegraeumen-Knopf bleibt aber -- die beiden sind nicht dasselbe"); await browser.close(); } /* ---- Ende ------------------------------------------------------------- */ d.close(); try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ } console.log(""); console.log(`${geprueft} Pruefungen`); console.log(fehler === 0 ? "ALLES IN ORDNUNG" : `${fehler} FEHLER`); process.exit(fehler ? 1 : 0);