Files
dogfather-universe/server/pruef-leistung.mjs
T
DogFatherGitandClaude Opus 5 c8a4e7628c Stufe 1+2: Zahlen ueber das Geschaeft, und eine Fruehwarnung
Der grosse Befund aus dem Plan vom 31.08.2026: Der Workspace
organisiert ARBEIT hervorragend, wusste ueber das GESCHAEFT aber
nichts. Keine Diamanten, keine LIVE-Tage, keine Verweildauer. Damit
hing die ganze Betreuung in der Luft -- der Start-Check bewertete ohne
zu messen, der Report fragte "was hat funktioniert" ohne Beleg, das
90-Tage-Ziel war ein Satz statt eines Fortschritts.

STUFE 1 -- LEISTUNG

Eine Zeile je Creator und TAG (nicht Woche: Feineres laesst sich immer
zusammenfassen, Groeberes nie aufteilen). Diamanten, LIVE-Dauer,
gueltiger Tag, Zuschauer im Schnitt und in der Spitze, Verweildauer,
Schenker, neue Follower -- und eine NOTIZ. Ohne sie sieht man in drei
Monaten einen Einbruch und weiss nicht mehr, dass die Person Grippe
hatte.

DIE VERWEILDAUER IST DIE LEITZAHL (Recherche 06.09.2026): 2026 haengt
der Algorithmus alles an der Completion Rate, und sie gehoert als
BETRIEBSkennzahl behandelt -- sie soll die naechste Runde steuern,
nicht die letzte erklaeren.

Jede Zahl kommt mit Vergleich, Verlauf und Einordnung. Eine Zahl ohne
diese drei ist eine Eitelkeitszahl: Sie sieht nach Auskunft aus und ist
keine, weil man nichts entscheiden kann.

Zwei Wege hinein: Schnelleingabe und CSV-Import aus dem offiziellen
Export, mit Vorschau vor dem Uebernehmen.

STUFE 2 -- FRUEHWARNUNG

Sechs benannte Signale (Zahlen fallen, LIVE-Tage brechen weg, lange
nicht angemeldet, kein Gespraech, Aufgabenstau, Start-Check haengt) und
daraus EIN ruhiger Satz: "Bei Nora wuerde ich diese Woche nachfassen."

BEWUSST KEIN SCORE. Eine Note von 1 bis 100 wirkt objektiv und ist es
nicht; sie verfuehrt dazu, Menschen nach einer Zahl zu behandeln. Am
Score kann man nichts tun, am Signal schon. Deshalb Saetze statt
Punkte, und jedes Signal einzeln nachvollziehbar.

Ein CREATOR sieht die Fruehwarnung nicht: Das ist eine
Arbeitsgrundlage fuer die Betreuung, keine Mitteilung an die
betroffene Person.

ZWEI ECHTE FEHLER, VON DER PRUEFUNG GEFUNDEN

1. ZAHLENFORMAT, Faktor tausend daneben. "1.250" wurde als 1,25
   gelesen und "1,234" als 1,234. Die Regel "das letzte Trennzeichen
   ist das Dezimalzeichen" liefert bei nur EINEM Trennzeichen fuer
   beide Faelle das Falsche -- und die Zahl sieht danach trotzdem
   plausibel aus. Jetzt entscheidet, wie viele Ziffern dahinter stehen:
   genau drei heisst Tausendertrenner.

2. DIE ZIELE-ROUTE WURDE VERSCHLUCKT. Express nimmt die erste passende
   Route, und `:tag` passt auch auf "ziele" -- ein PUT auf .../ziele
   landete in der Tages-Route und scheiterte am Datumsmuster. Die
   Meldung sagte "ungueltig", was auf die Zahlen deutet, nicht auf den
   Weg. Reihenfolge getauscht.

Beides waere still gewesen: falsche Zahlen sehen richtig aus, und ein
Ziel, das sich nicht setzen laesst, probiert man zweimal und laesst es
dann.

pruef-leistung.mjs: 47 Punkte, darunter jede Rechnung einzeln
nachgerechnet (Durchschnitte ohne Tage ohne Wert, Prozent von null,
Wochengrenzen), Import zweimal eingelesen, deutsche und englische
Zahlenformate, Rechte, und die Gegenprobe, dass die Fruehwarnung bei
Unauffaelligkeit SCHWEIGT.

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

