Files
dogfather-universe/server/pruef-leistung.mjs
T
DogFatherGitandClaude Opus 5 6e46a08543 Portnummern werden abgeleitet, nicht mehr vergeben
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]>
2026-09-20 22:51:21 +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 { eigenerPort } = await import("./helfer-port.mjs");
const PORT = await eigenerPort(import.meta, "die Leistungspruefung");
process.env.PORT = `${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 }); };
await import("./index.js");
await new Promise((r) => setTimeout(r, 1000));
const BASIS = `http://127.0.0.1:${PORT}`;
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);