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]>
1210 lines
60 KiB
JavaScript
1210 lines
60 KiB
JavaScript
/* =====================================================================
|
||
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);
|