Files
DogFatherGit d5e873d597 Zehn Messungen bekommen einen Finger -- und eine Zeitbombe faellt auf
Die Grundlinie der Fingerwache sinkt von 17 auf 6.

WAS UMGESTELLT WURDE

pruef-alter, pruef-countdown, pruef-gespraech, pruef-treffchat,
pruef-modi-verborgen, pruef-nachwuchs, pruef-personen-liste,
pruef-serien, pruef-team, pruef-kalender.

Alle zehn legen ein eigenes Handyfenster an; dort fehlte nur
`hasTouch`. Ohne ihn meldet der Browser einen feinen Zeiger, und
keine Regel aus `@media (pointer: coarse)` greift -- dort stehen die
44-Pixel-Beruehrziele.

JEDE EINZELN GELAUFEN, ALLE GRUEN

Diese Seiten halten die Fingerregeln also schon ein. Es hatte nur
nie jemand nachgesehen.

Vorher geprueft, wie gross jede ist: 1 bis 7 Seitenaufrufe, 1 bis 5
Fenster -- echte Einzelpruefungen. (Zum Vergleich: die Pruefung von
gestern Nacht hatte 244 Seitenaufrufe, und genau das hatte ich
vorher nicht nachgesehen.)

DIE ZEITBOMBE IN pruef-kalender

Beim Umstellen fiel sie rot aus -- zweimal, an einer Stelle, die mit
dem Finger nichts zu tun hat:

  FEHL im naechsten Monat ist kein Tag mehr "heute"

Gegen den ausgelieferten Stand gemessen: identisch rot. Nicht
meine Aenderung, sondern der Kalender.

Heute ist der 29. September. Das Oktober-Raster zeigt in seiner
ersten Zeile die letzten Septembertage mit, und einer davon IST
heute. Die Anwendung macht dabei alles richtig: `kalender.js` setzt
die Marke an `tag === daten.heute`, also am DATUM und nicht an der
Rasterposition. Genau das sollte die Pruefung beweisen -- und schlug
an, weil die Anwendung es tat.

Geschrieben wurde sie an einem Tag in der Monatsmitte. Von da an war
sie richtig, bis der Kalender sie einholte. Dasselbe Muster wie
`gate-oeffnung.mjs` am 06.09.2026 im Shop: Ein Test, der die
Wanduhr als Annahme benutzt, misst irgendwann das Gegenteil.

Gefragt wird jetzt, was gemeint war: Ein EIGENER Tag des
Folgemonats darf nie „heute" sein; ein mitgezeigter Randtag
(`data-fremd="ja"`) dagegen sehr wohl, und zwar genau dann, wenn er
es ist. Das gilt an jedem Tag des Jahres. Die Meldung nennt jetzt
beide Zahlen:

  ok  im naechsten Monat ist kein EIGENER Tag mehr "heute"
      (0 eigene, 1 mitgezeigte Randtage)

WAS UEBRIG BLEIBT: SECHS SCHWERE FAELLE

pruef-befinden, pruef-notizen, pruef-teamlage-karten,
pruef-terminregel, pruef-werdegang, pruef-grosscheck.

Dort schaltet `setViewportSize` mitten im Ablauf auf Handyformat um
-- und der Finger laesst sich nachtraeglich nicht setzen, er gehoert
zum Kontext. Jeder Fall braucht einen eigenen Kontext samt
Anmeldung: Umbau, nicht Einzeiler.

`pruef-grosscheck` bleibt unangetastet, bis Filipe einen Lauf
freigibt. Eine Aenderung, die ich nicht pruefen darf, liefere ich
nicht aus.

GEPRUEFT
  alle zehn einzeln: EXIT 0, keine FEHL-Zeile
  pruef-kalender     EXIT 0 (vorher 2 Fehlschlaege, auch auf HEAD)
  pruef-fingermass   3 / 0, Grundlinie 6
2026-09-29 14:51:52 +02:00

497 lines
26 KiB
JavaScript
Raw Permalink 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");
/* DER PORT MUSS MEINER SEIN (nachgeruestet 19.09.2026).
Ohne diese Zeile startet der eigene Server bei belegtem Port
STILL nicht -- und alles Folgende misst gegen einen fremden
Stand. Das ist der dritte Ausgang: belegt ist etwas anderes
als kaputt. */
const PORT = await eigenerPort(import.meta, "pruef-serien");
process.env.PORT = `${PORT}`;
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";
import { eigenerPort } from "./helfer-port.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:${PORT}`;
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 });
/* MIT FINGER. Ohne `hasTouch` meldet der Browser einen feinen
Zeiger, und keine Regel aus `@media (pointer: coarse)` greift --
dort stehen die 44-Pixel-Beruehrziele. Siehe pruef-fingermass.mjs. */
const handy = await browser.newContext({ viewport: { width: 390, height: 844 },
hasTouch: true, isMobile: true });
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);