Files
dogfather-universe/server/pruef-erwaehnung.mjs
T
DogFatherGitandClaude Opus 5 24f9be8e4f Drei Fächer, Gesichter, Zeichen an jedem Handgriff -- der Chat ist nicht wiederzuerkennen
Filipe: "ich will 3 kategorien haben. chats mit einzelnen personen,
gruppen chats und kanäle." Und: "ich will dass du überhaupt die
komplette kachel veränderst, ich will dass alles anderst aussieht und
gestaltet ist, mach wirklich was verrücktes und übertrieben krank
geiles ... dass die ganze community und team morgen total überrascht
sind und den chat nicht wieder erkennen."

DIE LISTE HAT DREI FÄCHER. Personen, Gruppen, Kanäle -- als Mulde mit
drei Schaltern über dem Suchfeld, nicht als drei freie Knöpfe: Drei
Dinge, die einander ausschließen, liest man nur als EINE Entscheidung,
wenn sie in einer gemeinsamen Fassung sitzen. Das gewählte Fach liegt
oben auf (Licht, Schatten, Akzentsaum), die anderen liegen darin -- man
sieht die Wahl an der Tiefe, nicht nur an der Farbe. Die Wahl überlebt
das Neuladen; beim Suchen gilt sie nicht, wer einen Namen tippt will
ihn finden und nicht raten, in welchem Fach er liegt.

WAS EIN GESCHLOSSENES FACH NICHT VERSCHLUCKEN DARF: die Ungelesenen und
den Ruf. Beides steht deshalb AM Fach -- die Zahl in der Warnfarbe, das
@ in der Akzentfarbe. Gefunden hat die Lücke nicht ein Blick, sondern
pruef-erwaehnung: Die Erwähnung lag in einer Gruppe, offen war
"Personen", und das @ war damit nirgends zu sehen.

EIN LEERES FACH MERKT MAN SICH NICHT. Wer nur einen Kanal hat -- jeder
Neue im Haus -- landete auf "Personen" und sah eine leere Liste neben
einem vollen Kanal. Beim ersten Zeichnen wird deshalb ins erste Fach
gewechselt, in dem etwas steht; Reihenfolge: gerufen, dann ungelesen,
dann überhaupt vorhanden. Gespeichert wird das NICHT -- es ist geraten,
nicht gewählt. Gefunden von pruef-gifs.

EIN GESICHT IM KOPF DES GESPRÄCHS. Links in der Liste trägt jedes
Gespräch sein Zeichen, und ausgerechnet beim Öffnen verschwand es. Es
ist dasselbe Zeichen, nicht ein ähnliches: `zeichenFuellen()` füllt jetzt
Liste und Kopf -- rund fünfzig Zeilen standen vorher mitten im Zeichnen
und hätten sonst ein zweites Mal dagestanden. Am Handy bleibt es weg,
nachgerechnet: mit ihm blieben dem Namen 126 px bei 128 Untergrenze, die
Knopfreihe fiele eine Zeile tiefer.

JEDER HANDGRIFF BEKOMMT SEIN ZEICHEN. Unter jeder Blase standen fünf
Wörter in Versalien -- bei zwölf Nachrichten sechzig. Jetzt Pfeil,
Gesicht, Papierkorb, Nadel und zwei Blätter, das Wort klein daneben.
Die Wörter bleiben: "anheften" und "lösen" sehen als Nadel gleich aus,
und "löschen (Notfall)" darf nie ein Rätsel sein. Breiter wird es
trotzdem nicht -- gesperrte Versalien kosten rund ein Viertel mehr
Breite, genau das, was die Zeichen brauchen. Die Zeichen sind Masken:
sie folgen `currentColor` und damit jedem Zustand der Schrift daneben.

AUS DER FUSSZEILE WIRD EINE MULDE, und der Grund wird dabei dunkler,
nie heller -- das ist die Bedingung dafür, dass die Kontrastzusage
gültig bleibt. Die Uhrzeit bekommt ein eigenes Schild: eine Angabe,
keine Bedienung.

AUS DEM FARBFLECK WIRD EIN RING. Der Knopf für die eigene Kachel war
ein voller Kreis in der gewählten Farbe, direkt neben einer gleich
großen Marke -- man las ihn als Meldung, und er meldet nichts. Farbe
erscheint auf dieser Seite überall als Kontur; jetzt auch hier.

AUS DEM TOTEN TRENNER WIRD LICHT. Die senkrechte Linie am Verlauf
stammte aus der Zeit, als Liste und Verlauf EIN Kasten waren; seit dem
Umbau auf zwei Tafeln klebte sie ohne Aufgabe an der Kante. An ihrer
Stelle ein sehr weicher Schein oben rechts, unter vier Prozent Deckung
-- Tiefe, kein Leuchten.

DIE KONSOLE: Das Schreibfeld ist eine Rinne statt eines flachen
Kastens, die vier Werkzeuge sprechen dieselbe Sprache, und der
Absendeknopf ist als einziger gefüllt. Keine Maßzahl angefasst -- die
Zeile ist seit dem 23.09. auf den Pixel voll.

ZWEI PRÜFUNGEN WURDEN GENAUER, NICHT NACHSICHTIGER:

  pruef-chatkachel suchte ihre "freie Stelle" nicht, sie rechnete sie
  aus -- 6 px vom rechten Rand, halbe Höhe. Das lag mal auf dem
  Rollbalken, mal auf einer Blase, und meldete beides Mal "das
  Bühnenbild ist gar nicht da". Sie sucht die Stelle jetzt mit
  `elementFromPoint` und sagt es, wenn es keine gibt.

  pruef-erwaehnung prüft jetzt beides: dass das Fach den Ruf meldet,
  ohne geöffnet zu werden, UND dass die Zeile nach dem Wechsel dasteht
  -- mit der Gegenprobe, dass das Fach wirklich filtert. 126 -> 129.

Nachgebessert: die Ungelesen-Marke am Fach stand auf 0,64 rem = 10,24 px,
unter der Hausgrenze von 11,5. Gemeldet von pruef-css-klassen, bevor es
jemand auf einem Telefon sehen musste -- der zweite Anlauf desselben
Reflexes an einem Tag.

Geprüft: pruef-chat-optik, pruef-chatkachel (40), pruef-chat (ALLES IN
ORDNUNG), pruef-chat-neu (32), pruef-chat-ausbau (64),
pruef-chat-kanaele (81), pruef-chat-aufloesen (126),
pruef-chat-anhaenge (109), pruef-erwaehnung (129), pruef-css-klassen,
pruef-tippziele (11), pruef-lesbarkeit. pruef-gifs hat weiterhin die
zwei Fehler, die schon vor diesem Umbau da waren (an HEAD nachgemessen).

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

