Gemessen: 165 Pruefdateien, 129 verschiedene Nummern -- NEUNZEHN
doppelt, vier davon dreifach. Niemand hatte das gewollt; jede neue
Pruefung wurde von einer vorhandenen abgeschrieben, und die Nummer kam
mit. Meine eigene Notiz sagte "sieben" -- auch eine Bestandsliste
altert.
Der Waechter faengt den Schaden ab, aber er kann nur melden, was schon
passiert ist: Zwei Pruefungen mit derselben Nummer koennen nie
gleichzeitig laufen, und ein liegengebliebener Prozess der einen laesst
die andere abbrechen mit einer Meldung, die wie ein Befund aussieht.
Genau das ist mir am 20.09. zweimal passiert.
Jetzt leitet jede Datei ihre Nummer aus ihrer STELLE IM ALPHABET ab
(eigenerPort in helfer-port.mjs), zwei je Datei. Nicht ueber eine
Pruefsumme: Bei 165 Namen in 4900 Nummern waeren nach dem
Geburtstagsproblem rund DREI Zusammenstoesse zu erwarten -- ein Hash
tauscht eine sichtbare Doppelung gegen eine unsichtbare. Die Stelle im
Alphabet ist eindeutig von der Bauart her.
--- ZWEI FEHLER AUF DEM WEG, BEIDE LEHRREICH -------------------------
1. DER ERSTE VERSUCH WAR GRUEN UND KAPUTT. Ersetzt wurde mit einem
Muster: "([^"]*4231[^"]*)". Das hielt
{ host: "127.0.0.1", port: 4231, path: "/404.html" }
fuer eine Zeichenkette -- ein Muster kann eine oeffnende nicht von
einer schliessenden Anfuehrung unterscheiden. Heraus kam
{ host: "127.0.0.1`, port: ${PORT}, path: `/404.html" }
also GUELTIGER Code ohne port-Feld. `node --check` sagte gruen fuer
alle 164 Dateien. Aufgefallen ist es erst, weil ich vier Vertreter
gegen eine vorher gemessene Grundlinie laufen liess: pruef-schranke
39/0 vorher, 38/1 nachher.
Alles zurueckgenommen und mit einem Zerleger neu gemacht, der weiss,
ob eine Stelle Code, Zeichenkette, Vorlage, Kommentar oder
regulaerer Ausdruck ist. In pruef-ics stand die Nummer in einem
regulaeren Ausdruck -- der wird jetzt gebaut statt hingeschrieben.
2. EIN MODUL, DAS BEIM IMPORTIEREN ARBEITET, IST EINE FALLE. Der
zweite Durchgang importierte den ersten, um seine Mechanik zu
benutzen -- und fuehrte dessen Hauptlauf gleich mit aus. Die
zweiten Nummern wurden dadurch als erste behandelt, zwei Aufrufe
bekamen dieselbe Nummer, und in pruef-content stand `const PORT`
zweimal.
--- WAS DAS DAUERHAFT HAELT -----------------------------------------
pruef-portnummern.mjs (neu, 8 Pruefungen) fragt nicht "welche Nummern
sind doppelt", sondern "wer traegt ueberhaupt noch eine von Hand ein"
und "wer startet einen Server, ohne seine Nummer abzuleiten". Die
zweite Frage hat sofort etwas gefunden, das in KEINER Doppelungsliste
stand: pruef-push-weg belegte 4341 und 4342, rief den Waechter aber
gar nicht auf -- dieselbe Nummer wie pruef-agentur. Eine Liste zeigt
nur, was auf ihr steht.
Mit Gegenprobe: Eine unbekannte Datei bekommt keine geratene Nummer,
sondern einen Abbruch, und eine dritte Nummer je Datei gibt es nicht.
--- NACHGEMESSEN ----------------------------------------------------
Sechzehn Pruefungen gegen ihre vorher gemessene Grundlinie, je eine
Vertreterin jeder umgestellten Bauweise (eine Nummer, zwei Nummern,
Nummer in einer Zeichenkette, in einer Vorlage, in einem regulaeren
Ausdruck, dynamische Einfuhr, Nachtlauf mit zwei Laeufen):
crew-adresse 132/0 · schranke 39/0 · content 45/0 · entwicklung 46/0
anruf 127/0 · ics 37/0 · push-weg 20/0 · arten 28/0 · video 67/0
kanalzeile 14/0 · agentur 62/0 · spicy 83/0 · creator-anlegen 50/0
push-ziel 10/0 · portnummern 8/0
Alle exakt wie vorher. Zwei waren schon vorher rot und sind es
unveraendert geblieben (crew-wand-bild 41/4, teilen 14/3) -- per
`git stash` belegt, nicht angenommen.
Co-Authored-By: Claude Opus 5 <[email protected]>
493 lines
26 KiB
JavaScript
493 lines
26 KiB
JavaScript
/* =====================================================================
|
||
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 });
|
||
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);
|