456 lines
21 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.
/* LEISTUNGSZAHLEN UND FRÜHWARNUNG.
Stufe 1 und 2 des Plans vom 31.08.2026 -- das Fundament, auf dem
jede Betreuungsentscheidung stehen soll.
WARUM HIER BESONDERS GENAU GEPRÜFT WIRD: Eine falsche Zahl ist
schlimmer als eine fehlende. Eine fehlende sieht man; eine falsche
sieht aus wie eine Auskunft und führt zu einer Entscheidung. Wenn
das Dashboard "−40 %" sagt, ruft jemand an.
DIE FALLEN, die diese Prüfung stellt:
1. RECHNEN. Durchschnitte über Tage ohne Wert, Prozent von null,
Wochengrenzen. Jede einzeln nachgerechnet.
2. IMPORT ZWEIMAL. Der häufigste Fehler bei Importen -- dieselbe
Datei doppelt eingelesen darf nichts verdoppeln.
3. ZAHLENFORMATE. "1.234,5" (deutsch) und "1,234.5" (englisch)
bedeuten dasselbe. Wer das verwechselt, liest 1234,5 als 1,2345.
4. RECHTE. Ein Creator trägt seine Zahlen nicht selbst ein, und
niemand sieht fremde.
5. DIE FRÜHWARNUNG SCHLÄGT NICHT ZU FRÜH AN. Ein System, das
jede Woche Alarm gibt, wird jede Woche weniger gelesen.
GEGENPROBEN durchgehend. */
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
const ordner = mkdtempSync(join(tmpdir(), "ws-leistung-"));
process.env.WORKSPACE_DB = join(ordner, "workspace.db");
const { portMussFreiSein } = await import("./helfer-port.mjs");
await portMussFreiSein(4333, "die Leistungspruefung");
process.env.PORT = "4333";
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 }); };
await import("./index.js");
await new Promise((r) => setTimeout(r, 1000));
const BASIS = "http://127.0.0.1:4333";
let fehler = 0;
const ok = (b, t) => { console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; };
/* ---- Bestand ------------------------------------------------------------ */
const { DatabaseSync } = await import("node:sqlite");
const { scryptSync, randomBytes } = await import("node:crypto");
await fetch(BASIS + "/workspace/api/anmelden", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ rolle: "admin", code: "x" }) }).catch(() => {});
for (let i = 0; i < 60; i++) {
try {
const p = new DatabaseSync(process.env.WORKSPACE_DB);
try { p.prepare("SELECT 1 FROM personen LIMIT 1").get(); break; } finally { p.close(); }
} catch { await new Promise((r) => setTimeout(r, 250)); }
}
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 idPat = anlegen("Patrick", "scout", "CODE-SCOU-0001");
const idLuna = anlegen("Luna", "creator", "CODE-CREA-0001");
const idNora = anlegen("Nora", "creator", "CODE-CREA-0002");
d.prepare("INSERT INTO betreuung (creator_id, betreuer_id, seit) VALUES (?,?,?)").run(idLuna, idPat, jetzt);
/* ORTSZEIT -- nie toISOString(). Zwischen Mitternacht und zwei Uhr
läge das Datum sonst einen Tag daneben, und "diese Woche" wäre
verschoben. */
const tag = (v) => {
const t = new Date(); t.setDate(t.getDate() + v);
const p = (n) => String(n).padStart(2, "0");
return `${t.getFullYear()}-${p(t.getMonth() + 1)}-${p(t.getDate())}`;
};
async function anmelden(rolle, code) {
const a = await fetch(BASIS + "/workspace/api/anmelden", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ rolle, code }) });
const keks = (a.headers.getSetCookie?.() || []).map((k) => k.split(";")[0]).join("; ");
return { ok: a.ok, keks };
}
const rufe = (keks, weg, koerper, methode = "GET") =>
fetch(BASIS + weg, {
method: methode,
headers: { "Content-Type": "application/json", Cookie: keks },
body: koerper === undefined ? undefined : JSON.stringify(koerper),
});
const holen = async (keks, weg) => (await rufe(keks, weg)).json();
const dogi = await anmelden("admin", "CODE-DOGI-0001");
const pat = await anmelden("scout", "CODE-SCOU-0001");
const luna = await anmelden("creator", "CODE-CREA-0001");
const nora = await anmelden("creator", "CODE-CREA-0002");
ok([dogi, pat, luna, nora].every((x) => x.ok && x.keks), "alle vier Rollen angemeldet");
/* =======================================================================
1. Eintragen und wiederfinden
======================================================================= */
console.log("\n=== Einen Tag eintragen ===");
{
const a = await rufe(dogi.keks, `/workspace/api/leistung/${idLuna}/${tag(-1)}`, {
diamanten: 1200, dauer_min: 95, gueltiger_tag: true,
zuschauer_avg: 48, zuschauer_max: 130, verweildauer_s: 210,
schenker: 14, follower_neu: 23, notiz: "PK gegen Mia",
}, "PUT");
ok(a.ok, `eingetragen (${a.status})`);
const { tage } = await holen(dogi.keks, `/workspace/api/leistung/${idLuna}/tage`);
const z = tage?.[0];
ok(z?.diamanten === 1200 && z?.verweildauer_s === 210 && z?.notiz === "PK gegen Mia",
`alles gespeichert (${z?.diamanten} Diamanten, ${z?.verweildauer_s}s, "${z?.notiz}")`);
ok(z?.quelle === "hand", `Quelle vermerkt: ${z?.quelle}`);
/* DERSELBE TAG NOCHMAL überschreibt, statt zu verdoppeln. */
await rufe(dogi.keks, `/workspace/api/leistung/${idLuna}/${tag(-1)}`,
{ diamanten: 1500, gueltiger_tag: true }, "PUT");
const p = new DatabaseSync(process.env.WORKSPACE_DB);
const n = p.prepare("SELECT COUNT(*) AS n FROM leistung WHERE creator_id = ? AND tag = ?")
.get(idLuna, tag(-1)).n;
const wert = p.prepare("SELECT diamanten FROM leistung WHERE creator_id = ? AND tag = ?")
.get(idLuna, tag(-1)).diamanten;
p.close();
ok(n === 1 && wert === 1500, `zweimal eingetragen: ${n} Zeile, Wert ${wert} (überschrieben)`);
/* KEINE ZUKUNFT. Ein Tippfehler im Jahr legt sonst eine Zeile an,
die nie wieder auffällt -- sie taucht in keiner Auswertung auf. */
const z2 = await rufe(dogi.keks, `/workspace/api/leistung/${idLuna}/${tag(3)}`,
{ diamanten: 999 }, "PUT");
ok(z2.status === 400, `ein Tag in der Zukunft: abgelehnt (${z2.status})`);
}
/* =======================================================================
2. Das Rechnen — hier wird jede Zahl nachgerechnet
======================================================================= */
console.log("\n=== Die Rechnung stimmt ===");
{
const p = new DatabaseSync(process.env.WORKSPACE_DB);
p.prepare("DELETE FROM leistung WHERE creator_id = ?").run(idLuna);
/* Diese Woche: 3 Tage mit Werten. Ein vierter Tag OHNE Zuschauerzahl
-- er darf den Durchschnitt nicht verwässern. */
const setz = p.prepare(`INSERT INTO leistung (creator_id, tag, diamanten, dauer_min,
gueltiger_tag, zuschauer_avg, verweildauer_s, erfasst, quelle)
VALUES (?,?,?,?,?,?,?,?, 'hand')`);
setz.run(idLuna, tag(-1), 100, 60, 1, 40, 200, jetzt);
setz.run(idLuna, tag(-2), 200, 60, 1, 60, 300, jetzt);
setz.run(idLuna, tag(-3), 300, 60, 1, 50, 250, jetzt);
setz.run(idLuna, tag(-4), 0, 0, 0, null, null, jetzt); // Ruhetag
/* Vorwoche: zusammen 300 -- damit der Vergleich +100 % ergibt. */
setz.run(idLuna, tag(-8), 300, 60, 1, 30, 100, jetzt);
p.close();
const z = await holen(dogi.keks, `/workspace/api/leistung/${idLuna}`);
ok(z.woche.diamanten.wert === 600,
`Diamanten diese Woche: ${z.woche.diamanten.wert} (100+200+300+0 = 600)`);
ok(z.woche.diamanten.vorher === 300,
`Vorwoche: ${z.woche.diamanten.vorher}`);
ok(z.woche.diamanten.wandel?.prozent === 100,
`Veränderung: +${z.woche.diamanten.wandel?.prozent} % (300 → 600)`);
/* DER DURCHSCHNITT ZÄHLT NUR TAGE MIT WERT. (40+60+50)/3 = 50.
Würde der Ruhetag als 0 mitgerechnet, käme 37,5 heraus -- und jede
Zahl würde kleingeredet, sobald jemand Urlaub hat. */
ok(z.woche.zuschauer_avg.wert === 50,
`Ø Zuschauer: ${z.woche.zuschauer_avg.wert} — der Ruhetag ohne Wert zählt nicht mit`);
/* Dasselbe für die Leitzahl. (200+300+250)/3 = 250 */
ok(z.woche.verweildauer_s.wert === 250,
`Ø Verweildauer: ${z.woche.verweildauer_s.wert}s`);
ok(z.woche.gueltige_tage.wert === 3,
`gültige Tage: ${z.woche.gueltige_tage.wert} (der Ruhetag zählt nicht)`);
/* Erfassungsgrad -- eine Zahl aus zwei von sieben Tagen ist etwas
anderes als eine aus sieben. */
ok(z.erfassung.tage === 4, `${z.erfassung.tage} von 7 Tagen erfasst`);
/* VERLAUF für die kleine Linie. */
ok(Array.isArray(z.verlauf) && z.verlauf.length === 8,
`Verlauf über ${z.verlauf?.length} Wochen`);
ok(z.verlauf.at(-1).diamanten === 600,
`die letzte Woche im Verlauf stimmt mit der Wochenzahl überein`);
}
/* =======================================================================
3. Prozent von null — die Falle
======================================================================= */
console.log("\n=== Von null aus gibt es keine Prozent ===");
{
const p = new DatabaseSync(process.env.WORKSPACE_DB);
p.prepare("DELETE FROM leistung WHERE creator_id = ?").run(idNora);
p.prepare(`INSERT INTO leistung (creator_id, tag, diamanten, gueltiger_tag, erfasst, quelle)
VALUES (?,?,?,1,?, 'hand')`).run(idNora, tag(-1), 500, jetzt);
p.close();
const z = await holen(dogi.keks, `/workspace/api/leistung/${idNora}`);
/* Vorwoche war 0. "+unendlich Prozent" wäre Unsinn auf dem
Bildschirm -- stattdessen die Auskunft "vorher nichts". */
ok(z.woche.diamanten.wandel?.neu === true && z.woche.diamanten.wandel?.prozent === undefined,
`von 0 auf 500: als "neu" gemeldet, nicht als Prozentwert`);
/* Und wenn beide null sind, gibt es gar keine Meldung. */
ok(z.woche.schenker.wandel === null,
`0 → 0 ergibt keine Veränderungsmeldung`);
}
/* =======================================================================
4. Ziele als Fortschritt
======================================================================= */
console.log("\n=== Ziele ===");
{
await rufe(dogi.keks, `/workspace/api/leistung/${idLuna}/ziele`,
{ diamanten_woche: 1200, tage_woche: 4, verweildauer_s: 300 }, "PUT");
const z = await holen(dogi.keks, `/workspace/api/leistung/${idLuna}`);
ok(z.ziele?.diamanten?.anteil === 50,
`600 von 1200 Diamanten = ${z.ziele?.diamanten?.anteil} %`);
ok(z.ziele?.tage?.anteil === 75,
`3 von 4 Tagen = ${z.ziele?.tage?.anteil} %`);
/* Aus "mehr Reichweite" wird eine prüfbare Größe. */
ok(z.ziele?.verweildauer?.ist === 250 && z.ziele?.verweildauer?.ziel === 300,
`Verweildauer ${z.ziele?.verweildauer?.ist}s von ${z.ziele?.verweildauer?.ziel}s`);
}
/* =======================================================================
5. Rechte
======================================================================= */
console.log("\n=== Wer darf was ===");
{
/* Ein Creator trägt seine Zahlen NICHT selbst ein. Nicht aus
Misstrauen: Die Zahlen kommen aus dem offiziellen Export. */
const a = await rufe(luna.keks, `/workspace/api/leistung/${idLuna}/${tag(-1)}`,
{ diamanten: 99999 }, "PUT");
ok(a.status === 403, `Creator trägt eigene Zahlen ein: abgelehnt (${a.status})`);
/* Aber SEHEN darf sie ihre eigenen. */
const b = await rufe(luna.keks, `/workspace/api/leistung/${idLuna}`);
ok(b.ok, `ihre eigenen Zahlen sehen: geht (${b.status})`);
/* Fremde nicht -- und zwar 404, nicht 403. */
const c = await rufe(luna.keks, `/workspace/api/leistung/${idNora}`);
ok(c.status === 404, `fremde Zahlen: ${c.status} (404 — die Nummer verrät nichts)`);
/* Der Scout darf bei SEINEM Creator eintragen. */
const e = await rufe(pat.keks, `/workspace/api/leistung/${idLuna}/${tag(-5)}`,
{ diamanten: 50, gueltiger_tag: true }, "PUT");
ok(e.ok, `Scout bei seinem Creator: geht (${e.status})`);
/* Bei einem fremden nicht. */
const f = await rufe(pat.keks, `/workspace/api/leistung/${idNora}/${tag(-1)}`,
{ diamanten: 50 }, "PUT");
ok(f.status === 404, `Scout bei fremdem Creator: ${f.status}`);
}
/* =======================================================================
6. Der CSV-Import
======================================================================= */
console.log("\n=== Import aus dem Export ===");
{
const p = new DatabaseSync(process.env.WORKSPACE_DB);
p.prepare("DELETE FROM leistung WHERE creator_id = ?").run(idNora);
p.close();
/* Deutsches Format: Semikolon, Punkt als Tausendertrenner. */
const csv = [
"Datum;Diamanten;LIVE-Dauer (Min);Ø Zuschauer;Spitze Zuschauer;Verweildauer;Schenker;Neue Follower",
`${tag(-3)};1.250;95;48;130;210;14;23`,
`${tag(-2)};2.100;120;62;180;245;19;31`,
`${tag(-1)};890;45;30;70;180;8;12`,
].join("\n");
/* ERST DIE VORSCHAU -- sie schreibt nichts. */
const v = await (await rufe(dogi.keks, `/workspace/api/leistung/${idNora}/import/vorschau`,
{ text: csv }, "POST")).json();
ok(v.neu === 3 && v.ersetzt === 0 && v.fehlerhaft === 0,
`Vorschau: ${v.neu} neu, ${v.ersetzt} ersetzt, ${v.fehlerhaft} fehlerhaft`);
/* Die Spalten wurden erkannt. */
ok(v.spalten?.diamanten === 1 && v.spalten?.verweildauer_s === 5,
`Spalten erkannt: Diamanten=${v.spalten?.diamanten}, Verweildauer=${v.spalten?.verweildauer_s}`);
const p2 = new DatabaseSync(process.env.WORKSPACE_DB);
const vorher = p2.prepare("SELECT COUNT(*) AS n FROM leistung WHERE creator_id = ?").get(idNora).n;
p2.close();
ok(vorher === 0, `die Vorschau hat NICHTS geschrieben (${vorher} Zeilen)`);
/* Jetzt übernehmen. */
const u = await (await rufe(dogi.keks, `/workspace/api/leistung/${idNora}/import`,
{ text: csv }, "POST")).json();
ok(u.geschrieben === 3, `übernommen: ${u.geschrieben} Tage`);
/* DAS DEUTSCHE ZAHLENFORMAT: "1.250" sind 1250, nicht 1,25. */
const p3 = new DatabaseSync(process.env.WORKSPACE_DB);
const z = p3.prepare("SELECT * FROM leistung WHERE creator_id = ? AND tag = ?")
.get(idNora, tag(-3));
ok(z?.diamanten === 1250,
`"1.250" wurde als ${z?.diamanten} gelesen (nicht als 1,25)`);
ok(z?.quelle === "import", `Quelle: ${z?.quelle}`);
ok(z?.gueltiger_tag === 1,
`gültiger Tag aus der Dauer geschlossen (95 Min > 0)`);
p3.close();
/* ZWEIMAL EINLESEN — der häufigste Fehler bei Importen. */
const u2 = await (await rufe(dogi.keks, `/workspace/api/leistung/${idNora}/import`,
{ text: csv }, "POST")).json();
const p4 = new DatabaseSync(process.env.WORKSPACE_DB);
const n = p4.prepare("SELECT COUNT(*) AS n FROM leistung WHERE creator_id = ?").get(idNora).n;
p4.close();
ok(n === 3,
`dieselbe Datei zweimal: ${n} Zeilen (nicht 6) — überschrieben statt verdoppelt`);
/* EINE VON HAND GESCHRIEBENE NOTIZ ÜBERLEBT den Import. Sie steht
in keinem Export -- würde er sie leeren, wäre die Erklärung für
einen Einbruch weg. */
await rufe(dogi.keks, `/workspace/api/leistung/${idNora}/${tag(-1)}`,
{ diamanten: 890, notiz: "war krank" }, "PUT");
await rufe(dogi.keks, `/workspace/api/leistung/${idNora}/import`, { text: csv }, "POST");
const p5 = new DatabaseSync(process.env.WORKSPACE_DB);
const mitNotiz = p5.prepare("SELECT notiz FROM leistung WHERE creator_id = ? AND tag = ?")
.get(idNora, tag(-1));
p5.close();
ok(mitNotiz?.notiz === "war krank",
`die Notiz "${mitNotiz?.notiz}" hat den Import überlebt`);
/* Englisches Format muss auch gehen. */
const csvEn = [
"Date,Diamonds,Duration,Avg Viewers",
`${tag(-5)},"1,234",60,45`,
].join("\n");
const v2 = await (await rufe(dogi.keks, `/workspace/api/leistung/${idNora}/import/vorschau`,
{ text: csvEn }, "POST")).json();
ok(v2.neu === 1 && v2.beispiele?.[0]?.diamanten === 1234,
`englisches Format: "1,234" wurde als ${v2.beispiele?.[0]?.diamanten} gelesen`);
/* Kaputte Zeilen werden gemeldet, nicht verschluckt. */
const csvKaputt = [
"Datum;Diamanten",
`${tag(-1)};100`,
"keindatum;200",
`${tag(20)};300`,
].join("\n");
const v3 = await (await rufe(dogi.keks, `/workspace/api/leistung/${idNora}/import/vorschau`,
{ text: csvKaputt }, "POST")).json();
ok(v3.fehlerhaft === 2 && v3.probleme?.length === 2,
`2 kaputte Zeilen gemeldet: ${v3.probleme?.map((p) => p.grund).join(" · ")}`);
}
/* =======================================================================
7. Die Frühwarnung
======================================================================= */
console.log("\n=== Frühwarnung ===");
{
const p = new DatabaseSync(process.env.WORKSPACE_DB);
p.prepare("DELETE FROM leistung").run();
/* Luna: alles in Ordnung -- gleichbleibende Zahlen, frisch
angemeldet, gerade ein Gespräch gehabt. */
const setz = p.prepare(`INSERT INTO leistung (creator_id, tag, diamanten, dauer_min,
gueltiger_tag, erfasst, quelle) VALUES (?,?,?,?,?,?, 'hand')`);
for (let w = 0; w < 3; w++) {
for (let t = 0; t < 4; t++) setz.run(idLuna, tag(-(w * 7 + t + 1)), 300, 60, 1, jetzt);
}
p.prepare("UPDATE personen SET letzter_login = ? WHERE id = ?").run(jetzt, idLuna);
p.prepare(`INSERT INTO termine (titel, art, beginn, dauer_min, creator_id, erstellt, erstellt_von)
VALUES ('Call','call',?,30,?,?,?)`).run(tag(-2) + "T10:00", idLuna, jetzt, idDogi);
/* Nora: fallende Zahlen über drei Wochen, LIVE-Tage brechen weg,
lange nicht angemeldet, nie ein Gespräch. */
for (let t = 0; t < 5; t++) setz.run(idNora, tag(-(14 + t + 1)), 400, 60, 1, jetzt);
for (let t = 0; t < 3; t++) setz.run(idNora, tag(-(7 + t + 1)), 200, 60, 1, jetzt);
setz.run(idNora, tag(-1), 50, 30, 1, jetzt);
p.prepare("UPDATE personen SET letzter_login = ? WHERE id = ?")
.run(new Date(Date.now() - 40 * 86400000).toISOString(), idNora);
p.close();
const f = await holen(dogi.keks, "/workspace/api/fruehwarnung");
const nora = f.creator?.find((c) => c.name === "Nora");
const lunaW = f.creator?.find((c) => c.name === "Luna");
ok(!!nora, `Nora hat Befunde (${nora?.signale?.length || 0} Signale)`);
ok(!lunaW, `Luna hat KEINE — ein System, das jede Woche Alarm gibt, wird nicht gelesen`);
const arten = (nora?.signale || []).map((s) => s.art);
ok(arten.includes("zahlen_fallen"), `Signal "Zahlen fallen": ${arten.includes("zahlen_fallen")}`);
ok(arten.includes("tage_weg"), `Signal "LIVE-Tage brechen weg"`);
ok(arten.includes("nicht_angemeldet"), `Signal "lange nicht angemeldet"`);
ok(arten.includes("nie_gesprochen"), `Signal "nie ein Gespräch"`);
/* JEDES SIGNAL IST EIN SATZ, keine Zahl. Das ist die Kernentscheidung
gegen einen Score. */
const ersterSatz = nora?.signale?.[0]?.text || "";
ok(/\D/.test(ersterSatz) && ersterSatz.length > 20,
`die Signale sind Sätze, keine Punktzahl: "${ersterSatz}"`);
/* KEIN SCORE nach außen. */
ok(nora && nora._rang === undefined && nora.punkte === undefined && nora.score === undefined,
`keine Gesamtnote in der Antwort — bewusst`);
/* Ein Satz für die Startseite. */
ok(/Nora/.test(f.satz || ""), `Satz für die Startseite: "${f.satz}"`);
/* EIN CREATOR SIEHT DIE FRÜHWARNUNG NICHT. Das ist eine
Arbeitsgrundlage für die Betreuung, keine Mitteilung an die
betroffene Person. */
const beiLuna = await holen(luna.keks, "/workspace/api/fruehwarnung");
ok(beiLuna.gilt_nicht === true && (beiLuna.creator || []).length === 0,
`ein Creator bekommt keine Frühwarnung zu sehen`);
/* Und ein Scout sieht nur seine eigenen. */
const beiPat = await holen(pat.keks, "/workspace/api/fruehwarnung");
ok(!(beiPat.creator || []).some((c) => c.name === "Nora"),
`der Scout sieht nur seine Betreuten (${(beiPat.creator || []).map((c) => c.name).join(", ") || "keine"})`);
}
/* =======================================================================
GEGENPROBE
======================================================================= */
console.log("\n=== Gegenprobe ===");
{
/* Schlägt die Frühwarnung an, wenn wirklich nichts ist? Sie darf
nicht -- sonst wäre "Nora hat Befunde" oben wertlos. */
const p = new DatabaseSync(process.env.WORKSPACE_DB);
p.prepare("DELETE FROM leistung").run();
p.prepare("UPDATE personen SET letzter_login = ? WHERE rolle = 'creator'").run(jetzt);
p.prepare(`INSERT INTO termine (titel, art, beginn, dauer_min, creator_id, erstellt, erstellt_von)
VALUES ('Call','call',?,30,?,?,?)`).run(tag(-1) + "T10:00", idNora, jetzt, idDogi);
p.close();
const f = await holen(dogi.keks, "/workspace/api/fruehwarnung");
ok((f.creator || []).length === 0 && f.satz === null,
`ohne Auffälligkeiten meldet sie nichts (${(f.creator || []).length} Befunde)`);
/* Und ohne Anmeldung gar nichts. */
for (const weg of ["/workspace/api/leistung", "/workspace/api/fruehwarnung"]) {
const a = await fetch(BASIS + weg);
ok(a.status === 401, `${weg} ohne Anmeldung: ${a.status}`);
}
}
d.close();
try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ }
console.log(`\n${fehler === 0 ? "ALLES IN ORDNUNG" : `${fehler} FEHLER`}`);
/* Der Server läuft weiter -- siehe pruef-chat-optik. */
process.exit(fehler ? 1 : 0);