Reaction: Rollen sichtbar, Moderation im Live-Chat

Filipes Wunsch: „die modis, linke hand und rechte hand soll im chat
extra aussehen und auch sachen wie stummschaltungen, sperrungen,
meldunge und alles mögliche im chat machen können falls sich jemand
nicht benimmt."

WER SPRICHT
Jeder Beitrag aus dem Team trägt jetzt ein Abzeichen: „Dogi", „Team"
(beide Hände) und „Modi". Ein WORT und nicht nur eine Farbe — rund
acht Prozent der Männer unterscheiden Rot und Grün schlecht, und hier
hängt an der Unterscheidung etwas. Gedeckte Töne auf schwacher Fläche,
11,52 px (die Hausgrenze ist 11,5), weil daneben ein Video läuft.
Die Wörter sagen den RANG nicht: Filipe steht nie über seinem Team.
Eine Prüfung schlägt an, wenn dort „Chef", „Boss" oder „Leitung"
stünde.

WAS DIE MODERATION KANN
Am Namen öffnet sich ein Menü mit vier Wegen: Beitrag wegnehmen,
10 Minuten stumm, aus der Sendung — und der Verweis in den Treff, wo
längere Maßnahmen hingehören (mit Begründungspflicht und Frist in
Tagen). Die Reaction baut kein zweites Sperrsystem daneben: Wer im
Treff eine Pause hat, schreibt hier auch nicht. Neu ist nur, was der
Treff nicht hat — MINUTEN. Höchstens eine Stunde; wer länger etwas
braucht, nimmt den Treff.
Nicht gegen das Team: Wer einen Modi stummschalten könnte, hätte einen
Weg, die Moderation selbst auszuschalten, mitten in der Sendung.

MELDEN DARF JEDER
Die Moderation sieht nicht alles, wer mitliest schon. Zweimal melden
zählt einmal (sonst füllt einer allein die Liste). Bei der Moderation
erscheint im Kopf der Schiene „1 Meldung" — nur wenn etwas offen ist;
eine Zahl, die immer dasteht und meistens null ist, wird nach drei
Tagen nicht mehr gelesen.

VIER FEHLER, DIE DABEI AUFGEFALLEN SIND

1. `oeffentlich()` nahm `meldungen` und `massnahmen` NICHT aus dem
   Rundruf. Der Rundruf wird aus dem Stand dessen gebaut, der gerade
   etwas getan hat — bei einer Moderationshandlung wären der gemeldete
   Text, der Name des Gemeldeten und der Name des MELDERS an jeden im
   Saal gegangen. Wer meldet, muss sich darauf verlassen können, dass
   das niemand sieht.

2. `meldung.js` hatte zwei Schlüssel doppelt: `geschlossen` und
   `nur_leitung`. Der spätere gewinnt stillschweigend — in der Hilfe
   stand dadurch „Der Saal ist zu". Ein Wort, ein Satz; eine Prüfung
   hält das jetzt fest.

3. Wer stummgeschaltet war, bekam bei OFFENEM Chat „Der Chat ist
   gerade zu" — der Aufrufer reimte sich den Grund aus der Chat-Stufe
   zusammen. Jetzt gibt `schreibGrund()` den echten Grund zurück.

4. Wer rausgenommen wurde, merkte nichts: Das Video lief weiter, nur
   die Anwesenheitsmeldung schlug still fehl. Jetzt hält alles an und
   es steht ein Satz da, der sagt, dass es nur für heute gilt.

UND EINER, DEN NUR DAS BILDSCHIRMFOTO GEFUNDEN HAT
Das Menü lag messbar komplett im Fenster (x=1099..1315 von 1440,
y=652..840 von 900) und war trotzdem abgeschnitten: Die Chatliste
rollt, und ein rollender Vorfahre beschneidet sein Kind. „Im Fenster"
und „sichtbar" sind zwei verschiedene Fragen, und ich hatte die
falsche gemessen. Das Menü hängt jetzt fest am Fenster und klappt nach
oben oder links, wo kein Platz ist. Die Messung fasst seitdem mit
`elementFromPoint` an jeden Knopf an, statt Rechtecke zu vergleichen.

