Stufe 6 und 7: Der Treff wird ein Gespraech, jedes Brett bekommt seine Form

Damit sind alle acht Stufen des Plans gebaut.

STUFE 6 -- DAS GESPRAECH
"Der Treff -- Hallo sagen, fragen, loben" war eine Liste aus Karten.
Der einzige Bereich, in dem Menschen MITEINANDER reden sollen, zwang
sie, nebeneinander zu reden: Jeder schrieb eine eigene Karte, niemand
antwortete jemandem.

Jetzt: Antworten eingerueckt unter ihrem Beitrag, aelteste zuerst (so
liest man ein Gespraech), mit Namen und Rolle. Das Feld steht immer da
statt hinter einem "Antworten"-Knopf -- ein leeres Feld ist die
deutlichste Einladung, die es gibt. Nur EINE Stufe eingerueckt: Auf
390 px ist bei der zweiten Schluss.

Eine EIGENE Tabelle, kein Eintrag mit "antwort_auf": Eine Antwort hat
keine Art, keinen Zustand, keine Frist und gehoert in keinen Filter.
Als Eintrag gefuehrt haette sie zwanzig immer leere Spalten -- und
tauchte in jeder Zaehlung auf, die Beitraege zaehlt.

Und nicht ueberall: nur Treff und Wunschliste. Aus dem Plan, §9 -- ein
Antwortfeld unter jedem Aushang hiesse, jeder Aushang muss betreut
werden.

ZWEI FEHLER, BEIDE VON DER PRUEFUNG GEFUNDEN

1. EIN PFAD, DER AUSSAH WIE EIN BEREICH.
   `/api/bereich/antwort/:id` lief in die Middleware auf
   `/api/bereich/:bereich` -- "antwort" ist kein Bereich. Fuer
   DogFather ging es (ein frueherer Zweig liess ihn durch), fuer einen
   Modi kam 404. Ein Weg, der je nach ROLLE an voellig anderer Stelle
   scheitert, sucht man lange. Liegt jetzt unter `/api/antwort/:id`.

2. UND EIN ECHTES LOCH.
   Die Loeschregel hiess `!darfSchreiben(req.person, "treff")` -- also
   "wer schreiben darf", und das duerfen Gaeste ab der Stufe "dabei".
   JEDER GAST HAETTE JEDE FREMDE ANTWORT LOESCHEN KOENNEN.

   Gefunden hat es eine Pruefung mit einer FALSCHEN Erwartung: Sie
   verlangte, dass ein Modi keine fremde Antwort loeschen kann. Das war
   falsch -- er moderiert ja --, aber der Weg dorthin hat das echte
   Problem freigelegt. Jetzt entscheidet TREFF_TEAM_ROLLEN.

   Der Gast-Fall ist ueber HTTP nicht messbar (er kommt nur ueber die
   crew-Adresse herein, und den Host-Kopf kann fetch nicht setzen).
   Statt stillschweigend zu ueberspringen prueft die Pruefung die REGEL
   selbst -- und dass die Route wirklich diese Menge benutzt.

STUFE 7 -- JEDES BRETT BEKOMMT SEINE FORM
- "Regeln & Hilfe" ist ein DOKUMENT: Sprungmarken oben, vier
  Abschnitte (Regel, Was passiert wenn, Hilfe, Haeufige Frage). Man
  schlaegt es im Streitfall auf und will FINDEN, nicht scrollen.
  scroll-margin-top, sonst verschwindet die angesprungene Ueberschrift
  unter der Kopfleiste und der Sprung sieht aus wie ins Leere.
- "Anschlagbrett" zeigt grosse AUSHAENGE statt Zeilen -- gemessen
  21 px Titel statt 17. Ein Aushang zwischen dreissig anderen ist kein
  Aushang.
- "Mitmachen" gruppiert nach Art, die drei Schritte zuerst.

UND EINE SCHRIFT, DIE ZU KLEIN WAR: Die Rollen-Marke an einer Antwort
stand auf 11,2 px, die Hausgrenze liegt bei 11,5. Die Grenze anzuheben
waere der bequeme Weg gewesen -- und ab da haette pruef-css-klassen
nichts mehr gehalten.

