Files
dogfather-universe/server/pruef-serien.mjs
T
DogFatherGitandClaude Opus 5 4eab64bd20 Eine neue Form fuer jedes Modul -- und der Kalender zeigt nur noch, was einen angeht
Filipe: "DU HAST WIEDER EINE KLEINE AENDERUNG UEBERALL GEMACHT ANSTATT
EINE RIESEN AENDERUNG." Er hatte recht, und der Grund war jedes Mal
derselbe: Ich habe das MATERIAL getauscht (Mattglas, Leuchtschiene,
Verlauf) und die FORM gelassen. Ein abgerundetes Rechteck bleibt ein
abgerundetes Rechteck -- und die Silhouette ist das Einzige, was man aus
fuenf Metern erkennt.

Jetzt ist die Form eine andere: abgeschnittene Ecke oben links
(clip-path, echte Silhouette), ein Kantenlicht in der Kategoriefarbe
darauf, Eckwinkel unten rechts, ein feines Raster statt Koernung.
35 Bauteile auf allen 18 Seiten, in einer eigenen Datei (module.css).

DREI DINGE, DIE DABEI SCHIEFGINGEN UND JETZT ABGESICHERT SIND

1. Die Regeln standen in :where() -- Spezifitaet null, also gewann jede
   aeltere .kachel::before-Regel. Jetzt :is().
2. module.css stand an DRITTER Stelle im Ladeweg. Auf Calls,
   Automationen, Bereich und Dateien hat sie damit gar nichts bewirkt:
   Die Seitendateien setzen dort selbst border-radius und box-shadow und
   kommen spaeter. Genau das ergab wieder "ueberall ein bisschen". Sie
   wird jetzt als LETZTE geladen, geprueft auf allen 18 Seiten.
3. Die Klassenliste steht siebenmal in der Datei (CSS kennt keine
   Variable fuer Selektoren). Eine vergessene Kopie faellt niemandem
   auf -- pruef-css-klassen vergleicht sie deshalb alle, mit Gegenprobe.

DER KALENDER: NUR NOCH, WAS EINEN ANGEHT

Filipe: "calls oder termine soll jeder nur sehen die er selber macht
oder jeden betrifft." Das gilt auch fuer DogFather, und das ist die
eigentliche Aenderung -- er bekam bisher 1=1. "Jeden betrifft" heisst:
ohne Gegenueber und ohne Teilnehmerliste. Wer niemanden eintraegt, meint
alle. Die Rollenfrage entfaellt im Kalender damit vollstaendig.

SECHS PRUEFUNGEN, DIE EINE WELT GEMESSEN HABEN, DIE ES NICHT MEHR GIBT

Alle sechs wurden auf die neue Regel umgeschrieben, keine geloescht --
loeschen haette die Zahl gesenkt und den neuen Weg ungeprueft gelassen:
ics, serien, sicht, spicy, teilnehmer, tagesblick, team.

UND VIER ECHTE FUNDE, DIE DABEI HERAUSFIELEN

* pruef-workspace-seiten war seit dem Bildumbau von heute Frueh auf
  ALLEN 32 Seiten rot: Sie verlangte noch das eine Motiv. Die neue
  Bedingung ist schaerfer als beide alten -- das geladene Bild muss zu
  dem Merkmal passen, das die Seite selbst traegt. Und die Meldung sagt
  jetzt, WELCHE Bedingung gefallen ist.
* pruef-leistung-optik hat sich selbst ausgesperrt (meldete sich als
  Manager an, der die Zahlen seit Filipes Anweisung nicht mehr sehen
  darf) und stuerzte danach ab: 2 Pruefungen statt 56. Der Absturz hat
  verdeckt, dass sie sieben Fingerziele als "zu klein" meldete -- es
  waren die unsichtbaren Knoepfe der zugeklappten Woche, 0x0.