GEPRUEFT
pruef-reaktion: 216 Prüfungen (vorher 156), 0 Fehler — zwei neue
Abschnitte. mess-reaktion misst die Moderation jetzt im echten
Browser: Abzeichen, Überlauf der Schiene, Menü, Stummschaltung beim
Betroffenen, Meldung bis zur Moderation, Rauswurf.
Dazu grün: pruef-meldungen, pruef-struktur, pruef-css-klassen,
pruef-tippziele, pruef-deutsche-texte, pruef-hilfe.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
2026-09-28 09:32:10 +02:00
co-authored by Claude Opus 5
parent cb8e59bb08
commit a02cf5c026
52 changed files with 2071 additions and 691 deletions
+263 -1
View File
@@ -580,7 +580,7 @@ melde("=== 9. Beenden ===");
const c = await roh(CREW, "/workspace/api/reaktion/chat", {
method: "POST", keks: k.gast, rumpf: { text: "Hallo?" },
});
ok(c.code === 409 && c.j?.fehler === "geschlossen",
ok(c.code === 409 && c.j?.fehler === "saal_zu",
`und schreiben geht nicht mehr (${c.code}/${c.j?.fehler})`);
}
@@ -1095,6 +1095,268 @@ melde("=== 14. Keine doppelten Wege ===");
ok(reaktionswege >= 17, `die Reaction bringt ${reaktionswege} eigene Wege mit`);
}
/* =======================================================================
15. MODERATION IM LIVE-CHAT
Der heikelste Teil der ganzen Reaction: Hier kann jemand einem
anderen das Wort nehmen. Geprueft wird deshalb nicht nur, dass es
GEHT, sondern vor allem, wer es NICHT darf und was dabei nicht
herauskommen darf.
======================================================================= */
melde("");
melde("=== 15. Moderation im Live-Chat ===");
{
/* Sauberer Anfang: Der Chat steht auf „offen". */
await roh(CREW, "/workspace/api/reaktion/chatmodus", {
method: "POST", keks: k.admin, rumpf: { modus: "offen" },
});
/* ---- Wer eine Massnahme verhaengen darf ---- */
const zuschauer = await roh(CREW, "/workspace/api/reaktion/massnahme", {
method: "POST", keks: k.gast, rumpf: { person_id: IDS.gast2, art: "stumm", minuten: 10 },
});
ok(zuschauer.code === 403 && zuschauer.j?.fehler === "nur_moderation",
`ein Zuschauer schaltet niemanden stumm (${zuschauer.code}/${zuschauer.j?.fehler})`);
/* ---- Nicht gegen das Team ----
Wer einen Modi stummschalten koennte, haette einen Weg, die
Moderation selbst auszuschalten -- mitten in der Sendung. */
const gegenTeam = await roh(CREW, "/workspace/api/reaktion/massnahme", {
method: "POST", keks: k.modi, rumpf: { person_id: IDS.linke, art: "stumm", minuten: 10 },
});
ok(gegenTeam.code === 403 && gegenTeam.j?.fehler === "nicht_gegen_team",
`ein Modi schaltet die linke Hand nicht stumm (${gegenTeam.code}/${gegenTeam.j?.fehler})`);
const gegenHost = await roh(CREW, "/workspace/api/reaktion/massnahme", {
method: "POST", keks: k.modi, rumpf: { person_id: IDS.admin, art: "raus" },
});
ok(gegenHost.code === 403, `und DogFather erst recht nicht (${gegenHost.code})`);
/* ---- Die Dauer hat eine Obergrenze ---- */
const { STUMM_MAX_MINUTEN, MASSNAHMEN } = await import("./reaktion-tabellen.js");
for (const [min, wort] of [[0, "null"], [-5, "minus"], [STUMM_MAX_MINUTEN + 1, "zu lang"]]) {
const a = await roh(CREW, "/workspace/api/reaktion/massnahme", {
method: "POST", keks: k.modi, rumpf: { person_id: IDS.gast2, art: "stumm", minuten: min },
});
ok(a.code === 400 && a.j?.fehler === "dauer_unklar",
`${min} Minuten (${wort}) wird abgelehnt (${a.code})`);
}
const quatsch = await roh(CREW, "/workspace/api/reaktion/massnahme", {
method: "POST", keks: k.modi, rumpf: { person_id: IDS.gast2, art: "verbannen" },
});
ok(quatsch.code === 400 && quatsch.j?.fehler === "massnahme_unbekannt",
`eine erfundene Massnahme wird abgelehnt (${quatsch.code})`);
ok(MASSNAHMEN.length === 2 && MASSNAHMEN.includes("stumm") && MASSNAHMEN.includes("raus"),
`es gibt genau zwei kurze Massnahmen: ${MASSNAHMEN.join(", ")}`);
/* ---- Stumm wirkt -- und sagt den richtigen Grund ----
Der Chat steht dabei auf „offen". Genau das ist der Fall, der
vorher die falsche Auskunft gab („Der Chat ist gerade zu"). */
const vorher = await roh(CREW, "/workspace/api/reaktion/chat", {
method: "POST", keks: k.gast2, rumpf: { text: "vorher geht es" },
});
ok(vorher.code === 201, `vor der Massnahme schreibt gast2 (${vorher.code})`);
const stumm = await roh(CREW, "/workspace/api/reaktion/massnahme", {
method: "POST", keks: k.modi, rumpf: { person_id: IDS.gast2, art: "stumm", minuten: 10 },
});
ok(stumm.code === 200, `ein Modi schaltet einen Zuschauer stumm (${stumm.code})`);
const nachher = await roh(CREW, "/workspace/api/reaktion/chat", {
method: "POST", keks: k.gast2, rumpf: { text: "und jetzt?" },
});
ok(nachher.code === 403 && nachher.j?.fehler === "stumm_geschaltet",
`danach nicht mehr -- und der Grund stimmt (${nachher.code}/${nachher.j?.fehler})`);
const sichtGast2 = await roh(CREW, "/workspace/api/reaktion", { keks: k.gast2 });
ok(sichtGast2.j?.ich?.darf_schreiben === false
&& sichtGast2.j?.ich?.schreib_grund === "stumm_geschaltet",
"und er steht auch in seinem eigenen Stand");
ok(sichtGast2.j?.ich?.massnahme?.art === "stumm" && !!sichtGast2.j.ich.massnahme.bis,
"mit Art und Ablaufzeitpunkt");
/* ZUSEHEN GEHT WEITER. „stumm" ist nicht „raus" -- wer das
verwechselt, wirft jemanden fuer einen Satz aus der Sendung. */
const nochDabei = await roh(CREW, "/workspace/api/reaktion/dabei", {
method: "POST", keks: k.gast2,
});
ok(nochDabei.code === 200, `zusehen geht weiter (${nochDabei.code})`);
/* ---- Die Massnahmen sind keine oeffentliche Liste ----
In `meldungen` steht der Name dessen, der gemeldet hat. Wer
meldet, muss sich darauf verlassen koennen, dass das niemand
sieht -- sonst meldet beim naechsten Mal keiner mehr. */
ok(Array.isArray(stumm.j?.massnahmen) && stumm.j.massnahmen.length >= 1,
"die Moderation sieht die laufenden Massnahmen");
ok(sichtGast2.j?.massnahmen === null && sichtGast2.j?.meldungen === null,
"ein Zuschauer sieht weder Massnahmen noch Meldungen");
const sichtModi = await roh(CREW, "/workspace/api/reaktion", { keks: k.modi });
ok(Array.isArray(sichtModi.j?.meldungen), "ein Modi schon");
/* GEGENPROBE AM RUNDRUF: `oeffentlich()` muss beide Felder
wegnehmen. Es aus dem Quelltext zu lesen ist hier genauer als
jede Messung -- der Rundruf geht an alle, und ein Fehler darin
waere erst aufgefallen, wenn jemand mitgelesen haette. */
{
const quelle = readFileSync(join(HIER, "workspace-reaktion.js"), "utf8");
const zeile = quelle.match(/const \{([^}]*)\} = l;/);
const weg = (zeile ? zeile[1] : "").split(",").map((x) => x.trim().replace(/\.\.\..*/, ""));
for (const feld of ["zuschauer", "ich", "liste", "meldungen", "massnahmen"]) {
ok(weg.includes(feld), `oeffentlich() nimmt „${feld}" aus dem Rundruf`);
}
}
/* ---- Wieder aufheben ---- */
const auf = await roh(CREW, `/workspace/api/reaktion/massnahme/${IDS.gast2}`, {
method: "DELETE", keks: k.gast,
});
ok(auf.code === 403, `ein Zuschauer hebt sie nicht auf (${auf.code})`);
const auf2 = await roh(CREW, `/workspace/api/reaktion/massnahme/${IDS.gast2}`, {
method: "DELETE", keks: k.modi,
});
ok(auf2.code === 200, `ein Modi schon (${auf2.code})`);
const wieder = await roh(CREW, "/workspace/api/reaktion/chat", {
method: "POST", keks: k.gast2, rumpf: { text: "wieder da" },
});
ok(wieder.code === 201, `Gegenprobe: danach schreibt er wieder (${wieder.code})`);
/* ---- Melden ---- */
const zuMelden = wieder.j.id;
const selbst = await roh(CREW, `/workspace/api/reaktion/chat/${zuMelden}/melden`, {
method: "POST", keks: k.gast2, rumpf: {},
});
ok(selbst.code === 400 && selbst.j?.fehler === "eigener_beitrag",
`den eigenen Beitrag meldet man nicht (${selbst.code})`);
const gemeldet = await roh(CREW, `/workspace/api/reaktion/chat/${zuMelden}/melden`, {
method: "POST", keks: k.gast, rumpf: { grund: "unfreundlich" },
});
ok(gemeldet.code === 201, `ein Zuschauer darf melden (${gemeldet.code})`);
/* ZWEIMAL MELDEN IST EINMAL. Ohne die Sperre koennte einer allein
die Liste fuellen, und die Moderation sieht die echte nicht. */
const nochmal = await roh(CREW, `/workspace/api/reaktion/chat/${zuMelden}/melden`, {
method: "POST", keks: k.gast, rumpf: {},
});
ok(nochmal.code === 200 && nochmal.j?.schon === true,
`zweimal melden zaehlt einmal -- ohne Fehlermeldung (${nochmal.code})`);
const beiModi = await roh(CREW, "/workspace/api/reaktion", { keks: k.modi });
const m = (beiModi.j?.meldungen || []).find((x) => x.beitrag_id === zuMelden);
ok(!!m, "die Meldung steht bei der Moderation");
ok(m?.text === "wieder da" && m?.wer_id === IDS.gast2,
"mit dem Text und der Person, um die es geht");
/* ---- Erledigen ---- */
const nein = await roh(CREW, `/workspace/api/reaktion/meldung/${m.id}/erledigt`, {
method: "POST", keks: k.gast,
});
ok(nein.code === 403, `ein Zuschauer erledigt keine Meldung (${nein.code})`);
const ja = await roh(CREW, `/workspace/api/reaktion/meldung/${m.id}/erledigt`, {
method: "POST", keks: k.modi,
});
ok(ja.code === 200, `ein Modi schon (${ja.code})`);
ok(!(ja.j?.meldungen || []).some((x) => x.id === m.id),
"und sie ist aus der offenen Liste");
const zweimal = await roh(CREW, `/workspace/api/reaktion/meldung/${m.id}/erledigt`, {
method: "POST", keks: k.modi,
});
ok(zweimal.code === 409 && zweimal.j?.fehler === "nicht_offen",
`zweimal erledigen geht nicht (${zweimal.code})`);
/* ---- „raus" heisst raus ---- */
const raus = await roh(CREW, "/workspace/api/reaktion/massnahme", {
method: "POST", keks: k.hand, rumpf: { person_id: IDS.gast2, art: "raus" },
});
ok(raus.code === 200, `die rechte Hand nimmt jemanden aus der Sendung (${raus.code})`);
const rein = await roh(CREW, "/workspace/api/reaktion/dabei", {
method: "POST", keks: k.gast2,
});
ok(rein.code === 403 && rein.j?.fehler === "aus_der_sendung",
`er kommt nicht mehr herein -- und erfaehrt warum (${rein.code}/${rein.j?.fehler})`);
const weg2 = await roh(CREW, "/workspace/api/reaktion/chat", {
method: "POST", keks: k.gast2, rumpf: { text: "hallo?" },
});
ok(weg2.code === 403 && weg2.j?.fehler === "aus_der_sendung",
`und schreiben auch nicht (${weg2.code}/${weg2.j?.fehler})`);
await roh(CREW, `/workspace/api/reaktion/massnahme/${IDS.gast2}`, {
method: "DELETE", keks: k.admin,
});
const zurueck = await roh(CREW, "/workspace/api/reaktion/dabei", {
method: "POST", keks: k.gast2,
});
ok(zurueck.code === 200, `Gegenprobe: aufgehoben, und er ist wieder dabei (${zurueck.code})`);
}
/* =======================================================================
16. WAS DIE SEITE DARAUS MACHT
======================================================================= */
melde("");
melde("=== 16. Rollen und Moderation auf der Seite ===");
{
const js = readFileSync(join(HIER, "..", "workspace", "assets", "js", "reaktion.js"), "utf8");
const css = readFileSync(join(HIER, "..", "workspace", "assets", "css", "reaktion.css"), "utf8");
const html = readFileSync(join(HIER, "..", "workspace", "reaktion.html"), "utf8");
const saetze = readFileSync(join(HIER, "..", "workspace", "assets", "js", "meldung.js"), "utf8");
/* JEDE ROLLE MIT EIGENEM AUSSEHEN BEKOMMT AUCH EINE REGEL.
Ein Abzeichen ohne Stil ist ein Wort ohne Kasten -- es faellt
nicht auf, und damit ist der ganze Zweck weg. */
const abzeichen = [...js.matchAll(/^ {4}(admin|hand|linke|modi): \['([^']+)', '([^']+)'\],/gm)];
ok(abzeichen.length === 4, `vier Rollen tragen ein Abzeichen (${abzeichen.length})`);
for (const [, rolle, wort, marke] of abzeichen) {
ok(wort.length >= 3 && wort.length <= 6, `„${wort}" fuer ${rolle} ist kurz genug`);
ok(css.includes(`.beitrag__rolle[data-rolle="${marke}"]`),
`und „${marke}" hat eine eigene Farbe`);
}
/* GEGENPROBE: Kann diese Pruefung „nein" sagen? */
ok(!css.includes('.beitrag__rolle[data-rolle="gibtesnicht"]'),
"Gegenprobe: eine erfundene Marke hat keine Regel");
/* DAS WORT IST NICHT DER RANG. Filipe steht nie ueber seinem Team --
Woerter wie „Chef" oder „Leitung" haetten hier nichts zu suchen. */
const rangwoerter = ["Chef", "Boss", "Leiter", "Leitung", "Kopf"];
for (const [, , wort] of abzeichen) {
ok(!rangwoerter.includes(wort), `„${wort}" ist kein Rang`);
}
ok(html.includes('id="chat-meldungen"'), "der Meldungs-Knopf steht in der Seite");
ok(css.includes(".schiene__meldungen"), "und hat eine Regel");
ok(js.includes("meldungenZeichnen()") && js.includes("meldungenZeigen"),
"die Seite zeichnet und oeffnet ihn");
/* DIE TIPPZIELE IM MODERATIONSMENUE. Hier wird jemand stumm
geschaltet; ein Fehlgriff trifft den Falschen. */
const knopf = css.match(/\.modmenue__knopf \{[^}]*\}/);
ok(!!knopf && /min-height:\s*44px/.test(knopf[0]),
"jeder Knopf im Moderationsmenue ist 44 px hoch");
/* JEDES FEHLERWORT DES SERVERS HAT EINEN DEUTSCHEN SATZ.
Sonst steht im Chat ein Maschinenwort wie „nicht_gegen_team". */
const server = readFileSync(join(HIER, "workspace-reaktion.js"), "utf8");
const woerter = [...new Set([...server.matchAll(/fehler:\s*"([a-z_0-9]+)"/g)]
.map((x) => x[1]))].filter((w) => w !== "nicht_verfuegbar");
ok(woerter.length > 20, `${woerter.length} Fehlerwoerter im Server gefunden`);
const ohneSatz = woerter.filter((w) => !new RegExp(`^\\s+${w}:`, "m").test(saetze));
ok(ohneSatz.length === 0,
ohneSatz.length ? `OHNE SATZ: ${ohneSatz.join(", ")}` : "alle haben einen deutschen Satz");
/* Die Gegenprobe braucht ein Wort, das es WIRKLICH nicht gibt --
„gibt_es_nicht" steht seit Langem in der Tafel und liess die
Gegenprobe genau das melden, wovor sie warnen sollte. */
ok(!/^\s+zwetschgenknoedel:/m.test(saetze),
"Gegenprobe: ein erfundenes Wort hat keinen Satz");
/* KEIN SCHLUESSEL STEHT ZWEIMAL IN DER SATZTAFEL.
Ein zweiter Eintrag mit demselben Namen gewinnt stillschweigend --
genau so stand am 28.09. in der Hilfe „Der Saal ist zu". */
const schluessel = [...saetze.matchAll(/^ {4}([a-z_0-9]+):/gm)].map((x) => x[1]);
const doppelt = [...new Set(schluessel.filter((x, i) => schluessel.indexOf(x) !== i))];
ok(doppelt.length === 0,
doppelt.length ? `DOPPELT: ${doppelt.join(", ")}` : `${schluessel.length} Saetze, keiner doppelt`);
}
melde("");
melde(`${geprueft} Pruefungen, ${fehler} Fehler`);
melde(fehler === 0 ? "ALLES IN ORDNUNG" : "NICHT IN ORDNUNG");