Files
dogfather-universe/server/pruef-treff-start.mjs
T
DogFatherGitandClaude Opus 5 7701f5e5e5 47 fertige Inhalte statt acht leerer Bretter
Filipe, mit drei Bildschirmfotos nebeneinander -- Der Treff, das
Anschlagbrett, Was ansteht, alle drei mit demselben grauen Kasten:

  "ich meine alle kategorien sehen einfach so scheisse und leer aus,
   ich will fertige sachen schon drin wo auch die community und alle
   anderen benutzen koennen"

Er hat recht, und das ist mein Fehler: Es stand in MEINEM Plan, §5.1
-- "Acht leere Seiten sind schlimmer als keine." Ich habe es
aufgeschrieben und danach fuenf Stufen lang Technik gebaut.

Dreimal derselbe Satz auf drei Bildschirmen sieht nicht nach "neu" aus,
sondern nach defekt.

WAS JETZT BEREITLIEGT (47 Eintraege)
  Regeln & Hilfe   15   das Nachschlagewerk: sechs Regeln, drei
                        Folgen-Erklaerungen, drei Hilfen, drei Fragen
  Mitmachen        11   der Weg in drei Schritten, vier Aufgabengebiete,
                        vier ehrliche Fragen ("Was habe ich davon?")
  Der Treff         6   Gespraechsanfaenge, auf die man in einem Satz
                        antworten kann
  Anschlagbrett     5   Willkommen, Ablauf, wo was hingehoert, Danke
  Wunschliste       4   Beispiele, die zeigen, wie ein guter Wunsch
                        aussieht
  Was ansteht       4   Terminvorlagen
  Highlights        2   was hier hingehoert

NICHT AUTOMATISCH BEIM START. Ein Text, den niemand gelesen hat,
stuende sonst als Ansage des Teams auf einer oeffentlichen Seite.
"Bleibt fair" klingt harmlos, bis jemand fragt, was das im Streitfall
heisst -- und dann muss dahinterstehen, WER es gemeint hat. Uebernehmen
ist eine Entscheidung; automatisch fuellen waere eine Behauptung. Und
jeder Text laesst sich vorher aendern.

ZWEI BEREICHE SIND KEIN KATALOG, SONDERN FERTIGE SEITEN: "Regeln &
Hilfe" und "Mitmachen" sind Nachschlagewerke, keine Feeds. Ein leeres
Regelwerk ist schlimmer als gar keins -- im Streitfall hat dann niemand
etwas in der Hand. Die Regeln sagen ausserdem jeweils, was PASSIERT,
nicht nur was verboten ist, und die Hilfe nennt die Telefonseelsorge.

UND JEDER BEREICH SAGT JETZT SEINEN EIGENEN SATZ, wenn er leer ist:
"Gerade gilt nichts Besonderes. Das ist eine gute Nachricht." /
"Noch hat niemand Hallo gesagt. Sei der Erste -- ein Satz reicht."

DIE FALLE, DIE ICH MIR SELBST GEBAUT HATTE: Die Bremse aus Stufe 4
zaehlt zehn Beitraege je Stunde. Wer fuenfzehn Regeln uebernimmt,
haette nach der zehnten eine halb gefuellte Seite und die Meldung, er
habe zu viel geschrieben -- genau die Sorte Sicherung, die das richtige
Verhalten bestraft und deshalb abgeschaltet wird. Das Uebernehmen ist
eine einzelne Handlung des Teams und geht nicht durch die Bremse; die
Pruefung weist beides nach.

NEU: pruef-treff-start (27 Pruefungen)
Der Katalog selbst (jede Art gibt es im Bereich, jeder Text sagt mehr
als sein Titel, kein Titel doppelt, echte Umlaute), das Uebernehmen
(einzeln, alle, zweimal verdoppelt nichts), die Verborgenheit, die
sieben verschiedenen Leertexte -- und im Browser: elf Vorschlaege,
ein Klick, elf Eintraege, Katalog verschwindet.

pruef-treff 66, pruef-bremse 16, pruef-deutsche-texte 12,
pruef-bereiche-lesend, pruef-css-klassen: gruen.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-17 11:51:15 +02:00

339 lines
15 KiB
JavaScript