* personen.html beim Manager: Der Erklaersatz ("Codes, Sperren und das
  Protokoll bleiben bei DogFather") kam nie an -- das Element gab es
  nicht, und das && davor hat den Fehlgriff verschluckt. Die Liste stand
  ausserdem dauerhaft auf aria-busy="true": fuer einen Screenreader lud
  die Seite fuer immer.
* pruef-schranke hielt personen.html noch fuer DogFather-only. Die Seite
  wechselt jetzt die Erwartung, statt aus der Pruefung zu verschwinden:
  Seite auf fuer die Leitung, Verwaltungsdaten dahinter weiterhin zu.

Die Anmeldeseite wurde nicht angefasst (nur ihr Versionsstempel).

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-07 07:12:48 +02:00

486 lines
25 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* =====================================================================
Prüfung der wiederkehrenden Termine (02.09.2026).
Drei Teile, und der erste ist der wichtigste:
1. DIE RECHNUNG, gegen von Hand nachgeschlagene Datumsangaben.
Eine Terminserie ist genau die Sorte Sache, die still falsch sein
kann: "jeden letzten Freitag" um eine Woche daneben fällt beim
Ansehen niemandem auf, sondern erst, wenn jemand vor einem leeren
Meeting sitzt. Deshalb werden hier feste Daten geprüft und nicht
"es kommt irgendetwas heraus".
2. DER WEG DURCH DIE ANWENDUNG: anlegen, nachfüllen, einen einzelnen
Tag streichen, abstellen, wieder anstellen.
3. DIE OBERFLÄCHE im echten Browser, samt der Frage, ob Formular und
Serverliste DENSELBEN Satz sagen.
Zu jeder Prüfung, die "alles in Ordnung" melden kann, gehört eine
GEGENPROBE, die beweist, dass sie auch "nicht in Ordnung" sagen kann
(Projektregel vom 01.09.2026). Sie stehen unten ausdrücklich als
solche gekennzeichnet. Und die ANZAHL der gelaufenen Prüfungen wird
am Ende gegen eine Erwartung gehalten -- ein grüner Lauf mit der
halben Menge Prüfungen ist kein grüner Lauf.
===================================================================== */
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
const ordner = mkdtempSync(join(tmpdir(), "ws-serien-"));
process.env.WORKSPACE_DB = join(ordner, "workspace.db");
process.env.PORT = "4189";
process.env.SITE_ACCESS_SECRET = "lokaler-test";
process.env.SITE_ACCESS_CODE_DOGI = "DOGI-TEST-1";
process.env.SITE_ACCESS_CODE_VANVAN = "VANVAN-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");
/* DIE NOTBREMSE. Muss NACH index.js stehen: Der Server haengt dort
zwei Auffangnetze ein, die einen Absturz nur protokollieren --
richtig fuer den Betrieb, fatal fuer eine Pruefung. Ohne diese
Zeile bleibt der Prozess nach einem Fehler ewig stehen, weil der
Server ihn am Leben haelt (siehe helfer-notbremse.mjs). */
notbremse(480_000, "pruef-serien");
await new Promise((r) => setTimeout(r, 700));
const BASIS = "http://127.0.0.1:4189";
setTimeout(() => { console.log("ABBRUCH"); process.exit(1); }, 200_000).unref?.();
const tagText = (d = new Date()) =>
`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`
+ `-${String(d.getDate()).padStart(2, "0")}`;
let fehler = 0;
let geprueft = 0;
const ok = (b, t) => { geprueft++; console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; };
const gleich = (a, b, t) => ok(JSON.stringify(a) === JSON.stringify(b),
`${t}${JSON.stringify(a) === JSON.stringify(b) ? "" : `\n erwartet ${JSON.stringify(b)}\n bekommen ${JSON.stringify(a)}`}`);
/* ===================================================================
TEIL 1 — Die Rechnung
===================================================================
Alle Daten hier sind von Hand nachgeschlagen. 2026 ist KEIN
Schaltjahr, der 1. September 2026 ist ein Dienstag. */
const { serienTage, taktText, serienText } = await import("./workspace-serien.js");
const regel = (o) => ({ uhrzeit: "18:00", ende_tag: null, ...o });
console.log("\n— Rechnung: Takte —");
gleich(serienTage(regel({ takt: "woechentlich", start_tag: "2026-09-01" }), "2026-09-01", "2026-09-30"),
["2026-09-01", "2026-09-08", "2026-09-15", "2026-09-22", "2026-09-29"],
"wöchentlich ab Dienstag, 01.09.2026 → alle fünf Dienstage im September");
gleich(serienTage(regel({ takt: "zweiwoechentlich", start_tag: "2026-09-01" }), "2026-09-01", "2026-10-15"),
["2026-09-01", "2026-09-15", "2026-09-29", "2026-10-13"],
"alle zwei Wochen → jeder zweite Dienstag, über den Monatswechsel hinweg");
gleich(serienTage(regel({ takt: "werktags", start_tag: "2026-09-04" }), "2026-09-04", "2026-09-11"),
["2026-09-04", "2026-09-07", "2026-09-08", "2026-09-09", "2026-09-10", "2026-09-11"],
"werktags → das Wochenende 05./06.09. fällt aus, wird nicht verschoben");
gleich(serienTage(regel({ takt: "taeglich", start_tag: "2026-09-01" }), "2026-09-01", "2026-09-05"),
["2026-09-01", "2026-09-02", "2026-09-03", "2026-09-04", "2026-09-05"],
"täglich → fünf Tage sind fünf Termine");
console.log("\n— Rechnung: Monatstakte, die still falsch sein können —");
/* Der 31.: Februar, April, Juni, September und November haben ihn
nicht. Ausgelassen, NICHT auf den 28./30. vorgezogen. */
gleich(serienTage(regel({ takt: "monatlich_datum", start_tag: "2026-01-31" }), "2026-01-01", "2026-12-31"),
["2026-01-31", "2026-03-31", "2026-05-31", "2026-07-31", "2026-08-31",
"2026-10-31", "2026-12-31"],
"jeden Monat am 31. → sieben Termine 2026, Monate ohne 31. fallen aus");
gleich(serienTage(regel({ takt: "monatlich_letzter", start_tag: "2026-01-31" }), "2026-01-01", "2026-05-31"),
["2026-01-31", "2026-02-28", "2026-03-31", "2026-04-30", "2026-05-31"],
"jeden Monatsletzten → 28.02. (2026 ist kein Schaltjahr), 30.04.");
gleich(serienTage(regel({ takt: "monatlich_letzter", start_tag: "2028-01-31" }), "2028-02-01", "2028-02-29"),
["2028-02-29"],
"Monatsletzter im Schaltjahr 2028 → der 29. Februar");
/* 08.09.2026 ist der ZWEITE Dienstag. Danach: 13.10. (Okt. 1 = Do,
erster Di = 6.), 10.11. (Nov. 1 = So, erster Di = 3.). */
gleich(serienTage(regel({ takt: "monatlich_wochentag", start_tag: "2026-09-08" }), "2026-09-01", "2026-11-30"),
["2026-09-08", "2026-10-13", "2026-11-10"],
"jeden zweiten Dienstag im Monat → 08.09., 13.10., 10.11.");
/* 29.09.2026 ist der LETZTE Dienstag im September (der 30. ist ein
Mittwoch). Rückwärts gerechnet: Oktober 27., November 24. Wer
vorwärts zählte ("der vierte"), läge im Oktober richtig und im
Dezember falsch -- deshalb steht der Dezember mit in der Prüfung:
Dezember 2026 hat FÜNF Dienstage (1., 8., 15., 22., 29.). */
gleich(serienTage(regel({ takt: "monatlich_wochentag", start_tag: "2026-09-29" }), "2026-09-01", "2026-12-31"),
["2026-09-29", "2026-10-27", "2026-11-24", "2026-12-29"],
"jeden LETZTEN Dienstag im Monat → im Dezember der 29., nicht der 22.");
gleich(serienTage(regel({ takt: "jaehrlich", start_tag: "2028-02-29" }), "2028-01-01", "2033-12-31"),
["2028-02-29", "2032-02-29"],
"jährlich am 29. Februar → nur in Schaltjahren, 2029–2031 fallen aus");
console.log("\n— Rechnung: Grenzen —");
gleich(serienTage(regel({ takt: "woechentlich", start_tag: "2026-09-01", ende_tag: "2026-09-16" }),
"2026-09-01", "2026-12-31"),
["2026-09-01", "2026-09-08", "2026-09-15"],
"Enddatum → nach dem 16.09. kommt nichts mehr");
gleich(serienTage(regel({ takt: "woechentlich", start_tag: "2026-09-22" }), "2026-09-01", "2026-09-30"),
["2026-09-22", "2026-09-29"],
"vor dem Start liegt nichts, auch wenn der Zeitraum früher beginnt");
ok(serienTage(regel({ takt: "taeglich", start_tag: "2020-01-01" }), "2026-09-01", "2026-09-03").length === 3,
"eine Serie von 2020 kostet keine tausend Durchläufe (vorgespult) → drei Tage");
ok(serienTage(regel({ takt: "unsinn", start_tag: "2026-09-01" }), "2026-09-01", "2026-09-30").length === 0,
"ein unbekannter Takt liefert nichts, statt etwas zu erfinden");
console.log("\n— Rechnung: der Klartext —");
gleich(taktText({ takt: "woechentlich", start_tag: "2026-09-01" }), "jeden Dienstag",
"Klartext wöchentlich");
gleich(taktText({ takt: "monatlich_wochentag", start_tag: "2026-09-29" }),
"jeden Monat am letzten Dienstag", "Klartext letzter Wochentag");
gleich(taktText({ takt: "monatlich_wochentag", start_tag: "2026-09-08" }),
"jeden Monat am zweiten Dienstag", "Klartext n-ter Wochentag");
gleich(serienText({ takt: "woechentlich", start_tag: "2026-09-01", uhrzeit: "18:00", ende_tag: null }),
"jeden Dienstag um 18:00 Uhr, bis du sie abstellst",
"der ganze Satz nennt die Uhrzeit UND dass die Serie ohne Ende läuft");
/* ===================================================================
TEIL 2 — Der Weg durch die Anwendung
=================================================================== */
const { DatabaseSync } = await import("node:sqlite");
const { scryptSync, randomBytes } = await import("node:crypto");
const d = new DatabaseSync(process.env.WORKSPACE_DB);
const jetzt = new Date().toISOString();
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, aktiv, erstellt) VALUES (?,?,?,?,?,1,?)")
.run(name, rolle, hash, salt, 32768, jetzt);
return d.prepare("SELECT last_insert_rowid() AS id").get().id;
}
const idDogi = anlegen("Filipe", "admin", "CODE-DOGI-0001");
const idTili = anlegen("Tili", "creator", "CODE-TILI-0001");
const zahl = (sql, ...w) => d.prepare(sql).get(...w)?.n ?? 0;
const { chromium } = await import(
"file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs");
const browser = await chromium.launch();
async function anmelden(rolle, code) {
const kontext = await browser.newContext({ viewport: { width: 1440, height: 1000 } });
const seite = await kontext.newPage();
await seite.goto(BASIS + "/workspace/", { waitUntil: "domcontentloaded" });
await seite.click(`.rolle[data-rolle="${rolle}"]`);
await seite.fill("#code", code);
await seite.click("button[type=submit]");
await seite.waitForURL("**/start.html", { timeout: 15000 });
return seite;
}
/** Ein API-Aufruf aus der angemeldeten Seite heraus -- mit echter
* Sitzung, echtem Origin, echter Schranke. */
const api = (seite, pfad, optionen) => seite.evaluate(async ([p, o]) => {
const a = await fetch(p, {
credentials: "same-origin",
...(o || {}),
headers: o?.koerper ? { "Content-Type": "application/json" } : undefined,
body: o?.koerper ? JSON.stringify(o.koerper) : undefined,
});
return { status: a.status, daten: await a.json().catch(() => ({})) };
}, [pfad, optionen]);
const seiteDogi = await anmelden("admin", "CODE-DOGI-0001");
console.log("\n— Anlegen über das Terminformular —");
/* Start morgen, damit die Serie ganz in der Zukunft liegt und die
Aufräum-Regeln ("was noch bevorsteht") überhaupt greifen können. */
const morgen = tagText(new Date(Date.now() + 86400_000));
const antwort = await api(seiteDogi, "/workspace/api/termine", {
method: "POST",
koerper: {
titel: "Wochen-Call mit Tili", art: "call", beginn: morgen + "T18:00",
dauer_min: 45, creator_id: idTili, teilnehmer_id: idTili,
wiederholung: { takt: "woechentlich", ende_tag: null },
},
});
ok(antwort.status === 201 && antwort.daten.serie_id > 0,
"ein Termin mit Rhythmus legt eine Serie an (201 + serie_id)");
const serieId = antwort.daten.serie_id;
const erwarteteAnzahl = serienTage(
{ takt: "woechentlich", start_tag: morgen, uhrzeit: "18:00", ende_tag: null },
tagText(), tagText(new Date(Date.now() + 180 * 86400_000))).length;
const angelegt = zahl("SELECT COUNT(*) AS n FROM termine WHERE serie_id = ?", serieId);
ok(erwarteteAnzahl > 20, `der Horizont von 180 Tagen ergibt ${erwarteteAnzahl} Termine (mehr als 20)`);
ok(angelegt === erwarteteAnzahl,
`die Serie hat genau diese ${erwarteteAnzahl} Termine wirklich angelegt (${angelegt})`);
ok(zahl("SELECT COUNT(*) AS n FROM termine WHERE serie_id IS NULL") === 0,
"und KEINEN einzelnen Termin daneben – der erste stünde sonst doppelt da");
ok(zahl("SELECT COUNT(*) AS n FROM termine WHERE serie_id = ? AND beginn LIKE '%T18:00'", serieId) === angelegt,
"alle Ausprägungen stehen auf 18:00 – auch über die Zeitumstellung hinweg");
ok(zahl("SELECT COUNT(*) AS n FROM termine WHERE serie_id = ? AND creator_id = ? AND dauer_min = 45",
serieId, idTili) === angelegt,
"Zuordnung und Dauer sind auf jeder Ausprägung angekommen");
console.log("\n— Nachfüllen legt nichts doppelt an —");
const { serieNachfuellen, nachfuellenAlle } = await import("./workspace-serien.js");
const nochmal = serieNachfuellen(serieId);
ok(nochmal === 0, "ein zweiter Lauf des Nachfüllers legt 0 neue Termine an");
ok(zahl("SELECT COUNT(*) AS n FROM termine WHERE serie_id = ?", serieId) === angelegt,
"die Anzahl ist unverändert");
console.log("\n— Einen einzelnen Tag streichen —");
const zweiter = d.prepare(
"SELECT id, serie_tag FROM termine WHERE serie_id = ? ORDER BY beginn LIMIT 1 OFFSET 1").get(serieId);
const geloescht = await api(seiteDogi, "/workspace/api/termine/" + zweiter.id, { method: "DELETE" });
ok(geloescht.status === 200, "ein einzelner Serientermin lässt sich löschen");
ok(zahl("SELECT COUNT(*) AS n FROM termin_serien_aus WHERE serie_id = ? AND tag = ?",
serieId, zweiter.serie_tag) === 1,
"der Tag ist als Ausnahme vermerkt");
serieNachfuellen(serieId);
ok(zahl("SELECT COUNT(*) AS n FROM termine WHERE serie_id = ? AND serie_tag = ?",
serieId, zweiter.serie_tag) === 0,
"der Nachfüller bringt ihn NICHT zurück");
/* GEGENPROBE. Ohne sie wäre die Zeile darüber wertlos: Sie wäre auch
dann grün, wenn der Nachfüller überhaupt nichts täte. Wird die
Ausnahme entfernt, MUSS der Termin wiederkommen. */
d.prepare("DELETE FROM termin_serien_aus WHERE serie_id = ? AND tag = ?").run(serieId, zweiter.serie_tag);
serieNachfuellen(serieId);
ok(zahl("SELECT COUNT(*) AS n FROM termine WHERE serie_id = ? AND serie_tag = ?",
serieId, zweiter.serie_tag) === 1,
"GEGENPROBE: ohne die Ausnahme legt der Nachfüller ihn sehr wohl wieder an");
console.log("\n— Abstellen räumt die Zukunft, aber nicht die Arbeit —");
/* Einen Termin von Hand anfassen: Er gilt danach als berührt und darf
beim Abstellen nicht verschwinden. */
const dritter = d.prepare(
"SELECT id FROM termine WHERE serie_id = ? ORDER BY beginn LIMIT 1 OFFSET 2").get(serieId);
await api(seiteDogi, "/workspace/api/termine/" + dritter.id, {
method: "PATCH", koerper: { titel: "Von Hand umbenannt" },
});
ok(zahl("SELECT COUNT(*) AS n FROM termine WHERE id = ? AND serie_beruehrt = 1", dritter.id) === 1,
"ein von Hand geänderter Serientermin gilt als berührt");
const aus = await api(seiteDogi, "/workspace/api/serien/" + serieId, {
method: "PATCH", koerper: { aktiv: 0 },
});
ok(aus.status === 200, "die Serie lässt sich abstellen");
ok(zahl("SELECT COUNT(*) AS n FROM termin_serien WHERE id = ? AND aktiv = 0", serieId) === 1,
"sie steht als abgestellt in der Datenbank");
ok(zahl("SELECT COUNT(*) AS n FROM termine WHERE serie_id = ? AND serie_beruehrt = 0", serieId) === 0,
"alle unberührten künftigen Termine sind aus dem Kalender genommen");
ok(zahl("SELECT COUNT(*) AS n FROM termine WHERE id = ?", dritter.id) === 1,
"der von Hand bearbeitete Termin steht weiterhin da");
/* GEGENPROBE zum Abstellen: Der Nachfüller darf für eine abgestellte
Serie nichts mehr tun -- sonst wäre "deaktiviert" nur ein Wort. */
const trotzdem = serieNachfuellen(serieId);
ok(trotzdem === 0, "GEGENPROBE: der Nachfüller legt für eine abgestellte Serie nichts nach");
nachfuellenAlle(true);
ok(zahl("SELECT COUNT(*) AS n FROM termine WHERE serie_id = ? AND serie_beruehrt = 0", serieId) === 0,
"auch der Lauf über alle Serien lässt sie in Ruhe");
console.log("\n— Wieder anstellen —");
const an = await api(seiteDogi, "/workspace/api/serien/" + serieId, {
method: "PATCH", koerper: { aktiv: 1 },
});
ok(an.status === 200 && an.daten.angelegt > 20,
`wieder angestellt legt die Termine neu an (${an.daten.angelegt})`);
console.log("\n— Fuer jeden: auch ein Creator darf eine Wiederholung anlegen —");
const seiteTili = await anmelden("creator", "CODE-TILI-0001");
const vonTili = await api(seiteTili, "/workspace/api/termine", {
method: "POST",
koerper: {
titel: "Eigener Drehtag", art: "termin", beginn: morgen + "T09:00",
dauer_min: 60, wiederholung: { takt: "monatlich_wochentag", ende_tag: null },
},
});
ok(vonTili.status === 201 && vonTili.daten.serie_id > 0,
"ein Creator legt eine eigene Wiederholung an");
ok(zahl("SELECT COUNT(*) AS n FROM termin_serien WHERE id = ? AND creator_id = ? AND teilnehmer_id = ?",
vonTili.daten.serie_id, idTili, idTili) === 1,
"sie ist zwangsweise ihm selbst zugeordnet – die Serie umgeht die Regel nicht");
/* Sichtbarkeit. Tili sieht ZWEI Serien -- ihre eigene und den
Wochen-Call, der mit ihr stattfindet. Das ist richtig so: Die Regel
lautet "woran ich beteiligt bin", und beim Wochen-Call ist sie
creator_id und teilnehmer_id. Ändern darf sie ihn aber nicht, sonst
könnte sie einen Call abstellen, den das Management angesetzt hat. */
const sichtTili = await api(seiteTili, "/workspace/api/serien");
ok(sichtTili.daten.serien.length === 2,
`Tili sieht ihre eigene UND den Call, an dem sie teilnimmt (${sichtTili.daten.serien?.length})`);
ok(sichtTili.daten.serien.filter((s) => s.darf_aendern).length === 1,
"ändern darf sie aber nur ihre eigene – nicht die des Managements");
const fremd = await api(seiteTili, "/workspace/api/serien/" + serieId, {
method: "PATCH", koerper: { aktiv: 0 },
});
ok(fremd.status === 403, "GEGENPROBE: der Versuch, die fremde Serie abzustellen, wird abgewiesen");
ok(zahl("SELECT COUNT(*) AS n FROM termin_serien WHERE id = ? AND aktiv = 1", serieId) === 1,
"und sie läuft danach unverändert weiter");
/* Eine Person, die mit beiden nichts zu tun hat, sieht nichts. Ohne
diese Prüfung wäre die Zeile darüber grün, selbst wenn jeder alles
sähe -- Tili ist an beiden Serien beteiligt. */
const idScout = anlegen("Ben", "scout", "CODE-BEN-00001");
const seiteBen = await anmelden("scout", "CODE-BEN-00001");
const sichtBen = await api(seiteBen, "/workspace/api/serien");
ok(sichtBen.daten.serien.length === 0,
`ein unbeteiligter Scout sieht keine der beiden Serien (${sichtBen.daten.serien?.length})`);
ok(idScout > 0, "der Scout ist dabei wirklich angelegt worden – sonst prüfte die Zeile davor nichts");
console.log("\n— Fehleingaben —");
for (const [koerper, was] of [
[{ takt: "hackerei", start_tag: morgen, uhrzeit: "10:00", titel: "X" }, "unbekannter Takt"],
[{ takt: "woechentlich", start_tag: "morgen", uhrzeit: "10:00", titel: "X" }, "Datum als Wort"],
[{ takt: "woechentlich", start_tag: morgen, uhrzeit: "25:00", titel: "X" }, "Uhrzeit 25:00"],
[{ takt: "woechentlich", start_tag: morgen, uhrzeit: "10:00", titel: "X", ende_tag: "2020-01-01" },
"Ende vor dem Start"],
]) {
const a = await api(seiteDogi, "/workspace/api/serien", { method: "POST", koerper });
ok(a.status === 400, `abgelehnt: ${was}`);
}
/* ===================================================================
TEIL 3 — Die Oberfläche
=================================================================== */
console.log("\n— Der Kalender im Browser —");
await seiteDogi.goto(BASIS + "/workspace/kalender.html", { waitUntil: "networkidle" });
await seiteDogi.waitForSelector(".k-serie", { timeout: 15000 });
const liste = await seiteDogi.evaluate(() => ({
serien: [...document.querySelectorAll(".k-serie")].map((k) => ({
titel: k.querySelector(".k-serie__titel")?.textContent,
satz: k.querySelector(".k-serie__satz")?.textContent,
zustand: k.querySelector(".k-serie__zustand")?.textContent,
naechster: k.querySelector(".k-serie__naechster")?.textContent,
knoepfe: [...k.querySelectorAll(".k-serie__knoepfe button")].map((b) => b.textContent),
})),
zeichen: document.querySelectorAll(".k-pille .k-wdh-zeichen").length,
pillen: document.querySelectorAll(".k-pille").length,
sichtbar: !document.getElementById("serien-block").hidden,
/* Abgeschnittener Text ist der häufigste stille Fehler -- er sieht
auf den ersten Blick nach Absicht aus. */
abgeschnitten: [...document.querySelectorAll(".k-serie__titel, .k-serie__satz, .k-serie__naechster")]
.filter((e) => e.scrollWidth > e.clientWidth + 1).map((e) => e.textContent),
ueber: document.documentElement.scrollWidth - document.documentElement.clientWidth,
}));
ok(liste.sichtbar, "die Leiste „Läuft von allein\" ist sichtbar");
/* SEIT DEM 07.09.2026 SIEHT AUCH DOGFATHER NUR SEINE EIGENEN.
Hier stand "beide Serien stehen darin", und das war richtig, solange
DogFather jeden Termin sah. Beide Serien gehören Tili; angelegt hat
er nur die erste (den Wochen-Call mit ihr), die zweite hat sie
selbst angelegt. Nach der neuen Regel steht in seinem Kalender
deshalb genau eine -- und dass es die RICHTIGE ist, wird gleich
mitgeprüft: eine Zahl allein wäre auch erfüllt, wenn die falsche
übrig geblieben wäre. */
ok(liste.serien.length === 1, `seine eigene Serie steht darin (${liste.serien.length})`);
ok(liste.serien[0]?.titel === "Wochen-Call mit Tili",
`und zwar die, die er selbst angelegt hat (${liste.serien[0]?.titel})`);
ok(!liste.serien.some((s) => s.titel === "Eigener Drehtag"),
"Tilis eigene Wiederholung steht NICHT in seinem Kalender");
ok(liste.serien.some((s) => s.satz === "jeden Dienstag um 18:00 Uhr, bis du sie abstellst")
|| liste.serien.some((s) => /^jeden \w+ um 18:00 Uhr, bis du sie abstellst$/.test(s.satz)),
"der Satz nennt Rhythmus, Uhrzeit und dass sie ohne Ende läuft");
ok(liste.serien.every((s) => /Nächster Termin: /.test(s.naechster)),
"zu jeder Serie steht, wann sie das nächste Mal zuschlägt");
ok(liste.serien.every((s) => s.zustand === "läuft"),
"der Zustand steht als WORT da, nicht nur als Farbe");
ok(liste.serien.every((s) => s.knoepfe.includes("abstellen") && s.knoepfe.includes("löschen")),
"abstellen und löschen sind erreichbar");
ok(liste.pillen > 0 && liste.zeichen === liste.pillen,
`jeder Termin im Raster trägt das Wiederhol-Zeichen (${liste.zeichen}/${liste.pillen})`);
ok(!liste.abgeschnitten.length,
`kein Text ist abgeschnitten${liste.abgeschnitten.length ? ": " + liste.abgeschnitten.join(" | ") : ""}`);
ok(liste.ueber <= 0, "nichts ragt seitlich aus der Seite");
/* Die Liste im Bild -- sie ist der Teil, den man beim Abstellen sucht. */
await seiteDogi.evaluate(() =>
document.getElementById("serien-block").scrollIntoView({ block: "center" }));
await seiteDogi.screenshot({ path: "pruef-serien-liste.png", fullPage: false });
console.log("\n— Formular: die Auswahl beschriftet sich nach dem Datum —");
await seiteDogi.click("#neu-oeffnen");
await seiteDogi.fill("#f-beginn", "2026-09-29T18:00");
await seiteDogi.dispatchEvent("#f-beginn", "change");
const auswahl = await seiteDogi.evaluate(() =>
[...document.querySelectorAll("#f-takt option")].map((o) => o.textContent));
ok(auswahl.includes("jeden Dienstag"), "„jeden Dienstag\" statt „wöchentlich\"");
ok(auswahl.includes("jeden Monat am letzten Dienstag"),
"der 29.09. ist der letzte Dienstag – die Auswahl sagt es");
ok(auswahl.some((t) => t.startsWith("jeden Monat am 29.")),
"„jeden Monat am 29.\" ist als eigene Möglichkeit da");
ok(auswahl.some((t) => t.includes("(Monate ohne den 29. fallen aus)")),
"und der Hinweis, dass der Februar dabei ausfällt, steht dran");
/* Der Vorschausatz im Formular MUSS derselbe sein wie der, den der
Server später in die Liste schreibt. Formular und Liste dürfen nicht
Verschiedenes behaupten -- sonst glaubt man der falschen Stelle. */
await seiteDogi.selectOption("#f-takt", "monatlich_wochentag");
const vorschau = await seiteDogi.textContent("#wdh-satz");
ok(/^Läuft jeden Monat am letzten Dienstag um 18:00 Uhr, bis du sie abstellst\.$/.test(vorschau.trim()),
`die Vorschau sagt den ganzen Satz: „${vorschau.trim()}"`);
gleich(vorschau.trim(), "Läuft " + serienText(
{ takt: "monatlich_wochentag", start_tag: "2026-09-29", uhrzeit: "18:00", ende_tag: null }) + ".",
"Formular und Server formulieren WORTGLEICH – sonst laufen sie auseinander");
await seiteDogi.screenshot({ path: "pruef-serien-computer.png", fullPage: false });
const handy = await browser.newContext({ viewport: { width: 390, height: 844 } });
const seiteHandy = await handy.newPage();
await seiteHandy.goto(BASIS + "/workspace/", { waitUntil: "domcontentloaded" });
await seiteHandy.click('.rolle[data-rolle="admin"]');
await seiteHandy.fill("#code", "CODE-DOGI-0001");
await seiteHandy.click("button[type=submit]");
await seiteHandy.waitForURL("**/start.html", { timeout: 15000 });
await seiteHandy.goto(BASIS + "/workspace/kalender.html", { waitUntil: "networkidle" });
await seiteHandy.waitForSelector(".k-serie", { timeout: 15000 });
const engUeber = await seiteHandy.evaluate(() =>
document.documentElement.scrollWidth - document.documentElement.clientWidth);
ok(engUeber <= 0, "auch auf dem Handy ragt nichts heraus");
await seiteHandy.screenshot({ path: "pruef-serien-handy.png", fullPage: false });
/* ===================================================================
Abschluss — die ANZAHL zählt mit
===================================================================
Ein grüner Lauf mit weniger Prüfungen als beim letzten Mal ist kein
grüner Lauf: Genau so fallen Prüfungen still weg, wenn sich der
Datenbestand ändert (Projektregel vom 28.08.2026). */
/* Stand 02.09.2026: 66 Prüfungen vor dieser Zeile. Sinkt die Zahl, ist
eine Prüfung stillschweigend weggefallen -- dann gehört das erklärt,
bevor der Lauf als grün gilt. */
const ERWARTET = 66;
console.log("");
ok(geprueft >= ERWARTET,
`es sind ${geprueft} Prüfungen gelaufen (erwartet mindestens ${ERWARTET})`);
await browser.close();
d.close();
try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ }
console.log(`\n${fehler ? "FEHLER: " + fehler : "Alles in Ordnung"} — ${geprueft} Prüfungen`);
process.exit(fehler ? 1 : 0);