Gemessen: 165 Pruefdateien, 129 verschiedene Nummern -- NEUNZEHN
doppelt, vier davon dreifach. Niemand hatte das gewollt; jede neue
Pruefung wurde von einer vorhandenen abgeschrieben, und die Nummer kam
mit. Meine eigene Notiz sagte "sieben" -- auch eine Bestandsliste
altert.
Der Waechter faengt den Schaden ab, aber er kann nur melden, was schon
passiert ist: Zwei Pruefungen mit derselben Nummer koennen nie
gleichzeitig laufen, und ein liegengebliebener Prozess der einen laesst
die andere abbrechen mit einer Meldung, die wie ein Befund aussieht.
Genau das ist mir am 20.09. zweimal passiert.
Jetzt leitet jede Datei ihre Nummer aus ihrer STELLE IM ALPHABET ab
(eigenerPort in helfer-port.mjs), zwei je Datei. Nicht ueber eine
Pruefsumme: Bei 165 Namen in 4900 Nummern waeren nach dem
Geburtstagsproblem rund DREI Zusammenstoesse zu erwarten -- ein Hash
tauscht eine sichtbare Doppelung gegen eine unsichtbare. Die Stelle im
Alphabet ist eindeutig von der Bauart her.
--- ZWEI FEHLER AUF DEM WEG, BEIDE LEHRREICH -------------------------
1. DER ERSTE VERSUCH WAR GRUEN UND KAPUTT. Ersetzt wurde mit einem
Muster: "([^"]*4231[^"]*)". Das hielt
{ host: "127.0.0.1", port: 4231, path: "/404.html" }
fuer eine Zeichenkette -- ein Muster kann eine oeffnende nicht von
einer schliessenden Anfuehrung unterscheiden. Heraus kam
{ host: "127.0.0.1`, port: ${PORT}, path: `/404.html" }
also GUELTIGER Code ohne port-Feld. `node --check` sagte gruen fuer
alle 164 Dateien. Aufgefallen ist es erst, weil ich vier Vertreter
gegen eine vorher gemessene Grundlinie laufen liess: pruef-schranke
39/0 vorher, 38/1 nachher.
Alles zurueckgenommen und mit einem Zerleger neu gemacht, der weiss,
ob eine Stelle Code, Zeichenkette, Vorlage, Kommentar oder
regulaerer Ausdruck ist. In pruef-ics stand die Nummer in einem
regulaeren Ausdruck -- der wird jetzt gebaut statt hingeschrieben.
2. EIN MODUL, DAS BEIM IMPORTIEREN ARBEITET, IST EINE FALLE. Der
zweite Durchgang importierte den ersten, um seine Mechanik zu
benutzen -- und fuehrte dessen Hauptlauf gleich mit aus. Die
zweiten Nummern wurden dadurch als erste behandelt, zwei Aufrufe
bekamen dieselbe Nummer, und in pruef-content stand `const PORT`
zweimal.
--- WAS DAS DAUERHAFT HAELT -----------------------------------------
pruef-portnummern.mjs (neu, 8 Pruefungen) fragt nicht "welche Nummern
sind doppelt", sondern "wer traegt ueberhaupt noch eine von Hand ein"
und "wer startet einen Server, ohne seine Nummer abzuleiten". Die
zweite Frage hat sofort etwas gefunden, das in KEINER Doppelungsliste
stand: pruef-push-weg belegte 4341 und 4342, rief den Waechter aber
gar nicht auf -- dieselbe Nummer wie pruef-agentur. Eine Liste zeigt
nur, was auf ihr steht.
Mit Gegenprobe: Eine unbekannte Datei bekommt keine geratene Nummer,
sondern einen Abbruch, und eine dritte Nummer je Datei gibt es nicht.
--- NACHGEMESSEN ----------------------------------------------------
Sechzehn Pruefungen gegen ihre vorher gemessene Grundlinie, je eine
Vertreterin jeder umgestellten Bauweise (eine Nummer, zwei Nummern,
Nummer in einer Zeichenkette, in einer Vorlage, in einem regulaeren
Ausdruck, dynamische Einfuhr, Nachtlauf mit zwei Laeufen):
crew-adresse 132/0 · schranke 39/0 · content 45/0 · entwicklung 46/0
anruf 127/0 · ics 37/0 · push-weg 20/0 · arten 28/0 · video 67/0
kanalzeile 14/0 · agentur 62/0 · spicy 83/0 · creator-anlegen 50/0
push-ziel 10/0 · portnummern 8/0
Alle exakt wie vorher. Zwei waren schon vorher rot und sind es
unveraendert geblieben (crew-wand-bild 41/4, teilen 14/3) -- per
`git stash` belegt, nicht angenommen.
Co-Authored-By: Claude Opus 5 <[email protected]>
643 lines
31 KiB
JavaScript
643 lines
31 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 } 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";
|
||
|
||
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");
|
||
}
|
||
|
||
/* =====================================================================
|
||
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");
|
||
}
|
||
|
||
/* =====================================================================
|
||
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`);
|
||
}
|
||
}
|
||
|
||
|
||
/* =====================================================================
|
||
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.
|
||
===================================================================== */
|
||
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. */
|
||
await vs.goto(`${BASIS}/workspace/chat.html`, { waitUntil: "networkidle" });
|
||
await vs.waitForTimeout(900);
|
||
const vorSchon = await vs.locator(".chat-raum__erwaehnt").count();
|
||
ok(vorSchon === 0,
|
||
`solange nichts Neues da ist, leuchtet auch nichts (${vorSchon})`);
|
||
|
||
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);
|
||
|
||
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);
|
||
const beiFunny = await funny.seite.locator(".chat-raum__erwaehnt").count();
|
||
const zahlFunny = await funny.seite.locator(".chat-raum__zahl").count();
|
||
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})`);
|
||
|
||
await dogi.ctx.close();
|
||
await van.ctx.close();
|
||
} finally {
|
||
await browser.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);
|