Files
dogfather-universe/server/pruef-serien.mjs
T
DogFatherGitandClaude Opus 5 23429627ef Termine, die von allein weiterlaufen -- bis man sie abstellt
Wunsch: "ich will dass ich da im kalender auch sachen machen die jede
woche jeden monat automatisiert immer weiter laufen so wie ich es
auswaehle bis ich es selber deaktiviere. aktiviere diese option fuer
jeden."

Acht Rhythmen (taeglich, werktags, woechentlich, alle zwei Wochen,
monatlich am Datum, am Monatsletzten, am n-ten Wochentag, jaehrlich),
wahlweise mit Enddatum oder ohne Ende. Fuer JEDE Rolle -- Creator und
Scouts legen sie fuer sich selbst an, mit denselben Grenzen wie beim
einzelnen Termin.

WARUM ECHTE TERMINE UND KEINE GERECHNETEN AUSPRAEGUNGEN

Der naheliegende Weg waere gewesen, die Wiederholung nur beim Anzeigen
auszurechnen. Das waere ein stiller Ausfall geworden: ACHT andere Stellen
lesen die Tabelle termine direkt -- Calls, Protokolle, Berichte,
Hinweise/Ampel, Suche, Aufgaben ("naechster Termin"), Uebersicht und die
Startseite. In all diesen Ansichten haette schlicht nichts gestanden, und
aufgefallen waere es erst Monate spaeter. Stattdessen macht ein
Nachfueller aus der Regel echte Zeilen (Horizont 180 Tage) -- jedes andere
Modul sieht sie mit, ohne dass dort eine Zeile geaendert werden musste.
Beim geplanten ICS-Abo gilt dasselbe.

KEIN ZEITGEBER. Der Nachfueller laeuft beim Serverstart, beim Anlegen und
Aendern und beim Oeffnen des Kalenders (dort hoechstens alle fuenf
Minuten). Er ist idempotent. Ein Cron waere eine weitere Sache gewesen,
die stillschweigend ausfallen kann.

WAS BEIM ABSTELLEN PASSIERT: Vergangene Termine und alles, was jemand von
Hand angefasst hat (verschoben, umbenannt, abgehakt), bleibt stehen. Nur
die unberuehrte Zukunft wird aus dem Kalender genommen. Wer einen
einzelnen Tag loescht, streicht nur diesen einen -- der Tag wird als
Ausnahme vermerkt, sonst legte der Nachfueller ihn wieder an.

ZEITUMSTELLUNG: Gerechnet wird auf reinen Datumstexten in UTC-Mittag, die
Uhrzeit wird als Text angehaengt. 18:00 bleibt dadurch 18:00 und wandert
im Oktober nicht auf 17:00.

DIE SICHTBARKEITSREGEL STEHT JETZT NUR NOCH EINMAL (workspace.js,
termineSichtbar) statt zweimal fast gleich. Zwei Fassungen waeren
auseinandergelaufen -- und dann haette eine Wiederholung jemandem etwas
gezeigt, was der einzelne Termin ihm verbirgt.

IN DER OBERFLAECHE beschriften sich die Rhythmen nach dem gewaehlten
Datum ("jeden Dienstag", "jeden Monat am letzten Dienstag") statt
abstrakt ("woechentlich"), inklusive des ehrlichen Hinweises, dass "jeden
31." den Februar auslaesst. Darunter steht der ganze Satz in Worten. Eine
neue Leiste "Laeuft von allein" zeigt alle laufenden Wiederholungen mit
Abstell-Schalter -- was von selbst weiterlaeuft, muss man sehen koennen.

GEPRUEFT: 67 Pruefungen (server/pruef-serien.mjs), darunter von Hand
nachgeschlagene Datumsangaben fuer jeden Takt (der letzte Dienstag im
Dezember 2026 ist der 29., nicht der 22.; der 29. Februar nur in
Schaltjahren) und vier GEGENPROBEN, die beweisen, dass die Pruefungen
auch "nicht in Ordnung" sagen koennen. Die Anzahl der Pruefungen steht in
der Bedingung, nicht nur im Meldetext. Bestehende Pruefungen unveraendert
gruen: Kalender 84, Sicht 48, Startansicht 133, Protokoll 52, Ampel 47,
Handy 50, Aufgabenbrett 44, Sprung 43, Personen-Loeschen 40, Uebersicht
33, Workspace-Seiten 32, Formulare 19, Betreuung 18, Grosscheck 15,
Lesbarkeit 14.

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

467 lines
24 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 }); };
await import("./index.js");
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");
ok(liste.serien.length === 2, `beide Serien stehen darin (${liste.serien.length})`);
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);