/* =====================================================================
FERTIGE INHALTE STATT LEERER BRETTER (17.09.2026)
Filipe, mit drei Bildschirmfotos nebeneinander -- Der Treff, das
Anschlagbrett, Was ansteht, alle drei mit demselben grauen Kasten
"Noch kein Eintrag in diesem Bereich":
"ich meine alle kategorien sehen einfach so scheisse und leer aus,
ich will fertige sachen schon drin wo auch die community und alle
anderen benutzen koennen"
Er hatte recht, und es stand sogar im Plan (§5.1): "Acht leere Seiten
sind schlimmer als keine." Ein Bereich, der nur aus Filterknoepfen
und einem grauen Kasten besteht, sieht nicht neu aus, sondern
aufgegeben -- und dreimal derselbe Satz sieht aus wie ein Defekt.
---------------------------------------------------------------------
WAS HIER GEPRUEFT WIRD
1. Der Katalog selbst: Passt jede Art zu ihrem Bereich? Ist jeder
Text mehr als eine Ueberschrift?
2. Das Uebernehmen: einzeln, alle auf einmal -- und zweimal
uebernehmen darf nichts verdoppeln.
3. UND DER FALL, DEN ICH MIR SELBST GEBAUT HABE: Die Bremse aus
Stufe 4 zaehlt Beitraege pro Stunde. Wer fuenfzehn Regeln
uebernimmt, haette nach der zehnten eine halb gefuellte Seite
und die Meldung, er habe zu viel geschrieben.
4. Die Verborgenheit: Wer hier nicht schreiben darf, bekommt den
Katalog gar nicht.
Aufrufen mit: node server/pruef-treff-start.mjs
===================================================================== */
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { portMussFreiSein } from "./helfer-port.mjs";
import { ersatzschreibungen } from "./helfer-deutsch.mjs";
const PORT = await portMussFreiSein(4489, "pruef-treff-start");
const ordner = mkdtempSync(join(tmpdir(), "ws-start-"));
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-treff-start");
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 { TREFF_START } = await import("./workspace-treff-start.js");
const { BEREICHE, BEITRAEGE_JE_STUNDE } = await import("./workspace-bereiche.js");
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("Tili", "creator", "CODE-CREA-0001");
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 crea = await anmelden("creator", "CODE-CREA-0001");
const kopf = (keks) => ({ "Content-Type": "application/json", cookie: keks, origin: BASIS });
/* =======================================================================
1. DER KATALOG SELBST
======================================================================= */
melde("");
melde("=== 1. Was bereitliegt ===");
{
const gesamt = Object.values(TREFF_START).reduce((n, l) => n + l.length, 0);
ok(gesamt >= 40, `${gesamt} fertige Eintraege in ${Object.keys(TREFF_START).length} Bereichen`);
/* JEDE ART MUSS ES IM BEREICH GEBEN. Sonst landet beim Uebernehmen
eine Art in der Datenbank, die das Formular nicht kennt -- und die
Zeile laesst sich hinterher nicht mehr aendern. */
const falsch = [];
for (const [bereich, liste] of Object.entries(TREFF_START)) {
const arten = BEREICHE[bereich]?.arten || {};
for (const v of liste) if (!Object.hasOwn(arten, v.art)) falsch.push(`${bereich}/${v.art}`);
}
ok(falsch.length === 0,
falsch.length ? `unbekannte Arten: ${falsch.join(", ")}` : "jede Art gibt es in ihrem Bereich");
/* EIN TEXT, DER NUR DEN TITEL WIEDERHOLT, ist keiner. Dieselbe Regel
wie beim Aufgabenkatalog der Modis. */
const duenn = [];
for (const [bereich, liste] of Object.entries(TREFF_START)) {
for (const v of liste) {
if (!v.text || v.text.length < v.titel.length + 25) duenn.push(`${bereich}/${v.titel}`);
}
}
ok(duenn.length === 0,
duenn.length ? `zu duenn: ${duenn[0]}` : "jeder Text sagt mehr als sein Titel");
/* KEINE DOPPELTEN TITEL innerhalb eines Bereichs -- das Uebernehmen
erkennt "schon da" am Titel. */
let doppelt = 0;
for (const liste of Object.values(TREFF_START)) {
doppelt += liste.length - new Set(liste.map((v) => v.titel)).size;
}
ok(doppelt === 0, `kein Titel doppelt (${doppelt})`);
/* UND SIE TRAGEN UMLAUTE. Dieselbe Falle wie bei den Kategorien und
den Bereichsnamen -- beide Male auf einem Bildschirmfoto gefunden. */
const ascii = [];
for (const [bereich, liste] of Object.entries(TREFF_START)) {
for (const v of liste) {
const t = ersatzschreibungen(`${v.titel} ${v.text}`);
if (t.length) ascii.push(`${bereich}/${v.titel} [${t.join(",")}]`);
}
}
ok(ascii.length === 0,
ascii.length ? `Ersatzschreibungen: ${ascii[0]}` : "alle Texte tragen echte Umlaute");
/* DIE BEIDEN NACHSCHLAGEWERKE muessen ordentlich gefuellt sein. Ein
leeres Regelwerk ist schlimmer als gar keins: Im Streitfall hat
dann niemand etwas in der Hand. */
ok((TREFF_START.regeln || []).length >= 12,
`Regeln & Hilfe hat ${(TREFF_START.regeln || []).length} Eintraege`);
ok((TREFF_START.mitmachen || []).length >= 8,
`Mitmachen hat ${(TREFF_START.mitmachen || []).length}`);
/* UND DIE HILFE NENNT EINE ECHTE NUMMER. Ein Community-Bereich fuer
junge Leute ohne Notrufnummer ist eine vertane Gelegenheit. */
ok((TREFF_START.regeln || []).some((v) => /0800 111 0 111|116 111/.test(v.text || "")),
"und die Hilfe nennt die Telefonseelsorge");
}
/* =======================================================================
2. UEBERNEHMEN
======================================================================= */
melde("");
melde("=== 2. Uebernehmen ===");
{
const a = await fetch(`${BASIS}/workspace/api/bereich/treff/start`, { headers: { cookie: dogi } });
const j = await a.json().catch(() => null);
ok(a.status === 200 && (j?.vorschlaege || []).length > 0,
`der Katalog kommt (${j?.offen} offen)`);
const e = await fetch(`${BASIS}/workspace/api/bereich/treff/start`, {
method: "POST", headers: kopf(dogi), body: JSON.stringify({ nr: 0 }),
});
const ej = await e.json().catch(() => null);
ok(e.status === 201 && ej?.angelegt === 1, `einer einzeln (${ej?.angelegt})`);
/* ZWEIMAL DARF NICHTS VERDOPPELN. Wer den Knopf zweimal drueckt --
oder zwei Leute gleichzeitig --, soll dieselbe Seite bekommen. */
const z = await fetch(`${BASIS}/workspace/api/bereich/treff/start`, {
method: "POST", headers: kopf(dogi), body: JSON.stringify({ nr: 0 }),
});
const zj = await z.json().catch(() => null);
ok(zj?.angelegt === 0 && zj?.schon === 1, `zweimal legt nichts nach (${zj?.angelegt})`);
}
/* =======================================================================
3. DIE BREMSE DARF DAS NICHT VERHINDERN
======================================================================= */
melde("");
melde("=== 3. Fuenfzehn Regeln auf einmal ===");
{
const wie_viele = (TREFF_START.regeln || []).length;
ok(wie_viele > BEITRAEGE_JE_STUNDE,
`es sind ${wie_viele} Regeln -- mehr als die Bremse je Stunde zulaesst (${BEITRAEGE_JE_STUNDE})`);
const a = await fetch(`${BASIS}/workspace/api/bereich/regeln/start`, {
method: "POST", headers: kopf(dogi), body: "{}",
});
const j = await a.json().catch(() => null);
/* DAS IST DER PUNKT. Ginge der Weg durch die Bremse, stuenden hier
zehn Regeln und eine Fehlermeldung -- eine halb gefuellte Seite
mit dem Vorwurf, man habe zu viel geschrieben. Genau die Sorte
Sicherung, die das richtige Verhalten bestraft. */
ok(a.status === 201 && j?.angelegt === wie_viele,
`alle ${wie_viele} auf einmal (${j?.angelegt})`);
const l = await fetch(`${BASIS}/workspace/api/bereich/regeln`, { headers: { cookie: dogi } });
const lj = await l.json().catch(() => null);
ok((lj?.eintraege || []).length === wie_viele,
`und sie stehen wirklich da (${(lj?.eintraege || []).length})`);
/* GEGENPROBE: Die Bremse ist deshalb nicht abgeschaltet. Ein
gewoehnlicher Beitrag danach muss sie spueren. */
let letzte = 0;
for (let i = 0; i < BEITRAEGE_JE_STUNDE + 2; i++) {
const r = await fetch(`${BASIS}/workspace/api/bereich/treff`, {
method: "POST", headers: kopf(dogi),
body: JSON.stringify({ art: "frage", titel: `Frage Nummer ${i}`,
datum: jetzt.slice(0, 10) }),
});
letzte = r.status;
if (r.status === 429) break;
}
ok(letzte === 429, `die Bremse greift trotzdem noch (${letzte})`);
}
/* =======================================================================
4. WER DEN KATALOG NICHT BEKOMMT
======================================================================= */
melde("");
melde("=== 4. Die Verborgenheit ===");
{
const a = await fetch(`${BASIS}/workspace/api/bereich/regeln/start`, { headers: { cookie: crea } });
ok(a.status === 404, `ein Creator bekommt ihn nicht (${a.status})`);
const b = await fetch(`${BASIS}/workspace/api/bereich/regeln/start`, {
method: "POST", headers: kopf(crea), body: "{}",
});
ok(b.status === 403 || b.status === 404,
`und kann auch nichts uebernehmen (${b.status})`);
}
/* =======================================================================
5. JEDER BEREICH SAGT ETWAS ANDERES, WENN ER LEER IST
======================================================================= */
melde("");
melde("=== 5. Die leeren Bretter ===");
{
/* DREI BILDSCHIRMFOTOS MIT DEMSELBEN SATZ sahen aus wie ein Defekt.
Jetzt sagt jeder Bereich in seinen eigenen Worten, was hier
hingehoert. */
const saetze = Object.entries(BEREICHE)
.filter(([k]) => TREFF_START[k])
.map(([k, e]) => [k, e.leer || ""]);
ok(saetze.every(([, t]) => t.length > 25),
`alle ${saetze.length} Community-Bereiche haben einen eigenen Leertext`);
ok(new Set(saetze.map(([, t]) => t)).size === saetze.length,
"und keiner davon ist derselbe");
ok(saetze.every(([, t]) => !/Noch kein Eintrag in diesem Bereich/.test(t)),
"der alte Einheitssatz kommt nicht mehr vor");
}
/* =======================================================================
6. IM BROWSER: VORHER UND NACHHER
======================================================================= */
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: 1100, height: 950 } });
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()); });
/* "mitmachen" ist noch unberuehrt -- dort ist der Katalog voll. */
await seite.goto(`${BASIS}/workspace/bereich.html?b=mitmachen`, { waitUntil: "networkidle" });
await seite.waitForSelector("#startkatalog:not([hidden])", { timeout: 15_000 });
await seite.waitForTimeout(400);
const vorher = await seite.evaluate(() => ({
karten: document.querySelectorAll(".startkatalog__karte").length,
alleKnopf: document.querySelector(".startkatalog__alle")?.textContent?.trim() || "",
leer: document.querySelector(".leer-hinweis")?.textContent?.trim() || "",
}));
ok(vorher.karten >= 8, `${vorher.karten} Vorschlaege stehen bereit`);
ok(/Alle \d+ übernehmen/.test(vorher.alleKnopf), `mit einem Knopf ("${vorher.alleKnopf}")`);
/* DER LEERTEXT IST DER DES BEREICHS, nicht der Einheitssatz. */
ok(vorher.leer.includes("dazugehören"),
`und der leere Bereich sagt seinen eigenen Satz ("${vorher.leer}")`);
if (process.env.SCHIRM) {
const { fileURLToPath } = await import("node:url");
await seite.screenshot({ path: fileURLToPath(new URL("pruef-treff-start-vorher.png",
import.meta.url)), fullPage: false });
}
await seite.click(".startkatalog__alle");
await seite.waitForTimeout(1500);
const nachher = await seite.evaluate(() => ({
katalogWeg: !!document.getElementById("startkatalog")?.hidden,
eintraege: document.querySelectorAll(".eintrag-karte").length,
leerWeg: !document.querySelector(".leer-hinweis"),
}));
ok(nachher.eintraege >= 8, `nach einem Klick stehen ${nachher.eintraege} Eintraege da`);
ok(nachher.leerWeg, "der leere Kasten ist weg");
/* UND DER KATALOG VERSCHWINDET, wenn nichts mehr zu holen ist --
ein Angebot, das leer weiterhin dasteht, ist eine Sackgasse. */
ok(nachher.katalogWeg, "und der Katalog verschwindet, weil nichts mehr offen ist");
ok(konsole.length === 0,
konsole.length ? `Konsole: ${konsole[0]}` : "keine Fehler in der Konsole");
if (process.env.SCHIRM) {
const { fileURLToPath } = await import("node:url");
await seite.screenshot({ path: fileURLToPath(new URL("pruef-treff-start-nachher.png",
import.meta.url)), fullPage: false });
melde(" -- Bilder: server/pruef-treff-start-vorher.png und -nachher.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);