NEU: pruef-gespraech (27 Pruefungen)
pruef-treff-start jetzt 34 (Stufe 7 mitgeprueft)
Alle Community-Pruefungen gruen: gespraech 27, treff-start 34,
kreislauf 23, countdown 22, wunschliste 30, galerie 26, bremse 16,
treff 66, bereiche-lesend, css-klassen, deutsche-texte 12.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
2026-09-17 12:12:21 +02:00
co-authored by Claude Opus 5
parent b396e548ab
commit 79e675b846
36 changed files with 1197 additions and 359 deletions
+324
View File
@@ -0,0 +1,324 @@
/* =====================================================================
STUFE 6: DER TREFF WIRD EIN GESPRÄCH (17.09.2026)
"Der Treff -- Hallo sagen, fragen, loben" war eine Liste aus Karten.
Der einzige Bereich, in dem Menschen MITEINANDER reden sollen,
zwang sie, nebeneinander zu reden: Jeder schrieb eine eigene Karte,
niemand antwortete jemandem.
---------------------------------------------------------------------
EINE EIGENE TABELLE, KEIN HALBER EINTRAG
Eine Antwort hat keine Art, keinen Zustand, keine Frist und gehoert
in keinen Filter. Sie als Eintrag mit `antwort_auf` zu fuehren hiesse,
ihr zwanzig Spalten mitzugeben, die immer leer sind -- und sie
tauchte in jeder Zaehlung auf, die Beitraege zaehlt.
---------------------------------------------------------------------
NICHT UEBERALL
Aus dem Plan, §9: "Keine Kommentarfunktion ueberall. Nur dort, wo ein
Gespraech gemeint ist." Ein Antwortfeld unter jedem Aushang hiesse:
Jeder Aushang muss betreut werden -- und ein unbeantwortetes
Antwortfeld ist schlimmer als keins.
Aufrufen mit: node server/pruef-gespraech.mjs
Bild: SCHIRM=1 node server/pruef-gespraech.mjs
===================================================================== */
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { portMussFreiSein } from "./helfer-port.mjs";
import { heuteLokal } from "./helfer-tag.mjs";
const PORT = await portMussFreiSein(4493, "pruef-gespraech");
const ordner = mkdtempSync(join(tmpdir(), "ws-gespr-"));
process.env.WORKSPACE_DB = join(ordner, "workspace.db");
process.env.PORT = String(PORT);
process.env.SITE_ACCESS_SECRET = "lokaler-test";
process.env.SITE_ACCESS_CODE_DOGI = "DOGI-TEST-1";
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 }); };
import { notbremse } from "./helfer-notbremse.mjs";
await import("./index.js");
notbremse(180_000, "pruef-gespraech");
await new Promise((r) => setTimeout(r, 700));
const BASIS = `http://127.0.0.1:${PORT}`;
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++; };
const { DatabaseSync } = await import("node:sqlite");
const { scryptSync, randomBytes, createHmac } = await import("node:crypto");
const d = new DatabaseSync(process.env.WORKSPACE_DB);
const jetzt = new Date().toISOString();
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);
function anlegen(name, rolle, code) {
const salt = randomBytes(16).toString("hex");
const hash = scryptSync(code, salt, 64,
{ N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex");
d.prepare("INSERT INTO personen"
+ " (name, rolle, code_hash, code_salt, code_n, code_kennung, aktiv, erstellt)"
+ " VALUES (?,?,?,?,?,?,1,?)")
.run(name, rolle, hash, salt, 32768,
createHmac("sha256", schluessel).update(code).digest("hex"), jetzt);
}
anlegen("Filipe", "admin", "CODE-DOGI-0001");
anlegen("Lena", "modi", "CODE-LENA-0001");
const tabellen = d.prepare("SELECT name FROM sqlite_master WHERE type='table'").all()
.map((z) => z.name);
d.close();
async function anmelden(kachel, code) {
const a = await fetch(BASIS + "/workspace/api/anmelden", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ rolle: kachel, code }),
});
return (a.headers.getSetCookie?.() || []).map((z) => z.split(";")[0]).join("; ");
}
const dogi = await anmelden("admin", "CODE-DOGI-0001");
const lena = await anmelden("creator", "CODE-LENA-0001");
const kopf = (keks) => ({ "Content-Type": "application/json", cookie: keks, origin: BASIS });
async function liste(bereich, keks = dogi) {
const a = await fetch(`${BASIS}/workspace/api/bereich/${bereich}`, { headers: { cookie: keks } });
return (await a.json().catch(() => null))?.eintraege || [];
}
async function antworten(bereich, id, text, keks = dogi) {
const a = await fetch(`${BASIS}/workspace/api/bereich/${bereich}/${id}/antwort`, {
method: "POST", headers: kopf(keks), body: JSON.stringify({ text }),
});
return { status: a.status, daten: await a.json().catch(() => null) };
}
/* ---------- Ein Beitrag im Treff ------------------------------------- */
const b = await fetch(`${BASIS}/workspace/api/bereich/treff`, {
method: "POST", headers: kopf(lena),
body: JSON.stringify({ art: "hallo", titel: "Hallo, ich bin neu hier",
text: "Bin über TikTok gekommen.", datum: heuteLokal() }),
});
const beitragId = (await b.json().catch(() => null))?.id || 0;
/* =======================================================================
1. DER PLATZ DAFUER
======================================================================= */
melde("");
melde("=== 1. Die Tabelle ===");
ok(tabellen.includes("eintrag_antworten"), "es gibt eine eigene Tabelle fuer Antworten");
ok(beitragId > 0, `Lena hat etwas geschrieben (#${beitragId})`);
/* =======================================================================
2. EIN GESPRAECH ENTSTEHT
======================================================================= */
melde("");
melde("=== 2. Antworten ===");
{
const a = await antworten("treff", beitragId, "Willkommen! Schön, dass du da bist.");
ok(a.status === 201, `DogFather antwortet (${a.status} ${a.daten?.fehler || ""})`);
const z = await antworten("treff", beitragId, "Danke! Freut mich.", lena);
ok(z.status === 201, `Lena antwortet zurueck (${z.status})`);
const e = (await liste("treff")).find((x) => x.id === beitragId);
ok((e?.antworten || []).length === 2, `zwei Antworten am Beitrag (${(e?.antworten || []).length})`);
/* AELTESTE ZUERST -- so liest man ein Gespraech. Neueste zuerst
waere eine Liste, kein Gespraech. */
ok(e?.antworten?.[0]?.text?.startsWith("Willkommen"),
`die aeltere steht oben ("${e?.antworten?.[0]?.text?.slice(0, 20)}…")`);
ok(e?.antworten?.[0]?.von_name === "Filipe",
`mit Namen (${e?.antworten?.[0]?.von_name})`);
/* WER AUS DEM TEAM ANTWORTET, ist eine andere Auskunft als wer
mitredet. */
ok(!!e?.antworten?.[0]?.von_rolle_name,
`und mit Rolle (${e?.antworten?.[0]?.von_rolle_name})`);
/* "MEINE" ENTSCHEIDET UEBER DEN LOESCHKNOPF -- und muss je Person
verschieden sein, sonst sieht jeder ihn ueberall. */
const beiLena = (await liste("treff", lena)).find((x) => x.id === beitragId);
const meineDogi = (e?.antworten || []).filter((a2) => a2.meine).length;
const meineLena = (beiLena?.antworten || []).filter((a2) => a2.meine).length;
ok(meineDogi === 1 && meineLena === 1,
`jeder sieht genau seine eigene als "meine" (${meineDogi} / ${meineLena})`);
}
/* =======================================================================
3. NICHT UEBERALL
======================================================================= */
melde("");
melde("=== 3. Wo es KEIN Gespraech gibt ===");
{
const an = await fetch(`${BASIS}/workspace/api/bereich/anschlag`, {
method: "POST", headers: kopf(dogi),
body: JSON.stringify({ art: "ansage", titel: "Eine Ansage", datum: heuteLokal() }),
});
const id = (await an.json().catch(() => null))?.id;
const a = await antworten("anschlag", id, "Darf ich hier antworten?");
ok(a.status === 404, `am Anschlagbrett nicht (${a.status})`);
const e = (await liste("anschlag")).find((x) => x.id === id);
ok(e && e.antworten === undefined,
"und die Ansage bringt gar kein Antwortfeld mit");
/* GEGENPROBE: In der Wunschliste geht es sehr wohl -- dort ist ein
Gespraech gemeint ("ich auch, weil ..."). */
const w = await fetch(`${BASIS}/workspace/api/bereich/wunsch`, {
method: "POST", headers: kopf(lena),
body: JSON.stringify({ art: "stream", titel: "Ein Wunsch", datum: heuteLokal() }),
});
const wid = (await w.json().catch(() => null))?.id;
const wa = await antworten("wunsch", wid, "Das will ich auch, weil …");
ok(wa.status === 201, `in der Wunschliste dagegen schon (${wa.status})`);
}
/* =======================================================================
4. WAS NICHT DURCHGEHT
======================================================================= */
melde("");
melde("=== 4. Grenzen ===");
{
const leer = await antworten("treff", beitragId, " ");
ok(leer.status === 400, `eine leere Antwort nicht (${leer.status})`);
const lang = await antworten("treff", beitragId, "x".repeat(1500));
ok(lang.status === 400, `eine zu lange auch nicht (${lang.status})`);
const weg = await antworten("treff", 999999, "An nichts");
ok(weg.status === 404, `an einen erfundenen Beitrag ebenfalls nicht (${weg.status})`);
}
/* =======================================================================
5. LOESCHEN
======================================================================= */
melde("");
melde("=== 5. Wieder weg ===");
{
const e = (await liste("treff")).find((x) => x.id === beitragId);
const vonLena = (e?.antworten || []).find((a) => a.von_name === "Lena");
/* DIE EIGENE IMMER. */
const a = await fetch(`${BASIS}/workspace/api/antwort/${vonLena.id}`,
{ method: "DELETE", headers: { cookie: lena, origin: BASIS } });
const atext = await a.text().catch(() => "");
ok(a.status === 200, `Lena loescht ihre eigene (${a.status} ${atext.slice(0,80)}) id=${vonLena.id}`);
const e2 = (await liste("treff")).find((x) => x.id === beitragId);
ok((e2?.antworten || []).length === 1, `eine bleibt uebrig (${(e2?.antworten || []).length})`);
/* UND DAS TEAM AUCH FREMDE -- sonst waere Moderation nicht moeglich. */
const rest = e2.antworten[0];
const b2 = await fetch(`${BASIS}/workspace/api/antwort/${rest.id}`,
{ method: "DELETE", headers: { cookie: dogi, origin: BASIS } });
ok(b2.status === 200, `das Team loescht auch fremde (${b2.status})`);
/* EIN MODI DARF FREMDE LOESCHEN -- er moderiert den Treff. Das war
beim Schreiben dieser Pruefung meine falsche Erwartung; der Weg
dorthin hat aber ein echtes Loch freigelegt (siehe Kommentar an
der Route): Die Regel hiess "wer schreiben darf", und das duerfen
auch Gaeste. */
const c = await antworten("treff", beitragId, "Noch eine von DogFather");
const e3 = (await liste("treff")).find((x) => x.id === beitragId);
const fremd = e3.antworten.find((x) => x.id === c.daten?.id);
const d2 = await fetch(`${BASIS}/workspace/api/antwort/${fremd.id}`,
{ method: "DELETE", headers: { cookie: lena, origin: BASIS } });
ok(d2.status === 200, `ein Modi darf fremde loeschen -- er moderiert (${d2.status})`);
/* UND DIE ENTSCHEIDENDE GEGENPROBE: EIN GAST DARF NICHT.
Ohne sie waere nie aufgefallen, dass die Regel urspruenglich
`!darfSchreiben(...)` hiess -- also "wer schreiben darf", und das
duerfen Gaeste ab der Stufe "dabei". Jeder Gast haette jede fremde
Antwort loeschen koennen.
UEBER HTTP IST DAS HIER NICHT MESSBAR, und das wird gesagt statt
verschwiegen: Ein Gast kommt nur ueber die crew-Adresse herein, und
den Host-Kopf kann `fetch` nicht setzen (Node verbietet es). Statt
die Pruefung stillschweigend zu ueberspringen, wird die REGEL
selbst geprueft -- dieselbe Menge, die die Route benutzt. */
const { TREFF_TEAM_ROLLEN } = await import("./workspace-treff.js");
ok(TREFF_TEAM_ROLLEN.has("modi") && TREFF_TEAM_ROLLEN.has("hand")
&& TREFF_TEAM_ROLLEN.has("admin"),
`das Moderationsteam ist ${[...TREFF_TEAM_ROLLEN].join(", ")}`);
ok(!TREFF_TEAM_ROLLEN.has("gast"),
"und ein Gast gehoert nicht dazu -- er kann keine fremde Antwort loeschen");
/* Und die Route benutzt wirklich DIESE Menge und nicht wieder
`darfSchreiben`. Ein Kommentar daneben wuerde es nicht verhindern. */
const { readFileSync } = await import("node:fs");
const quelle = readFileSync(new URL("./workspace-bereiche.js", import.meta.url), "utf8");
const stelle = quelle.slice(quelle.indexOf("/workspace/api/antwort/:id"));
ok(/TREFF_TEAM_ROLLEN\.has\(req\.person\?\.rolle\)/.test(stelle.slice(0, 2000)),
"und die Route entscheidet mit genau dieser Menge");
}
/* =======================================================================
6. IM BROWSER
======================================================================= */
melde("");
melde("=== 6. Wie es aussieht ===");
{
const { chromium } = await import(
"file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs");
const browser = await chromium.launch();
try {
const kontext = await browser.newContext({ viewport: { width: 390, height: 900 } });
await kontext.addCookies(dogi.split("; ").map((k) => {
const [name, ...rest] = k.split("=");
return { name, value: rest.join("="), domain: "127.0.0.1", path: "/" };
}));
const seite = await kontext.newPage();
const konsole = [];
seite.on("console", (e) => { if (e.type() === "error") konsole.push(e.text()); });
await seite.goto(`${BASIS}/workspace/bereich.html?b=treff`, { waitUntil: "networkidle" });
await seite.waitForSelector(".antwort-neu__feld", { timeout: 15_000 });
await seite.waitForTimeout(400);
/* UEBER DIE OBERFLAECHE ANTWORTEN, nicht ueber die Schnittstelle --
ein Feld, das der Server annimmt und das Formular nicht
abschickt, waere gruen und nutzlos. */
await seite.fill(".antwort-neu__feld", "Geschrieben im Browser");
await seite.click(".antwort-neu__ab");
await seite.waitForTimeout(1500);
const m = await seite.evaluate(() => ({
antworten: document.querySelectorAll(".antwort").length,
texte: [...document.querySelectorAll(".antwort__text")].map((z) => z.textContent.trim()),
/* Nur EINE Stufe eingerueckt -- auf 390 px ist bei der zweiten
Schluss. */
verschachtelt: document.querySelectorAll(".antworten .antworten").length,
feldBreit: Math.round(
document.querySelector(".antwort-neu__feld")?.getBoundingClientRect().width || 0),
}));
ok(m.antworten >= 1, `${m.antworten} Antworten stehen da`);
ok(m.texte.some((t) => t === "Geschrieben im Browser"),
"die im Browser geschriebene ist dabei");
ok(m.verschachtelt === 0, "nur eine Stufe eingerueckt");
/* AUF DEM HANDY MUSS MAN TIPPEN KOENNEN. Ein Feld von 80 px ist
kein Feld. */
ok(m.feldBreit > 150, `das Feld ist auf 390 px noch benutzbar (${m.feldBreit} px)`);
ok(konsole.length === 0,
konsole.length ? `Konsole: ${konsole[0]}` : "keine Fehler in der Konsole");
if (process.env.SCHIRM) {
await seite.screenshot({ path: fileURLToPath(new URL("pruef-gespraech.png",
import.meta.url)), fullPage: false });
melde(" -- Bildschirmfoto: server/pruef-gespraech.png");
}
await kontext.close();
} finally {
await browser.close();
}
}
melde("");
melde(`${geprueft} Pruefungen, ${fehler} Fehler`);
melde(fehler ? "NICHT IN ORDNUNG" : "ALLES IN ORDNUNG");
try { rmSync(ordner, { recursive: true, force: true }); } catch { /* Aufraeumrest */ }
process.exit(fehler ? 1 : 0);