Filipe, 22.09.2026: "ersetze die zentrale durch, Die IrrenAnstalt.
genau so geschrieben bitte. und alles was treff heißt oder wo treff
steht soll durch Rudel ersetzt werden bitte."
Die Schreibweise "IrrenAnstalt" mit grossem A in der Mitte ist so
gewollt. Das steht als Hinweis daneben, damit sie beim naechsten Mal
niemand "korrigiert".
WAS UMBENANNT WURDE: alles, was jemand LIEST -- Kachelnamen,
Ueberschriften, Markenzeilen, Saetze, Meldungen, Aufgabenvorlagen.
116 Zeilen in 42 Dateien.
WAS BLEIBT: Adressen (treff-regeln.html), Bezeichner (TREFF_ROLLEN),
Datenbankwerte (bereich = 'treff'), Abfrageteile (b=treff). Eine
Adresse umzubenennen bricht jedes Lesezeichen, und ein Datenbankwert
umzuschreiben waere eine Umstellung ohne Gegenwert. Dieselbe
Entscheidung wie heute frueh bei Dogi-Media, wo material.html auch
material.html geblieben ist.
DREI GRAMMATIKFEHLER IM EIGENEN ENTWURF, alle vor dem Ausliefern
gefunden -- ein blindes Ersetzen reicht hier nicht:
1. "der Treff" ist maennlich, "das Rudel" saechlich. Ohne Tabelle
waere ueberall "Der Rudel" gestanden. (Und "Treffer" waere zu
"Rudeler" geworden, "Treffen" zu "Rudelen" -- deshalb greift die
Regel nur bei Wortgrenze und nie vor einem Kleinbuchstaben.)
2. BINDESTRICH-ZUSAMMENSETZUNGEN. In "der Treff-Chat" gehoert der
Artikel zu "Chat", nicht zu "Treff". Der erste Durchlauf machte
daraus "das Rudel-Chat", "das Rudel-Kacheln" und "ein Rudel-Raum".
Jetzt greift die Artikelregel nur, wenn das Wort allein steht.
3. GESCHUETZTE LEERZEICHEN. In den Markenzeilen steht
`Der Treff`, damit die zwei Woerter nicht umbrechen. Das
Muster hat daran vorbeigegriffen: "Der Rudel", auf fuenf
Seiten. Jetzt wird das Trennzeichen mitgefasst und unveraendert
wieder eingesetzt.
Gefunden wurden alle drei, weil jede einzelne der 116 Zeilen vor dem
Schreiben als ALT/NEU ausgegeben und gelesen wurde -- und danach
gezielt nach falschen Artikeln gesucht ("den Rudel", "der Rudel",
"einen Rudel"). Uebrig blieben sechs Treffer, und die sind alle
richtig: "einen Rudel-Raum", "der Rudel-Chat", "den Rudel-Regeln" --
Zusammensetzungen, bei denen der Artikel zum letzten Wort gehoert.
UND ZWEI PRUEFUNGEN, die die Umbenennung nicht mitbekommen haetten:
`/Treff/.test(...)` und `/Treff/i.test(...)`. Ein regulaerer Ausdruck
ist keine Zeichenkette -- sie haetten ab sofort nach einem Wort
gesucht, das die Seite nicht mehr sagt, und waeren still rot geworden,
ohne dass etwas kaputt ist.
Geprueft, alle gruen: pruef-treff, pruef-treffchat,
pruef-deutsche-texte, pruef-uebernahme, pruef-crew-adresse,
pruef-willkommen, pruef-wege-nach-draussen, pruef-vorlagen,
pruef-start-ansicht, pruef-struktur.
Co-Authored-By: Claude Opus 5 <[email protected]>
325 lines
15 KiB
JavaScript
325 lines
15 KiB
JavaScript
/* =====================================================================
|
|
STUFE 6: DER TREFF WIRD EIN GESPRÄCH (17.09.2026)
|
|
|
|
"Das Rudel -- 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 { eigenerPort } from "./helfer-port.mjs";
|
|
import { heuteLokal } from "./helfer-tag.mjs";
|
|
|
|
const PORT = await eigenerPort(import.meta, "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);
|