/* ===================================================================== Wie lange geht das schon so? (15.09.2026) Stufe 9: „Eskalationsstufe sichtbar“. --------------------------------------------------------------------- GEPRÜFT WIRD AN DEN GRENZEN, NICHT IN DER MITTE Eine Stufenfunktion ist überall dort richtig oder falsch, wo sie umspringt — dazwischen ist sie langweilig. Jede Schwelle wird deshalb dreimal gemessen: einen Tag davor, genau darauf, einen Tag danach. Die wichtigste Grenze ist die erste: **am Tag der Frist ist nichts überfällig.** Wer bis zum 15. Zeit hat, ist am 15. pünktlich. Eine Eskalation, die einen Tag zu früh anschlägt, verliert genau das Vertrauen, das sie braucht. --------------------------------------------------------------------- UND ZWEI DINGE, DIE NICHT ESKALIEREN DÜRFEN Erledigtes und Abgebrochenes — es liegt niemandem mehr im Weg. Eine Aufgabe mit Frist in der Zukunft — sie ist eingeplant, nicht still. Aufrufen mit: node server/pruef-eskalation.mjs ===================================================================== */ import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { request as httpAnfrage } from "node:http"; import { eigenerPort } from "./helfer-port.mjs"; import { heuteLokal, tagLokal, tagVon } from "./helfer-tag.mjs"; const PORT = await eigenerPort(import.meta, "pruef-eskalation"); const ordner = mkdtempSync(join(tmpdir(), "ws-eskal-")); process.env.WORKSPACE_DB = join(ordner, "workspace.db"); process.env.PORT = String(PORT); process.env.SITE_ACCESS_SECRET = "lokaler-test"; process.env.SITE_PUBLIC_LAUNCH_AT = "2020-01-01T00:00:00+01:00"; const express = (await import("express")).default; const ec = express.response.cookie; express.response.cookie = function (n, w, o) { return ec.call(this, n, w, { ...(o || {}), secure: false }); }; import { notbremse } from "./helfer-notbremse.mjs"; await import("./index.js"); notbremse(180_000, "pruef-eskalation"); await new Promise((r) => setTimeout(r, 700)); process.on("uncaughtException", (f) => { console.error("ABSTURZ:", f); process.exit(7); }); process.on("unhandledRejection", (f) => { console.error("ABSTURZ (Versprechen):", f); process.exit(7); }); let fehler = 0, geprueft = 0; const melde = (t) => console.log(t); const ok = (b, t) => { geprueft++; console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; }; const HAUS = "workspace.dogfather-universe.com"; const { DatabaseSync } = await import("node:sqlite"); const { scryptSync, randomBytes, createHmac } = await import("node:crypto"); const { eskalation, STUFEN, STILL_TAGE } = await import("./workspace-eskalation.js"); const oeffne = () => new DatabaseSync(process.env.WORKSPACE_DB); const jetzt = new Date().toISOString(); /* EIN FESTER ZEITPUNKT, keine Wanderuhr. Eine Pruefung, die "heute" als Annahme benutzt, misst irgendwann das Gegenteil -- der Kalender holt sie ein. Hier wird die Zeit uebergeben, also ist sie Eingabe. */ const NUN = Date.parse("2026-09-15T12:00:00+02:00"); const tagVor = (n) => tagVon(NUN, -n); let idDogi; { const d = oeffne(); const schluessel = randomBytes(32).toString("hex"); d.prepare("INSERT INTO einstellungen (schluessel, wert, geaendert, von)" + " VALUES (?,?,?,NULL) ON CONFLICT(schluessel) DO NOTHING") .run("code_kennung_schluessel", schluessel, jetzt); const salt = randomBytes(16).toString("hex"); const hash = scryptSync("CODE-DOGI-0001", salt, 64, { N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex"); d.prepare("INSERT INTO personen (name, rolle, code_hash, code_salt, code_n, code_kennung, aktiv, erstellt)" + " VALUES ('Filipe','admin',?,?,?,?,1,?)") .run(hash, salt, 32768, createHmac("sha256", schluessel).update("CODE-DOGI-0001").digest("hex"), jetzt); idDogi = d.prepare("SELECT last_insert_rowid() AS id").get().id; d.close(); } function ruf(pfad, methode, kopf = {}, koerper = null) { return new Promise((fertig, schief) => { const a = httpAnfrage({ host: "127.0.0.1", port: PORT, path: pfad, method: methode, headers: { Host: HAUS, ...(koerper ? { "Content-Type": "application/json" } : {}), ...kopf } }, (antwort) => { let text = ""; antwort.on("data", (s) => { text += s; }); antwort.on("end", () => fertig({ code: antwort.statusCode, text, kopf: antwort.headers })); }); a.on("error", schief); if (koerper) a.write(JSON.stringify(koerper)); a.end(); }); } const json = (a) => { try { return JSON.parse(a.text); } catch { return {}; } }; const anm = await ruf("/workspace/api/anmelden", "POST", {}, { rolle: "admin", code: "CODE-DOGI-0001" }); const kDogi = [].concat(anm.kopf?.["set-cookie"] || []).map((c) => c.split(";")[0]).join("; "); ok(!!kDogi, "DogFather ist angemeldet"); /** Kurzform: eine gedachte Aufgabe einstufen. */ const stufeVon = (a) => eskalation(a, NUN)?.stufe ?? null; const tageVon = (a) => eskalation(a, NUN)?.tage ?? null; /* ======================================================================= 1. DIE ERSTE GRENZE: AM TAG DER FRIST IST NICHTS ÜBERFÄLLIG ======================================================================= */ melde(""); melde("=== 1. Der Tag der Frist ==="); { const offen = (frist) => ({ status: "offen", frist }); ok(stufeVon(offen(tagVor(-1))) === null, "eine Frist von morgen eskaliert nicht"); ok(stufeVon(offen(tagVor(0))) === null, "am Tag der Frist selbst auch nicht — wer bis zum 15. Zeit hat, ist am 15. pünktlich"); ok(stufeVon(offen(tagVor(1))) === "faellig", `einen Tag danach schon (${stufeVon(offen(tagVor(1)))})`); ok(tageVon(offen(tagVor(1))) === 1, `und zwar seit ${tageVon(offen(tagVor(1)))} Tag`); } /* ======================================================================= 2. JEDE SCHWELLE DREIMAL: DAVOR, DARAUF, DANACH ======================================================================= */ melde(""); melde("=== 2. An den Grenzen ==="); { const bei = (tage) => eskalation({ status: "offen", frist: tagVor(tage) }, NUN); ok(STUFEN.length === 3, `${STUFEN.length} Stufen im Katalog`); for (const s of STUFEN) { const davor = bei(s.ab - 1); const drauf = bei(s.ab); const danach = bei(s.ab + 1); ok(drauf?.stufe === s.schluessel, `bei ${s.ab} Tagen gilt „${s.schluessel}“ (${drauf?.stufe})`); ok(danach?.rang >= drauf.rang, `einen Tag danach nicht milder (${danach?.stufe})`); /* Die Stufe DAVOR muss eine andere sein -- sonst waere die Schwelle wirkungslos, und die Zeile darueber trotzdem gruen. */ ok(davor === null || davor.stufe !== s.schluessel, `und einen Tag davor noch nicht (${davor?.stufe ?? "gar nichts"})`); } /* Die Zahl im Wort muss zur Zahl im Feld passen -- sonst liest jemand "liegt seit 5 Tagen" an einer Karte, die seit 12 liegt. */ const zwoelf = bei(12); ok(zwoelf.wort.includes(String(zwoelf.tage)), `das Wort nennt dieselbe Zahl wie das Feld (${zwoelf.wort} / ${zwoelf.tage})`); ok(zwoelf.stufe === "haengt", `und zwölf Tage sind „${zwoelf.stufe}“`); } /* ======================================================================= 3. WAS NICHT ESKALIEREN DARF ======================================================================= */ melde(""); melde("=== 3. Was ruhig bleibt ==="); { ok(stufeVon({ status: "erledigt", frist: tagVor(60) }) === null, "eine erledigte Aufgabe eskaliert nicht, auch nach 60 Tagen nicht"); ok(stufeVon({ status: "abgebrochen", frist: tagVor(60) }) === null, "eine abgebrochene ebenso wenig"); ok(stufeVon({ status: "arbeit", frist: tagVor(-30) }) === null, "und eine mit Frist in der Zukunft ist eingeplant, nicht still"); /* GEGENPROBE -- ohne sie beweisen die drei Zeilen nichts: Dieselbe Frist bei offenem Status eskaliert sehr wohl. */ ok(stufeVon({ status: "offen", frist: tagVor(60) }) === "haengt", "dieselbe alte Frist bei einer offenen Aufgabe dagegen schon"); } /* ======================================================================= 4. OHNE FRIST: EINE BEOBACHTUNG, KEIN GEBROCHENES VERSPRECHEN ======================================================================= */ melde(""); melde("=== 4. Ohne Frist ==="); { const still = (tage) => ({ status: "offen", frist: null, geaendert: new Date(NUN - tage * 86400000).toISOString(), }); ok(stufeVon(still(STILL_TAGE - 1)) === null, `${STILL_TAGE - 1} Tage ohne Bewegung sind noch nichts`); ok(stufeVon(still(STILL_TAGE)) === "still", `ab ${STILL_TAGE} Tagen steht es da (${stufeVon(still(STILL_TAGE))})`); ok(eskalation(still(20), NUN)?.grund === "keine_bewegung", "und der Grund ist ein anderer als bei einer Frist"); ok(/unberührt/.test(eskalation(still(20), NUN)?.wort || ""), `das Wort wirft niemandem etwas vor: „${eskalation(still(20), NUN)?.wort}“`); /* Ohne `geaendert` zaehlt das Anlegedatum -- sonst waere eine nie angefasste Aufgabe fuer immer unsichtbar. */ const nie = { status: "offen", frist: null, geaendert: null, erstellt: new Date(NUN - 30 * 86400000).toISOString() }; ok(stufeVon(nie) === "still", "eine nie angefasste Aufgabe zählt ab dem Anlegen"); /* GEGENPROBE: Eine Aufgabe MIT Frist wird nicht zusaetzlich als "still" gemeldet -- zwei Marken an einer Karte waeren Laerm. */ const beides = { status: "offen", frist: tagVor(-5), geaendert: new Date(NUN - 40 * 86400000).toISOString() }; ok(stufeVon(beides) === null, "eine eingeplante Aufgabe wird nicht zusätzlich als still gemeldet"); } /* ======================================================================= 5. DIE STUFE KOMMT MIT DER LISTE — GERECHNET, NICHT IM BROWSER ======================================================================= */ melde(""); melde("=== 5. Über die Leitung ==="); { const d = oeffne(); const rein = d.prepare(`INSERT INTO aufgaben (titel, status, prioritaet, frist, erstellt, erstellt_von) VALUES (?,?,'mittel',?,?,?)`); const heute = heuteLokal(); const vor = (n) => tagLokal(-n); rein.run("Seit gestern faellig", "offen", vor(1), jetzt, idDogi); rein.run("Liegt seit fuenf Tagen", "offen", vor(5), jetzt, idDogi); rein.run("Haengt seit zwanzig", "offen", vor(20), jetzt, idDogi); rein.run("Erst naechste Woche", "offen", tagLokal(7), jetzt, idDogi); rein.run("Heute faellig", "offen", heute, jetzt, idDogi); d.close(); const liste = json(await ruf("/workspace/api/aufgaben", "GET", { cookie: kDogi })).aufgaben || []; ok(liste.length >= 5, `${liste.length} Aufgaben in der Liste`); const nach = (t) => liste.find((a) => a.titel === t); ok(nach("Seit gestern faellig")?.eskalation?.stufe === "faellig", `„seit gestern“ → ${nach("Seit gestern faellig")?.eskalation?.stufe}`); ok(nach("Liegt seit fuenf Tagen")?.eskalation?.stufe === "liegt", `„seit fünf Tagen“ → ${nach("Liegt seit fuenf Tagen")?.eskalation?.stufe}`); ok(nach("Haengt seit zwanzig")?.eskalation?.stufe === "haengt", `„seit zwanzig“ → ${nach("Haengt seit zwanzig")?.eskalation?.stufe}`); ok(nach("Erst naechste Woche")?.eskalation === null, "„erst nächste Woche“ trägt keine Stufe"); ok(nach("Heute faellig")?.eskalation === null, "und „heute fällig“ ebenfalls nicht — heute ist nicht zu spät"); /* Jede Stufe bringt ihr WORT mit. Rechnete der Browser es selbst, waere das die zweite Wahrheit. */ ok(/\d/.test(nach("Haengt seit zwanzig")?.eskalation?.wort || ""), `das Wort kommt fertig mit: „${nach("Haengt seit zwanzig")?.eskalation?.wort}“`); } /* ======================================================================= 6. IM BROWSER ======================================================================= */ melde(""); melde("=== 6. Im Browser ==="); { const { chromium } = await import( "file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs"); const browser = await chromium.launch(); const kontext = await browser.newContext({ viewport: { width: 1280, height: 900 } }); const seite = await kontext.newPage(); const konsole = []; seite.on("console", (m) => { if (m.type() === "error" && !/favicon|ERR_/i.test(m.text())) konsole.push(m.text()); }); seite.on("pageerror", (e) => konsole.push("Absturz: " + e.message)); await seite.goto(`http://127.0.0.1:${PORT}/workspace/`, { waitUntil: "domcontentloaded" }); await seite.click('.rolle[data-rolle="admin"]'); await seite.fill("#code", "CODE-DOGI-0001"); await seite.click("button[type=submit]"); await seite.waitForURL("**/start.html", { timeout: 20000 }); await seite.goto(`http://127.0.0.1:${PORT}/workspace/aufgaben.html`, { waitUntil: "networkidle" }); await seite.waitForTimeout(900); const stand = await seite.evaluate(() => { const m = [...document.querySelectorAll(".karte__eskal")]; return { anzahl: m.length, stufen: [...new Set(m.map((x) => x.dataset.stufe))].sort(), texte: m.map((x) => x.textContent.trim()), /* Steht das Wort "ueberfaellig" noch zweimal an derselben Karte? */ doppelt: [...document.querySelectorAll(".karte")] .filter((k) => (k.innerText.match(/überfällig/gi) || []).length > 1).length, }; }); ok(stand.anzahl >= 3, `${stand.anzahl} Karten tragen eine Stufe`); ok(stand.stufen.length >= 3, `und es sind verschiedene: ${stand.stufen.join(", ")}`); ok(stand.texte.some((t) => /\d+ Tagen/.test(t)), `die Zahl steht im Text: ${stand.texte.join(" | ").slice(0, 90)}`); /* DIE FARBE IST NICHT DIE AUSKUNFT. Wer Farben schlecht sieht, muss dasselbe lesen koennen -- also steht in jedem Kaestchen ein Wort. */ ok(stand.texte.every((t) => t.length > 3), "jede Stufe steht als Wort da, nicht nur als Farbe"); ok(stand.doppelt === 0, `und „überfällig“ steht an keiner Karte zweimal (${stand.doppelt})`); ok(konsole.length === 0, konsole.length ? "Fehler im Browser: " + konsole[0].slice(0, 90) : "keine Fehler im Browser"); await browser.close(); } /* ------------------------------------------------------------------- */ melde(""); melde(`${geprueft} Pruefungen, ${fehler} Fehler`); melde(fehler ? "NICHT IN ORDNUNG" : "ALLES IN ORDNUNG"); try { rmSync(ordner, { recursive: true, force: true }); } catch { /* Aufraeumrest */ } process.exit(fehler ? 1 : 0);