1210 lines
60 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* =====================================================================
pruef-erwaehnung.mjs — "@Name" im Chat
Filipe, 20.09.2026: "ich will das man die leute mit @ markieren
kann im chat."
---------------------------------------------------------------------
WAS HIER GEPRÜFT WIRD, UND WARUM GERADE DAS
Markieren ist keine Farbe, sondern eine Ansprache. Es funktioniert
nur, wenn DREI Dinge zusammen stimmen:
1. Der Richtige ist gemeint. -> Abschnitt 1 und 3
2. Er merkt es. -> Abschnitt 2 und 4
3. Man sieht es im Satz. -> Abschnitt 5 (echter Browser)
Stimmt eines davon nicht, ist die ganze Funktion eine Attrappe --
und zwar eine, die gut aussieht. Deshalb hört diese Prüfung nicht
beim "wird gespeichert" auf.
---------------------------------------------------------------------
DIE GEFÄHRLICHSTE STELLE
Es gibt ZWEI Auflösungen desselben Problems: eine im Server
(chat-erwaehnung.js) und eine im Browser (chat.js). Die zweite ist
nötig, weil der Browser sofort hervorheben muss, ohne zu fragen.
Zwei Rechnungen für dieselbe Frage sind genau die Stelle, an der
etwas auseinanderläuft -- dann hebt die Seite jemanden hervor, den
der Server nie benachrichtigt hat. Abschnitt 6 stellt beide
nebeneinander und prüft sie an denselben Fällen.
AUFRUF: node server/pruef-erwaehnung.mjs
===================================================================== */
import { mkdtempSync, rmSync, readFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { request as httpAnfrage } from "node:http";
import { eigenerPort } from "./helfer-port.mjs";
import { notbremse } from "./helfer-notbremse.mjs";
import { erwaehnungenFinden, erwaehnteIds, rudelGerufen, RUDEL_WORT } from "./chat-erwaehnung.js";
const HIER = dirname(fileURLToPath(import.meta.url));
const PORT = await eigenerPort(import.meta, "pruef-erwaehnung");
const ordner = mkdtempSync(join(tmpdir(), "ws-erwaehnung-"));
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";
/* KEINE NACHTRUHE WAEHREND DER PRUEFUNG (23.09.2026).
Abschnitt 7 schreibt im Treff -- und dort gilt von 0 bis 6 Uhr
Schreibverbot. Eine Pruefung, die zwischen Mitternacht und sechs
scheitert und danach wieder gruen ist, misst die Uhr, nicht das
Programm. Genau diese Falle steht in den Hausregeln ("Ein Test, der
die Wanduhr als Annahme benutzt, misst irgendwann das Gegenteil").
Gleiche Werte heissen "keine Nachtruhe" -- so steht es in
istNachtruhe(), erste Zeile. Die Nachtruhe SELBST wird in
pruef-treffchat geprueft, dort gehoert sie hin. */
process.env.TREFF_NACHT_AB = "0";
process.env.TREFF_NACHT_BIS = "0";
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(300_000, "pruef-erwaehnung");
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++; };
/* KEINE HOST-UMSCHREIBUNG, UND DAS HAT EINEN GRUND.
Der erste Anlauf meldete sich ueber HTTP mit `Host: crew.…` an und
liess den Browser auf 127.0.0.1 laufen. Beides zusammen geht nicht:
Auf 127.0.0.1 liefert der Server die AGENTUR-Anmeldewand, und dort
gibt es die Rolle "hand" gar nicht -- die Pruefung wartete 30
Sekunden auf eine Kachel, die es nie geben konnte.
Das Markieren mit "@" ist von Haus und Rolle voellig unabhaengig:
Es fragt nur, wer im RAUM sitzt. Also wird hier durchgehend die
Adresse benutzt, auf der auch der Browser landet, und es werden
Rollen genommen, die dort vorkommen. Wer die Trennung der beiden
Haeuser pruefen will, findet das in pruef-haus-trennung. */
console.log('\n=== @Name im Chat ===');
/* =====================================================================
1. DIE REGELN — ohne Server, ohne Datenbank
===================================================================== */
melde("\n=== 1. Wer ist gemeint? ===");
{
const leute = [
{ id: 1, name: "Dogfather" }, { id: 2, name: "VanVan" }, { id: 3, name: "Tili" },
{ id: 4, name: "Miss" }, { id: 5, name: "Marina" },
];
/* Jeder Fall steht für eine Regel aus chat-erwaehnung.js. Die
beiden wichtigsten sind die E-Mail-Adresse und das längere Wort:
Ohne sie piepst jede Adresse im Chat jemanden an, und "@Tilikum"
spricht Tili an. */
const faelle = [
["@VanVan kannst du kurz?", [2], "der einfache Fall"],
["@vanvan schaust du mal", [2], "klein geschrieben trifft trotzdem"],
["Hey @Tili, und @Miss auch!", [3, 4], "zwei Leute, mit Satzzeichen dahinter"],
["@Tili @Tili @Tili", [3], "dreimal derselbe zählt einmal"],
["schreib an [email protected]", [], "eine E-Mail-Adresse spricht niemanden an"],
["@Tilikum ist ein Wal", [], "ein längeres Wort ist kein Name"],
["@Missgeschick passiert", [], "ein Name im Wortinneren zählt nicht"],
["@Ghost ist nicht im Raum", [], "wer nicht im Raum sitzt, ist nicht gemeint"],
["@Marina", [5], "ganz am Ende des Satzes"],
["(@Marina)", [5], "in Klammern"],
["@VanVan-Team", [2], "ein Bindestrich beendet den Namen"],
["x@VanVan", [], "ein Buchstabe davor macht es zur Adresse"],
["Ohne at-Zeichen hier", [], "kein @ — keine Ansprache"],
];
let stimmt = 0;
for (const [text, soll, was] of faelle) {
const ist = erwaehnteIds(text, leute);
const passt = JSON.stringify(ist) === JSON.stringify(soll);
if (passt) stimmt++;
ok(passt, `${was}${passt ? "" : ` (${JSON.stringify(text)} -> ${JSON.stringify(ist)}, erwartet ${JSON.stringify(soll)})`}`);
}
/* DIE ZAHL GEHÖRT IN DIE BEDINGUNG: Liefe die Schleife über null
Fälle, wäre oben nichts rot -- und nichts geprüft. */
ok(stimmt === faelle.length && faelle.length >= 13,
`alle ${faelle.length} Regelfälle stimmen`);
/* DER LÄNGSTE NAME GEWINNT. Heute steckt kein Name im anderen --
das ist eine Tatsache von heute und kein Gesetz. Geprüft mit
erfundenen Namen, damit die Regel unabhängig vom Bestand hält. */
const ineinander = [{ id: 10, name: "Van" }, { id: 11, name: "VanVan" }];
ok(JSON.stringify(erwaehnteIds("@VanVan hallo", ineinander)) === "[11]",
"steckt ein Name im anderen, gewinnt der längere");
ok(JSON.stringify(erwaehnteIds("@Van hallo", ineinander)) === "[10]",
" und der kürzere trifft trotzdem, wenn er allein steht");
/* Die Stellen fürs Hervorheben. Jedes Vorkommen wird gemeldet, nicht
nur das erste -- sonst bliebe das zweite "@Anna" im Satz grau. */
const roh = "Hey @Tili und nochmal @Tili";
const st = erwaehnungenFinden(roh, leute);
ok(st.length === 2 && st.every((t) => roh.slice(t.von, t.bis) === "@Tili"),
`jedes Vorkommen bekommt eine Stelle (${st.length} Stück)`);
/* GEGENPROBE: Sie muss auch NEIN sagen können. Eine Auflösung, die
immer jemanden findet, findet nichts. */
ok(erwaehnteIds("@VanVan", []).length === 0,
"Gegenprobe: ohne Teilnehmerliste ist niemand gemeint");
/* ---- "@RUDEL" — ALLE AUF EINMAL (23.09.2026) ------------------
Filipe: „dan will ich auch dass nur die modis, rechte hand, linke
hand und dogfather, alle auch auf einmal markieren können im chat
mit einem, @rudel ,dann sollen alle eine benarichtigung bekommen."
DIE WICHTIGSTE PRÜFUNG HIER IST DIE ZWEITE: dass es NICHTS tut,
wenn der Schreibende nicht darf. Sonst hätte die Einschränkung,
die Filipe ausdrücklich genannt hat, nur im Kommentar gestanden.
Die Grenzregeln (2) und (3) gelten für das Schlüsselwort genauso
wie für einen Namen — sonst ruft eine Mailadresse das ganze Team. */
const rudelFaelle = [
["@rudel alle mal herhören", true, true, "der einfache Fall"],
["@RUDEL", true, true, "gross geschrieben trifft auch"],
["@rudel alle mal herhören", false, false,
"wer nicht darf, ruft nichts — und merkt nichts davon"],
["schreib an [email protected]", true, false,
"eine Mailadresse ruft das Rudel nicht"],
["@rudeltier", true, false, "ein längeres Wort ist kein Ruf"],
["das rudel ohne at", true, false, "ohne @ ist es nur ein Wort"],
["(@rudel)", true, true, "in Klammern"],
["@Tili und @rudel", true, true, "neben einem Namen"],
];
let rudelStimmt = 0;
for (const [text, darf, soll, was] of rudelFaelle) {
const ist = rudelGerufen(text, leute, { rudel: darf });
if (ist === soll) rudelStimmt++;
ok(ist === soll, `@rudel: ${was}${ist === soll ? "" : ` (${JSON.stringify(text)}, darf=${darf} -> ${ist})`}`);
}
ok(rudelStimmt === rudelFaelle.length && rudelFaelle.length >= 8,
`alle ${rudelFaelle.length} Rudel-Fälle stimmen`);
/* OHNE ANGABE DARF MAN NICHT. Eine vergessene Angabe soll zu wenig
können, nicht zu viel — sonst ruft der erste Aufruf, der die
Option nicht kennt, aus Versehen das ganze Team. */
ok(rudelGerufen("@rudel", leute) === false,
"ohne Angabe gilt: darf nicht (eine vergessene Angabe kann zu wenig, nie zu viel)");
/* NEBEN EINEM NAMEN BLEIBT DER NAME. "@Tili und @rudel" ist beides. */
ok(JSON.stringify(erwaehnteIds("@Tili und @rudel", leute, { rudel: true })) === "[3]",
"neben dem Ruf bleibt der einzeln Genannte einzeln genannt");
/* DAS SCHLÜSSELWORT GEWINNT GEGEN EINEN MENSCHEN NAMENS "RUDEL".
Heute heisst niemand so — aber das ist eine Tatsache von heute.
So herum verliert niemand eine Meldung: Wer "Rudel" heisst, ist
im Rudel ohnehin dabei. Andersherum wäre der Ruf ans Team durch
einen einzigen Zugang stillschweigend abgeschaltet. */
const mitRudel = [{ id: 20, name: "Rudel" }, { id: 21, name: "Anna" }];
ok(rudelGerufen("@Rudel hallo", mitRudel, { rudel: true }) === true,
"heisst jemand „Rudel“, gewinnt trotzdem das Schlüsselwort");
ok(JSON.stringify(erwaehnteIds("@Rudel hallo", mitRudel, { rudel: false })) === "[20]",
" und wer nicht rufen darf, spricht damit genau diesen Menschen an");
/* DAS WORT STEHT AN EINER STELLE. Stünde es zweimal, hiesse es nach
einer Umbenennung an einer davon anders. */
ok(RUDEL_WORT === "rudel", `das Wort kommt aus der Datei („${RUDEL_WORT}“)`);
/* GEGENPROBE: Die Auflösung liefert beim Rudel KEINE Personennummer
— es ist keine Person. Wer daraus eine ID läse, bekäme `null` in
die Empfängerliste und benachrichtigte niemanden. */
const rt = erwaehnungenFinden("@rudel", leute, { rudel: true });
ok(rt.length === 1 && rt[0].rudel === true && rt[0].id === null,
"der Ruf ist keine Person (id null, rudel true)");
ok(erwaehnteIds("@rudel", leute, { rudel: true }).length === 0,
" und taucht deshalb in der Namensliste gar nicht auf");
}
/* =====================================================================
2. DURCH DIE SCHNITTSTELLE
===================================================================== */
const { DatabaseSync } = await import("node:sqlite");
const { scryptSync, randomBytes, createHmac } = await import("node:crypto");
const jetzt = new Date().toISOString();
let idDogi, idVan, idFunny;
{
const d = new DatabaseSync(process.env.WORKSPACE_DB);
const schluessel = randomBytes(32).toString("hex");
d.prepare("INSERT INTO einstellungen (schluessel, wert, geaendert, von)"
+ " VALUES (?,?,?,NULL) ON CONFLICT(schluessel) DO NOTHING")
.run("code_kennung_schluessel", schluessel, jetzt);
const anlegen = (name, rolle, code) => {
const salt = randomBytes(16).toString("hex");
const hash = scryptSync(code, salt, 64, { N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex");
d.prepare("INSERT INTO personen (name, rolle, code_hash, code_salt, code_n, code_kennung, aktiv, erstellt)"
+ " VALUES (?,?,?,?,?,?,1,?)")
.run(name, rolle, hash, salt, 32768, createHmac("sha256", schluessel).update(code).digest("hex"), jetzt);
return d.prepare("SELECT last_insert_rowid() AS id").get().id;
};
idDogi = anlegen("Filipe", "admin", "CODE-DOGI-0001");
idVan = anlegen("VanVan", "scout", "CODE-SCOU-0001");
idFunny = anlegen("Funny", "scout", "CODE-SCOU-0002");
d.close();
}
function ruf(pfad, keks, methode = "GET", koerper = null) {
return new Promise((fertig, schief) => {
const a = httpAnfrage({
host: "127.0.0.1", port: PORT, path: pfad, method: methode,
headers: {
...(keks ? { Cookie: keks } : {}),
...(koerper ? { "Content-Type": "application/json",
Origin: `http://127.0.0.1:${PORT}` } : {}),
},
}, (antwort) => {
let text = "";
antwort.on("data", (s) => { text += s; });
antwort.on("end", () => fertig({ code: antwort.statusCode, text }));
});
a.on("error", schief);
if (koerper) a.write(JSON.stringify(koerper));
a.end();
});
}
const json = (a) => { try { return JSON.parse(a.text); } catch { return {}; } };
async function anmelden(rolle, code) {
const a = await ruf("/workspace/api/anmelden", null, "POST", { rolle, code });
if (a.code !== 200) console.log(` (Anmeldung ${rolle}: ${a.code} ${a.text.slice(0, 80)})`);
return "";
}
/* Die Kekse holen wir direkt aus der Antwort -- `ruf` gibt sie nicht
zurück, also ein eigener Weg fürs Anmelden. */
function anmeldenMitKeks(rolle, code) {
return new Promise((fertig, schief) => {
const koerper = 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(koerper) },
}, (antwort) => {
let text = "";
antwort.on("data", (s) => { text += s; });
antwort.on("end", () => fertig({
code: antwort.statusCode, text,
keks: [].concat(antwort.headers["set-cookie"] || []).map((c) => c.split(";")[0]).join("; "),
}));
});
a.on("error", schief);
a.write(koerper);
a.end();
});
}
void anmelden;
melde('\n=== 2. Ein @ geht durch die Schnittstelle ===');
const aDogi = await anmeldenMitKeks("admin", "CODE-DOGI-0001");
const aVan = await anmeldenMitKeks("scout", "CODE-SCOU-0001");
const aFunny = await anmeldenMitKeks("scout", "CODE-SCOU-0002");
ok(!!aDogi.keks && !!aVan.keks && !!aFunny.keks, "drei Zugänge sind angemeldet");
/* Eine Gruppe mit allen dreien. */
const gruppe = await ruf("/workspace/api/chat/gruppe", aDogi.keks, "POST",
{ name: "Testgruppe", mit: [idVan, idFunny] });
const raumId = json(gruppe).raum_id;
ok(Number.isInteger(raumId), `eine Gruppe mit drei Leuten steht (Raum ${raumId})`);
/* Die Teilnehmer kommen MIT der Raumliste -- die Auswahlliste im
Browser braucht deshalb keine eigene Abfrage. Genau das wird hier
festgehalten, damit niemand später eine zweite baut. */
{
const l = json(await ruf("/workspace/api/chat/raeume", aDogi.keks));
const r = (l.raeume || []).find((x) => x.id === raumId);
ok(Array.isArray(r?.teilnehmer) && r.teilnehmer.length === 3,
`die Raumliste bringt die Teilnehmer schon mit (${r?.teilnehmer?.length})`);
ok(r?.teilnehmer?.every((t) => t.id && t.name),
" mit Nummer und Namen — mehr braucht die Auswahl nicht");
/* ---- UND MIT DEM PROFILBILD (20.09.2026) ---------------------------
Filipe: "da soll man im chat auch die profilfotos von den leuten
sehen wenn die schon eins drin haben."
Es fehlte in GENAU DIESER Abbildung: `teilnehmerVon` liest das Bild
aus der Datenbank, und die Zeile in der Raumliste warf es weg. Die
Detailansicht nahm es mit. Folge: im offenen Gespraech ein Gesicht,
in der Liste daneben ein Buchstabe -- vom selben Menschen.
Geprueft wird mit einem eingetragenen Dateinamen; ob die Datei
existiert, ist eine andere Frage und gehoert zum Steckbrief. Hier
geht es darum, dass der WEG ueberhaupt ankommt. */
{
const d = new DatabaseSync(process.env.WORKSPACE_DB);
d.prepare("UPDATE personen SET bild = ? WHERE id = ?").run("probe.webp", idVan);
d.close();
const l2 = json(await ruf("/workspace/api/chat/raeume", aDogi.keks));
const r2 = (l2.raeume || []).find((x) => x.id === raumId);
const van = r2?.teilnehmer?.find((t) => t.id === idVan);
ok(typeof van?.bild === "string" && van.bild.includes("probe.webp"),
`das Profilbild kommt in der Gespraechsliste an (${van?.bild})`);
const ohne = r2?.teilnehmer?.find((t) => t.id === idFunny);
ok(ohne && !ohne.bild,
"Gegenprobe: wer keines hinterlegt hat, bekommt auch keines vorgegaukelt");
const d2 = new DatabaseSync(process.env.WORKSPACE_DB);
d2.prepare("UPDATE personen SET bild = NULL WHERE id = ?").run(idVan);
d2.close();
}
}
/* Jetzt die eigentliche Nachricht. */
const gesendet = await ruf(`/workspace/api/chat/raeume/${raumId}/nachrichten`,
aDogi.keks, "POST", { text: "Kannst du das übernehmen, @VanVan? Danke!" });
ok(gesendet.code === 201, `die Nachricht geht raus (${gesendet.code})`);
const nachrichtId = json(gesendet).nachricht?.id;
{
const d = new DatabaseSync(process.env.WORKSPACE_DB);
const zeilen = d.prepare("SELECT person_id FROM chat_erwaehnungen WHERE nachricht_id = ?")
.all(nachrichtId);
d.close();
ok(zeilen.length === 1 && zeilen[0].person_id === idVan,
`genau VanVan ist vermerkt (${zeilen.length} Eintrag/Einträge)`);
}
/* SELBST ERWÄHNEN LÖST NICHTS AUS. */
{
const s = await ruf(`/workspace/api/chat/raeume/${raumId}/nachrichten`,
aDogi.keks, "POST", { text: "@Filipe hat recht, sagt @Filipe" });
const d = new DatabaseSync(process.env.WORKSPACE_DB);
const n = d.prepare("SELECT COUNT(*) AS n FROM chat_erwaehnungen WHERE nachricht_id = ?")
.get(json(s).nachricht?.id).n;
d.close();
ok(n === 0, `wer sich selbst erwähnt, spricht niemanden an (${n} Einträge)`);
}
/* WER NICHT IM RAUM IST, KANN NICHT ANGESPROCHEN WERDEN -- auch nicht
über die Schnittstelle. Dafür ein vierter Mensch, der draußen bleibt. */
{
const d = new DatabaseSync(process.env.WORKSPACE_DB);
const salt = randomBytes(16).toString("hex");
d.prepare("INSERT INTO personen (name, rolle, code_hash, code_salt, code_n, aktiv, erstellt)"
+ " VALUES (?,?,?,?,?,1,?)").run("Draussen", "modi", "x", salt, 32768, jetzt);
const idAussen = d.prepare("SELECT last_insert_rowid() AS id").get().id;
d.close();
const s = await ruf(`/workspace/api/chat/raeume/${raumId}/nachrichten`,
aDogi.keks, "POST", { text: "@Draussen bist du da?" });
const d2 = new DatabaseSync(process.env.WORKSPACE_DB);
const n = d2.prepare("SELECT COUNT(*) AS n FROM chat_erwaehnungen WHERE nachricht_id = ? AND person_id = ?")
.get(json(s).nachricht?.id, idAussen).n;
d2.close();
ok(n === 0, "wer nicht im Raum sitzt, wird auch über die Schnittstelle nicht angesprochen");
}
/* =====================================================================
3. MERKT ES DER RICHTIGE? — das Zeichen in der Gesprächsliste
===================================================================== */
melde("\n=== 3. Man merkt es auch ohne offenen Raum ===");
{
const beiVan = json(await ruf("/workspace/api/chat/raeume", aVan.keks))
.raeume?.find((r) => r.id === raumId);
const beiFunny = json(await ruf("/workspace/api/chat/raeume", aFunny.keks))
.raeume?.find((r) => r.id === raumId);
ok(beiVan?.erwaehnt === true, "VanVan sieht das Zeichen in ihrer Gesprächsliste");
/* DIE GEGENPROBE, die alles trägt: Funny war im selben Raum, hat
dieselben Nachrichten ungelesen -- und ist NICHT gemeint. Leuchtete
es bei ihr auch, wäre das Zeichen wertlos. */
ok(beiFunny?.erwaehnt === false,
"Gegenprobe: Funny war dabei, aber nicht gemeint — bei ihr leuchtet nichts");
ok((beiFunny?.ungelesen || 0) > 0,
` und sie hat sehr wohl Ungelesenes (${beiFunny?.ungelesen}) — es liegt nicht daran`);
}
/* GELESEN HEISST WEG. Sonst leuchtet es für immer. */
{
/* 99999 IST ABSICHTLICH ZU GROSS -- und genau daran ist eine echte
Luecke aufgefallen (20.09.2026): Der Server nahm jede Zahl an.
Wer einmal "gelesen bis 999999" schickte, sah in diesem Gespraech
nie wieder einen Zaehler und nie wieder ein @-Zeichen; alles
Kuenftige galt als gelesen, bevor es geschrieben war. Niemand
haette den Zusammenhang je gefunden.
Der Wert bleibt deshalb stehen: Er ist ab jetzt die Gegenprobe. */
await ruf(`/workspace/api/chat/raeume/${raumId}/gelesen`, aVan.keks, "PUT", { bis: 99999 });
{
const d = new DatabaseSync(process.env.WORKSPACE_DB);
const stand = d.prepare(
"SELECT gelesen_bis FROM chat_teilnehmer WHERE raum_id = ? AND person_id = ?")
.get(raumId, idVan).gelesen_bis;
const hoechste = d.prepare(
"SELECT COALESCE(MAX(id),0) AS n FROM chat_nachrichten WHERE raum_id = ?").get(raumId).n;
d.close();
ok(stand === hoechste,
`„gelesen bis 99999" wird auf die letzte echte Nachricht begrenzt (${stand} statt 99999, höchste ist ${hoechste})`);
}
const beiVan = json(await ruf("/workspace/api/chat/raeume", aVan.keks))
.raeume?.find((r) => r.id === raumId);
ok(beiVan?.erwaehnt === false, "nach dem Lesen ist das Zeichen weg");
}
/* =====================================================================
4. WAS AUF DEM HANDY STEHT
===================================================================== */
melde("\n=== 4. Die Meldung sagt, dass DU gemeint bist ===");
{
const { pushTextFuer } = await import("./workspace-chat.js");
const n = { von: "VanVan", text: "schaust du mal drauf?" };
const erw = pushTextFuer("erwaehnung", n, raumId);
const normal = pushTextFuer("neu", n, raumId);
ok(/hat dich erwähnt/.test(erw.titel), `-> ${erw.titel}`);
ok(erw.titel !== normal.titel,
` und das ist etwas anderes als: ${normal.titel}`);
ok(erw.text.includes("schaust du mal"),
" der Anfang der Nachricht steht darunter — man muss nicht öffnen");
ok(erw.ziel === normal.ziel && erw.ziel.includes(String(raumId)),
" und beide führen in dasselbe Gespräch");
/* Die Art muss in der Liste stehen, sonst lässt sie sich nicht
abschalten -- und eine Meldung, die man nicht abschalten kann,
schaltet irgendwann alle ab. */
const { ARTEN } = await import("./workspace-push.js");
const art = ARTEN.find((a) => a.schluessel === "chat_erwaehnung");
ok(!!art, 'chat_erwaehnung steht in den Benachrichtigungs-Einstellungen');
ok(art?.vorgabe === true, " und ist von sich aus an");
ok(ARTEN.find((a) => a.schluessel === "chat_nachricht")?.schluessel !== art?.schluessel,
" getrennt von der gewöhnlichen Chatnachricht — zwei Schalter, zwei Entscheidungen");
/* ---- UND BEIM RUDEL SAGT SIE, DASS ALLE GEMEINT SIND ----------
Filipe: „dann sollen alle eine benarichtigung bekommen."
WARUM NICHT DERSELBE SATZ WIE BEI DER ERWÄHNUNG: Weil er nicht
stimmt. Beim Rudel ist man mitgemeint, nicht gemeint — und wer
dreimal „hat dich erwähnt" liest und jedes Mal merkt, dass es
alle betraf, glaubt beim vierten Mal auch dem echten nicht mehr. */
const gerufen = pushTextFuer("rudel", n, raumId);
ok(/Rudel gerufen/.test(gerufen.titel), `-> ${gerufen.titel}`);
ok(gerufen.titel !== erw.titel && gerufen.titel !== normal.titel,
" und unterscheidet sich von BEIDEN anderen Meldungen");
ok(gerufen.text.includes("schaust du mal"),
" der Anfang der Nachricht steht auch hier darunter");
ok(gerufen.ziel === normal.ziel, " und führt in dasselbe Gespräch");
}
/* =====================================================================
5. BEIDE RECHNUNGEN, NEBENEINANDER
===================================================================== */
melde("\n=== 5. Server und Browser rechnen gleich ===");
{
/* Der Browser hat eine eigene, kurze Fassung derselben Regeln --
er muss sofort hervorheben können, ohne zu fragen. Genau deshalb
wird sie hier gegen das Original gestellt: Zwei Rechnungen für
dieselbe Frage laufen sonst irgendwann auseinander, und dann hebt
die Seite jemanden hervor, den niemand benachrichtigt hat. */
/* ZEILENENDEN VEREINHEITLICHEN -- und das ist kein Schoenheitsfehler.
Der erste Anlauf suchte das Ende der Funktion mit einem Muster,
das auf einem Zeilenumbruch endete. Die Dateien dieses Hauses
stehen unter Windows aber mit CRLF auf der Platte; nach " }"
kommt also erst ein Wagenruecklauf -- und das Muster fand
NICHTS. Die Pruefung stuerzte ab, was hier Glueck war: Haette
sie mit dem leeren Ergebnis weitergerechnet, waere sie gruen
geworden und haette null Proben verglichen. */
const chatJs = readFileSync(join(HIER, "..", "workspace", "assets", "js", "chat.js"), "utf8")
.replace(/\r\n/g, "\n");
ok(/erwaehnungenFinden/.test(chatJs),
"der Browser hat eine Fassung derselben Auflösung");
/* Sie wird aus der Datei geholt und ausgeführt -- nicht nachgebaut.
Ein Nachbau würde prüfen, ob ICH zweimal dasselbe schreiben kann. */
const anfang = chatJs.indexOf("function erwaehnungenFinden");
ok(anfang > 0, " und sie steht als eigene Funktion da");
if (anfang > 0) {
/* Den Block bis zur schließenden Klammer auf Spaltenhöhe holen. */
const rest = chatJs.slice(anfang);
const ende = rest.indexOf("\n }\n");
/* DRITTER AUSGANG: Findet der Schnitt sein Ende nicht, wird das
GESAGT -- statt mit einem Stummel weiterzurechnen und dabei
null Proben zu vergleichen. */
ok(ende > 100, `das Ende der Funktion ist auffindbar (Zeichen ${ende})`);
const quelle = rest.slice(0, ende + 4);
// eslint-disable-next-line no-new-func
const browserFassung = new Function(`${quelle}; return erwaehnungenFinden;`)();
const leute = [
{ id: 1, name: "Dogfather" }, { id: 2, name: "VanVan" }, { id: 3, name: "Tili" },
{ id: 4, name: "Miss" },
];
const proben = [
"@VanVan kannst du?", "@vanvan klein", "Hey @Tili, und @Miss!",
"[email protected]", "@Tilikum", "@Missgeschick", "x@VanVan",
"(@Miss)", "@Tili @Tili", "nichts hier", "@VanVan-Team",
];
let gleich = 0;
for (const t of proben) {
const a = JSON.stringify(erwaehnungenFinden(t, leute));
const b = JSON.stringify(browserFassung(t, leute));
if (a === b) gleich++;
else console.log(` ungleich bei ${JSON.stringify(t)}:\n Server: ${a}\n Browser: ${b}`);
}
ok(gleich === proben.length && proben.length >= 10,
`beide kommen bei allen ${proben.length} Proben auf dasselbe Ergebnis`);
/* ---- UND DASSELBE MIT "@RUDEL" (23.09.2026) ----------------
DER DRITTE PARAMETER MUSS MITGEPRUEFT WERDEN, sonst waere er die
Stelle, an der die beiden Fassungen auseinanderlaufen -- und
zwar unbemerkt, weil die elf Proben darueber ihn gar nicht
benutzen. Beide Wahrheitswerte, damit auch das Nichtstun
verglichen wird. */
const rudelProben = [
"@rudel alle mal herhoeren", "@RUDEL", "[email protected]",
"@rudeltier", "(@rudel)", "@Tili und @rudel", "kein at rudel",
];
let rGleich = 0, rVerglichen = 0;
for (const t of rudelProben) {
for (const darf of [true, false]) {
rVerglichen++;
const a = JSON.stringify(erwaehnungenFinden(t, leute, { rudel: darf }));
const b = JSON.stringify(browserFassung(t, leute, { rudel: darf }));
if (a === b) rGleich++;
else {
console.log(` ungleich bei ${JSON.stringify(t)} (darf=${darf})`);
console.log(` Server: ${a}`);
console.log(` Browser: ${b}`);
}
}
}
ok(rGleich === rVerglichen && rVerglichen >= 14,
`auch beim Rudel dasselbe Ergebnis (${rVerglichen} Proben, beide Rechte)`);
/* GEGENPROBE DES VERGLEICHS: Er muss einen Unterschied auch SEHEN.
Ohne das waere er eine Schleife, die immer „gleich“ sagt -- und
genau so eine stand am 01.09. gruen da, ohne je etwas anzusehen. */
const ungleichErkannt =
JSON.stringify(erwaehnungenFinden("@rudel", leute, { rudel: true }))
!== JSON.stringify(browserFassung("@rudel", leute, { rudel: false }));
ok(ungleichErkannt,
"Gegenprobe: bei verschiedenen Rechten kommt Verschiedenes heraus — der Vergleich kann Unterschiede sehen");
}
}
/* =====================================================================
6. AM ECHTEN BILDSCHIRM
Alles bisher hat bewiesen, dass der Richtige vermerkt wird. Das ist
die Hälfte. Die andere: Sieht man es? Und kommt man mit der Tastatur
dorthin, ohne mitten im Satz zur Maus zu greifen?
Es gibt einen Weg, auf dem alles Bisherige grün ist und die Funktion
trotzdem nicht benutzbar: wenn die Liste hinter der Schreibzeile
liegt, wenn Enter statt zu wählen die halbe Nachricht abschickt,
oder wenn der Name am Ende grau bleibt wie jedes andere Wort.
===================================================================== */
/* EIN RAUM, IN DEM DER RUF ETWAS BEWIRKT (23.09.2026).
Fuer den Bildschirmteil wird ein Gespraech gebraucht, in dem ausser
mir noch jemand aus dem Team sitzt -- sonst bietet die Liste den Ruf
gar nicht erst an (und das ist richtig so: ein Vorschlag, der
niemanden erreicht, ist ein Vorschlag, der nichts tut).
ZWEI ZUGAENGE MIT DER DOGFATHER-ROLLE, und zwar bewusst statt eines
Modis: Der Browser dieser Pruefung laeuft auf 127.0.0.1, und dort
steht die Agentur-Wand -- die Rolle "modi" gibt es auf ihr nicht
(siehe der Kommentar ganz oben). Wer hier das Team braucht, nimmt
die einzige Team-Rolle, die auf beiden Waenden vorkommt. */
let raumTeam = null;
{
const d3 = new DatabaseSync(process.env.WORKSPACE_DB);
const sch = d3.prepare("SELECT wert FROM einstellungen WHERE schluessel = ?")
.get("code_kennung_schluessel").wert;
const salt = randomBytes(16).toString("hex");
const hash = scryptSync("CODE-DOGI-0002", salt, 64,
{ N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex");
d3.prepare("INSERT INTO personen (name, rolle, code_hash, code_salt, code_n, code_kennung, aktiv, erstellt)"
+ " VALUES (?,?,?,?,?,?,1,?)")
.run("Zweiter", "admin", hash, salt, 32768,
createHmac("sha256", sch).update("CODE-DOGI-0002").digest("hex"), jetzt);
const idZwei = d3.prepare("SELECT last_insert_rowid() AS id").get().id;
d3.close();
const g = await ruf("/workspace/api/chat/gruppe", aDogi.keks, "POST",
{ name: "Team intern", mit: [idZwei] });
raumTeam = json(g).raum_id ?? null;
ok(Number.isInteger(raumTeam),
`ein Raum, in dem noch jemand aus dem Team sitzt (Raum ${raumTeam})`);
}
melde("\n=== 6. Am echten Bildschirm ===");
const BASIS = `http://127.0.0.1:${PORT}`;
const pw = await import("file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs");
const browser = await pw.chromium.launch();
try {
async function alsRolle(rolle, code, breite = 1280, wohin = `chat.html?raum=${raumId}`) {
const ctx = await browser.newContext({ viewport: { width: breite, height: 900 } });
const seite = await ctx.newPage();
await seite.goto(BASIS + "/workspace/", { waitUntil: "networkidle" });
await seite.click(`.rolle[data-rolle="${rolle}"]`);
await seite.fill("#code", code);
await seite.click("button[type=submit]");
await seite.waitForURL("**/start.html", { timeout: 25000 });
await seite.goto(`${BASIS}/workspace/${wohin}`, { waitUntil: "networkidle" });
await seite.waitForTimeout(1100);
return { ctx, seite };
}
const dogi = await alsRolle("admin", "CODE-DOGI-0001");
const s = dogi.seite;
ok(await s.locator("#text").isVisible(), "das Schreibfeld steht da");
/* ---- Die Liste erscheint beim Tippen --------------------------- */
const vorher = await s.locator(".chat-nachricht").count();
await s.click("#text");
await s.type("#text", "Hallo @", { delay: 40 });
await s.waitForTimeout(400);
const liste = s.locator("#erwaehn-liste");
ok(await liste.isVisible(), 'ein @ oeffnet die Liste');
const namen = await liste.locator(".erwaehn-liste__name").allInnerTexts();
ok(namen.length === 2, `sie zeigt die anderen zwei aus diesem Raum (${namen.join(", ")})`);
ok(!namen.includes("Filipe"),
" und NICHT mich selbst — ein Eintrag, der nichts bewirkt, gehört nicht hinein");
/* Liegt sie auch wirklich sichtbar, oder hinter der Schreibzeile? */
const lage = await s.evaluate(() => {
const l = document.getElementById("erwaehn-liste");
const f = document.getElementById("text");
const a = l.getBoundingClientRect(), b = f.getBoundingClientRect();
return { unten: a.bottom, oben: b.top, breit: a.width, hoch: a.height,
imBild: a.top >= 0 && a.left >= 0 && a.right <= innerWidth };
});
ok(lage.unten <= lage.oben + 12,
`sie steht ÜBER der Schreibzeile (${Math.round(lage.unten)} ≤ ${Math.round(lage.oben)})`);
ok(lage.imBild && lage.breit > 100 && lage.hoch > 40,
` und vollständig im Bild (${Math.round(lage.breit)}×${Math.round(lage.hoch)})`);
/* ---- Tippen filtert --------------------------------------------- */
await s.type("#text", "Van", { delay: 40 });
await s.waitForTimeout(350);
const gefiltert = await liste.locator(".erwaehn-liste__name").allInnerTexts();
ok(gefiltert.length === 1 && gefiltert[0] === "VanVan",
`Weitertippen grenzt ein (${gefiltert.join(", ") || "nichts"})`);
/* ---- Die Berührfläche ------------------------------------------- */
const hoch = await liste.locator(".erwaehn-liste__eintrag").first()
.evaluate((e) => e.getBoundingClientRect().height);
ok(hoch >= 44, `ein Eintrag ist ${Math.round(hoch)} px hoch — am Daumen treffbar`);
/* ---- Enter WÄHLT, es schickt nicht ab ---------------------------- */
await s.keyboard.press("Enter");
await s.waitForTimeout(400);
const nachEnter = await s.inputValue("#text");
ok(nachEnter === "Hallo @VanVan ",
`Enter setzt den Namen ein: ${JSON.stringify(nachEnter)}`);
ok(!(await liste.isVisible()), " und die Liste ist wieder zu");
/* DER ENTSCHEIDENDE PUNKT: Der halbe Satz darf NICHT rausgegangen
sein. Ohne diese Prüfung wäre der häufigste Handgriff überhaupt
eine versehentlich abgeschickte Nachricht. */
const raus = await s.locator(".chat-nachricht").count();
/* GEGEN DEN AUSGANGSWERT, NICHT GEGEN NULL. Im Raum stehen schon
die Nachrichten aus Abschnitt 2 -- der erste Anlauf verglich mit
0 und wurde rot, obwohl nichts abgeschickt worden war. Eine
Pruefung, die den Anfangszustand nicht kennt, misst ihn mit. */
ok(raus === vorher, `nichts wurde abgeschickt (${raus}, vorher ${vorher})`);
/* ---- Und jetzt wirklich abschicken ------------------------------- */
await s.type("#text", "schaust du mal?", { delay: 20 });
await s.keyboard.press("Enter");
await s.waitForTimeout(1400);
const blase = s.locator(".chat-nachricht").last();
ok(await blase.isVisible(), "die Nachricht steht im Verlauf");
const marke = blase.locator(".chat-erwaehnung");
ok(await marke.count() === 1, "der Name ist im Satz hervorgehoben");
ok((await marke.innerText()).trim() === "@VanVan",
` und zwar genau: ${(await marke.innerText()).trim()}`);
/* GEGENPROBE: Beim Absender ist es NICHT der eigene Name -- die
stärkere Hervorhebung gehört nur dem, der gemeint ist. */
ok(await marke.getAttribute("data-ich") === null,
"Gegenprobe: beim Absender ist es nicht die eigene Marke");
/* ---- Und bei der, die gemeint ist -------------------------------- */
const van = await alsRolle("scout", "CODE-SCOU-0001");
const vs = van.seite;
await vs.waitForTimeout(900);
const vMarke = vs.locator(".chat-nachricht").last().locator(".chat-erwaehnung");
ok(await vMarke.count() === 1, "VanVan sieht den Namen ebenfalls hervorgehoben");
ok(await vMarke.getAttribute("data-ich") === "ja",
" und bei ihr deutlicher — sie ist gemeint");
/* NICHT NUR FARBE: Im Kontrastmodus fällt jede Tönung weg. Geprüft
wird, dass auch etwas anderes anders ist. */
const anders = await vMarke.evaluate((e) => {
const m = getComputedStyle(e);
const nachbar = e.parentElement;
const n = getComputedStyle(nachbar);
return { gewicht: m.fontWeight, gewichtNachbar: n.fontWeight,
schatten: m.boxShadow !== "none", grund: m.backgroundColor };
});
ok(Number(anders.gewicht) > Number(anders.gewichtNachbar) || anders.schatten,
` erkennbar nicht nur an der Farbe (Gewicht ${anders.gewicht} statt ${anders.gewichtNachbar}, Rahmen: ${anders.schatten})`);
/* ---- Das Zeichen in der Gesprächsliste ---------------------------
ERST ZURÜCK ZUR LISTE, DANN EINE FRISCHE ERWÄHNUNG. Der erste
Anlauf prüfte es direkt nach dem Lesen und wurde rot -- zu Recht:
VanVan hatte den Raum offen, also hat sie es gesehen, und ein
Zeichen für etwas Gelesenes wäre falsch. Die Prüfung mass die
eigene Reihenfolge, nicht das Verhalten.
Jetzt der Fall, um den es wirklich geht: Sie ist woanders, und es
kommt etwas für sie herein. */
/* ==== DIE LISTE HAT SEIT DEM 25.09.2026 DREI FAECHER =============
Filipe: „ich will 3 kategorien haben. chats mit einzelnen
personen, gruppen chats und kanäle."
Diese Erwaehnung liegt in einer GRUPPE („Testgruppe", weiter
oben), das voreingestellte Fach ist „Personen" -- die Zeile mit
dem @ steht also nicht in der Liste, bevor man das Fach wechselt.
Die Pruefung ist daran rot geworden, und das war RICHTIG: Sie hat
eine echte Luecke gefunden, keine Aeusserlichkeit. Ein Ruf, den
ein geschlossenes Fach verschluckt, ist schlimmer als gar keiner.
GEBAUT WURDE DARAUFHIN das @ AM FACH (`.chat-art__at`). Die
Pruefung misst jetzt beides und sagt damit mehr als vorher:
1. Das Fach „Gruppen" traegt das Zeichen, OHNE dass man es
oeffnet -- man sieht, dass etwas wartet.
2. Im Fach „Personen" steht die Zeile dabei NICHT (Gegenprobe:
das Fach filtert wirklich, das Zeichen ist keine Dekoration).
3. Nach dem Wechsel steht die Zeile mit ihrem @ da.
EIN HELFER STATT DREI ABSCHRIFTEN: Jede Stelle, die ein Fach
waehlt, tut es hier gleich -- sonst waere die dritte die, die
auf das Warten verzichtet. */
const fachWaehlen = async (seite, art) => {
await seite.click(`.chat-art[data-art="${art}"]`);
await seite.waitForTimeout(400);
};
await vs.goto(`${BASIS}/workspace/chat.html`, { waitUntil: "networkidle" });
await vs.waitForTimeout(900);
await fachWaehlen(vs, "gruppe");
const vorSchon = await vs.locator(".chat-raum__erwaehnt").count();
ok(vorSchon === 0,
`solange nichts Neues da ist, leuchtet auch nichts (${vorSchon})`);
const vorFach = await vs.locator(".chat-art__at").count();
ok(vorFach === 0, ` und auch an keinem der drei Fächer (${vorFach})`);
await s.click("#text");
await s.type("#text", "@VanVan noch eine Sache", { delay: 15 });
await s.keyboard.press("Enter");
await s.waitForTimeout(1500);
await vs.reload({ waitUntil: "networkidle" });
await vs.waitForTimeout(1200);
/* ERST ZURUECK AUF „Personen". Der erste Anlauf verliess sich
darauf, dass das Neuladen dort landet -- tat es nicht, und die
Gegenprobe war rot: Die Wahl des Fachs UEBERLEBT das Neuladen
(localStorage, Absicht seit dem 25.09.2026 -- wer im Kanal
arbeitet, arbeitet meist den ganzen Tag im Kanal).
Das ist ein Fund und kein Aergernis: Die Pruefung hat damit
nebenbei belegt, dass das Merken wirklich greift. Der Fall, um
den es hier geht, muss aber ausdruecklich hergestellt werden --
sie steht in einem ANDEREN Fach, und der Ruf liegt woanders. */
await fachWaehlen(vs, "direkt");
const fachAt = vs.locator(".chat-art__at");
ok(await fachAt.count() === 1,
`das Fach mit der Erwähnung trägt das @-Zeichen, ungeöffnet (${await fachAt.count()})`);
const amFach = await vs.locator('.chat-art[data-art="gruppe"] .chat-art__at').count();
ok(amFach === 1, ` und zwar genau an „Gruppen" (${amFach})`);
ok((await fachAt.first().getAttribute("aria-label") || "").includes("erwähnt"),
" mit einem Satz für Vorleseprogramme statt eines nackten Zeichens");
/* GEGENPROBE: Im falschen Fach steht die Zeile nicht -- das Fach
filtert wirklich. Ohne diese Zeile koennte das @ am Fach auch
dann gruen sein, wenn die Liste gar nicht filtert. */
const zeileFalschesFach = await vs.locator(".chat-raum__erwaehnt").count();
ok(zeileFalschesFach === 0,
`Gegenprobe: im Fach „Personen" steht die Zeile nicht (${zeileFalschesFach}) — das Fach filtert wirklich`);
await fachWaehlen(vs, "gruppe");
const zeichen = vs.locator(".chat-raum__erwaehnt");
ok(await zeichen.count() === 1, `in ihrer Gesprächsliste steht das @-Zeichen (${await zeichen.count()})`);
ok((await zeichen.first().getAttribute("aria-label") || "").includes("erwähnt"),
" mit einem Satz für Vorleseprogramme statt eines nackten Zeichens");
/* GEGENPROBE BEI FUNNY: Sie sitzt im selben Raum und hat dieselben
Nachrichten ungelesen -- und ist nicht gemeint. Leuchtete es bei
ihr auch, wäre das Zeichen wertlos. Am Bildschirm, nicht nur in
der Antwort des Servers. */
const funny = await alsRolle("scout", "CODE-SCOU-0002", 1280, "chat.html");
await funny.seite.waitForTimeout(1200);
/* AUCH HIER INS RICHTIGE FACH. Im Fach „Personen" waere bei Funny
ohnehin nichts zu sehen -- die Gegenprobe waere dann gruen, ohne
die Gruppe je angesehen zu haben. Genau die Sorte Haken, die
nichts bestaetigt. */
await fachWaehlen(funny.seite, "gruppe");
const beiFunny = await funny.seite.locator(".chat-raum__erwaehnt").count();
const beiFunnyFach = await funny.seite.locator(".chat-art__at").count();
const zahlFunny = await funny.seite.locator(".chat-raum__zahl").count();
ok(beiFunnyFach === 0,
`Gegenprobe: bei Funny trägt auch kein Fach das @ (${beiFunnyFach})`);
ok(beiFunny === 0, `Gegenprobe: bei Funny leuchtet nichts (${beiFunny})`);
ok(zahlFunny >= 1,
` obwohl sie Ungelesenes hat (${zahlFunny} Gespräch(e) mit Zahl) — es liegt nicht daran`);
await funny.ctx.close();
/* ---- Die Gegenprobe der Liste: ein @ ohne Treffer -------------- */
await s.click("#text");
await s.type("#text", "@Zzz", { delay: 30 });
await s.waitForTimeout(350);
ok(!(await liste.isVisible()),
"Gegenprobe: passt niemand, bleibt die Liste zu");
/* Und Enter schickt dann wieder ab, wie immer. */
await s.keyboard.press("Enter");
await s.waitForTimeout(1200);
const jetztRaus = await s.locator(".chat-nachricht").count();
ok(jetztRaus > vorher + 1, ` und Enter schickt wieder ab (${jetztRaus}, vorher ${vorher})`);
/* ---- "@RUDEL" AM BILDSCHIRM (23.09.2026) -----------------------
Alles bisher hat bewiesen, dass der Server den Richtigen ruft.
Hier geht es um die andere Haelfte: Findet man es, und sieht man
es? Ein Ruf, den niemand vorgeschlagen bekommt und der im Satz
grau bleibt wie jedes andere Wort, ist eine Attrappe. */
if (Number.isInteger(raumTeam)) {
await s.goto(`${BASIS}/workspace/chat.html?raum=${raumTeam}`, { waitUntil: "networkidle" });
await s.waitForTimeout(1200);
await s.click("#text");
await s.type("#text", "@", { delay: 40 });
await s.waitForTimeout(400);
const eintraege = await liste.locator(".erwaehn-liste__eintrag").count();
const ersterName = await liste.locator(".erwaehn-liste__name").first().innerText();
const ersterZusatz = await liste.locator(".erwaehn-liste__rolle").first().innerText();
ok(eintraege >= 2 && ersterName === "rudel",
`der Ruf steht ganz oben in der Liste (${eintraege} Eintraege, erster: ${ersterName})`);
/* WAS ES BEDEUTET, MUSS DANEBENSTEHEN. "rudel" allein sagt keinem
Neuen, dass damit das ganze Team gemeint ist -- und wer das erst
nach dem Absenden erfaehrt, hat schon alle geweckt. */
ok(/alle im Team/i.test(ersterZusatz),
` und daneben steht, was es bedeutet ("${ersterZusatz}")`);
const alsPerson = await liste.locator('.erwaehn-liste__eintrag[data-rudel="ja"]').count();
ok(alsPerson === 1, ` genau einmal, als eigene Sorte Eintrag (${alsPerson})`);
await s.keyboard.press("Enter");
await s.waitForTimeout(300);
ok((await s.inputValue("#text")) === "@rudel ",
`Enter setzt den Ruf ein: ${JSON.stringify(await s.inputValue("#text"))}`);
await s.type("#text", "bitte alle kurz schauen", { delay: 10 });
await s.keyboard.press("Enter");
await s.waitForTimeout(1500);
const marke = await s.evaluate(() => {
const m = document.querySelector('.chat-erwaehnung[data-rudel="ja"]');
if (!m) return null;
const st = getComputedStyle(m);
const normal = document.querySelector(".chat-nachricht__text");
return {
wort: m.textContent.trim(),
gewicht: st.fontWeight,
schatten: st.boxShadow !== "none",
eigen: m.dataset.ich === "ja",
farbeGleichText: st.color === getComputedStyle(normal).color,
};
});
ok(!!marke, "der Ruf ist im Satz hervorgehoben");
/* Das Zeichen steht per Stilvorlage davor (::before) und zaehlt
nicht zum Textinhalt -- geprueft wird deshalb das Wort. */
ok(marke?.wort === "@rudel", ` und zwar genau: ${marke?.wort}`);
/* NICHT NUR DIE FARBE. Wer Farben schlecht unterscheidet, muss es
trotzdem sehen -- dieselbe Regel wie beim eigenen Namen. */
ok(Number(marke?.gewicht) >= 600 && marke?.schatten === true,
` erkennbar an Gewicht (${marke?.gewicht}) und Rahmen (${marke?.schatten}), nicht nur an der Farbe`);
/* GEGENPROBE: Es ist NICHT die Marke fuer "du bist gemeint". Saehen
beide gleich aus, verloere die persoenliche ihre Wirkung. */
ok(marke?.eigen === false,
" und es ist nicht die Marke fuer den eigenen Namen — alle gemeint ist etwas anderes als du gemeint");
} else {
ok(false, "ohne Team-Raum ist der Ruf am Bildschirm nicht messbar");
}
await dogi.ctx.close();
await van.ctx.close();
} finally {
await browser.close();
}
/* =====================================================================
7. "@RUDEL" DURCH DIE SCHNITTSTELLE (23.09.2026)
Filipe: „dan will ich auch dass nur die modis, rechte hand, linke
hand und dogfather, alle auch auf einmal markieren können im chat
mit einem, @rudel ,dann sollen alle eine benarichtigung bekommen."
ALLES BISHER WAR RECHNEN OHNE SERVER. Hier geht es durch die
richtige Tür.
GEPRÜFT WIRD IM TREFF, UND DAS IST KEINE BEQUEMLICHKEIT. Der Treff
ist der einzige Raum des Hauses, in dem Team Dogi und Menschen
sitzen, die NICHT dazugehören (Rolle „gast" -- die Community). In
einem gewöhnlichen Gespräch geht das gar nicht: Die Häusertrennung
lässt einen Scout und einen Modi nicht in denselben Raum, das ist
beim Bauen dieser Prüfung gemessen worden (403, „Mit mindestens
einer Person kannst du nicht schreiben").
Damit ist der Treff die einzige Stelle, an der der Unterschied
zwischen „alle im Raum" und „alle im Team" überhaupt sichtbar wird
-- und zugleich die, an der die falsche Lesart wehtäte: Sie hätte
jeden Zuschauer geweckt.
ZWEI FRAGEN, UND DIE ZWEITE IST DIE WICHTIGERE:
1. Ruft der Modi wirklich sein Team?
2. Ruft ein Gast wirklich NICHTS? -- Ohne diese Prüfung stünde
Filipes „nur die modis, rechte hand, linke hand und dogfather"
bloss im Kommentar.
===================================================================== */
melde("\n" + "=== 7. Der Ruf ans Rudel geht durch die Tür ===");
{
/* Die Anmeldewand entscheidet am HOST, in welchem Haus sie steht.
Team Dogi gibt es nur auf crew.… -- ohne diesen Kopf käme jede
Anfrage auf der Agenturseite an, und die Rolle "modi" gibt es
dort gar nicht. `fetch` verwirft den Host-Kopf, deshalb node:http. */
const CREW = "crew.dogfather-universe.com";
function crewRuf(pfad, keks, methode = "GET", koerper = null) {
return new Promise((fertig, schief) => {
const daten = koerper === null ? null : Buffer.from(JSON.stringify(koerper));
const a = httpAnfrage({
host: "127.0.0.1", port: PORT, path: pfad, method: methode,
headers: {
Host: CREW, Origin: `https://${CREW}`, Referer: `https://${CREW}/workspace/`,
...(daten ? { "Content-Type": "application/json", "Content-Length": daten.length } : {}),
...(keks ? { Cookie: keks } : {}),
},
}, (antwort) => {
const st = [];
antwort.on("data", (x) => st.push(x));
antwort.on("end", () => {
const text = Buffer.concat(st).toString("utf8");
let j = null; try { j = JSON.parse(text); } catch { /* kein JSON */ }
fertig({
code: antwort.statusCode, json: j, text,
keks: [].concat(antwort.headers["set-cookie"] || [])
.map((c) => c.split(";")[0]).join("; "),
});
});
});
a.on("error", schief);
if (daten) a.write(daten);
a.end();
});
}
/* Drei weitere Zugänge: ein Modi, die linke Hand und ein Gast. */
const d2 = new DatabaseSync(process.env.WORKSPACE_DB);
const schluessel2 = d2.prepare(
"SELECT wert FROM einstellungen WHERE schluessel = ?").get("code_kennung_schluessel").wert;
const anlegen2 = (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");
d2.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", schluessel2).update(code).digest("hex"), jetzt);
return d2.prepare("SELECT last_insert_rowid() AS id").get().id;
};
const idModi = anlegen2("Frida", "modi", "CODE-MODI-0001");
const idLinke = anlegen2("Lino", "linke", "CODE-LINK-0001");
const idGast = anlegen2("Zuschauer Zoe", "gast", "CODE-GAST-0001");
d2.close();
const aModi = await crewRuf("/workspace/api/anmelden", null, "POST",
{ rolle: "modi", code: "CODE-MODI-0001" });
const aLinke = await crewRuf("/workspace/api/anmelden", null, "POST",
{ rolle: "linke", code: "CODE-LINK-0001" });
/* EIN GAST BESTAETIGT SEIN ALTER. Ohne `alter_ok` kommt 400 --
gemessen, nicht vermutet; der erste Anlauf dieser Pruefung ist
genau daran haengengeblieben. Das Feld gilt NUR fuer die
Community-Kachel, fuer alle anderen Rollen verlangt der Server es
gar nicht erst. Dieselbe Stelle steht in pruef-treff. */
const aGast = await crewRuf("/workspace/api/anmelden", null, "POST",
{ rolle: "gast", code: "CODE-GAST-0001", alter_ok: true });
ok(!!aModi.keks && !!aLinke.keks && !!aGast.keks,
`Modi, linke Hand und ein Gast sind angemeldet (${aModi.code}/${aLinke.code}/${aGast.code})`);
/* DER RAUM HEISST FALSCH -- ABSICHTLICH, ALS GEGENPROBE.
Am 23.09.2026 stand im Code laengst "Das Rudel", in der
Gespraechsliste aber weiter "Der Treff": treffRaumId() setzt den
Namen nur BEIM ANLEGEN, und der Raum existierte schon. Niemandem
faellt so etwas auf -- gemeldet hat es Filipe, nach Tagen.
Seitdem gleicht treffAngleichen() den Namen mit ab. Damit diese
Zeile etwas beweist und nicht nur bestaetigt, wird der alte Name
hier VORHER wieder eingetragen. Faende der Abgleich ihn nicht,
bliebe er stehen, und die Pruefung darunter wuerde rot. */
{
const vor = new DatabaseSync(process.env.WORKSPACE_DB);
vor.prepare("UPDATE chat_raeume SET name = 'Der Treff' WHERE art = 'kanal' AND kategorie = 'treff'").run();
vor.close();
}
/* DER TREFF FUELLT SICH, WENN MAN IHN AUFMACHT. treffAngleichen()
laeuft beim Abrufen der Gespraechsliste -- also einmal je Zugang. */
for (const k of [aModi.keks, aLinke.keks, aGast.keks]) {
await crewRuf("/workspace/api/chat/raeume", k);
}
{
const nach = new DatabaseSync(process.env.WORKSPACE_DB, { readOnly: true });
const heisst = nach.prepare(
"SELECT name FROM chat_raeume WHERE art = 'kanal' AND kategorie = 'treff'").get()?.name;
nach.close();
const { TREFF_NAME } = await import("./workspace-treffchat.js");
ok(heisst === TREFF_NAME,
`der Raumname gleicht sich von selbst an ("Der Treff" -> "${heisst}")`);
}
const drin = new DatabaseSync(process.env.WORKSPACE_DB);
const treff = drin.prepare(
"SELECT id FROM chat_raeume WHERE art = 'kanal' AND kategorie = ?").get("treff")?.id;
/* DRITTER AUSGANG: Gibt es den Treff nicht, steht hier warum --
statt dass die folgenden Pruefungen an `undefined` arbeiten und
Fehler melden, die mit dem Rudel nichts zu tun haben. */
ok(Number.isInteger(treff), `der Treff steht (Raum ${treff})`);
const besetzung = Number.isInteger(treff) ? drin.prepare(
`SELECT t.person_id AS id, p.rolle FROM chat_teilnehmer t
JOIN personen p ON p.id = t.person_id
WHERE t.raum_id = ? AND t.raus_am IS NULL`).all(treff) : [];
const gaeste = besetzung.filter((x) => x.rolle === "gast").map((x) => x.id);
/* DIE ZAHL GEHOERT IN DIE BEDINGUNG. Saesse kein Gast im Treff,
waere die Gegenprobe weiter unten leer und trotzdem gruen --
genau der Fehler, der am 01.09. dreimal gruen dastand. */
ok(gaeste.includes(idGast) && besetzung.length >= 3,
`im Treff sitzen ${besetzung.length} Leute, darunter ein Gast`);
/* WER ERWARTET WIRD, WIRD ABGELEITET -- nicht abgeschrieben. Eine
feste Liste hier waere beim naechsten Rollenzuschnitt still
falsch, und die Pruefung haette den Fehler mitgemacht.
SEIT DEM 23.09.2026 IST DAS HIER DER GANZE RAUM. Filipe: "wenn wir
da im chat @rudel machen will ich dass jeder in dem chat markiert
wird. nicht nur team ... ich rede nur von dem chat wo die ganze
community auch drin ist." Der Raum heisst "Das Rudel" -- wer drin
sitzt, gehoert dazu, Gast oder nicht.
DIE ALTE REGEL IST DAMIT NICHT WEG, sie gilt nur woanders. Dass
sie ueberall sonst weiterhin gilt, prueft die Gegenprobe unten in
der Gruppe. Ohne die waere diese Zeile hier eine Erlaubnis statt
einer Aussage: Haette jemand die Regel versehentlich im ganzen
Haus umgestellt, bliebe dieser Block gruen. */
const TEAM = new Set(["admin", "hand", "linke", "modi"]);
const erwartet = besetzung
.filter((x) => x.id !== idModi)
.map((x) => x.id).sort((x, y) => x - y);
/* ---- 1. DER MODI RUFT ------------------------------------------ */
const ruf1 = await crewRuf(`/workspace/api/chat/raeume/${treff}/nachrichten`,
aModi.keks, "POST", { text: "@rudel bitte alle kurz herschauen" });
ok(ruf1.code === 201, `der Modi darf im Treff schreiben (${ruf1.code} ${ruf1.code === 201 ? "" : ruf1.text.slice(0, 90)})`);
ok(ruf1.json?.nachricht?.rudel === true,
" und die Nachricht weiss, dass sie das Rudel gerufen hat");
const nrId = ruf1.json?.nachricht?.id;
const gemeint = drin.prepare(
"SELECT person_id FROM chat_erwaehnungen WHERE nachricht_id = ? ORDER BY person_id")
.all(nrId).map((x) => x.person_id).sort((x, y) => x - y);
ok(gemeint.length > 0 && JSON.stringify(gemeint) === JSON.stringify(erwartet),
`gerufen wird jeder im Raum (${JSON.stringify(gemeint)}, erwartet ${JSON.stringify(erwartet)})`);
/* DIE EIGENTLICHE AUSSAGE, und sie hat sich am 23.09.2026 umgedreht.
Der Gast ist KEIN Sonderfall mehr, sondern der Beweis: In diesem
einen Raum meint der Ruf alle Anwesenden. Waere er weiterhin
draussen, waere die Bestellung nicht ausgefuehrt -- und zwar
unsichtbar, denn der Rufer sieht nicht, wen er erreicht hat. */
ok(gemeint.includes(idGast),
" der Gast im selben Raum AUCH — hier ist das Rudel der Raum, nicht nur das Team");
ok(!gemeint.includes(idModi),
" und der Rufer selbst auch nicht — man ruft nicht sich");
ok(gemeint.includes(idLinke),
" die linke Hand ist dabei — sie gehoert zum Team");
/* ---- GEGENPROBE: UEBERALL SONST GILT WEITER DAS TEAM -----------
"sonst nichts anfassen" ist eine Zusage, und eine Zusage ohne
Messung ist eine Hoffnung. Die Testgruppe besteht aus DogFather
und zwei Scouts; Scouts sind nicht Team Dogi. Ein Ruf dort darf
deshalb NIEMANDEN erreichen.
WARUM DAS TRENNSCHARF IST: Haette jemand die neue Regel im ganzen
Haus eingeschaltet, waeren hier die beiden Scouts markiert. Die
Pruefung kann also "nicht in Ordnung" sagen -- sonst waere sie
nur eine Verzierung.
ZWEI HAEUSER, EIN PORT: Die Testgruppe wurde ueber `ruf` angelegt
(Agentur-Haus, ohne Host-Kopf), der Treff-Block darueber misst
ueber `crewRuf` (crew., mit Host-Kopf). Hier gehoert deshalb
`ruf` hin -- mit dem crew-Keks in einem Agentur-Raum haette die
Gegenprobe vermutlich nur bewiesen, dass eine Anmeldung fehlt. */
const rufGruppe = await ruf(`/workspace/api/chat/raeume/${raumId}/nachrichten`,
aDogi.keks, "POST", { text: "@rudel gilt hier nicht" });
if (rufGruppe.code !== 201) {
ok(false, `Gegenprobe: DogFather kann in der Gruppe schreiben (${rufGruppe.code} ${rufGruppe.text.slice(0, 80)})`);
} else {
const inGruppe = drin.prepare(
"SELECT person_id FROM chat_erwaehnungen WHERE nachricht_id = ?")
.all(json(rufGruppe)?.nachricht?.id).map((x) => x.person_id);
ok(inGruppe.length === 0,
`Gegenprobe: in einer Gruppe ohne Team ruft @rudel niemanden (${inGruppe.length})`);
ok(!inGruppe.includes(idVan) && !inGruppe.includes(idFunny),
" die beiden Scouts bleiben unberuehrt — die neue Regel gilt NUR im Rudel-Raum");
}
/* Die Spalte bleibt stehen -- sie ist es, aus der beim Lesen die
Hervorhebung entsteht. */
const gespeichert = drin.prepare(
"SELECT rudel FROM chat_nachrichten WHERE id = ?").get(nrId)?.rudel;
ok(gespeichert === 1, `und es steht an der Nachricht (rudel = ${gespeichert})`);
const verlauf = await crewRuf(`/workspace/api/chat/raeume/${treff}/nachrichten`, aModi.keks);
const geladen = (verlauf.json?.nachrichten || []).find((x) => x.id === nrId);
ok(geladen?.rudel === true, "der Verlauf bringt es mit — auch nach dem Neuladen");
ok(verlauf.json?.darf_rudel === true, "und der Modi bekommt gesagt, dass er rufen darf");
/* UND WEN ER DAMIT MEINT. Ohne diese Auskunft stuende im
Auswahlfeld weiter "alle im Team" -- im Rudel-Raum schlicht
falsch, und zwar nach unten: Man drueckt leichter, wenn man
weniger Leute zu wecken glaubt. */
ok(verlauf.json?.rudel_wen === "raum",
`und wen er meint, steht dabei (${verlauf.json?.rudel_wen})`);
ok(verlauf.json?.rudel_wort === RUDEL_WORT,
`das Wort kommt vom Server, nicht aus dem Browser (${verlauf.json?.rudel_wort})`);
/* ---- 2. GEGENPROBE: DER GAST RUFT INS LEERE --------------------- */
const ruf2 = await crewRuf(`/workspace/api/chat/raeume/${treff}/nachrichten`,
aGast.keks, "POST", { text: "@rudel und ihr alle mal" });
ok(ruf2.code === 201, `der Gast darf im Treff schreiben wie alle (${ruf2.code})`);
const nr2 = ruf2.json?.nachricht?.id;
ok(ruf2.json?.nachricht?.rudel !== true, " aber sein @rudel ruft niemanden");
const gemeint2 = drin.prepare(
"SELECT person_id FROM chat_erwaehnungen WHERE nachricht_id = ?").all(nr2);
ok(gemeint2.length === 0,
` kein einziger Eintrag (${gemeint2.length}) — nur Team Dogi und DogFather duerfen rufen`);
const gespeichert2 = drin.prepare(
"SELECT rudel FROM chat_nachrichten WHERE id = ?").get(nr2)?.rudel;
ok(!gespeichert2, ` und an der Nachricht steht nichts (rudel = ${gespeichert2})`);
const verlauf2 = await crewRuf(`/workspace/api/chat/raeume/${treff}/nachrichten`, aGast.keks);
ok(verlauf2.json?.darf_rudel === false,
"dem Gast sagt der Server, dass er nicht rufen darf — der Vorschlag erscheint gar nicht erst");
/* ---- 3. EIN NAME WIRKT WEITERHIN FUER ALLE ---------------------- */
const ruf3 = await crewRuf(`/workspace/api/chat/raeume/${treff}/nachrichten`,
aGast.keks, "POST", { text: "@Frida schaust du mal?" });
const gemeint3 = drin.prepare(
"SELECT person_id FROM chat_erwaehnungen WHERE nachricht_id = ?")
.all(ruf3.json?.nachricht?.id).map((x) => x.person_id);
ok(gemeint3.length === 1 && gemeint3[0] === idModi,
"wer nicht rufen darf, kann trotzdem einzeln ansprechen — das bleibt unberuehrt");
drin.close();
}
console.log(`\n${fehler ? fehler + " FEHLER" : "Alles in Ordnung"} — ${geprueft} Prüfungen\n`);
try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ }
process.exit(fehler ? 1 : 0);