# Workers P2 – Hochhaus in WOMS – Umsetzungsplan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** WOMS bekommt unter `/hustler` (Menüpunkt „Hustler“) das Pixel-Hochhaus: 10 Etagen, 19 Worker, echte Ereignisse als Animation, Kennzahlen, Worker-Karte mit Spur, Pausenschalter für alle Mitarbeiter und Einstellungen für Admins. **Architecture:** Eine Plain-Canvas-2D-Engine (ES-Module unter `src/components/workers/engine/`, Layout als Daten, Regie über reine Schritte aus `eventToSteps`) wird vom React-Teil nur eingehängt: `useWorkersFeed` holt Zustand (15 s) und Ereignisse (5 s) über die Dienst-API (Übersicht §6.2) und reicht sie an `mountTower` weiter; Klick auf eine Figur ruft `onSelect(workerId)`, React lädt die Spur. Die Seite wird per `React.lazy` geladen, damit Engine und Schrift nicht im Haupt-Bundle liegen. **Tech Stack:** React 18, Vite 5, Canvas 2D (kein PixiJS), `node:test` + esbuild-`loadModule`, `node:vm` für den Engine-Rauchtest, Playwright (headless shell) nur als manuelle Abnahme, Pixelify Sans (SIL OFL) selbst gehostet. **Spec:** /home/webklar/apps/ticket.webklar.com/dev-workers/docs/superpowers/specs/2026-10-17-workers-design.md · **Schnittstellen:** /home/webklar/apps/ticket.webklar.com/dev-workers/docs/superpowers/plans/2026-10-17-workers-00-uebersicht.md (verbindlich) ## Global Constraints - Alle Regeln aus Übersicht §2 gelten. - Pfad `WK=/home/webklar/apps/ticket.webklar.com/dev-workers` (Worktree, Branch `feature/workers`); Agenten pushen nie, Live nur durch Kenso (`git push origin feature/workers:test`); `git add` nur mit Dateinamen; Commit-Nachrichten enden mit `Co-Authored-By: Claude Opus 5.5 `. - Testbefehl je Datei: `cd $WK && timeout 300 node --max-old-space-size=1024 --test tests/unit/.test.mjs`; Gesamtlauf: `cd $WK && timeout 600 node --max-old-space-size=1024 --test tests/*.test.mjs tests/unit/*.test.mjs`; vor schweren Läufen `free -m`. - Stil wie WOMS: ES-Module, keine Semikolons, einfache Anführungszeichen, deutsche Kommentare, UI-Texte deutsch mit Umlauten, keine Emojis; Dateien unter `src/lib/` und `engine/` importieren weder React noch Appwrite und greifen beim Import nicht auf `window`/`document` zu (laden mit `loadModule()` aus `tests/unit/load.mjs`). - Keine neue npm-Abhängigkeit (kein PixiJS, kein Test-Framework); `package.json` bleibt unverändert. - Welt 320 × 256 logische Pixel (5:4); Turm x 14..230, Räume ab x 42 (186 breit), Etage 22 hoch plus 1 Bodenlinie (Raster 23), Brücke 34 hoch, 10 Etagen in `FLOORS`-Reihenfolge, 19 Worker. - Takt: höchstens 30 Bilder/s (`FRAME_MS` 33), `dt` ≤ 0,05 s, höchstens 3 Sprechblasen gleichzeitig (≤ 20 Zeichen), höchstens 80 gleichzeitige Effekte, Canvas-Breite ≤ 2400 Geräte-Pixel. - Rückstau: je `pushEvents`-Aufruf animieren nur die neuesten 6 Ereignisse (`BACKLOG_KEEP`), ältere wirken still (nur Aufgabe/Pose); je Worker höchstens 4 wartende Gruppen; ein Schritt höchstens 45 s (`STEP_TIMEOUT_S`). - Polling: Zustand alle 15 000 ms, Ereignisse alle 5 000 ms (`limit=200`, höchstens 5 Seiten je Runde), Spur `limit=50`; nichts bei `document.visibilityState === 'hidden'`, sofortiger Abruf beim Zurückkehren; JWT 10 min gecacht (`createJwtCache`), Header `X-Appwrite-JWT`, Basis `/api/workers`. - Rechte: Pausenschalter für alle Mitarbeiter; `WorkersSettingsDialog` nur für `isAdmin`; `mailColdPerDay` ≤ 3 (Spec §8.1), `mailReplyCapPerDay` ≤ 15; der Dialog sendet nie `paused`; die Formulargrenzen sind gleich oder enger als die des Dienstes (`retentionDays` 7–90, `offerText` 1–400, `offerProductKey` nur `hosting`, A11). Die Bounce-Notbremse (H4) zeigt WOMS nur an und hebt sie nur für Admins auf: Dialog-Knopf „Versand wieder freigeben“ sendet genau `{ sendingHalted: false }`, nie als Teil des Formular-Patches; `sendingHalted`, `sendingHaltedReason` und `privacyInteressentenLive` sind schreibgeschützt (`READONLY_KEYS`, Übersicht §7). - Weißes Theme (Konzept 2026-10-04): keine Karten/Kästen, nur Haarlinien `var(--line)`, nur Tokens aus `global.css` (keine Hex-Farben in `workers.css`), Klassen mit Präfix `wk-`, Zahlen `tabular-nums`, Breakpoints 1100 px und 860 px, „ד statt Icons. - Schrift: Pixelify Sans (SIL OFL 1.1) als `public/fonts/PixelifySans-Variable.woff2` (Gewichte 400–700) plus Lizenztext `public/fonts/OFL-PixelifySans.txt`, `font-display: swap`, kein Google-Fonts-Link; Download nur in P2-T20, in einen neuen leeren Ordner, Prüfung der Magic Bytes `wOF2`. - Bewegung und Lebenszyklus: `prefers-reduced-motion` ⇒ keine Schleife, Standbild je Änderung; `visibilitychange` ⇒ `setPaused`; `mountTower`/`destroy` idempotent (React StrictMode: mount, destroy, mount). - Playwright/Chromium nur seriell unter `systemd-run --scope -q -p MemoryMax=700M -p MemorySwapMax=0`, vite-Build unter `MemoryMax=1500M` mit `NODE_OPTIONS=--max-old-space-size=1200`; Ausgabeordner immer neu unter `$TMPDIR`. - Keine `.env`-, Compose- oder nginx-Änderung in P2 (der `/api/workers/`-Block gehört zu P1); P2 schreibt nie nach Appwrite. - **Anzeigename [Kenso, 17.10.: Name „Hustler“]:** Alles, was Mitarbeiter sehen, heißt „Hustler“, nicht „Workers“: Menüpunkt, Seitentitel (H1), Browser-Tab-Titel, Titel des Einstellungs-Dialogs („Hustler-Einstellungen“), Leerzustände, Hinweise, Fehlertexte (`Der Hustler-Dienst …`, `Hustler-Fehler `) und die Beschriftung der Leinwand. Die Seite liegt unter `/hustler`; `/leads` und `/workers` leiten per `` dorthin um und behalten die Query (`/workers?tab=botleads&bot=` → `/hustler?tab=botleads&bot=`, Übersicht §7). Technische Bezeichner bleiben unverändert: Sammlungen `workers*`, Dienst/Container `webklar-workers`, Repo `knso/workers-dienst`, API `/api/workers/*`, Dateien und Komponenten (`WorkersPage.jsx`, `workersApi.js`, `workersRoster.js` …), CSS-Präfix `wk-`, Umgebungsvariablen `WORKERS_*`, `workerId`, Ereignisarten. Das Wort „Worker“ für eine einzelne Figur oder Rolle bleibt (Übersicht §7, Namensregel). „Workers“ in Commit-Präfixen und Plan-Dateinamen ist das Projekt-Kürzel. - **Kosten [Kenso, 17.10.: max. 150 € im Monat]:** Anthropic rechnet in USD ab, die Grenzen sind deshalb `monthlyBudgetUsd` 150 (150 $ ≈ 130 €, solange 1 € ≥ 1 $), `dailyBudgetUsd` 6 (Ausreißerschutz, vorher 10) und `warnPct` 80. P2 legt keine Standardwerte fest (die setzt der Dienst, P1); sie stehen hier nur in Test-Fixtures, Beispielwerten und Formulartests. Das harte Limit ist das Ausgabenlimit des Console-Workspaces (150 $/Monat, Betriebsanleitung P1). > **Ergänzungen E1–E7** (Fixture-Form, JWT, Zusatzdateien, `mountTower`-Optionen, API-Formate, Ereignis-`meta`, Reiter/Tagesziel) sind in der Übersicht nachgetragen: siehe Übersicht §11.1, P2-E1 bis P2-E7 (dort auch der Reiter `freigabe`, den P3 anhängt: P3-E11); „E1“ bis „E7“ in diesem Plan meinen diese Zeilen. ## Review Focus 1. Dienst nicht erreichbar oder nginx-Block fehlt (HTML-Seite mit Status 200, 502, Netzfehler) → deutsche Meldung „Der Hustler-Dienst antwortet nicht richtig.“ bzw. „… ist gerade nicht erreichbar.“ statt Absturz oder `NaN`; die Seite bleibt bedienbar und fragt weiter ab. Tests: P2-T02 (`kpiTiles` ohne Daten), P2-T04 (Fehlertexte), P2-T19 (Board-Zustände). 2. Tab lange im Hintergrund, danach 200+ Ereignisse auf einmal → keine Animationsflut: höchstens 6 Gruppen laufen, der Rest wirkt still, alle Warteschlangen sind nach ≤ 60 s Simulationszeit leer; der Feed holt seitenweise (200, höchstens 5 je Runde). Tests: P2-T05, P2-T13. 3. Dasselbe Ereignis kommt in zwei Abfragen (gleicher Zeitstempel am Cursor) und die Vorgeschichte vor dem Öffnen der Seite → nie doppelt abgespielt, Vorgeschichte nicht nachgespielt. Tests: P2-T03 (`takeNewEvents`), P2-T05 (Cursor aus dem Snapshot). 4. Raum ohne Quelle (nur die acht Such-Räume aus `SEARCH_ROOMS` können es sein; der Dienst meldet alle anderen Worker von Anfang an als angeschlossen, P1-E9), Worker fehlt im Zustand, unbekannte Ereignisart oder unbekannte `workerId` → grauer Raum mit Schild „noch nicht angeschlossen“, schlafende Figur, keine vorgespielte Arbeit, kein Wurf. Tests: P2-T03, P2-T11, P2-T12, P2-T13. 5. `link` im Ereignisstrom mit `javascript:`, `data:` oder `//fremder.host` → nur Text, nie ein `href`; externe Links mit `rel="noopener noreferrer"`. Tests: P2-T02 (`safeLink`), P2-T17 (Quelltext-Test der Karte). 6. Bounce-Notbremse (H4): `state.sendingHalted` → rote Warnzeile im Board mit dem Grund als Textknoten (nie HTML); „Versand wieder freigeben“ nur im Admin-Dialog, sendet allein `{ sendingHalted: false }` (kein Patch aus dem Formular, kein `paused`), und die drei Felder `sendingHalted`, `sendingHaltedReason`, `privacyInteressentenLive` landen nie im Formular-Patch. Tests: P2-T16 (`READONLY_KEYS`, leerer Patch), P2-T18, P2-T19, P2-T21 (Prüfstand). 7. Das Formular lässt nichts zu, was der Dienst ablehnt (sonst kommt eine Serverfehlermeldung statt der Feldmeldung): `retentionDays` 7–90, `offerText` 1–400 Zeichen. Test: P2-T16 (Grenzen 6/7/90/91 und 400/401). ## File Structure Alles unter `$WK`. **Neu:** | Datei | Verantwortung | |---|---| | `src/lib/workersRoster.js` | Spiegel von Übersicht §3: `ROLES`, `FLOORS`, `FLOOR_TITLES`, `ROOM_TITLES`, `MODEL_IDS`, `hatForModel`, `hatForWorker`, `roleOf` | | `tests/fixtures/workers-roster.json` | gemeinsame Fixture (E1) | | `src/lib/workersView.js` | reine Anzeige-Ableitungen (Formate, Status, Kacheln, Zähler, `safeLink`, Spur-Zeilen, Reiterwahl) | | `src/lib/workersEvents.js` | `EVENT_KINDS`, Deduplizierung, `reduceEvents`, `eventToSteps`, `needsStateRefresh` | | `src/lib/workersApi.js` · `src/lib/workersClient.js` | API-Client mit Fehlertexten (fetch injiziert) · Verdrahtung mit Appwrite-JWT | | `src/lib/workersFeed.js` · `src/hooks/useWorkersFeed.js` | Polling-Steuerung (Timer injiziert, testbar) · React-Hooks `useWorkersFeed`, `useWorkerTrace` | | `src/lib/workersSettingsForm.js` | Formularlogik der Einstellungen (Umwandlung, Prüfung mit den Grenzen des Dienstes, Patch; schreibgeschützt: `READONLY_KEYS` inkl. `privacyInteressentenLive`, `sendingHalted`, `sendingHaltedReason`) | | `engine/sprites.js` | Raster `setGrid/px`, Palette, Figuren mit Posen/Grau, Text, Sprechblasen (Quelle `entwuerfe/sprites.js` Z. 7–142, 277–307) | | `engine/props.js` | Möbel/Requisiten und 7×7-Branchen-Icons (Quelle `sprites.js` Z. 146–273) | | `engine/layout.js` · `engine/camera.js` | Etagen/Räume/Plätze als Daten · Kamera, Zoom, Umkehr-Transformation, Treffer-Auswahl | | `engine/fx.js` · `engine/lift.js` | Effekt-System · Aufzug (Quelle `scene-turm.js` Z. 366–393, 316–330, 354–492) | | `engine/rooms.js` · `engine/building.js` | Raumzeichnung nach `kind`, Pokal-Seiten · Gebäudehülle, Schacht, Etagenschilder, Pausenschleier (Quelle `scene-turm.js` Z. 99–314) | | `engine/director.js` | Agenten, Snapshot ohne Animation, Schritt-Regie, Rückstau-Kappung, Leerlauf, Wachhund (Quelle `scene-turm.js` Z. 416–464, 864–905) | | `engine/scene.js` | `mountTower`: Größe, Schleife, Eingabe, Kamera, Lebenszyklus | | `src/components/workers/{WorkersKpis,WorkerCard,WorkerList,WorkersSettingsDialog,WorkersStage,WorkersBoard}.jsx` | UI-Teile (Board = Inhalt des Reiters „Hochhaus“, ohne Auth, damit der Prüfstand ihn mounten kann; Board zeigt bei `sendingHalted` eine Warnzeile, der Einstellungs-Dialog hat den Block „Versand wieder freigeben“, H4) | | `src/pages/WorkersPage.jsx` · `src/styles/workers.css` | Seite mit `WORKERS_TABS` (zunächst nur `hochhaus`) und der `api`-Instanz · Stil (`wk-`, `@font-face`) | | `public/fonts/PixelifySans-Variable.woff2` · `public/fonts/OFL-PixelifySans.txt` | Schrift und Lizenz | | `scripts/workers-harness/{build.mjs,entry.jsx,index.html,scripted-api.js,shot.cjs}` | Prüfstand für die manuelle Playwright-Abnahme | | `tests/unit/helpers/{fakeCtx,workersFixtures,engineVm}.mjs` | Test-Hilfen (aufzeichnender Kontext, 19-Worker-Zustand, vm-Sandbox) | | `tests/unit/workers{Roster,View,Events,Api,Feed,Sprites,Props,Layout,Camera,Fx,Lift,Rooms,Director,Scene,Smoke,SettingsForm,Components,Css,Font,Route,Harness}.test.mjs` | je ein Test pro Baustein | **Ändern:** `src/App.jsx` (lazy Route `/hustler`, `/leads` und `/workers` → `/hustler`), `src/lib/navLinks.js` (Eintrag `hustler`), `tests/unit/navLinks.test.mjs`. **Kein Offscreen-Cache** für die Kulisse (Entscheidung: erst messen; `stats().avgDrawMs` wird in P2-T21 geprüft). --- ### P2-T01: Roster-Spiegel und gemeinsame Fixture **Files:** - Create: `src/lib/workersRoster.js`, `tests/fixtures/workers-roster.json` - Test: `tests/unit/workersRoster.test.mjs` **Interfaces:** - Consumes: Übersicht §3 (Tabelle), E1. - Produces: ```js export const FLOORS // ['bruecke','anfragen','ohne_website','bewertung','gespraeche','werkstatt','pruefung','freigabe','tresor','keller'] export const ROLES // 19 × { workerId, name, floor, room, model:'opus'|'sonnet'|'haiku', role, caps } export const MODEL_IDS // { opus:'claude-opus-5-5', sonnet:'claude-sonnet-5-5', haiku:'claude-haiku-5-5' } export const SEARCH_ROOMS // ['netz','kleinanzeigen','portale','foren','aerzte','handwerk','selbst','laden'] – Spiegel von `src/roles.js` des Dienstes (Räume der Etagen anfragen + ohne_website); nur diese Räume können „nicht angeschlossen“ (grau) sein, alle anderen Worker meldet der Dienst als angeschlossen (Übersicht §11.1 P1-E9) export const FLOOR_TITLES // id → 'Kommandobrücke' | 'Anfragen' | 'Ohne Website' | 'Bewertung' | 'Gespräche' | 'Werkstatt' | 'Prüfung' | 'Freigabe' | 'Tresor' | 'Keller' export const ROOM_TITLES // 21 Räume (19 Worker-Räume + 'pult','tresor') → Titel, Werte siehe Schritt 3 export function hatForModel(model): 'opus'|'sonnet'|'haiku' // Kurzname oder voller Modellname, sonst 'haiku' export function hatForWorker(workerId): 'overseer'|'opus'|'sonnet'|'haiku' // otto → 'overseer' export function roleOf(workerId): object | null ``` - [ ] **Step 1: Failing test schreiben** (`tests/unit/workersRoster.test.mjs`) ```js import test from 'node:test' import assert from 'node:assert/strict' import { readFileSync } from 'node:fs' import { loadModule } from './load.mjs' const { ROLES, FLOORS, SEARCH_ROOMS, FLOOR_TITLES, ROOM_TITLES, MODEL_IDS, hatForModel, hatForWorker, roleOf } = await loadModule('src/lib/workersRoster.js') const fixture = JSON.parse(readFileSync(new URL('../fixtures/workers-roster.json', import.meta.url), 'utf8')) test('Spiegel ist gleich der gemeinsamen Fixture', () => { assert.deepEqual([...FLOORS], fixture.floors) assert.deepEqual(JSON.parse(JSON.stringify(ROLES)), fixture.roles) }) test('19 Worker, workerId und Raum einmalig, Etagen und Titel vollständig', () => { assert.equal(ROLES.length, 19) assert.equal(new Set(ROLES.map((r) => r.workerId)).size, 19) assert.equal(new Set(ROLES.map((r) => r.room)).size, 19) for (const r of ROLES) assert.ok(FLOORS.includes(r.floor), r.workerId) assert.equal(FLOORS.length, 10) assert.deepEqual(Object.keys(FLOOR_TITLES).sort(), [...FLOORS].sort()) assert.deepEqual(Object.keys(ROOM_TITLES).sort(), [...ROLES.map((r) => r.room), 'pult', 'tresor'].sort()) }) test('Hut zeigt das Modell, Otto trägt die Kommandomütze', () => { assert.deepEqual(['opus', 'claude-sonnet-5-5', 'claude-haiku-5-5', 'claude-xyz', undefined].map(hatForModel), ['opus', 'sonnet', 'haiku', 'haiku', 'haiku']) assert.deepEqual(['otto', 'bob', 'bea', 'kim', 'zed'].map(hatForWorker), ['overseer', 'opus', 'sonnet', 'haiku', 'haiku']) assert.equal(roleOf('paul').room, 'pruefung') assert.equal(roleOf('zed'), null) assert.deepEqual(MODEL_IDS, { opus: 'claude-opus-5-5', sonnet: 'claude-sonnet-5-5', haiku: 'claude-haiku-5-5' }) }) test('SEARCH_ROOMS: festgenagelt wie im Dienst (P1-T05) und aus der Fixture ableitbar; nur diese Worker können grau sein (P1-E9)', () => { assert.deepEqual([...SEARCH_ROOMS], ['netz', 'kleinanzeigen', 'portale', 'foren', 'aerzte', 'handwerk', 'selbst', 'laden']) assert.deepEqual([...SEARCH_ROOMS], fixture.roles.filter((r) => ['anfragen', 'ohne_website'].includes(r.floor)).map((r) => r.room)) assert.deepEqual(ROLES.filter((r) => SEARCH_ROOMS.includes(r.room)).map((r) => r.workerId), ['rita', 'kim', 'pia', 'finn', 'ali', 'hanna', 'sven', 'lea']) assert.equal(ROLES.filter((r) => !SEARCH_ROOMS.includes(r.room)).length, 11) }) ``` - [ ] **Step 2: Test laufen lassen, Scheitern sehen** Einmalig: `cd $WK && git status --short` (keine geänderten Dateien; `??`-Zeilen für noch nicht eingecheckte Plan-Dokumente unter `docs/superpowers/plans/` sind erlaubt) und `ln -s ../build/node_modules node_modules` (erscheint als `?? node_modules`, nie committen). Run: `cd $WK && timeout 300 node --max-old-space-size=1024 --test tests/unit/workersRoster.test.mjs` Expected: FAIL, esbuild `Could not resolve …/src/lib/workersRoster.js`. - [ ] **Step 3: Implementieren** in `src/lib/workersRoster.js` und `tests/fixtures/workers-roster.json` Fixture und `ROLES` aus Übersicht §3 erzeugen (P2-E1): Form `{ "floors": [<10 Etagen-Ids>], "roles": [{ "workerId", "name", "floor", "room", "model", "role", "caps" }] }`, 19 Einträge in Tabellenreihenfolge, `model` = `opus|sonnet|haiku`, `role` = Spalte „Einzige Rolle“ wörtlich, `caps` = Spalte „Erlaubte Fähigkeiten“ (bei „wie ali“ aufgelöst), 2 Leerzeichen Einrückung, Zeilenumbruch am Ende. Liegt `/home/webklar/apps/workers-dev/test/fixtures/workers-roster.json` (P1) schon vor, wird sie byte-identisch kopiert und `ROLES` danach ausgerichtet. `ROLES`/`FLOORS` mit `Object.freeze` (tief genug für `deepEqual` nach JSON-Rundlauf). `SEARCH_ROOMS` = Räume der Etagen `anfragen` und `ohne_website` in `ROLES`-Reihenfolge, eingefroren (dieselbe Ableitung wie in P1-T05; WOMS berechnet `connected` nicht selbst, die Konstante dient Anzeige und Prüfstand). `ROOM_TITLES`: netz „Netz-Radar“, kleinanzeigen „Kleinanzeigen“, portale „Portale“, foren „Foren & Fragen“, aerzte „Ärzte & Praxen“, handwerk „Handwerk“, selbst „Selbstständige“, laden „Läden & Gastro“, waage „Waage“, mail „Mail-Stube“, forum „Forum-Stube“, posteingang „Posteingang“, abschluss „Abschluss“, tisch1/2/3 „Tisch 1/2/3“, pruefung „Prüftisch“, archiv „Archiv & Register“, bruecke „Brücke“, pult „Dein Pult“, tresor „Tresor“. `hatForModel`: Treffer auf `/opus|sonnet|haiku/` im Text, sonst `'haiku'`. - [ ] **Step 4: Test laufen lassen, Bestehen sehen** — gleicher Befehl wie Step 2. Expected: `# pass 4`, `# fail 0`. - [ ] **Step 5: Commit** ```bash cd $WK && git add src/lib/workersRoster.js tests/fixtures/workers-roster.json tests/unit/workersRoster.test.mjs git commit -m "Workers P2: Roster-Spiegel und gemeinsame Fixture" -m "Co-Authored-By: Claude Opus 5.5 " ``` --- ### P2-T02: Anzeige-Logik `workersView.js` **Files:** - Create: `src/lib/workersView.js` - Test: `tests/unit/workersView.test.mjs` **Interfaces:** - Consumes: `workersRoster.js` (`MODEL_IDS`). - Produces (Namen aus Übersicht §7 fett): ```js export function formatUsd(n): string // 7.84 → '7,84 $' (NBSP vor $), null/NaN/Text → '–' export function formatEur(n): string // 120 → '120 €', 19.5 → '19,50 €' (NBSP) export function formatCount(n): string // 1234 → '1.234', sonst '–' export function formatTime(iso, now = Date.now()): string // Europe/Berlin; gleicher Tag 'HH:MM', sonst 'TT.MM. HH:MM'; ungültig '–' export function workerStatusLabel(status): string export function statusTone(status): 'green'|'orange'|'plain'|'red' export function kpiTiles(kpis): [{ key, label, value, sub, tone }] // 6 Kacheln export function counterLines(counters): [{ key, label, value }] export function tokenTotal(tokens): number // Zahl oder { in, out } export function safeLink(link): { href, external } | null export function traceRows(events, now?): [{ id, time, text, link }] // neueste zuerst, höchstens 50 export function pauseLabel(paused): string export function pickTab(tabs, id): tab // unbekannt → erster Reiter ``` - [ ] **Step 1: Failing test schreiben** (`tests/unit/workersView.test.mjs`, `const V = await loadModule('src/lib/workersView.js')`) ```js const NB = String.fromCharCode(160) // geschütztes Leerzeichen vor $ und € const K = { newAbosMonth: 3, mrrNewEur: 60, dealsMonth: 3, openThreads: 7, pultOpen: 2, botLeads: 112, apiTodayUsd: 5.1, apiMonthUsd: 50, dailyBudgetUsd: 6, monthlyBudgetUsd: 150 } // [Kenso, 17.10.: max. 150 € im Monat]: Tagesgrenze 6 $, Monatsgrenze 150 $ test('Zahlenformate', () => { assert.equal(V.formatUsd(7.84), `7,84${NB}$`); assert.equal(V.formatUsd(1234.5), `1.234,50${NB}$`); assert.equal(V.formatUsd(0), `0,00${NB}$`) for (const bad of [null, undefined, NaN, 'x']) assert.equal(V.formatUsd(bad), '–') assert.equal(V.formatEur(120), `120${NB}€`); assert.equal(V.formatEur(19.5), `19,50${NB}€`) assert.equal(V.formatCount(1234), '1.234'); assert.equal(V.formatCount(null), '–') }) test('formatTime: Berlin-Zeit, heute nur Uhrzeit', () => { const now = Date.parse('2026-10-17T10:00:00Z') assert.equal(V.formatTime('2026-10-17T20:30:00Z', now), '22:30') assert.equal(V.formatTime('2026-10-16T21:30:00Z', now), '16.10. 23:30') assert.equal(V.formatTime('kaputt', now), '–'); assert.equal(V.formatTime(null, now), '–') }) test('Status: Texte und Töne', () => { const s = ['arbeitet', 'wartet', 'leerlauf', 'grau', 'fehler', 'x'] assert.deepEqual(s.map(V.workerStatusLabel), ['Arbeitet', 'Wartet auf Auftrag', 'Leerlauf', 'Noch nicht angeschlossen', 'Fehler', 'Unbekannt']) assert.deepEqual(s.map(V.statusTone), ['green', 'orange', 'plain', 'plain', 'red', 'plain']) }) test('kpiTiles: sechs Kacheln, Töne nach 80-%-Regel', () => { const t = V.kpiTiles(K) assert.deepEqual(t.map((x) => x.key), ['abos', 'deals', 'threads', 'pult', 'botleads', 'api']) assert.deepEqual(t.map((x) => x.label), ['Neue Abos (Monat)', 'Deals (Monat)', 'Gespräche offen', 'Wartet auf dich', 'Bot-Leads gesamt', 'API heute']) assert.deepEqual(t.map((x) => x.value), ['3', '3', '7', '2', '112', `5,10${NB}$`]) assert.equal(t[0].sub, `60${NB}€ neu pro Monat`); assert.equal(t[5].sub, `von 6,00${NB}$`) assert.deepEqual(t.map((x) => x.tone), ['', '', '', 'warn', '', 'warn']) assert.equal(V.kpiTiles({ ...K, apiTodayUsd: 6 })[5].tone, 'neg'); assert.equal(V.kpiTiles({ ...K, apiTodayUsd: 4.7 })[5].tone, '') assert.equal(V.kpiTiles({ ...K, dailyBudgetUsd: 0 })[5].tone, ''); assert.equal(V.kpiTiles({ ...K, pultOpen: 0 })[3].tone, '') }) test('kpiTiles ohne Daten: Striche, kein NaN (Review Focus 1)', () => { for (const k of [null, undefined, {}]) for (const x of V.kpiTiles(k)) { assert.equal(x.value, '–'); assert.ok(!/NaN|undefined/.test(x.sub)); assert.equal(x.tone, '') } }) test('counterLines: bekannte Zähler zuerst, Rest alphabetisch, unbekannter Schlüssel als Beschriftung', () => { assert.deepEqual(V.counterLines({ zeta: 1, gesendet: 3, gelesen: 41 }), [ { key: 'gelesen', label: 'gelesen', value: '41' }, { key: 'gesendet', label: 'gesendet', value: '3' }, { key: 'zeta', label: 'zeta', value: '1' }]) assert.equal(V.counterLines({ entwuerfe: 2 })[0].label, 'Entwürfe'); assert.deepEqual(V.counterLines(null), []) assert.equal(V.tokenTotal(1200), 1200); assert.equal(V.tokenTotal({ in: 1000, out: 234 }), 1234); assert.equal(V.tokenTotal(undefined), 0) }) test('safeLink: nur http(s) und interne Pfade (Review Focus 5)', () => { assert.deepEqual(V.safeLink('https://x.de/a'), { href: 'https://x.de/a', external: true }) assert.deepEqual(V.safeLink('/customers/1'), { href: '/customers/1', external: false }) for (const bad of ['javascript:alert(1)', ' JavaScript:alert(1)', 'data:text/html,x', '//evil.example', 'mailto:a@b.de', '', null, 42]) assert.equal(V.safeLink(bad), null, String(bad)) }) test('traceRows: neueste zuerst, höchstens 50, unsicherer Link bleibt Text', () => { const evs = Array.from({ length: 60 }, (_, i) => ({ id: `e${i}`, ts: new Date(Date.UTC(2026, 9, 17, 8, 0, i)).toISOString(), text: `T${i}`, link: i === 59 ? 'javascript:x' : null })) const rows = V.traceRows(evs, Date.parse('2026-10-17T12:00:00Z')) assert.equal(rows.length, 50); assert.equal(rows[0].id, 'e59'); assert.equal(rows[0].link, null); assert.equal(rows[0].text, 'T59') }) test('pauseLabel und pickTab', () => { assert.equal(V.pauseLabel(false), 'Alles anhalten'); assert.equal(V.pauseLabel(true), 'Fortsetzen') const tabs = [{ id: 'hochhaus' }, { id: 'pult' }] assert.equal(V.pickTab(tabs, 'pult').id, 'pult'); assert.equal(V.pickTab(tabs, 'nope').id, 'hochhaus'); assert.equal(V.pickTab(tabs, null).id, 'hochhaus') }) ``` - [ ] **Step 2: Scheitern sehen** — `cd $WK && timeout 300 node --max-old-space-size=1024 --test tests/unit/workersView.test.mjs`. Expected: FAIL `Could not resolve …/workersView.js`. - [ ] **Step 3: Implementieren** `src/lib/workersView.js` Zahlen über `Intl.NumberFormat('de-DE')`; nur `typeof n === 'number' && Number.isFinite(n)` zählt, sonst `'–'`; Zeichen vor `$`/`€` ist das geschützte Leerzeichen (Code 160, im Test `NB`). `formatEur` ohne Nachkommastellen bei ganzen Zahlen. `formatTime` über `Intl.DateTimeFormat('de-DE', { timeZone: 'Europe/Berlin', … })`, „gleicher Tag“ = gleiches Berliner Datum wie `now`. Kachel-Ton `api`: `ratio = apiTodayUsd / dailyBudgetUsd` nur bei `dailyBudgetUsd > 0`: ≥ 1 ⇒ `'neg'`, ≥ 0,8 ⇒ `'warn'`, sonst `''`; Kachel `pult` `'warn'` bei `pultOpen > 0`. Bekannte Zähler (Reihenfolge, Beschriftung): `gelesen`, `gefunden`, `passend`, `bewertet`, `entwuerfe` „Entwürfe“, `gesendet`, `gebaut`, `geprueft` „geprüft“. `safeLink` trimmt, prüft `/^https?:\/\//i` (extern) oder `/^\/(?!\/)/` (intern). `traceRows` sortiert per `Date.parse` absteigend, ungültige Zeiten zuletzt. - [ ] **Step 4: Bestehen sehen** — gleicher Befehl. Expected: `# pass 9`, `# fail 0`. - [ ] **Step 5: Commit** ```bash cd $WK && git add src/lib/workersView.js tests/unit/workersView.test.mjs git commit -m "Workers P2: Anzeige-Logik (Formate, Kacheln, safeLink, Spur-Zeilen)" -m "Co-Authored-By: Claude Opus 5.5 " ``` --- ### P2-T03: Ereignis-Logik `workersEvents.js` **Files:** - Create: `src/lib/workersEvents.js` - Test: `tests/unit/workersEvents.test.mjs` **Interfaces:** - Consumes: `workersRoster.js` (`ROLES`). - Produces (`reduceEvents`, `eventToSteps` laut Übersicht §7): ```js export const EVENT_KINDS // die 26 Arten aus Übersicht §5, in dieser Reihenfolge export const STEP_OPS // ['task','say','pose','give','fx','cheer'] export const SPOTS // ['pult','tresor','aussen','archiv'] export function takeNewEvents(seen: Set, events): event[] // neu = Id noch nicht gesehen, aufsteigend nach ts export function latestTs(events, prev = null): string | null export function indexWorkers(state): { [workerId]: { status, task, lastTs, connected, counters } } export function reduceEvents(stateByWorker, events): { [workerId]: { status, task, lastTs, … } } // unverändert eingegebenes Objekt export function eventToSteps(event): step[] export function needsStateRefresh(events): boolean export function cutBubble(text): string // höchstens 20 Zeichen ``` Schritte: `{op:'task',who,text}` · `{op:'say',who,text}` · `{op:'pose',who,pose:'type'|'stand'|'coffee'|'sleep'}` · `{op:'give',from,to}` · `{op:'fx',fx:'paper'|'envelope'|'coin'|'stampOk'|'stampNo'|'sparkle'|'alert'|'drawer',at,to?,delay?}` (`at`/`to` = workerId oder Platz aus `SPOTS`) · `{op:'cheer',who}`. - [ ] **Step 1: Failing test schreiben** (`tests/unit/workersEvents.test.mjs`, `const E = await loadModule('src/lib/workersEvents.js')`, Roster per zweitem `loadModule` für `ROLES`) ```js const TS = '2026-10-17T20:00:00.000Z' const ev = (kind, workerId, extra = {}) => ({ id: `${kind}-${workerId}`, ts: TS, workerId, kind, text: 'Text', ...extra }) test('EVENT_KINDS: 26 Arten aus Übersicht §5', () => { assert.deepEqual([...E.EVENT_KINDS], ['auftrag', 'leerlauf', 'gelesen', 'fund', 'doppelt', 'uebergabe', 'bewertet', 'top3', 'botlead', 'entwurf', 'pult', 'gesendet', 'eingang', 'sortiert', 'bau_start', 'bau_fertig', 'pruefung_zurueck', 'pruefung_ok', 'vorschau_link', 'angebot', 'deal', 'gesperrt', 'budget_warnung', 'budget_stopp', 'pause', 'fehler']) }) test('takeNewEvents: dedupliziert nach id, sortiert nach ts, Gedächtnis begrenzt (Review Focus 3)', () => { const seen = new Set(), a = { id: 'a', ts: '2026-10-17T20:00:02Z' }, b = { id: 'b', ts: '2026-10-17T20:00:01Z' } assert.deepEqual(E.takeNewEvents(seen, [a, b]).map((e) => e.id), ['b', 'a']) assert.deepEqual(E.takeNewEvents(seen, [a, b, { id: 'c', ts: '2026-10-17T20:00:03Z' }]).map((e) => e.id), ['c']) const noId = { ts: '2026-10-17T20:00:04Z', workerId: 'kim', kind: 'gelesen', text: 'x' } assert.equal(E.takeNewEvents(seen, [noId]).length, 1); assert.equal(E.takeNewEvents(seen, [noId]).length, 0) assert.deepEqual(E.takeNewEvents(seen, [null, {}, { id: 'z' }]), []) const big = new Set(); E.takeNewEvents(big, Array.from({ length: 1500 }, (_, i) => ({ id: `i${i}`, ts: new Date(Date.UTC(2026, 9, 17, 0, 0, i)).toISOString() }))) assert.ok(big.size <= 1000) }) test('latestTs: größter gültiger Zeitstempel', () => { assert.equal(E.latestTs([{ ts: '2026-10-17T20:00:01+00:00' }, { ts: '2026-10-17T20:00:05Z' }, { ts: 'kaputt' }], null), '2026-10-17T20:00:05Z') assert.equal(E.latestTs([], 'alt'), 'alt'); assert.equal(E.latestTs([], null), null) }) test('reduceEvents: Status aus der Ereignisart, grau bleibt grau, Altes und Fremdes wird ignoriert (Review Focus 4)', () => { const base = { kim: { status: 'wartet', task: '', lastTs: null }, rita: { status: 'grau', task: '', lastTs: null }, bea: { status: 'arbeitet', task: 'alt', lastTs: '2026-10-17T20:00:09Z' } } const copy = JSON.parse(JSON.stringify(base)) const out = E.reduceEvents(base, [ev('gelesen', 'kim', { text: 'Kim hat 3 Gesuche gelesen' }), ev('fund', 'rita'), ev('fehler', 'bea'), ev('pause', 'system'), ev('fund', 'zed'), ev('auftrag', 'otto', { to: 'kim' })]) assert.deepEqual(base, copy) assert.deepEqual(out.kim, { status: 'wartet', task: 'Text', lastTs: TS }) // zuletzt: Auftrag von Otto assert.equal(out.rita.status, 'grau'); assert.equal(out.bea.status, 'arbeitet'); assert.equal(out.bea.task, 'alt') // Ereignis älter als lastTs assert.equal(E.reduceEvents(base, [ev('leerlauf', 'kim')]).kim.status, 'leerlauf') assert.equal(E.reduceEvents(base, [ev('fehler', 'kim')]).kim.status, 'fehler') assert.equal(E.indexWorkers({ lastEventTs: TS, workers: [{ workerId: 'kim', status: 'arbeitet', task: 't', connected: false, counters: { a: 1 } }] }).kim.status, 'grau') }) test('eventToSteps: feste Zeilen', () => { assert.deepEqual(E.eventToSteps(ev('gelesen', 'kim', { meta: { count: 3 } })), [{ op: 'pose', who: 'kim', pose: 'type' }, { op: 'task', who: 'kim', text: 'Text' }, { op: 'say', who: 'kim', text: '3 gelesen' }]) assert.deepEqual(E.eventToSteps(ev('doppelt', 'arne')), [{ op: 'task', who: 'arne', text: 'Text' }, { op: 'say', who: 'arne', text: 'Schon bekannt' }, { op: 'fx', fx: 'stampNo', at: 'arne' }]) assert.deepEqual(E.eventToSteps(ev('uebergabe', 'rita', { to: 'bea' })), [{ op: 'task', who: 'rita', text: 'Text' }, { op: 'give', from: 'rita', to: 'bea' }]) assert.deepEqual(E.eventToSteps(ev('pruefung_zurueck', 'paul', { to: 'bob', meta: { round: 2 } })), [{ op: 'task', who: 'paul', text: 'Text' }, { op: 'fx', fx: 'stampNo', at: 'paul' }, { op: 'say', who: 'paul', text: 'Runde 2 zurück' }, { op: 'give', from: 'paul', to: 'bob' }]) assert.deepEqual(E.eventToSteps(ev('deal', 'system')), [0, 0.4, 0.8].map((delay) => ({ op: 'fx', fx: 'coin', at: 'aussen', to: 'tresor', delay })).concat([{ op: 'cheer', who: 'otto' }, { op: 'say', who: 'otto', text: 'Deal! Super!' }])) assert.deepEqual(E.eventToSteps(ev('gesperrt', 'system')), [{ op: 'fx', fx: 'stampNo', at: 'arne' }, { op: 'say', who: 'arne', text: 'Gesperrt' }]) // system: kein task-Schritt, Arne stempelt }) test('eventToSteps: jede Art liefert gültige Schritte, Blasen ≤ 20 Zeichen, Unbekanntes wird verworfen (Review Focus 4)', () => { const ids = new Set(ROLES.map((r) => r.workerId)), known = (v) => ids.has(v) || E.SPOTS.includes(v) for (const kind of E.EVENT_KINDS) for (const w of ['kim', 'system', 'zed']) { for (const s of E.eventToSteps(ev(kind, w, { to: 'bea', meta: { count: 99999999, score: 123456789, round: 2, classification: 'ja' } }))) { assert.ok(E.STEP_OPS.includes(s.op), kind) if (s.op === 'say') assert.ok(s.text.length <= 20, s.text) if (s.op === 'fx') { assert.ok(known(s.at), `${kind}/${w}: at=${s.at}`); if (s.to) assert.ok(known(s.to)) } else for (const k of ['who', 'from', 'to']) if (s[k]) assert.ok(ids.has(s[k]), `${kind}/${w}: ${k}=${s[k]}`) } } assert.deepEqual(E.eventToSteps(ev('fund', 'zed')), []); assert.deepEqual(E.eventToSteps(ev('fund', 'system')), []) assert.deepEqual(E.eventToSteps(ev('neueArt', 'kim')), [{ op: 'task', who: 'kim', text: 'Text' }]); assert.deepEqual(E.eventToSteps(ev('neueArt', 'zed')), []) assert.deepEqual(E.eventToSteps(ev('uebergabe', 'rita', { to: 'zed' })), [{ op: 'task', who: 'rita', text: 'Text' }]) }) test('needsStateRefresh und cutBubble', () => { assert.equal(E.needsStateRefresh([ev('gelesen', 'kim'), ev('pult', 'clara')]), true); assert.equal(E.needsStateRefresh([ev('gelesen', 'kim'), ev('fund', 'kim')]), false) for (const k of ['pult', 'gesendet', 'deal', 'botlead', 'gesperrt', 'budget_warnung', 'budget_stopp', 'pause', 'top3', 'angebot']) assert.equal(E.needsStateRefresh([ev(k, 'otto')]), true, k) assert.equal(E.cutBubble('Dies ist ein sehr langer Satz!'), 'Dies ist ein sehr la'); assert.equal(E.cutBubble(null), '') }) ``` Hinweis zu `gesperrt`: Bei `workerId: 'system'` fällt der `task`-Schritt weg, weil `task` nur für bekannte Worker erzeugt wird; Arne stempelt trotzdem (Tabelle: `A = arne`). - [ ] **Step 2: Scheitern sehen** — `cd $WK && timeout 300 node --max-old-space-size=1024 --test tests/unit/workersEvents.test.mjs`. Expected: FAIL `Could not resolve …/workersEvents.js`. - [ ] **Step 3: Implementieren** `src/lib/workersEvents.js` `takeNewEvents`: Schlüssel = `id`, ohne `id` `ts|workerId|kind|text`; ohne gültiges `ts` verwerfen; Set auf 1000 Einträge begrenzen (ältesten zuerst löschen); Ergebnis stabil nach `Date.parse(ts)` aufsteigend. `reduceEvents`: nur Worker, die in `stateByWorker` stehen **und** in `ROLES` vorkommen; ein Ereignis wirkt nur, wenn `Date.parse(ts) >= Date.parse(lastTs)` (oder `lastTs` leer); `grau` bleibt `grau`; Status: `leerlauf` ⇒ `'leerlauf'`, `fehler` ⇒ `'fehler'`, jede andere Art ⇒ `'arbeitet'`, `task = text`, `lastTs = ts`; der Empfänger `to` von `auftrag`/`uebergabe` wird (falls nicht grau und nicht neuer) `'wartet'` mit `task = text`; Rückgabe ist ein neues Objekt, Einträge als Kopien (Zusatzfelder wie `connected`/`counters` bleiben). `indexWorkers`: `status = connected === false ? 'grau' : status`, `lastTs = state.lastEventTs ?? null`. `eventToSteps`: erst Schritte nach der Tabelle bilden, dann **alle Schritte verwerfen**, die auf unbekannte `workerId`/Plätze zeigen (`system` und `zed` fallen weg); `meta` darf Objekt, JSON-String oder `null` sein (P1 liefert `null`, wenn leer); `W` = `event.workerId`, `T` = `text`: | kind | Schritte in dieser Reihenfolge | |---|---| | auftrag | task W; say W `${Name(to)}, los!`; say to `Verstanden` | | leerlauf | task W; pose W coffee | | gelesen | pose W type; task W; say W (`meta.count` Zahl ? `${count} gelesen` : `Gelesen`) | | fund | pose W type; task W; say W `Neuer Fund!`; fx paper at W | | doppelt | task W; say W `Schon bekannt`; fx stampNo at W | | uebergabe | task W; give W→to | | bewertet | pose W type; task W; say W (`meta.score` Zahl ? `${score} Punkte` : `Bewertet`) | | top3 | task W; say W `Top 3!`; fx stampOk at W | | botlead | task W; say W `Abgelegt`; fx drawer at W | | entwurf | pose W type; task W; say W `Entwurf fertig` | | pult | task W; say W `Zur Freigabe`; fx envelope at W to pult | | gesendet | task W; (W bekannt: say W `Raus damit!`; fx envelope at W to aussen) sonst: fx envelope at pult to aussen | | eingang | task W; fx envelope at aussen to W; say W `Antwort!` | | sortiert | task W; fx stampOk at W; say W (Beschriftung zu `meta.classification`: ja `Ja!`, frage `Frage`, nein `Nein`, abmelden `Abmeldung`, abwesend `Abwesend`, unzustellbar `Unzustellbar`, beschwerde `Beschwerde!`, unklar `Unklar`, sonst `Sortiert`) | | bau_start | pose W type; task W; say W `Los geht’s` | | bau_fertig | task W; say W `Fertig!`; give W→paul | | pruefung_zurueck | task W; fx stampNo at W; say W (`meta.round` Zahl ? `Runde ${round} zurück` : `Zurück`); give W→to | | pruefung_ok | task W; fx stampOk at W; say W `Passt!` | | vorschau_link | task W; say W `Link raus`; fx envelope at W to aussen | | angebot | task W; say W `Kauf-Link!`; fx paper at W | | deal | fx coin at aussen to tresor mit `delay` 0, 0.4, 0.8; cheer otto; say otto `Deal! Super!` | | gesperrt | task W (entfällt bei `system`); fx stampNo at A; say A `Gesperrt` mit A = W bekannt ? W : arne | | budget_warnung | task W; say otto `Budget 80 %`; fx alert at otto | | budget_stopp | task W; say otto `Budget leer`; fx alert at otto | | pause | (keine) | | fehler | task W; fx alert at W; say W `Fehler` | | andere Art | task W | Alle `say`-Texte laufen durch `cutBubble`. `needsStateRefresh`: wahr bei `pult`, `gesendet`, `deal`, `botlead`, `gesperrt`, `budget_warnung`, `budget_stopp`, `pause`, `top3`, `angebot`. - [ ] **Step 4: Bestehen sehen** — gleicher Befehl wie Step 2. Expected: `# pass 7`, `# fail 0`. - [ ] **Step 5: Commit** ```bash cd $WK && git add src/lib/workersEvents.js tests/unit/workersEvents.test.mjs git commit -m "Workers P2: Ereignis-Logik (Dedup, reduceEvents, eventToSteps)" -m "Co-Authored-By: Claude Opus 5.5 " ``` --- ### P2-T04: API-Client `workersApi.js` und `workersClient.js` **Files:** - Create: `src/lib/workersApi.js`, `src/lib/workersClient.js` - Test: `tests/unit/workersApi.test.mjs` **Interfaces:** - Consumes: Übersicht §6.2 (Pfade), E2; `createJwtCache` aus `serverpulsAuth.js` (nur `workersClient.js`). - Produces (Übersicht §7): ```js export function createWorkersApi({ getJwt, fetchImpl }): { state(), events(after, limit = 200), trace(workerId, limit = 50), pause(paused), settings(), saveSettings(patch), pult(status = 'offen'), pultAction(id, action, body?), botleads({ room, q, page, id }), prospect(id) } // pult(status): ein Pult-Status, eine Komma-Liste ('offen,in_arbeit', Übersicht §7 und §6.2) oder ein Array; jeder Eintrag (getrimmt) muss in PULT_STATUS stehen, // sonst Error('Unbekannter Pult-Status: ') ohne Anfrage; leer = 'offen'. botleads reicht id schon hier durch (P6 ändert diese Datei nicht mehr). export const PULT_STATUS // ['offen','in_arbeit','gesendet','verworfen','veraltet','erledigt'] export const DOWN_TEXT, BAD_REPLY_TEXT, SESSION_TEXT, FORBIDDEN_TEXT // 'Der Hustler-Dienst ist gerade nicht erreichbar.' · 'Der Hustler-Dienst antwortet nicht richtig.' · 'Anmeldung abgelaufen. Bitte neu anmelden.' · 'Dafür fehlt dir die Berechtigung.' // workersClient.js export const workersApi // createWorkersApi mit account.createJWT, 10-min-Cache ``` Fehler sind `Error` mit `message` (deutsch) und `status`. - [ ] **Step 1: Failing test schreiben** (`tests/unit/workersApi.test.mjs`) ```js const { createWorkersApi } = await loadModule('src/lib/workersApi.js') const jwts = [] const getJwt = async (o) => { jwts.push(o?.fresh === true); return o?.fresh ? 'jwt-neu' : 'jwt-alt' } function fakeFetch(replies) { const calls = [] const fn = async (url, init) => { calls.push({ url, init }); const r = replies.shift(); if (r instanceof Error) throw r return { ok: r.status >= 200 && r.status < 300, status: r.status, json: async () => { if (r.body === undefined) throw new SyntaxError('kein JSON'); return r.body } } } fn.calls = calls; return fn } test('URLs, Methoden, Header, Body', async () => { const f = fakeFetch(Array.from({ length: 12 }, () => ({ status: 200, body: {} }))) const api = createWorkersApi({ getJwt, fetchImpl: f }) await api.state(); await api.events('2026-10-17T20:00:00.000+00:00'); await api.events(null, 500); await api.trace('kim'); await api.pause(true) await api.settings(); await api.saveSettings({ warnPct: 70 }); await api.pult(); await api.pultAction('p1', 'claim') await api.botleads({ room: 'netz', q: 'Bäcker', page: 2 }); await api.botleads({ id: 'p1' }); await api.prospect('x/y') assert.deepEqual(f.calls.map((c) => [c.init.method || 'GET', c.url]), [ ['GET', '/api/workers/state'], ['GET', '/api/workers/events?after=2026-10-17T20%3A00%3A00.000%2B00%3A00&limit=200'], ['GET', '/api/workers/events?limit=200'], ['GET', '/api/workers/worker/kim/trace?limit=50'], ['POST', '/api/workers/pause'], ['GET', '/api/workers/settings'], ['PUT', '/api/workers/settings'], ['GET', '/api/workers/pult?status=offen'], ['POST', '/api/workers/pult/p1/claim'], ['GET', '/api/workers/botleads?room=netz&q=B%C3%A4cker&page=2'], ['GET', '/api/workers/botleads?id=p1'], ['GET', '/api/workers/prospect/x%2Fy']]) assert.equal(f.calls[0].init.headers['X-Appwrite-JWT'], 'jwt-alt') assert.equal(f.calls[4].init.body, JSON.stringify({ paused: true })); assert.equal(f.calls[4].init.headers['Content-Type'], 'application/json') assert.equal(f.calls[6].init.body, JSON.stringify({ warnPct: 70 })) }) test('Fehlertexte (Review Focus 1)', async () => { const run = (reply) => createWorkersApi({ getJwt, fetchImpl: fakeFetch([reply]) }).state().then(() => null, (e) => [e.message, e.status]) assert.deepEqual(await run({ status: 200, body: undefined }), ['Der Hustler-Dienst antwortet nicht richtig.', 200]) // nginx liefert index.html assert.deepEqual(await run({ status: 404, body: undefined }), ['Der Hustler-Dienst antwortet nicht richtig.', 404]) assert.deepEqual(await run({ status: 502, body: undefined }), ['Der Hustler-Dienst ist gerade nicht erreichbar.', 502]) assert.deepEqual(await run(new TypeError('fetch failed')), ['Der Hustler-Dienst ist gerade nicht erreichbar.', 0]) assert.deepEqual(await run({ status: 403, body: {} }), ['Dafür fehlt dir die Berechtigung.', 403]) assert.deepEqual(await run({ status: 400, body: { error: 'Tagesgrenze ungültig.' } }), ['Tagesgrenze ungültig.', 400]) assert.deepEqual(await run({ status: 500, body: {} }), ['Hustler-Fehler 500', 500]) await assert.rejects(createWorkersApi({ getJwt: async () => { throw new Error('x') }, fetchImpl: fakeFetch([]) }).state(), { message: 'Anmeldung abgelaufen. Bitte neu anmelden.', status: 401 }) }) test('401 ⇒ einmal mit frischem JWT wiederholen, danach Meldung', async () => { jwts.length = 0 const f = fakeFetch([{ status: 401, body: {} }, { status: 200, body: { ok: 1 } }]) assert.deepEqual(await createWorkersApi({ getJwt, fetchImpl: f }).state(), { ok: 1 }) assert.deepEqual(jwts, [false, true]); assert.equal(f.calls[1].init.headers['X-Appwrite-JWT'], 'jwt-neu') await assert.rejects(createWorkersApi({ getJwt, fetchImpl: fakeFetch([{ status: 401, body: {} }, { status: 401, body: {} }]) }).state(), { message: 'Anmeldung abgelaufen. Bitte neu anmelden.', status: 401 }) }) test('pult: ein Status, Komma-Liste oder Array (Übersicht §7); unbekannte Status werden abgewiesen, ohne Anfrage', async () => { const f = fakeFetch(Array.from({ length: 5 }, () => ({ status: 200, body: {} }))); const api = createWorkersApi({ getJwt, fetchImpl: f }) await api.pult('offen,in_arbeit'); await api.pult(['gesendet', 'verworfen', 'erledigt']); await api.pult(' veraltet '); await api.pult(''); await api.pult('gesendet, verworfen , erledigt') assert.deepEqual(f.calls.map((c) => c.url), ['/api/workers/pult?status=offen,in_arbeit', '/api/workers/pult?status=gesendet,verworfen,erledigt', '/api/workers/pult?status=veraltet', '/api/workers/pult?status=offen', '/api/workers/pult?status=gesendet,verworfen,erledigt']) // Kommas bleiben unkodiert, Einträge getrimmt, leer = offen for (const bad of ['offen,../x', 'quatsch', ['offen', 'x y'], 'offen;in_arbeit', 'offen,,in_arbeit']) await assert.rejects(api.pult(bad), /Unbekannter Pult-Status/) assert.equal(f.calls.length, 5) // die fünf abgewiesenen Aufrufe haben nichts gesendet }) test('pultAction: nur bekannte Aktionen, Pfad-Tricks abgewiesen', async () => { const f = fakeFetch(Array.from({ length: 7 }, () => ({ status: 200, body: {} }))); const api = createWorkersApi({ getJwt, fetchImpl: f }) for (const bad of ['../x', 'delete', '']) await assert.rejects(api.pultAction('p1', bad), /Unbekannte Aktion/) assert.equal(f.calls.length, 0) for (const a of ['claim', 'sent', 'discard', 'done', 'reply', 'approve-preview', 'paste']) await api.pultAction('p1', a, a === 'reply' || a === 'paste' ? { text: 'x' } : undefined) assert.deepEqual(f.calls.map((c) => c.url.split('/').pop()), ['claim', 'sent', 'discard', 'done', 'reply', 'approve-preview', 'paste']); assert.equal(f.calls[4].init.body, JSON.stringify({ text: 'x' })) assert.deepEqual([f.calls[6].url, f.calls[6].init.method, f.calls[6].init.body], ['/api/workers/pult/p1/paste', 'POST', JSON.stringify({ text: 'x' })]) // Plattform-Folge-Antwort einfügen (P4-T34) }) test('limit wird geklemmt (Ereignisse 1..200, Spur 1..50)', async () => { const f = fakeFetch(Array.from({ length: 3 }, () => ({ status: 200, body: {} }))); const api = createWorkersApi({ getJwt, fetchImpl: f }) await api.trace('kim', 999); await api.events(null, 0); await api.events(null, 150) assert.deepEqual(f.calls.map((c) => c.url), ['/api/workers/worker/kim/trace?limit=50', '/api/workers/events?limit=1', '/api/workers/events?limit=150']) }) ``` - [ ] **Step 2: Scheitern sehen** — `cd $WK && timeout 300 node --max-old-space-size=1024 --test tests/unit/workersApi.test.mjs`. Expected: FAIL `Could not resolve …/workersApi.js`. - [ ] **Step 3: Implementieren** `src/lib/workersApi.js`: ein `request(path, { method, body })` nach Muster `financeRequest.js`: `fetchImpl` wirft ⇒ `DOWN_TEXT` (status 0); `getJwt` wirft ⇒ `SESSION_TEXT` (401); Antwort 401 ⇒ einmal `getJwt({ fresh: true })`, danach erneut 401 ⇒ `SESSION_TEXT`; JSON-Parsefehler ⇒ bei 502/503/504 `DOWN_TEXT`, sonst `BAD_REPLY_TEXT` (mit dem echten Status); 403 ohne `error` ⇒ `FORBIDDEN_TEXT`; sonstiger Fehlerstatus ⇒ `data.error` oder `Hustler-Fehler `. Erlaubte `pultAction`-Namen: `claim`, `sent`, `discard`, `done`, `reply`, `approve-preview`, `paste` (`paste` = Antwort des Kunden von einer Plattform einfügen, Körper `{ text }`, P4-T34). `pult(status = 'offen')`: String (an Kommas teilen, Einträge trimmen) oder Array; leer ⇒ `offen`; jeder Eintrag muss in `PULT_STATUS` stehen (`offen`, `in_arbeit`, `gesendet`, `verworfen`, `veraltet`, `erledigt`), sonst `Error('Unbekannter Pult-Status: ')` vor jeder Anfrage; Anfrage `GET /api/workers/pult?status=` (das Komma bleibt unkodiert, die Einträge bestehen nur aus Kleinbuchstaben und `_`). `botleads({ room, q, page, id })`: Query-Reihenfolge `room`, `q`, `page`, `id`, leere Werte fallen weg (P6 braucht `id` für den Ereignislink `/hustler?tab=botleads&bot=`). `limit` hat den Standard (200 bzw. 50) nur bei `undefined` (nicht bei 0) und wird auf 1..200 (Ereignisse) bzw. 1..50 (Spur) geklemmt; leere Werte fallen aus der Query. `src/lib/workersClient.js` (nicht unter Node geladen, wie `serverpulsApi.js`): `account.createJWT` + `createJwtCache` (10 min), `getJwt = (o) => { if (o?.fresh) cache.invalidate(); return cache.get() }`, `export const workersApi = createWorkersApi({ getJwt })`. - [ ] **Step 4: Bestehen sehen** — gleicher Befehl. Expected: `# pass 6`, `# fail 0`. - [ ] **Step 5: Commit** ```bash cd $WK && git add src/lib/workersApi.js src/lib/workersClient.js tests/unit/workersApi.test.mjs git commit -m "Workers P2: API-Client mit deutschen Fehlertexten" -m "Co-Authored-By: Claude Opus 5.5 " ``` --- ### P2-T05: Feed-Steuerung `workersFeed.js` und Hooks **Files:** - Create: `src/lib/workersFeed.js`, `src/hooks/useWorkersFeed.js` - Test: `tests/unit/workersFeed.test.mjs` **Interfaces:** - Consumes: `takeNewEvents`, `latestTs`, `needsStateRefresh` (P2-T03); `api.state()`, `api.events(after, limit)`, `api.trace(id)` (P2-T04); `createRequestGuard` (`src/lib/requestGuard.js`). - Produces: ```js export function createFeedController({ api, setTimer, clearTimer, isVisible, onSnapshot, onEvents, onError, stateMs = 15000, eventsMs = 5000, pageLimit = 200, maxPages = 5 }): { start(), stop(), visibilityChanged(), refreshState() } // useWorkersFeed.js export function useWorkersFeed(api, { onEvents }): { state, error, loaded, refresh } export function useWorkerTrace(api, workerId): { trace, loading, error } ``` - [ ] **Step 1: Failing test schreiben** (`tests/unit/workersFeed.test.mjs`) Hilfsfunktion `makeFeedHarness({ state, eventReplies, eventsHang })` im Testfile: Fake-Timer (`setTimer(fn, ms)`/`clearTimer`, `timers` als Map, `advance(ms)` feuert fällige Timer in Zeitreihenfolge und wartet je Timer einen `setImmediate`), `settle()` (zwei `setImmediate`), Fake-`api` mit `state()` (zählt in `log.state`, gibt `state` zurück, Standard `{ lastEventTs: '2026-10-17T20:00:00.000Z', workers: [] }`) und `events(after, limit)` (hängt `[after, limit]` an `log.events`; antwortet mit `eventReplies.shift() ?? []`, ein `Error` in der Liste wird als Ablehnung zurückgegeben; mit `eventsHang` kehrt der Aufruf nie zurück), Schalter `visible.v`, Protokolle `log.snapshots` (je Aufruf von `onSnapshot` der Zustand), `log.batches` (je `onEvents` die Liste) und `log.errors` (je `onError(e)` der Eintrag `e ? e.message : null`). ```js const mk = (i) => ({ id: `e${i}`, ts: new Date(Date.UTC(2026, 9, 17, 20, 0, 1 + i)).toISOString(), workerId: 'kim', kind: 'fund', text: 't' }) test('Start: Zustand, Cursor aus lastEventTs, dann Ereignisse (Review Focus 3)', async () => { const h = makeFeedHarness({}); h.c.start(); await h.settle() assert.equal(h.log.snapshots.length, 1); assert.deepEqual(h.log.events[0], ['2026-10-17T20:00:00.000Z', 200]) const h2 = makeFeedHarness({ state: { workers: [] } }); h2.c.start(); await h2.settle() assert.equal(h2.log.events[0][0], '1970-01-01T00:00:00.000Z') }) test('Takt: Ereignisse alle 5 s mit weitergeschobenem Cursor, Zustand alle 15 s', async () => { const h = makeFeedHarness({ eventReplies: [[mk(1)], [], []] }); h.c.start(); await h.settle() await h.advance(5000); assert.equal(h.log.events[1][0], mk(1).ts) await h.advance(10000); assert.equal(h.log.state, 2); assert.equal(h.log.events.length, 4) }) test('Hintergrund: keine Abrufe, beim Zurückkehren sofort beide', async () => { const h = makeFeedHarness({}); h.c.start(); await h.settle() h.visible.v = false; await h.advance(60000); assert.equal(h.log.state, 1); assert.equal(h.log.events.length, 1) h.visible.v = true; h.c.visibilityChanged(); await h.settle(); assert.equal(h.log.state, 2); assert.equal(h.log.events.length, 2) await h.advance(5000); assert.equal(h.log.events.length, 3); assert.equal(h.timers.size, 2) // eine Kette je Schleife, kein doppelter Timer nach der Rückkehr }) test('Doppelte Ereignisse werden nie zweimal gemeldet (Review Focus 3)', async () => { const h = makeFeedHarness({ eventReplies: [[mk(1), mk(2)], [mk(1), mk(2), mk(3)]] }); h.c.start(); await h.settle(); await h.advance(5000) assert.deepEqual(h.log.batches.map((b) => b.map((e) => e.id)), [['e1', 'e2'], ['e3']]) }) test('Rückstau: volle Seiten werden sofort nachgeladen, höchstens 5 je Runde (Review Focus 2)', async () => { const page = (n) => Array.from({ length: 200 }, (_, i) => mk(n * 200 + i)) const h = makeFeedHarness({ eventReplies: [page(0), page(1), page(2), page(3), page(4), page(5)] }); h.c.start(); await h.settle() assert.equal(h.log.events.length, 5); assert.equal(h.log.events[1][0], mk(199).ts) }) test('keine überlappenden Abrufe, Fehler laufen weiter, Zustand-Auffrischung bei wichtigen Ereignissen', async () => { const hang = makeFeedHarness({ eventsHang: true }); hang.c.start(); await hang.settle(); await hang.advance(60000) assert.equal(hang.log.events.length, 1) const err = makeFeedHarness({ eventReplies: [new Error('Der Hustler-Dienst ist gerade nicht erreichbar.'), []] }); err.c.start(); await err.settle(); await err.advance(5000) assert.deepEqual(err.log.errors, ['Der Hustler-Dienst ist gerade nicht erreichbar.', null]) const deal = makeFeedHarness({ eventReplies: [[{ ...mk(1), kind: 'deal' }]] }); deal.c.start(); await deal.settle(); assert.equal(deal.log.state, 2) }) test('stop räumt auf, start ist idempotent, späte Antworten werden verworfen', async () => { const h = makeFeedHarness({}); h.c.start(); h.c.start(); await h.settle(); assert.equal(h.log.state, 1) h.c.stop(); assert.equal(h.timers.size, 0); await h.advance(60000); assert.equal(h.log.state, 1) }) test('Hooks: Sichtbarkeit und Aufräumen im Quelltext', () => { const src = readFileSync(new URL('../../src/hooks/useWorkersFeed.js', import.meta.url), 'utf8') assert.match(src, /visibilitychange/); assert.match(src, /removeEventListener\('visibilitychange'/); assert.match(src, /\.stop\(\)/); assert.match(src, /createRequestGuard/) }) ``` - [ ] **Step 2: Scheitern sehen** — `cd $WK && timeout 300 node --max-old-space-size=1024 --test tests/unit/workersFeed.test.mjs`. Expected: FAIL `Could not resolve …/workersFeed.js`. - [ ] **Step 3: Implementieren** `createFeedController` (Regeln): Zustand und Ereignisse laufen mit je **einem** `setTimer` nach Abschluss des vorherigen Abrufs (kein Intervall, kein Überlappen; `inFlight.state`/`inFlight.events`; je Schleife höchstens **ein** wartender Timer: `visibilityChanged()` und `refreshState()` löschen ihn vor dem sofortigen Abruf, nach dem Abruf wird neu gesetzt); Start: sichtbar ⇒ `loadState()`, bei Erfolg `onSnapshot(state)`, beim ersten Erfolg `cursor = state.lastEventTs || '1970-01-01T00:00:00.000Z'` und danach `loadEvents()`; `loadEvents`: `api.events(cursor, pageLimit)` ⇒ `fresh = takeNewEvents(seen, res.events)`, `cursor = latestTs(res.events, cursor)`, `fresh.length` ⇒ `onEvents(fresh)`, `needsStateRefresh(fresh)` ⇒ `refreshState()`; kam eine volle Seite (`res.events.length >= pageLimit`) und `pages < maxPages`, sofort die nächste; Fehler ⇒ `onError(err)` einmal je Fehlerfolge, nächster Erfolg ⇒ `onError(null)`; Timer feuern bei `!isVisible()` ohne Abruf und planen neu; `visibilityChanged()` sichtbar ⇒ sofort `loadState()` und (Cursor vorhanden) `loadEvents()`; `stop()` löscht Timer, erhöht eine Generationszahl (späte Antworten ändern nichts), `start()` nach `stop()` beginnt neu. `useWorkersFeed`: Controller mit echten Timern und `document.visibilityState`, `visibilitychange`-Listener, `onEvents` über `useRef` (stabil), Cleanup `stop()` + Listener entfernen; `refresh` = `controller.refreshState()`. `useWorkerTrace`: lädt bei `workerId`-Wechsel und alle 15 s bei sichtbarem Tab, `createRequestGuard` verwirft späte Antworten, `workerId` null ⇒ `{ trace: null }`. - [ ] **Step 4: Bestehen sehen** — gleicher Befehl. Expected: `# pass 8`, `# fail 0`. - [ ] **Step 5: Commit** ```bash cd $WK && git add src/lib/workersFeed.js src/hooks/useWorkersFeed.js tests/unit/workersFeed.test.mjs git commit -m "Workers P2: Feed-Steuerung und Hooks (Zustand 15 s, Ereignisse 5 s)" -m "Co-Authored-By: Claude Opus 5.5 " ``` --- ### P2-T06: Engine-Grundlagen `sprites.js` (Raster, Figuren, Text) **Files:** - Create: `src/components/workers/engine/sprites.js`, `tests/unit/helpers/fakeCtx.mjs` - Test: `tests/unit/workersSprites.test.mjs` **Interfaces:** - Consumes: nichts. Quelle der Portierung: `/root/reports/workers-2026-10-16/entwuerfe/sprites.js` Z. 7–142 (Palette, Sprite-Karten, `drawMap`, `drawWorker`, `drawSelect`, `drawPaper`) und Z. 277–307 (`label`, `drawBubble`). ES5-IIFE ⇒ benannte ES-Exporte; die Konstante `S` entfällt zugunsten des Rasters. - Produces: ```js export const PAL, HAT, TEAM, FONT_FAMILY // wie Vorlage; FONT_FAMILY = '"Pixelify Sans", ui-monospace, Menlo, monospace' export function setGrid(ctx, { k, ox = 0, oy = 0 }) // k = Geräte-Pixel je logischer Einheit; speichert auf ctx.wkGrid; ohne Aufruf k = 3, ox = oy = 0 export function px(ctx, x, y, w, h, color) // links = round(x*k+ox), rechts = round((x+w)*k+ox), analog y; Breite/Höhe mindestens 1 export function tint(ctx, x, y, w, h, color, alpha) export function label(ctx, text, x, y, { size = 12, color = PAL.ink, weight = 500, align = 'left', bg } = {}) // Schrift = size*k/3 px export function drawBubble(ctx, x, y, text) // Spitze bei (x,y), klemmt in ctx.canvas.width (Rand 2) export function drawAlertMark(ctx, x, y) // rotes „!“ (persistent bei Status fehler) export function drawPaper(ctx, x, y) export function drawWorker(ctx, x, y, { hat, frame = 0, facing = 1, carrying = false, scale = 1, selected = false, pose = 'stand', grey = false, t = 0 }): { x, y, w, h } ``` `tests/unit/helpers/fakeCtx.mjs`: `makeCtx({ width = 960, height = 768 } = {}) → { ctx, rects:[{x,y,w,h,fill}], texts:[{text,x,y,font}], counts:{save,restore}, alphas:[] }`: `Proxy` nach Muster `smoke.js` (`fillRect`/`fillText` aufzeichnen mit aktuellem `fillStyle`/`font`, `fillText` mit Nicht-String wirft, `measureText` ⇒ `{ width: länge*7 }`, `globalAlpha`-Zuweisungen in `alphas`, `canvas = { width, height }`, alles Übrige No-op). - [ ] **Step 1: Failing test schreiben** (`tests/unit/workersSprites.test.mjs`, `const S = await loadModule('src/components/workers/engine/sprites.js')`, `import { makeCtx } from './helpers/fakeCtx.mjs'`) ```js test('px: gerundete Kanten ohne Naht, Standardraster 3, Versatz, Mindestgröße', () => { let { ctx, rects } = makeCtx(); S.setGrid(ctx, { k: 3.4, ox: 0, oy: 0 }) S.px(ctx, 0, 0, 1, 1, '#000'); S.px(ctx, 1, 0, 1, 1, '#111'); S.px(ctx, 2, 0, 1, 1, '#222') assert.equal(rects[0].x + rects[0].w, rects[1].x); assert.equal(rects[1].x + rects[1].w, rects[2].x) ;({ ctx, rects } = makeCtx()); S.px(ctx, 1, 2, 3, 4, '#fff'); assert.deepEqual([rects[0].x, rects[0].y, rects[0].w, rects[0].h], [3, 6, 9, 12]) ;({ ctx, rects } = makeCtx()); S.setGrid(ctx, { k: 2, ox: -10, oy: 0 }); S.px(ctx, 5, 0, 0.1, 1, '#fff'); assert.equal(rects[0].x, 0); assert.ok(rects[0].w >= 1) }) test('label: Schriftgröße wächst mit dem Raster', () => { const { ctx, texts } = makeCtx(); S.setGrid(ctx, { k: 6 }); S.label(ctx, 'Hallo', 1, 1, { size: 11 }); assert.match(texts[0].font, /22px/) }) test('drawWorker: Trefferfläche', () => { const { ctx } = makeCtx() assert.deepEqual(S.drawWorker(ctx, 10, 20, { hat: 'haiku' }), { x: 10, y: 18, w: 12, h: 16 }) assert.equal(S.drawWorker(ctx, 10, 20, { hat: 'haiku', carrying: true }).w, 14) assert.deepEqual(S.drawWorker(ctx, 10, 20, { hat: 'overseer', scale: 2 }), { x: 10, y: 16, w: 24, h: 32 }) }) test('drawWorker: Hutfarbe zeigt das Modell, Augen je Pose, Grau mit Alpha', () => { for (const hat of ['opus', 'sonnet', 'haiku', 'overseer']) { const { ctx, rects } = makeCtx(); S.drawWorker(ctx, 0, 0, { hat }); assert.ok(rects.some((r) => r.fill === S.HAT[hat][0]), hat) } const eyes = (pose) => { const { ctx, rects } = makeCtx(); S.drawWorker(ctx, 0, 0, { hat: 'haiku', pose }); return rects.filter((r) => r.fill === S.PAL.e).length } assert.equal(eyes('stand'), 4); assert.equal(eyes('sleep'), 2) for (const pose of ['stand', 'type', 'coffee', 'sleep']) { const { ctx, rects } = makeCtx(); S.drawWorker(ctx, 0, 0, { hat: 'sonnet', pose, t: 1.3 }); assert.ok(rects.length > 20, pose) } const g = makeCtx(); S.drawWorker(g.ctx, 0, 0, { hat: 'opus', grey: true }); assert.ok(g.alphas.includes(0.55)); assert.equal(g.counts.save, g.counts.restore) }) test('drawBubble bleibt im Bild (Spitze nahe am linken und rechten Rand, Raster 3, Breite 960)', () => { const l = makeCtx(); S.drawBubble(l.ctx, 2, 30, 'Hallo'); assert.ok(Math.min(...l.rects.map((r) => r.x)) >= 0) const r = makeCtx(); S.drawBubble(r.ctx, 318, 30, 'Hallo'); assert.ok(Math.max(...r.rects.map((q) => q.x + q.w)) <= 960) }) ``` - [ ] **Step 2: Scheitern sehen** — `cd $WK && timeout 300 node --max-old-space-size=1024 --test tests/unit/workersSprites.test.mjs`. Expected: FAIL `Could not resolve …/engine/sprites.js`. - [ ] **Step 3: Implementieren** `engine/sprites.js` und `tests/unit/helpers/fakeCtx.mjs` Portieren wie oben. Neu gegenüber der Vorlage: (1) Raster über `ctx.wkGrid`; (2) Posen: `stand` und `type` zeichnen gleich (das Wippen macht die Regie), `sleep` ersetzt die Augenzeile 4 durch Körperfarbe (nur die Zeile 5 bleibt, also 2 Augenpunkte) und setzt ein kleines „z“ aus 3 Pixeln über den Hut (steigt mit `t`), `coffee` zeichnet eine 3×3-Tasse an der Hand und 1 Dampfpixel (flackert mit `t`); (3) `grey`: `ctx.save()`, `ctx.globalAlpha = 0.55`, Körper-/Hutfarben auf Grautöne abbilden, Trefferfläche unverändert, `ctx.restore()`; (4) `drawAlertMark`: 3×7-Pixel-„!“ in `PAL.red` mit Umriss. `drawBubble` klemmt `bx` in `[2, ctx.canvas.width - bw - 2]` (ohne `ctx.canvas` Standard 960). - [ ] **Step 4: Bestehen sehen** — gleicher Befehl. Expected: `# pass 5`, `# fail 0`. - [ ] **Step 5: Commit** ```bash cd $WK && git add src/components/workers/engine/sprites.js tests/unit/helpers/fakeCtx.mjs tests/unit/workersSprites.test.mjs git commit -m "Workers P2: Engine-Sprites (Raster, Figuren mit Posen, Text, Blasen)" -m "Co-Authored-By: Claude Opus 5.5 " ``` --- ### P2-T07: Requisiten `props.js` **Files:** - Create: `src/components/workers/engine/props.js` - Test: `tests/unit/workersProps.test.mjs` **Interfaces:** - Consumes: `px`, `label`, `PAL`, `TEAM`, `HAT` aus `sprites.js`. Quelle: `entwuerfe/sprites.js` Z. 146–273. - Produces: ```js export const ICONS // { netz, kleinanzeigen, portale, foren, aerzte, handwerk, selbst, laden } → 7 Zeilen à 7 Zeichen ('.' leer, 'x' Linie, 'a' Akzent) export function drawIcon(ctx, name, x, y, color) export function drawDesk(ctx, x, y, w, accent) · drawMonitor(ctx, x, y, t, color, on = true) · drawCabinet(ctx, x, y, drawers) export function drawPlant(ctx, x, y) · drawCoin(ctx, x, y, t) · drawTrophy(ctx, x, y) · drawStamp(ctx, x, y, ok) export function drawRadar(ctx, x, y, t) · drawMailbox(ctx, x, y, count) · drawVault(ctx, x, y, t) · drawScale(ctx, x, y, tilt) export function drawTray(ctx, x, y, filled) // Ablagekorb/Eingangskorb 10×4 export function drawSign(ctx, x, y, w, text) // weißes Schild mit dunklem Rand, Text zentriert ``` - [ ] **Step 1: Failing test schreiben** (`const P = await loadModule('…/engine/props.js')`) ```js test('alle Requisiten zeichnen ohne Wurf und mindestens ein Rechteck', () => { const calls = { desk: (c) => P.drawDesk(c, 0, 0, 18, '#f00'), monitor: (c) => P.drawMonitor(c, 0, 0, 1, '#7fb2ff'), cabinet: (c) => P.drawCabinet(c, 0, 0, 3), plant: (c) => P.drawPlant(c, 0, 0), coin: (c) => P.drawCoin(c, 0, 0, 0.3), trophy: (c) => P.drawTrophy(c, 0, 0), stampOk: (c) => P.drawStamp(c, 0, 0, true), stampNo: (c) => P.drawStamp(c, 0, 0, false), radar: (c) => P.drawRadar(c, 0, 0, 1), mailbox: (c) => P.drawMailbox(c, 0, 0, 2), vault: (c) => P.drawVault(c, 0, 0, 1), scale: (c) => P.drawScale(c, 0, 0, 2), tray: (c) => P.drawTray(c, 0, 0, true) } for (const [name, fn] of Object.entries(calls)) { const { ctx, rects } = makeCtx(); fn(ctx); assert.ok(rects.length >= 1, name) } }) test('Briefkasten-Abzeichen nur bei count > 0; Monitor aus zeigt keine Inhaltszeilen', () => { const red = (n) => { const { ctx, rects } = makeCtx(); P.drawMailbox(ctx, 0, 0, n); return rects.filter((r) => r.fill === PAL.red).length } assert.equal(red(0), 0); assert.ok(red(3) >= 1) const lines = (on) => { const { ctx, rects } = makeCtx(); P.drawMonitor(ctx, 0, 0, 1, '#abcdef', on); return rects.filter((r) => r.fill === '#abcdef').length } assert.ok(lines(true) >= 2); assert.equal(lines(false), 0) }) test('ICONS: genau die 8 Suchräume, je 7 Zeilen à 7 Zeichen; drawSign schreibt den Text', () => { assert.deepEqual(Object.keys(P.ICONS).sort(), ['aerzte', 'foren', 'handwerk', 'kleinanzeigen', 'laden', 'netz', 'portale', 'selbst']) for (const [k, rows] of Object.entries(P.ICONS)) { assert.equal(rows.length, 7, k); for (const r of rows) assert.match(r, /^[.xa]{7}$/, k) } const { ctx, texts, rects } = makeCtx(); P.drawSign(ctx, 0, 0, 40, 'noch nicht angeschlossen'); assert.equal(texts[0].text, 'noch nicht angeschlossen'); assert.ok(rects.length >= 2) const t = makeCtx(); P.drawIcon(t.ctx, 'aerzte', 0, 0, '#3fb6b2'); assert.ok(t.rects.length >= 5) }) ``` (`PAL` aus `sprites.js` per eigenem `loadModule` im Test importieren.) - [ ] **Step 2: Scheitern sehen** — `cd $WK && timeout 300 node --max-old-space-size=1024 --test tests/unit/workersProps.test.mjs`. Expected: FAIL `Could not resolve …/engine/props.js`. - [ ] **Step 3: Implementieren** `engine/props.js` Die elf Vorlagenfunktionen (`drawDesk` bis `drawScale`) 1:1 portieren (`px` aus `sprites.js`); `drawTray`, `drawSign`, `drawIcon` und `ICONS` sind neu; `drawMonitor` bekommt `on` (aus ⇒ nur Rahmen und Schirm, keine Zeilen, kein Blinken). `ICONS` als 7×7-Zeichenkarten (`aerzte` Kreuz, `handwerk` Hammer, `selbst` Person, `laden` Markise, `netz` Funkwellen, `kleinanzeigen` Umschlag mit @-Punkt, `portale` Zeilenliste, `foren` Sprechblase); `drawIcon` zeichnet `x` in `color`, `a` in `PAL.gold`. Entfällt aus der Vorlage: `drawConveyor`, `drawZone`. - [ ] **Step 4: Bestehen sehen** — gleicher Befehl. Expected: `# pass 3`, `# fail 0`. - [ ] **Step 5: Commit** ```bash cd $WK && git add src/components/workers/engine/props.js tests/unit/workersProps.test.mjs git commit -m "Workers P2: Engine-Requisiten und Branchen-Icons" -m "Co-Authored-By: Claude Opus 5.5 " ``` --- ### P2-T08: Layout als Daten und Kamera **Files:** - Create: `src/components/workers/engine/layout.js`, `src/components/workers/engine/camera.js` - Test: `tests/unit/workersLayout.test.mjs`, `tests/unit/workersCamera.test.mjs` **Interfaces:** - Consumes: `ROLES`, `FLOORS`, `ROOM_TITLES` (`workersRoster.js`). - Produces: ```js // layout.js export const WORLD = { w: 320, h: 256 } export const FLOOR_PITCH = 23, BRIDGE_H = 34 export const ROOM_META // roomId → { kind, team }; kinds: bruecke radar inbox feed board scout waage mail schreiber sortier vertrag tisch pruefung pult tresor archiv export function buildLayout(roles = ROLES, floors = FLOORS): { floors:[{ id, index, title, top, floorY, h, rooms:[{ id, kind, title, team, x, w, workerId|null }] }], homes:{ [workerId]: { x, y, floor, room } }, spots:{ pult, tresor, aussen, archiv } } export function floorIndex(layout, id) · roomById(layout, roomId) · homeOf(layout, workerId) · fy(layout, i) · cabTop(layout, i) // camera.js export const ZOOM_MIN = 1, ZOOM_MAX = 3 export function fitZoom(cssWidth): number export function createCamera(): { zoom:1, x:0, y:0 } export function clampCamera(cam, world = WORLD): cam export function zoomAt(cam, factor, fx, fy): cam // fx, fy = Bruchteile 0..1 der Ansicht; der Weltpunkt unter (fx,fy) bleibt stehen export function panBy(cam, dxFrac, dyFrac): cam // in Bruchteilen der Ansicht export function centerOn(cam, x, y): cam export function viewToWorld(cam, vx, vy): { x, y } // vx, vy = Bruchteile 0..1 der Ansicht → logische Weltkoordinaten export function worldToView(cam, x, y): { vx, vy } export function pickAgent(boxes, x, y, pad = 0): id | null // boxes: [{ id, box:{x,y,w,h} }] in Zeichenreihenfolge, der letzte liegt oben ``` - [ ] **Step 1: Failing tests schreiben** `tests/unit/workersLayout.test.mjs`: ```js const { buildLayout, homeOf, roomById, fy, cabTop, WORLD } = await loadModule('src/components/workers/engine/layout.js') const { ROLES, FLOORS } = await loadModule('src/lib/workersRoster.js') const layout = buildLayout() const rooms = (id) => layout.floors.find((f) => f.id === id).rooms.map((r) => [r.id, r.x, r.w]) test('10 Etagen in FLOORS-Reihenfolge mit festen Höhen, Welt 320×256', () => { assert.deepEqual(layout.floors.map((f) => f.id), [...FLOORS]); assert.deepEqual([WORLD.w, WORLD.h], [320, 256]) assert.deepEqual(layout.floors.map((f) => [f.top, f.floorY]), [[6, 40], [41, 63], [64, 86], [87, 109], [110, 132], [133, 155], [156, 178], [179, 201], [202, 224], [225, 247]]) assert.equal(fy(layout, 3), 95); assert.equal(cabTop(layout, 3), 88) }) test('Räume je Etage', () => { assert.deepEqual(rooms('anfragen'), [['netz', 42, 46], ['kleinanzeigen', 88, 46], ['portale', 134, 46], ['foren', 180, 48]]) assert.deepEqual(rooms('gespraeche').map((r) => r[0]), ['mail', 'forum', 'posteingang', 'abschluss']) assert.deepEqual(rooms('werkstatt'), [['tisch1', 42, 62], ['tisch2', 104, 62], ['tisch3', 166, 62]]) for (const id of ['bruecke', 'bewertung', 'pruefung', 'freigabe', 'tresor', 'keller']) assert.deepEqual(rooms(id).map((r) => [r[1], r[2]]), [[42, 186]], id) }) test('19 Worker haben genau einen Platz im eigenen Raum; Freigabe und Tresor ohne Worker', () => { for (const r of ROLES) { const h = homeOf(layout, r.workerId), room = roomById(layout, r.room); const wide = r.workerId === 'otto' ? 24 : 12 assert.ok(h.x >= room.x && h.x + wide <= room.x + room.w, r.workerId); assert.equal(h.floor, FLOORS.indexOf(r.floor)) } assert.equal(homeOf(layout, 'otto').y, 13); assert.equal(homeOf(layout, 'kim').y, 49) assert.deepEqual(layout.floors.filter((f) => f.rooms.some((r) => !r.workerId)).map((f) => f.id), ['freigabe', 'tresor']) assert.deepEqual(layout.spots, { pult: { x: 135, y: 186 }, tresor: { x: 163, y: 213 }, aussen: { x: 324, y: 212 }, archiv: { x: 130, y: 231 } }) }) test('neue Räume kommen als Daten; unbekannter Raum wirft', () => { assert.throws(() => buildLayout([{ workerId: 'x', name: 'X', floor: 'anfragen', room: 'zz', model: 'haiku', role: '', caps: [] }], ['anfragen']), /Unbekannter Raum: zz/) }) ``` `tests/unit/workersCamera.test.mjs`: ```js const C = await loadModule('src/components/workers/engine/camera.js') test('fitZoom: ab 720 px 1, darunter wachsend bis 2,2', () => { assert.equal(C.fitZoom(1280), 1); assert.equal(C.fitZoom(720), 1); assert.ok(Math.abs(C.fitZoom(390) - 720 / 390) < 1e-9); assert.equal(C.fitZoom(200), 2.2) }) test('zoomAt hält den Punkt unter dem Zeiger fest, klemmt Zoom und Rand', () => { const p0 = C.viewToWorld(C.createCamera(), 0.25, 0.75) const cam = C.zoomAt(C.createCamera(), 2, 0.25, 0.75) assert.equal(cam.zoom, 2); const p1 = C.viewToWorld(cam, 0.25, 0.75); assert.ok(Math.abs(p1.x - p0.x) < 1e-9 && Math.abs(p1.y - p0.y) < 1e-9) assert.equal(C.zoomAt(C.createCamera(), 10, 0.5, 0.5).zoom, 3); assert.equal(C.zoomAt(C.createCamera(), 0.1, 0.5, 0.5).zoom, 1) const edge = C.panBy({ zoom: 2, x: 0, y: 0 }, 5, 5); assert.deepEqual([edge.x, edge.y], [160, 128]) assert.deepEqual([C.panBy({ zoom: 2, x: 10, y: 10 }, -5, -5).x, C.panBy({ zoom: 2, x: 10, y: 10 }, -5, -5).y], [0, 0]) }) test('viewToWorld und worldToView sind zueinander invers; centerOn', () => { const cam = { zoom: 2.5, x: 40, y: 60 } const w = C.viewToWorld(cam, 0.3, 0.6), v = C.worldToView(cam, w.x, w.y); assert.ok(Math.abs(v.vx - 0.3) < 1e-9 && Math.abs(v.vy - 0.6) < 1e-9) const c = C.centerOn({ zoom: 2, x: 0, y: 0 }, 160, 128); assert.deepEqual([c.x, c.y], [80, 64]) }) test('pickAgent: oberste Figur gewinnt, Polster vergrößert die Fläche, Treffer nur im Kasten', () => { const boxes = [{ id: 'a', box: { x: 10, y: 10, w: 12, h: 16 } }, { id: 'b', box: { x: 16, y: 10, w: 12, h: 16 } }] assert.equal(C.pickAgent(boxes, 18, 14), 'b'); assert.equal(C.pickAgent(boxes, 11, 14), 'a'); assert.equal(C.pickAgent(boxes, 40, 14), null) assert.equal(C.pickAgent(boxes, 30, 14, 0), null); assert.equal(C.pickAgent(boxes, 30, 14, 3), 'b') }) ``` - [ ] **Step 2: Scheitern sehen** — `cd $WK && timeout 300 node --max-old-space-size=1024 --test tests/unit/workersLayout.test.mjs tests/unit/workersCamera.test.mjs`. Expected: FAIL `Could not resolve …/layout.js`. - [ ] **Step 3: Implementieren** `layout.js`: Etage 0 `top` 6 / `floorY` 40, Etage `i ≥ 1`: `top = 41 + 23*(i-1)`, `floorY = top + 22`; `fy(layout, i) = floorY − 14`, `cabTop(layout, i) = floorY − 21` (Kabinenhöhe als Zahl, `layout.js` importiert `lift.js` nicht). Räume je Etage aus `roles` (Reihenfolge der Rollen) plus `{ id:'pult' }` in `freigabe` und `{ id:'tresor' }` in `tresor`; Aufteilung: 4 Räume ⇒ x `[42, 88, 134, 180]`, w `[46, 46, 46, 48]`; 3 Räume ⇒ x `[42, 104, 166]`, w 62; 1 Raum ⇒ 42/186; sonst gleichmäßig. Heim: `x = room.x + floor((room.w - breite)/2)` mit `breite` 24 für Otto, sonst 12; `y = fy` = `floorY - 14`, Otto `floorY - 27`. `ROOM_META` aus der Tabelle (netz→radar, kleinanzeigen→inbox, portale→feed, foren→board, aerzte/handwerk/selbst/laden→scout, waage, mail, forum→schreiber, posteingang→sortier, abschluss→vertrag, tisch1–3→tisch, pruefung, pult, tresor, archiv, bruecke); Team-Farbschlüssel aus `TEAM` (Suche-Räume `radar` bzw. eigener Branchenschlüssel, Gespräche/Brücke/Pult `freigabe`, Tische `werkstatt`, Waage `bewertung`). `spots` wie im Test. `camera.js`: Sichtfeld = `W/zoom × H/zoom`; `zoomAt` setzt `zoom = clamp(zoom*factor, 1, 3)` und `x = wx - fx*W/zoom'` (mit `wx` vorher aus `viewToWorld`), danach `clampCamera`; `fitZoom = w >= 720 ? 1 : min(2.2, 720/w)`; `pickAgent` prüft von hinten nach vorn die um `pad` vergrößerten Kästen. - [ ] **Step 4: Bestehen sehen** — gleicher Befehl wie Step 2. Expected: `# pass 8`, `# fail 0`. - [ ] **Step 5: Commit** ```bash cd $WK && git add src/components/workers/engine/layout.js src/components/workers/engine/camera.js tests/unit/workersLayout.test.mjs tests/unit/workersCamera.test.mjs git commit -m "Workers P2: Layout als Daten (10 Etagen, 19 Plätze) und Kamera mit Umkehr-Transformation" -m "Co-Authored-By: Claude Opus 5.5 " ``` --- ### P2-T09: Effekt-System `fx.js` **Files:** - Create: `src/components/workers/engine/fx.js` - Test: `tests/unit/workersFx.test.mjs` **Interfaces:** - Consumes: `px`, `PAL` (`sprites.js`), `drawCoin`, `drawStamp` (`props.js`). Quelle: `scene-turm.js` Z. 366–393 (`addFx`/`updateFx`/`drawFx`/`envelope`). - Produces: ```js export const FX_NAMES, MAX_FX // ['paper','envelope','coin','stampOk','stampNo','sparkle','alert','drawer'], 80 export function createFx(): { add(life, draw, onEnd?, delay = 0), update(dt), draw(ctx), clear(), size() } export function spawnFx(fx, name, { from, to, delay = 0, onEnd }): void // from/to = { x, y } in Weltkoordinaten ``` `draw(ctx, progress 0..1, effect)`; `onEnd` genau einmal beim Ablauf; `clear()` ruft kein `onEnd`; über `MAX_FX` hinaus wird der neue Effekt verworfen und sein `onEnd` sofort gerufen. - [ ] **Step 1: Failing test schreiben** (`const F = await loadModule('…/engine/fx.js')`) ```js test('Verzögerung, Lebensdauer, onEnd genau einmal', () => { const fx = F.createFx(), log = []; fx.add(1, (c, p) => log.push(['draw', p]), () => log.push(['end']), 0.5) const { ctx } = makeCtx(); fx.update(0.4); fx.draw(ctx); assert.equal(log.length, 0) fx.update(0.4); fx.draw(ctx); assert.equal(log.at(-1)[0], 'draw') fx.update(1); assert.deepEqual(log.at(-1), ['end']); fx.update(1); assert.equal(log.filter((l) => l[0] === 'end').length, 1); assert.equal(fx.size(), 0) }) test('Obergrenze 80: Überschuss wird verworfen und meldet onEnd; clear ruft kein onEnd', () => { const fx = F.createFx(); let ended = 0 for (let i = 0; i < 200; i++) fx.add(5, () => {}, () => { ended++ }) assert.equal(fx.size(), F.MAX_FX); assert.equal(ended, 120); fx.clear(); assert.equal(fx.size(), 0); assert.equal(ended, 120) }) test('jeder Effektname zeichnet und endet in höchstens 6 s', () => { for (const name of F.FX_NAMES) { const fx = F.createFx(), { ctx, rects } = makeCtx(); let ended = false F.spawnFx(fx, name, { from: { x: 50, y: 100 }, to: { x: 150, y: 60 }, onEnd: () => { ended = true } }) for (let i = 0; i < 60; i++) { fx.update(0.1); fx.draw(ctx) } assert.ok(rects.length > 0, name); assert.equal(fx.size(), 0, name); assert.ok(ended, name) } }) ``` - [ ] **Step 2: Scheitern sehen** — `cd $WK && timeout 300 node --max-old-space-size=1024 --test tests/unit/workersFx.test.mjs`. Expected: FAIL `Could not resolve …/engine/fx.js`. - [ ] **Step 3: Implementieren** `engine/fx.js` Lebensdauern: `paper` 0,9 s (Bogen von `from` nach `to`), `envelope` 3,4 s (Bogen mit Höhe 12, wie `sentOut`), `coin` 0,7 s (Bogen, 14 hoch), `stampOk`/`stampNo` 1,2 s (Stempel hüpft über `from`), `sparkle` 1,0 s, `alert` 1,5 s (blinkendes „!“ über `from`), `drawer` 0,9 s (Papier fliegt von `from` nach `to`, Ende kürzer 3 px). Jeder Effekt zeichnet nur mit `px`/`label`. - [ ] **Step 4: Bestehen sehen** — gleicher Befehl. Expected: `# pass 3`, `# fail 0`. - [ ] **Step 5: Commit** ```bash cd $WK && git add src/components/workers/engine/fx.js tests/unit/workersFx.test.mjs git commit -m "Workers P2: Effekt-System mit Obergrenze" -m "Co-Authored-By: Claude Opus 5.5 " ``` --- ### P2-T10: Aufzug `lift.js` **Files:** - Create: `src/components/workers/engine/lift.js` - Test: `tests/unit/workersLift.test.mjs` **Interfaces:** - Consumes: `cabTop` (`layout.js`), `px`, `tint` (`sprites.js`). Quelle: `scene-turm.js` Z. 316–330 (Kabine), 354–357, 466–492 (Zustandsautomat). - Produces: ```js export const LIFT = { cabWx: 22, exitX: 50, slot0: 52, slotStep: 14, speed: 110, cabH: 21 } export function createLift(layout): { y, state, request(agent, from, to), update(dt), cancel(agent), reset(), at(floorIndex), drawBack(ctx), drawFront(ctx) } ``` `request` ⇒ `req = { a, from, to, st }` mit `st`: `queued → open → boarded → arrived → out`, außerdem `cancelled`; die Übergänge `boarded` und `out` setzt der Fahrgast (Regie), alle anderen der Aufzug. `state`: `idle|toPick|boarding|closing|riding|unloading`. Während der Fahrt folgt `req.a`: `a.y = lift.y + 7`, `a.x = LIFT.cabWx`, `a.frame = 0`. - [ ] **Step 1: Failing test schreiben** (`const { createLift, LIFT } = await loadModule('…/engine/lift.js')`, `buildLayout`, `cabTop` aus `layout.js`) ```js const layout = buildLayout() function until(lift, fn, max = 12) { let t = 0; while (!fn() && t < max) { lift.update(0.05); t += 0.05 } return t } test('Fahrt 1 → 8: queued, open, boarded, arrived, out; Fahrgast folgt der Kabine; danach idle', () => { const lift = createLift(layout), a = { x: 0, y: 0, frame: 1 }, req = lift.request(a, 1, 8) assert.equal(req.st, 'queued') let t = until(lift, () => req.st === 'open'); assert.ok(lift.at(1)); assert.equal(lift.state, 'boarding') req.st = 'boarded'; t += until(lift, () => req.st === 'arrived'); assert.ok(lift.at(8)); assert.equal(a.y, cabTop(layout, 8) + 7); assert.equal(a.x, LIFT.cabWx) assert.ok(t < 12); req.st = 'out'; lift.update(0.05); assert.equal(lift.state, 'idle') }) test('FIFO: zweite Anfrage wartet bis die erste ausgestiegen ist', () => { const lift = createLift(layout), r1 = lift.request({}, 1, 4), r2 = lift.request({}, 5, 2) until(lift, () => r1.st === 'open'); r1.st = 'boarded'; until(lift, () => r1.st === 'arrived'); assert.equal(r2.st, 'queued') r1.st = 'out'; until(lift, () => r2.st === 'open'); assert.equal(r2.st, 'open') }) test('cancel und reset geben die Kabine frei (kein Festhängen)', () => { const lift = createLift(layout), a = {}, r1 = lift.request(a, 7, 1), r2 = lift.request({}, 2, 3) lift.update(0.05); lift.cancel(a); assert.equal(r1.st, 'cancelled'); until(lift, () => r2.st === 'open'); assert.equal(r2.st, 'open') lift.reset(); assert.equal(r2.st, 'cancelled'); assert.equal(lift.state, 'idle') }) ``` - [ ] **Step 2: Scheitern sehen** — `cd $WK && timeout 300 node --max-old-space-size=1024 --test tests/unit/workersLift.test.mjs`. Expected: FAIL `Could not resolve …/engine/lift.js`. - [ ] **Step 3: Implementieren** `engine/lift.js` Zustandsautomat 1:1 aus der Vorlage (Z. 466–492) mit `cabMove` (`speed` 110) und `follow`; neu: `cancel(agent)` markiert Anfragen des Agenten `cancelled`, entfernt sie aus der Warteschlange und setzt bei der laufenden Anfrage `req = null`, `state = 'idle'`; `reset()` macht dasselbe für alle; `update` überspringt `cancelled`. Startposition `y = cabTop(layout, 2)`. `drawBack`/`drawFront` aus Z. 316–330 (Kabine 20 × 21 bei x 18, Seil, Lampe), Etagenlampen zeichnet `building.js` über `at(i)`. - [ ] **Step 4: Bestehen sehen** — gleicher Befehl. Expected: `# pass 3`, `# fail 0`. - [ ] **Step 5: Commit** ```bash cd $WK && git add src/components/workers/engine/lift.js tests/unit/workersLift.test.mjs git commit -m "Workers P2: Aufzug mit Abbruch und Reset" -m "Co-Authored-By: Claude Opus 5.5 " ``` --- ### P2-T11: Räume und Gebäude (`rooms.js`, `building.js`) **Files:** - Create: `src/components/workers/engine/rooms.js`, `src/components/workers/engine/building.js` - Test: `tests/unit/workersRooms.test.mjs` **Interfaces:** - Consumes: `layout.js` (`buildLayout`, `ROOM_META`), `props.js`, `sprites.js`, `ROOM_TITLES`/`FLOOR_TITLES` (`workersRoster.js`), `lift.at`. Quelle: `scene-turm.js` Z. 99–314. - Produces: ```js // rooms.js export function createLook(): { greyRooms:Set, busyRooms:Set, pultOpen:0, dealsMonth:0, botLeads:0, trophyPage:0, pageT:0, trophyPop:0, vaultFlash:0, paused:false } export function trophyPaging(total, page, perPage = 8): { shown, page, pages, from } export function drawRooms(ctx, layout, t, look): void // building.js export function drawOutside(ctx): void // Boden, Erde, Bodenlinie bei y 224 (Tresor-Etage), Dachlinie export function drawTower(ctx, layout, t, lift): void // Hülle, Schacht, Etagenböden/-wände, Lampen, Etagenschilder rechts außen (x 234) export function drawPauseVeil(ctx, look): void // weißer Schleier 45 % und „Pausiert“, nur bei look.paused ``` Raumtabelle (`kind` ⇒ Inhalt): bruecke Konsole mit 2 Monitoren, Tafel, Fenster, Pflanze · radar `drawRadar` + Tisch · inbox Tisch, Monitor, `drawTray` · feed Tisch, Monitor mit Zeilen · board Tisch, Monitor, Sprechblasen-Tafel · scout Tisch in Teamfarbe, Monitor, Branchen-Icon · waage `drawScale`, Tisch · mail Tisch, Monitor, Briefstapel · schreiber Tisch, Monitor (Schreib-Animation) · sortier Tisch, 3 Ablagekörbe · vertrag Tisch, Vertrag, Stempel · tisch Tisch, Monitor · pruefung Tisch, 2 Stempel, Handy · pult großer `drawMailbox` mit Zahl · tresor Pokalbrett (`trophyPaging`), `drawVault`, Zähler `${dealsMonth} Deals` · archiv 4 Schränke, Kartons, Zähler `${botLeads} Bot-Leads`. Jeder Raum bekommt Titel (`ROOM_TITLES`) und Teamfarbton (Alpha 0,10); Monitore laufen nur in Räumen aus `look.busyRooms`; Räume in `look.greyRooms`: Grautönung, Monitore aus, `drawSign` „noch nicht angeschlossen“ mittig. - [ ] **Step 1: Failing test schreiben** (`const R = await loadModule('…/engine/rooms.js')`, `B = …/building.js`, Layout/Roster/`PAL`) ```js const layout = buildLayout() const draw = (look, t = 1) => { const m = makeCtx(); R.drawRooms(m.ctx, layout, t, look); return m } test('trophyPaging: 8 je Seite, Seite wird geklemmt', () => { assert.deepEqual(R.trophyPaging(0, 0), { shown: 0, page: 0, pages: 1, from: 0 }); assert.deepEqual(R.trophyPaging(8, 0), { shown: 8, page: 0, pages: 1, from: 0 }) assert.deepEqual(R.trophyPaging(9, 1), { shown: 1, page: 1, pages: 2, from: 8 }); assert.deepEqual(R.trophyPaging(9, 5), { shown: 1, page: 1, pages: 2, from: 8 }) assert.deepEqual(R.trophyPaging(25, -1), { shown: 8, page: 0, pages: 4, from: 0 }) }) test('alle 21 Räume zeichnen, jeder Titel erscheint', () => { const { texts, rects } = draw(R.createLook()); assert.ok(rects.length > 300) for (const title of Object.values(ROOM_TITLES)) assert.ok(texts.some((x) => x.text === title), title) }) test('graue Räume tragen das Schild, sonst keins (Review Focus 4)', () => { const look = R.createLook(); look.greyRooms = new Set(['kleinanzeigen', 'portale']) assert.equal(draw(look).texts.filter((x) => x.text === 'noch nicht angeschlossen').length, 2) assert.equal(draw(R.createLook()).texts.filter((x) => x.text === 'noch nicht angeschlossen').length, 0) }) test('Pult-Zahl, Pokal- und Bot-Lead-Zähler', () => { const look = R.createLook(); look.pultOpen = 7; look.dealsMonth = 9; look.botLeads = 112 const t = draw(look).texts.map((x) => x.text); assert.ok(t.includes('7')); assert.ok(t.includes('9 Deals')); assert.ok(t.includes('112 Bot-Leads')) assert.ok(!draw(R.createLook()).texts.some((x) => x.text === '7')) }) test('Gebäude: Etagenschilder, Lampen, Pausenschleier', () => { const m = makeCtx(); B.drawTower(m.ctx, layout, 1, { at: (i) => i === 3 }); for (const title of Object.values(FLOOR_TITLES)) assert.ok(m.texts.some((x) => x.text === title), title) const on = makeCtx(); const look = R.createLook(); look.paused = true; B.drawPauseVeil(on.ctx, look); assert.ok(on.texts.some((x) => x.text === 'Pausiert')) const off = makeCtx(); B.drawPauseVeil(off.ctx, R.createLook()); assert.equal(off.texts.length, 0); B.drawOutside(makeCtx().ctx) }) ``` - [ ] **Step 2: Scheitern sehen** — `cd $WK && timeout 300 node --max-old-space-size=1024 --test tests/unit/workersRooms.test.mjs`. Expected: FAIL `Could not resolve …/engine/rooms.js`. - [ ] **Step 3: Implementieren** `engine/rooms.js`, `engine/building.js` Eine `drawRoom(ctx, room, floor, t, look)` je `kind` (Tabelle oben) über ein Objekt `ROOM_DRAWERS[kind]`, alle Koordinaten relativ zu `room.x`/`floor.floorY`; Schriften nur über `label`. Räume mit `workerId` null (`pult`, `tresor`) werden nie grau. `trophyPaging`: `pages = max(1, ceil(total/perPage))`, `page` auf `0..pages-1` geklemmt, `from = page*perPage`, `shown = min(perPage, total-from)`. `drawTower`: Hülle/Schacht/Böden/Schachttüren aus Vorlage Z. 118–172 auf 10 Etagen, Etagenschild rechts außen als Teamfarbpunkt plus `FLOOR_TITLES`-Text bei x 234. Die Stadtsilhouette der Vorlage entfällt. - [ ] **Step 4: Bestehen sehen** — gleicher Befehl. Expected: `# pass 5`, `# fail 0`. - [ ] **Step 5: Commit** ```bash cd $WK && git add src/components/workers/engine/rooms.js src/components/workers/engine/building.js tests/unit/workersRooms.test.mjs git commit -m "Workers P2: Räume nach kind, Pokal-Seiten, graue Räume, Gebäudehülle" -m "Co-Authored-By: Claude Opus 5.5 " ``` --- ### P2-T12: Regie 1 – Agenten, Snapshot, Posen, Leerlauf (`director.js`) **Files:** - Create: `src/components/workers/engine/director.js`, `tests/unit/helpers/workersFixtures.mjs` - Test: `tests/unit/workersDirector.test.mjs` **Interfaces:** - Consumes: `layout.js` (`homeOf`, `fy`), `lift.js`, `fx.js`, `rooms.js` (`createLook`, `trophyPaging`), `sprites.js` (`drawWorker`, `drawBubble`, `drawAlertMark`), `camera.js` (`pickAgent`), `workersRoster.js` (`ROLES`, `hatForWorker`), `workersEvents.js` (`indexWorkers`). - Produces: ```js export const BACKLOG_KEEP = 6, GROUPS_PER_AGENT = 4, IDLE_COFFEE_S = 25, STEP_TIMEOUT_S = 45, BUBBLE_MAX = 3 export function createDirector({ layout, lift, fx, look, roles = ROLES, rnd = Math.random }): { agents, byId, applyState(state), pushEvents(events), update(dt), flush(), pending(), boxes(), reset(), debug() } export function drawAgents(ctx, director, selectedId, t): void // y-sortiert, setzt agent.box, Blasen, Auswahlpfeil, „!“ bei alert ``` Agent: `{ id, name, hat, scale, x, y, floor, facing, frame, pose:'stand'|'type'|'coffee'|'sleep', carrying, stackN, say, bubT, grey, alert, status, task, counters, lane, bob, idleT, cheer, q, cur, stepT, groups, box, home }`. `tests/unit/helpers/workersFixtures.mjs`: `IDS` (die 19 workerIds in Rollenreihenfolge), `makeState({ statuses = {}, connected = {}, kpis = {}, paused = false, sendingHalted = false, sendingHaltedReason = '', lastEventTs } = {})` ⇒ Zustand (`paused`, `sendingHalted`, `sendingHaltedReason`, `lastEventTs`, wie Übersicht §6.2) mit 19 `workers` (`{ workerId, status: statuses[id] ?? 'arbeitet', task: '', counters: {}, connected: connected[id] ?? true }`; wie im Dienst (P1-E9) sind nur Such-Worker nicht angeschlossen: die Tests setzen `connected: false` ausschließlich für `rita`, `kim`, `pia`, `finn`, `ali`, `hanna`, `sven`, `lea`) und `kpis` (Standard `{ newAbosMonth: 3, mrrNewEur: 60, dealsMonth: 3, openThreads: 7, pultOpen: 2, botLeads: 112, apiTodayUsd: 5.1, apiMonthUsd: 50, dailyBudgetUsd: 6, monthlyBudgetUsd: 150 }` (Kosten: [Kenso, 17.10.: max. 150 € im Monat]), überschrieben durch `kpis`), `ev(kind, workerId, extra)` ⇒ Ereignis mit eindeutiger `id`, `ts` (zählt je Aufruf um 1 s hoch), `text: 'Text'`. - [ ] **Step 1: Failing test schreiben** (`tests/unit/workersDirector.test.mjs`) ```js function setup() { const layout = buildLayout(), lift = createLift(layout), fx = createFx(), look = createLook(); return { layout, lift, fx, look, d: createDirector({ layout, lift, fx, look, rnd: () => 0.5 }) } } test('erster Snapshot: alle 19 an ihrem Platz, Pose nach Status, „!“ bei Fehler', () => { const { d, layout } = setup(); d.applyState(makeState({ statuses: { kim: 'leerlauf', rita: 'wartet', bea: 'fehler' } })) assert.equal(d.agents.length, 19) for (const a of d.agents) { const h = homeOf(layout, a.id); assert.deepEqual([a.x, a.y], [h.x, h.y], a.id) } assert.deepEqual([d.byId.kim.pose, d.byId.rita.pose, d.byId.otto.pose, d.byId.bea.alert], ['coffee', 'stand', 'type', true]) }) test('nicht angeschlossen oder im Zustand fehlend ⇒ grau, schläft, Raum in look.greyRooms (Review Focus 4)', () => { const { d, look } = setup(); const st = makeState({ connected: { kim: false } }); st.workers = st.workers.filter((w) => w.workerId !== 'pia') d.applyState(st) assert.deepEqual([d.byId.kim.grey, d.byId.kim.pose, d.byId.pia.grey, d.byId.pia.pose, d.byId.bob.grey], [true, 'sleep', true, 'sleep', false]) assert.deepEqual([...look.greyRooms].sort(), ['kleinanzeigen', 'portale']) }) test('Kennzahlen landen in look; neuer Pokal blinkt nur bei steigender Zahl, nicht beim ersten Snapshot', () => { const { d, look } = setup(); d.applyState(makeState({ kpis: { pultOpen: 4, dealsMonth: 9, botLeads: 112 } })) assert.deepEqual([look.pultOpen, look.dealsMonth, look.botLeads, look.trophyPop], [4, 9, 112, 0]) d.applyState(makeState({ kpis: { pultOpen: 4, dealsMonth: 10, botLeads: 112 } })); assert.ok(look.trophyPop > 0) d.applyState(makeState({ paused: true })); assert.equal(look.paused, true) d.applyState(makeState({ sendingHalted: true, sendingHaltedReason: 'Zwei harte Bounces' })); assert.equal(look.paused, false) // Notbremse (H4) ist keine Pause: kein Schleier, die Meldung zeigt das Board }) test('Leerlauf: erst Kaffee, nach 25 s Schlaf; Pokal-Seiten blättern alle 6 s', () => { const { d, look } = setup(); d.applyState(makeState({ statuses: { kim: 'leerlauf' }, kpis: { dealsMonth: 9 } })) const step = (n) => { for (let i = 0; i < n; i++) d.update(0.1) } step(70); assert.equal(look.trophyPage, 1) // 7 s: erste Blätterung bei 6 s step(60); assert.equal(look.trophyPage, 0); assert.equal(d.byId.kim.pose, 'coffee') // 13 s: zweite Blätterung bei 12 s step(140); assert.equal(d.byId.kim.pose, 'sleep') // 27 s > 25 s }) test('drawAgents: Trefferflächen für alle 19, jeder ist anklickbar', () => { const { d } = setup(); d.applyState(makeState({ connected: { kim: false }, statuses: { rita: 'fehler' } })); const { ctx } = makeCtx(); drawAgents(ctx, d, 'bob', 1) const boxes = d.boxes(); assert.equal(boxes.length, 19) for (const { id, box } of boxes) assert.equal(pickAgent(boxes, box.x + box.w / 2, box.y + box.h / 2), id, id) }) ``` - [ ] **Step 2: Scheitern sehen** — `cd $WK && timeout 300 node --max-old-space-size=1024 --test tests/unit/workersDirector.test.mjs`. Expected: FAIL `Could not resolve …/engine/director.js`. - [ ] **Step 3: Implementieren** `engine/director.js` (Teil 1) und `tests/unit/helpers/workersFixtures.mjs` `applyState(state)`: `workers = indexWorkers(state)`; je Agent `w` = Eintrag oder fehlend ⇒ `grey`; `grey = status === 'grau'`; `look.greyRooms` aus den Räumen grauer Agenten; `look.busyRooms` = Räume mit Status `arbeitet`; Kennzahlen nach `look`; steigt `kpis.dealsMonth` gegenüber dem vorigen Snapshot (nicht beim ersten), `look.trophyPop = 1.2`; `look.paused = !!state.paused`. Pose-Regel `poseFor(a)`: grau ⇒ `sleep`; `leerlauf` ⇒ `idleT >= 25 ? sleep : coffee`; `arbeitet` ⇒ `type`; sonst `stand`; `alert = status === 'fehler'`. Der **erste** Snapshot setzt alle Agenten auf `homeOf`; spätere Snapshots bewegen nie, sie setzen Status/Pose nur bei Agenten ohne laufende Gruppe (sonst nach Ende der Gruppe). `update(dt)`: bei `look.paused` sofort zurück; sonst Agenten (`idleT` wächst bei Status `leerlauf`, Pose jedes Bild neu nach `poseFor` für Agenten ohne laufende Gruppe, Wippen, Blasen-Timer), `lift.update`, `fx.update`, `look.trophyPop/vaultFlash` herunterzählen, `look.pageT += dt` ⇒ bei ≥ 6 `trophyPage = (trophyPage + 1) % pages`. `drawAgents`: nach `y + lane + bob + 13*scale` sortieren, `drawWorker` mit `pose/grey/t`, `agent.box` merken, Stapel-Papiere und „!“ zeichnen, Blasen zuletzt (Otto seitlich wie in der Vorlage Z. 929–944). `bubble(a, text)`: `cutBubble`, höchstens `BUBBLE_MAX` gleichzeitig, sonst stillschweigend verworfen. `pushEvents`, `flush`, `reset`, `debug` kommen erst in P2-T13; hier liefert `pending()` vorerst 0. - [ ] **Step 4: Bestehen sehen** — gleicher Befehl. Expected: `# pass 5`, `# fail 0`. - [ ] **Step 5: Commit** ```bash cd $WK && git add src/components/workers/engine/director.js tests/unit/helpers/workersFixtures.mjs tests/unit/workersDirector.test.mjs git commit -m "Workers P2: Regie 1 (Agenten, Snapshot ohne Animation, Posen, Leerlauf)" -m "Co-Authored-By: Claude Opus 5.5 " ``` --- ### P2-T13: Regie 2 – Schritte, Aufzugfahrten, Rückstau, Wachhund **Files:** - Modify: `src/components/workers/engine/director.js` - Test: `tests/unit/workersDirector.test.mjs` (erweitern) **Interfaces:** - Consumes: `eventToSteps`, `reduceEvents`, `cutBubble` (P2-T03), `LIFT` (P2-T10), `spawnFx` (P2-T09), `layout.spots`. - Produces (ergänzt P2-T12): `pushEvents(events)`, `flush()`, `pending()` (wartende + laufende Gruppen über alle Agenten), `reset()` (Warteschlangen leeren, Aufzug und Effekte zurücksetzen, alle Agenten heim), `debug()` ⇒ `{ door: number[10], groups: number }`. - [ ] **Step 1: Failing tests ergänzen** (`ev`, `IDS`, `makeState` aus den Fixtures) ```js const run = (d, max = 60) => { let t = 0; while (d.pending() > 0 && t < max) { d.update(0.05); t += 0.05 } return t } test('Übergabe über zwei Etagen: Rita bringt Bea ein Dokument, fährt Aufzug, steht danach wieder zuhause', () => { const { d, lift, layout } = setup(); d.applyState(makeState()); d.pushEvents([ev('uebergabe', 'rita', { to: 'bea', text: 'Rita bringt 2 Funde zu Bea' })]) assert.ok(d.pending() >= 1); let ride = false, t = 0; while (d.pending() > 0 && t < 60) { d.update(0.05); t += 0.05; if (lift.state !== 'idle') ride = true } assert.ok(ride); assert.ok(t < 40, String(t)); const h = homeOf(layout, 'rita') assert.deepEqual([Math.round(d.byId.rita.x), Math.round(d.byId.rita.y), d.byId.rita.carrying, lift.state], [h.x, h.y, false, 'idle']); assert.equal(d.byId.rita.task, 'Rita bringt 2 Funde zu Bea') }) test('gleiche Etage: kein Aufzug', () => { const { d, lift } = setup(); d.applyState(makeState()); d.pushEvents([ev('uebergabe', 'kim', { to: 'pia' })]); let ride = false; for (let i = 0; i < 1200 && d.pending() > 0; i++) { d.update(0.05); if (lift.state !== 'idle') ride = true } assert.equal(ride, false); assert.equal(d.pending(), 0) }) test('Rückstau: 300 Ereignisse ⇒ höchstens 6 Gruppen, Rest still, nach ≤ 60 s ruhig, letzte Aufgaben stehen (Review Focus 2)', () => { const { d, lift, fx } = setup(); d.applyState(makeState()) d.pushEvents(Array.from({ length: 300 }, (_, i) => ev('uebergabe', IDS[i % 19], { to: IDS[(i + 3) % 19], text: `Aufgabe ${i}` }))) assert.ok(d.pending() <= 6, String(d.pending())); assert.ok(run(d) < 60); assert.equal(d.pending(), 0); assert.equal(lift.state, 'idle') for (let k = 0; k < 19; k++) { const last = 299 - ((299 - k) % 19); assert.equal(d.byId[IDS[k]].task, `Aufgabe ${last}`, IDS[k]) } d.pushEvents(Array.from({ length: 10 }, (_, i) => ev('fund', 'kim', { text: `K${i}` }))); assert.ok(d.byId.kim.groups <= 4) }) test('graue, fremde und unbekannte Ereignisse: keine Arbeit, kein Wurf (Review Focus 4)', () => { const { d } = setup(); d.applyState(makeState({ connected: { kim: false } })) d.pushEvents([ev('gelesen', 'kim'), ev('fund', 'zed'), ev('neueArt', 'rita'), ev('uebergabe', 'kim', { to: 'bea' })]) assert.equal(d.byId.kim.say, null); assert.equal(d.byId.kim.pose, 'sleep'); assert.equal(d.byId.kim.groups, 0) }) test('Blasen: höchstens drei gleichzeitig', () => { const { d } = setup(); d.applyState(makeState()); d.pushEvents(['kim', 'rita', 'pia', 'finn', 'ali'].map((w) => ev('fund', w))); d.update(0.05) assert.ok(d.agents.filter((a) => a.say).length <= 3) }) test('Pause friert ein, Fortsetzen läuft weiter', () => { const { d } = setup(); d.applyState(makeState({ paused: true })); d.pushEvents([ev('uebergabe', 'kim', { to: 'pia' })]); const x0 = d.byId.kim.x for (let i = 0; i < 400; i++) d.update(0.05); assert.equal(d.byId.kim.x, x0) d.applyState(makeState({ paused: false })); for (let i = 0; i < 40; i++) d.update(0.05); assert.notEqual(d.byId.kim.x, x0) }) test('Aufzug zurückgesetzt mitten in der Fahrt: Fahrgast erholt sich, Türzähler 0; hängender Aufzug: Wachhund nach 45 s', () => { const a = setup(); a.d.applyState(makeState()); a.d.pushEvents([ev('uebergabe', 'rita', { to: 'bea' })]) let g = 0; while (a.lift.state !== 'riding' && g++ < 400) a.d.update(0.05) assert.equal(a.lift.state, 'riding'); a.lift.reset(); assert.ok(run(a.d) < 10); assert.deepEqual(a.d.debug().door, new Array(10).fill(0)) const layout = buildLayout(), fx = createFx(), look = createLook(), stuck = { y: 0, state: 'idle', request: (ag, f, t) => ({ a: ag, from: f, to: t, st: 'queued' }), update() {}, cancel() {}, reset() {}, at: () => false, drawBack() {}, drawFront() {} } const d = createDirector({ layout, lift: stuck, fx, look }); d.applyState(makeState()); d.pushEvents([ev('uebergabe', 'rita', { to: 'bea' })]) const t = run(d, 80); assert.ok(t >= 45 && t < 60, String(t)); assert.deepEqual(d.debug().door, new Array(10).fill(0)) }) test('flush: alles sofort ruhig, alle zuhause; Deal löst Münzen und Jubel aus', () => { const { d, lift, fx, layout } = setup(); d.applyState(makeState()); d.pushEvents(Array.from({ length: 20 }, (_, i) => ev('uebergabe', IDS[i % 19], { to: IDS[(i + 5) % 19] }))) d.flush(); assert.equal(d.pending(), 0); assert.equal(lift.state, 'idle'); assert.equal(fx.size(), 0) for (const a of d.agents) { const h = homeOf(layout, a.id); assert.deepEqual([Math.round(a.x), Math.round(a.y)], [h.x, h.y], a.id) } d.pushEvents([ev('deal', 'system')]); d.update(0.1); assert.ok(fx.size() >= 3); assert.ok(d.byId.otto.cheer > 0); run(d); for (let i = 0; i < 100; i++) d.update(0.1); assert.equal(fx.size(), 0) }) ``` - [ ] **Step 2: Scheitern sehen** — `cd $WK && timeout 300 node --max-old-space-size=1024 --test tests/unit/workersDirector.test.mjs`. Expected: FAIL (`d.pushEvents is not a function` bzw. Assertion-Fehler bei `pending`). - [ ] **Step 3: Implementieren** in `engine/director.js` `pushEvents(events)`: (1) `workers = reduceEvents(workers, events)`, **nur den Status** auf die Agenten übertragen (`agent.task` setzen allein `task`-Schritte und der Snapshot) und `look.busyRooms` sowie `look.greyRooms` aus den Agenten neu bilden (dieselbe Hilfsfunktion wie in `applyState`); (2) je Ereignis `ops = eventToSteps(e)`, Besitzer der Gruppe = `from` des ersten `give`, sonst `W` (wenn in `ROLES`), sonst `otto`; (3) die letzten `BACKLOG_KEEP` Gruppen sind **animiert**, alle älteren **still**; zusätzlich wird eine Gruppe still, wenn der Besitzer grau ist oder schon `GROUPS_PER_AGENT` Gruppen hält; (4) stille Gruppen führen sofort nur `task`/`pose` aus, animierte werden an `agent.q` gehängt (`groups++`). Besitzer-Queue wie in der Vorlage (`nextStep`/`stepAgent`, Z. 864–883), jeder Op wird zu Schritten: `task` setzt `agent.task`; `say` ruft `bubble` (Ziel `who`); `pose` setzt Pose und `idleT = 0` (bei grauen Agenten wirkungslos); `cheer` setzt `cheer = 2`; `fx` ruft `spawnFx` mit `from = posOf(at)`, `to = posOf(to)` (Agent: Kopfmitte, Platz: `layout.spots`; `drawer` ohne `to` ⇒ `spots.archiv`; bei `coin`-Ende `look.vaultFlash = 0.7`); `give` = Folge: Dokument aufnehmen (`carrying = true`) → zum Empfänger (bei anderer Etage `rideStep(vonEtage, nachEtage)`, Algorithmus unverändert aus der Vorlage Z. 435–464 mit `lift.request`, Wartepositionen `LIFT.slot0 + LIFT.slotStep * door[etage]++`) → neben den Empfänger laufen (links oder rechts, je nach Seite, Abstand 12), Empfänger `bubble('Danke!')` → `carrying = false` → Rückfahrt → `walkTo(home.x)` → Blickrichtung und `poseFor`. `rideStep` bricht in jeder Phase bei `req.st === 'cancelled'` ab (Agent sofort heim; der Türzähler wird nur zurückgenommen, wenn der Agent noch mitgezählt ist, also vor dem Einsteigen). Wachhund: läuft ein Schritt länger als `STEP_TIMEOUT_S`, wird `lift.cancel(agent)` gerufen, der Türzähler zurückgenommen, der Agent heimgesetzt und die Gruppe beendet. `pending()` = Summe `groups` + laufende. `flush()`: wiederholt `update(0.25)` (höchstens 2000 Mal) bis `pending() === 0`, setzt alle Agenten heim, `lift.reset()`, `fx.clear()`. `reset()` = leeren + heim. - [ ] **Step 4: Bestehen sehen** — gleicher Befehl. Expected: `# pass 13`, `# fail 0` (5 aus P2-T12 + 8 neue). - [ ] **Step 5: Commit** ```bash cd $WK && git add src/components/workers/engine/director.js tests/unit/workersDirector.test.mjs git commit -m "Workers P2: Regie 2 (Schritte, Aufzugfahrten, Rückstau-Kappung, Wachhund)" -m "Co-Authored-By: Claude Opus 5.5 " ``` --- ### P2-T14: Szene `scene.js` (`mountTower`) **Files:** - Create: `src/components/workers/engine/scene.js`, `tests/unit/helpers/engineVm.mjs` - Test: `tests/unit/workersScene.test.mjs` **Interfaces:** - Consumes: alle Engine-Module, `pickAgent`/Kamera, `createDirector`, `drawAgents`, `drawOutside/drawTower/drawPauseVeil`, `drawRooms`, `createLift`, `createFx`, `createLook`. - Produces (Übersicht §7 plus E4): ```js export function mountTower(canvas, { onSelect, reducedMotion = false, coarsePointer = false, fontsReady, dpr = 1, raf, caf, now }): { destroy(), setState(state), pushEvents(events), select(workerId|null), setPaused(bool), resize(), zoomBy(faktor), resetView(), locate(workerId): { x, y } | null, stats(): { frames, avgDrawMs, zoom }, debug() } export const FRAME_MS = 33, DT_MAX = 0.05, CANVAS_MAX_W = 2400, ZOOM_STEP = 1.4, DRAG_PX = 4 ``` `raf/caf/now` Standard: `globalThis.requestAnimationFrame` usw. beim **Aufruf** (nie beim Import). `onSelect` kommt nur bei Klicks des Nutzers, nie bei `select()`. `tests/unit/helpers/engineVm.mjs`: `createEngineSandbox({ cssWidth = 960, dpr = 1 } = {})` bündelt `scene.js` mit esbuild als IIFE (`platform: 'neutral'`, `globalName: 'WKEngine'`), führt es in `vm.createContext` aus (Sandbox ohne `window`/`document`, nur `console`, `Math`, `Date`, `JSON`) und liefert `{ mountTower, canvas, counts, seenTexts, listeners, opts(extra), tick(seconds), rafPending(), click(cssX, cssY), pointer(type, cssX, cssY) }` (`counts = { fillRect, fillText }` zählt nur, es speichert keine Aufrufe, sonst sprengen 7 000 Bilder den Speicher; `seenTexts` = `Set` der verschiedenen gezeichneten Texte): Fake-Canvas (`getContext` ⇒ Proxy-Kontext nach `smoke.js`, `addEventListener`/`removeEventListener` führen Buch, `getBoundingClientRect` ⇒ `{ left:0, top:0, width: cssWidth, height: cssWidth*0.8 }`, `style`, `width`, `height`), `opts(extra)` ⇒ Optionen mit `raf/caf/now/dpr` aus dem Sandbox-Takt, `tick(s)` rückt `now` um 1000/60 ms je Schritt vor und führt fällige `raf`-Rückrufe aus. - [ ] **Step 1: Failing tests schreiben** (`tests/unit/workersScene.test.mjs`; Imports: `createEngineSandbox` aus `./helpers/engineVm.mjs`, `makeState`, `ev`, `IDS` aus `./helpers/workersFixtures.mjs`) ```js const sel = [] const open = async (o = {}, extra = {}) => { const sb = await createEngineSandbox(o); const h = sb.mountTower(sb.canvas, sb.opts({ onSelect: (id) => sel.push(id), ...extra })); return { sb, h } } test('Handle hat die Methoden aus Übersicht §7 und E4; Mount zeichnet sofort', async () => { const { sb, h } = await open(); for (const m of ['destroy', 'setState', 'pushEvents', 'select', 'setPaused', 'resize', 'zoomBy', 'resetView', 'locate', 'stats', 'debug']) assert.equal(typeof h[m], 'function', m) assert.ok(sb.counts.fillRect > 100); h.destroy() }) test('Größe: Breite·dpr (max. 2400), Höhe 4/5 davon', async () => { let { sb, h } = await open({ cssWidth: 960, dpr: 1 }); assert.deepEqual([sb.canvas.width, sb.canvas.height], [960, 768]); h.destroy() ;({ sb, h } = await open({ cssWidth: 1500, dpr: 2 })); assert.deepEqual([sb.canvas.width, sb.canvas.height], [2400, 1920]); h.destroy() }) test('Takt: höchstens 30 Bilder je Sekunde', async () => { const { sb, h } = await open(); h.setState(makeState()); const f0 = h.stats().frames; sb.tick(1); const f = h.stats().frames - f0; assert.ok(f >= 28 && f <= 32, String(f)); h.destroy() }) test('Lebenszyklus: destroy doppelt, nichts danach, Listener weg; StrictMode mount → destroy → mount', async () => { const sb = await createEngineSandbox(); const a = sb.mountTower(sb.canvas, sb.opts({})); a.destroy(); a.destroy() const n = sb.counts.fillRect; sb.tick(1); assert.equal(sb.counts.fillRect, n); assert.equal(Object.values(sb.listeners).flat().length, 0) const b = sb.mountTower(sb.canvas, sb.opts({})); sb.tick(0.5); assert.ok(sb.counts.fillRect > n); a.setState(makeState()); a.pushEvents([ev('fund', 'kim')]); b.destroy() assert.equal(Object.values(sb.listeners).flat().length, 0) }) test('fontsReady: Ablehnung oder Zerstörung davor lässt nichts mehr zeichnen', async () => { let resolve; const p = new Promise((r) => { resolve = r }); const { sb, h } = await open({}, { fontsReady: p }); h.destroy(); const n = sb.counts.fillRect; resolve(); await new Promise((r) => setImmediate(r)); assert.equal(sb.counts.fillRect, n) const sb2 = await createEngineSandbox(); sb2.mountTower(sb2.canvas, sb2.opts({ fontsReady: Promise.reject(new Error('x')) })).destroy() // abgelehntes Versprechen erst nach dem Sandbox-Aufbau erzeugen, sonst „unhandled rejection“ }) test('reducedMotion: keine Schleife, jede Änderung zeichnet ein Standbild und beruhigt die Regie', async () => { const { sb, h } = await open({}, { reducedMotion: true }); assert.equal(sb.rafPending(), 0); const n = sb.counts.fillRect h.setState(makeState()); h.pushEvents(Array.from({ length: 30 }, (_, i) => ev('uebergabe', IDS[i % 19], { to: IDS[(i + 4) % 19] }))); assert.ok(sb.counts.fillRect > n); assert.equal(h.debug().pending, 0); assert.equal(sb.rafPending(), 0); h.destroy() }) test('setPaused hält die Schleife an und setzt sie fort', async () => { const { sb, h } = await open(); h.setPaused(true); sb.tick(0.2); const n = sb.counts.fillRect; sb.tick(1); assert.equal(sb.counts.fillRect, n); h.setPaused(false); sb.tick(0.5); assert.ok(sb.counts.fillRect > n); h.destroy() }) test('Klick trifft über die Umkehr-Transformation, auch gezoomt und verschoben', async () => { const { sb, h } = await open(); h.setState(makeState()); sel.length = 0 for (const id of ['otto', 'ben', 'arne']) { const p = h.locate(id); sb.click(p.x, p.y); assert.equal(sel.at(-1), id) } h.zoomBy(2.5); h.select('ben'); const p = h.locate('ben'); assert.ok(p); sb.click(p.x, p.y); assert.equal(sel.at(-1), 'ben'); assert.equal(h.stats().zoom, 2.5) h.resetView(); assert.equal(h.stats().zoom, 1); sel.length = 0; h.select('kim'); assert.equal(sel.length, 0); h.destroy() }) test('Ziehen verschiebt bei Zoom > 1 und unterdrückt den Klick', async () => { const { sb, h } = await open(); h.setState(makeState()); h.zoomBy(2); h.select('bea'); const before = h.locate('bea'); sel.length = 0 sb.pointer('pointerdown', 400, 300); sb.pointer('pointermove', 460, 300); sb.pointer('pointerup', 460, 300); sb.click(460, 300); assert.equal(sel.length, 0) const after = h.locate('bea'); assert.ok(Math.abs(after.x - (before.x + 60)) < 2); assert.ok(Math.abs(after.y - before.y) < 1e-6); h.destroy() // 60 CSS-Pixel nach rechts gezogen }) ``` - [ ] **Step 2: Scheitern sehen** — `cd $WK && timeout 300 node --max-old-space-size=1024 --test tests/unit/workersScene.test.mjs`. Expected: FAIL `Could not resolve …/engine/scene.js`. - [ ] **Step 3: Implementieren** `engine/scene.js` und `tests/unit/helpers/engineVm.mjs` Regeln: `resize()` liest `canvas.getBoundingClientRect().width` (unter 10 ⇒ überspringen), `canvas.width = min(2400, round(cssW*dpr))`, `canvas.height = round(width*256/320)`; Raster je Bild: `k = canvas.width / (320/zoom)`, `ox = -cam.x*k`, `oy = -cam.y*k`; Startzoom `fitZoom(cssW)`. Reihenfolge je Bild: Weiß füllen → `drawOutside` → `drawTower` → `drawRooms` → `lift.drawBack` → `drawAgents` → `lift.drawFront` → `fx.draw` → `drawPauseVeil`. Schleife: `raf`-Rückruf, Bild höchstens alle `FRAME_MS` (dazwischen nur neu planen), `dt = min(DT_MAX, Δt)`, `director.update(dt)` vor dem Zeichnen; `stats()` misst `avgDrawMs` mit `now()`. Eingabe: `click` ⇒ `viewToWorld` aus Klickposition ⇒ `pickAgent(boxes, x, y, pad)` mit `pad = (coarsePointer ? 10 : 4) / (cssW / (320/zoom))` ⇒ `onSelect(id|null)`; `pointerdown/move/up` verschieben die Kamera (`panBy`) nur bei `zoom > 1`, Bewegung über `DRAG_PX` unterdrückt den nächsten `click`; `wheel` zoomt nur mit `ctrlKey` (`passive: false`); `style.touchAction` = `'none'` bei Zoom > 1, sonst `'pan-y'`. `select(id)` setzt die Markierung und zentriert bei Zoom > 1. `setState` und `pushEvents` zeichnen sofort ein Bild (`drawOnce`), damit Klick und `locate` frische Trefferflächen haben. `locate(id)` ⇒ Mitte der Trefferfläche (Weltkoordinaten der letzten Zeichnung, durch die Kamera in CSS-Pixel umgerechnet) oder `null` außerhalb der Ansicht. `reducedMotion`: kein `raf`; `setState/pushEvents/select/resize/zoomBy` rufen `director.flush()` (nur bei `pushEvents`) und zeichnen ein Standbild. `fontsReady.then(drawOnce, () => {})` mit Zerstörungswächter. `destroy()`: Flagge, `caf`, alle Listener entfernen, spätere Aufrufe ohne Wirkung. `debug()` ⇒ `{ pending, agents:[{ id, x, y, floor, pose, grey }], lift: lift.state, fx: fx.size() }`. - [ ] **Step 4: Bestehen sehen** — gleicher Befehl. Expected: `# pass 9`, `# fail 0`. - [ ] **Step 5: Commit** ```bash cd $WK && git add src/components/workers/engine/scene.js tests/unit/helpers/engineVm.mjs tests/unit/workersScene.test.mjs git commit -m "Workers P2: mountTower (Größe, 30-fps-Schleife, Eingabe, Kamera, Lebenszyklus)" -m "Co-Authored-By: Claude Opus 5.5 " ``` --- ### P2-T15: Engine-Rauchtest (19 anklickbar, 150 s Ereignisfeed) **Files:** - Test: `tests/unit/workersSmoke.test.mjs` **Interfaces:** - Consumes: `createEngineSandbox` (P2-T14), `makeState`/`ev`/`IDS` (P2-T12), `EVENT_KINDS` (P2-T03). Muster: `/root/reports/workers-2026-10-16/entwuerfe/smoke.js`. - Produces: nichts (reine Absicherung). - [ ] **Step 1: Test schreiben** (`tests/unit/workersSmoke.test.mjs`) Gerüst: `scripted(sec)` baut die Ereignisse für Sekunde `sec`: alle 26 Arten aus `EVENT_KINDS` verteilt auf die Sekunden 3, 8, 13, … (eine Art alle 5 s, in der Reihenfolge der folgenden Liste, Arbeiter passend zur Art: `gelesen` kim, `fund` rita, `doppelt` arne, `uebergabe` rita→bea, `bewertet` bea, `top3` bea, `botlead` arne, `entwurf` max, `pult` clara, `gesendet` max, `eingang` nora, `sortiert` nora, `bau_start` bob, `bau_fertig` bob, `pruefung_zurueck` paul→bob (`meta.round` 1), `pruefung_ok` paul, `vorschau_link` max, `angebot` theo, `deal` system, `gesperrt` system, `budget_warnung` otto, `budget_stopp` otto, `pause` system, `fehler` ben, `auftrag` otto→kim, `leerlauf` pia); zusätzlich ab Sekunde 100 ein Stoß von 120 `uebergabe`-Ereignissen auf einmal. ```js test('alle 19 Worker sind anklickbar (Auswahl liefert die richtige workerId)', async () => { const sb = await createEngineSandbox(), picked = []; const h = sb.mountTower(sb.canvas, sb.opts({ onSelect: (id) => picked.push(id) })); h.setState(makeState()) for (const id of IDS) { const p = h.locate(id); assert.ok(p, id); sb.click(p.x, p.y); assert.equal(picked.at(-1), id) } assert.deepEqual([...new Set(picked)].sort(), [...IDS].sort()); h.destroy() }) test('150 s mit allen 26 Ereignisarten: kein Wurf, alle endlich und im Bild, danach ruhig', async () => { const sb = await createEngineSandbox(), h = sb.mountTower(sb.canvas, sb.opts({ onSelect() {} })); h.setState(makeState({ connected: { kim: false, pia: false, finn: false } })) for (let s = 0; s < 150; s++) { const evs = scripted(s); if (evs.length) h.pushEvents(evs); if (s % 15 === 0) h.setState(makeState({ connected: { kim: false, pia: false, finn: false }, kpis: { pultOpen: s % 7, dealsMonth: 3 + Math.floor(s / 40), botLeads: 112 + s } })); sb.tick(1) if (s % 2 === 0) for (let x = 10; x < 960; x += 40) for (let y = 10; y < 768; y += 40) sb.click(x, y) } for (let s = 0; s < 90; s++) sb.tick(1) const dbg = h.debug(); assert.equal(dbg.pending, 0); assert.equal(dbg.lift, 'idle'); assert.equal(dbg.fx, 0) for (const a of dbg.agents) assert.ok(Number.isFinite(a.x) && Number.isFinite(a.y) && a.x > -30 && a.x < 350 && a.floor >= 0 && a.floor <= 9, a.id) assert.ok(sb.counts.fillRect > 100000); assert.ok(sb.counts.fillText > 1000); assert.ok(sb.seenTexts.has('noch nicht angeschlossen')) h.destroy(); assert.equal(Object.values(sb.listeners).flat().length, 0) }) ``` - [ ] **Step 2: Laufen lassen** — `cd $WK && timeout 300 node --max-old-space-size=1024 --test tests/unit/workersSmoke.test.mjs`. Expected: PASS ohne weitere Implementierung, wenn P2-T06–T14 stimmen; ein Fehlschlag zeigt den Fehler in der Engine (dann dort beheben, nicht den Test lockern). Zeitlimit des Laufs 300 s; typisch < 60 s. - [ ] **Step 3: Falls nötig Engine-Fehler beheben** (kleinste Änderung am betroffenen Engine-Modul, zugehörigen Modultest ergänzen), erneut laufen lassen. Expected: `# pass 2`, `# fail 0`. - [ ] **Step 4: Gesamte Engine-Gruppe gegenprüfen** — `cd $WK && timeout 600 node --max-old-space-size=1024 --test tests/unit/workers*.test.mjs`. Expected: `# fail 0`. - [ ] **Step 5: Commit** ```bash cd $WK && git add tests/unit/workersSmoke.test.mjs git commit -m "Workers P2: Engine-Rauchtest (19 anklickbar, 150 s Ereignisfeed)" -m "Co-Authored-By: Claude Opus 5.5 " ``` --- ### P2-T16: Formularlogik der Einstellungen **Files:** - Create: `src/lib/workersSettingsForm.js` - Test: `tests/unit/workersSettingsForm.test.mjs` **Interfaces:** - Consumes: `ROLES`, `ROOM_TITLES` (`workersRoster.js`); `workersSettings` aus Übersicht §4.1. - Produces: ```js export const ROOM_CHOICES // die 8 Suchräume [{ id, title }] (Räume der Etagen anfragen und ohne_website, Rollenreihenfolge) export const READONLY_KEYS // ['paused','previewsSent','updatedAt','updatedBy','offerProductKey','connectedRooms','privacyInteressentenLive','sendingHalted','sendingHaltedReason'] (Übersicht §7) export function settingsToForm(settings): form // Zahlen als Text, regions als Zeilen, Räume als Id-Listen export function validateForm(form): { ok, errors: { [feld]: 'Text' } } export function formToPatch(form, original): object // nur geänderte, getypte Felder; nie READONLY_KEYS ``` Regeln, gleich oder enger als die Prüfung des Dienstes (P1-T07; Fehlertext in Klammern): `dailyBudgetUsd` Zahl > 0 (`Tagesgrenze muss größer als 0 sein.`) und ≤ `monthlyBudgetUsd` (`Die Tagesgrenze darf nicht über der Monatsgrenze liegen.`); `monthlyBudgetUsd` > 0 (`Monatsgrenze muss größer als 0 sein.`) und ≤ 150 (`Monatsgrenze höchstens 150 $ (Vorgabe: max. 150 € im Monat).`); `warnPct` ganz 1..100 (`Warnschwelle: ganze Zahl von 1 bis 100.`); `mailColdPerDay` ganz 0..3 (`Höchstens 3 unverlangte Mails pro Tag.`); `mailReplyCapPerDay` ganz 0..15 (`Höchstens 15 Antwort-Mails pro Tag.`); `sendWindowStart`/`sendWindowEnd` `HH:MM`, Beginn vor Ende (`Uhrzeit als HH:MM, Beginn vor Ende.`); `runsPerNight` ganz 1..2 (`Läufe pro Nacht: 1 oder 2.`); `candidatesPerRun` ganz 1..20 (`Kandidaten pro Lauf: 1 bis 20.`); `regions` 1..30 Zeilen, je 1..64 Zeichen (`Mindestens eine Region, höchstens 30, je Zeile höchstens 64 Zeichen.`); `previewGate` ∈ `erste_n|immer|nie`; `previewGateN` ganz 0..1000; `offerText` 1..400 Zeichen (`Angebotstext: 1 bis 400 Zeichen.`; 400 hält eine Kaltmail unter 180 Wörtern, H3/A11); `pultStaleDays` ganz 1..30; `followupDays`/`previewReminderDays` ganz 1..60; `retentionDays` ganz 7..90 (`Aufbewahrung: 7 bis 90 Tage.`; der Datenschutztext verspricht höchstens 90 Tage, A11); `previewDeleteDays` ganz 7..365; `reportEmail` einfache Adresse. Dezimalkomma wird akzeptiert (`12,5`). Schreibgeschützt (nie im Patch, nie im Formular bearbeitbar): `READONLY_KEYS`; `connectedRooms` setzt allein der Dienst (P3 pflegt die Anfragen-Räume, Kenso die Branchen-Räume per Skript, P2-E5), `privacyInteressentenLive` der Datenschutz-Schalter (A5), `sendingHalted` und `sendingHaltedReason` die Notbremse (H4; aufgehoben wird sie nur über den Knopf des Dialogs, P2-T18). `offerProductKey` ist ebenfalls nur Anzeige (der Dienst erlaubt nur `hosting`). - [ ] **Step 1: Failing test schreiben** (`const F = await loadModule('src/lib/workersSettingsForm.js')`) ```js const S0 = { paused: true, dailyBudgetUsd: 6, monthlyBudgetUsd: 150, warnPct: 80, mailColdPerDay: 3, mailReplyCapPerDay: 15, sendWindowStart: '09:00', sendWindowEnd: '16:00', sendWeekdaysOnly: true, runsPerNight: 1, candidatesPerRun: 10, activeRooms: ['netz', 'kleinanzeigen', 'portale', 'foren', 'aerzte', 'handwerk', 'selbst', 'laden'], connectedRooms: [], regions: ['Rheinland-Pfalz'], previewGate: 'erste_n', previewGateN: 10, previewsSent: 4, offerText: 'Gratis-Vorschau …', offerProductKey: 'hosting', pultStaleDays: 3, retentionDays: 90, followupDays: 7, previewReminderDays: 5, previewDeleteDays: 60, reportEmail: 'kenso.gri@gmail.com', privacyInteressentenLive: false, sendingHalted: false, sendingHaltedReason: '', updatedAt: '2026-10-17T10:00:00Z', updatedBy: 'x' } test('unverändertes Formular ⇒ leerer Patch; schreibgeschützte Felder (auch H4/A5) nie im Patch', () => { const f = F.settingsToForm(S0); assert.deepEqual(F.formToPatch(f, S0), {}); assert.equal(F.validateForm(f).ok, true) assert.deepEqual(F.formToPatch({ ...f, paused: false, previewsSent: 99, updatedBy: 'y', offerProductKey: 'x', connectedRooms: ['netz'] }, S0), {}) assert.deepEqual(F.formToPatch({ ...f, privacyInteressentenLive: true, sendingHalted: true, sendingHaltedReason: 'x' }, S0), {}) // die drei neuen Felder: Änderungen ergeben {} assert.deepEqual([...F.READONLY_KEYS], ['paused', 'previewsSent', 'updatedAt', 'updatedBy', 'offerProductKey', 'connectedRooms', 'privacyInteressentenLive', 'sendingHalted', 'sendingHaltedReason']) const halted = { ...S0, sendingHalted: true, sendingHaltedReason: 'Zwei harte Bounces in den letzten 10 Mails.' } // Notbremse gesetzt: Formular bleibt gültig, Patch leer assert.deepEqual(F.formToPatch(F.settingsToForm(halted), halted), {}); assert.equal(F.validateForm(F.settingsToForm(halted)).ok, true) assert.deepEqual(F.formToPatch({ ...f, warnPct: '70', sendingHalted: true }, S0), { warnPct: 70 }) // ein echtes Feld nimmt die schreibgeschützten nicht mit }) test('Patch enthält nur Geändertes, getypt; Dezimalkomma, Zeilen, Räume', () => { const f = { ...F.settingsToForm(S0), dailyBudgetUsd: '12,5', regions: 'Rheinland-Pfalz\n\n Hessen ', activeRooms: S0.activeRooms.filter((r) => r !== 'kleinanzeigen'), previewGate: 'immer' } assert.deepEqual(F.formToPatch(f, S0), { dailyBudgetUsd: 12.5, regions: ['Rheinland-Pfalz', 'Hessen'], activeRooms: ['netz', 'portale', 'foren', 'aerzte', 'handwerk', 'selbst', 'laden'], previewGate: 'immer' }) }) test('Prüfung mit den Spec-Grenzen', () => { const bad = (over) => F.validateForm({ ...F.settingsToForm(S0), ...over }).errors assert.equal(bad({ mailColdPerDay: '4' }).mailColdPerDay, 'Höchstens 3 unverlangte Mails pro Tag.'); assert.equal(bad({ mailReplyCapPerDay: '16' }).mailReplyCapPerDay, 'Höchstens 15 Antwort-Mails pro Tag.') assert.equal(bad({ dailyBudgetUsd: '151' }).dailyBudgetUsd, 'Die Tagesgrenze darf nicht über der Monatsgrenze liegen.'); assert.equal(bad({ monthlyBudgetUsd: '151' }).monthlyBudgetUsd, 'Monatsgrenze höchstens 150 $ (Vorgabe: max. 150 € im Monat).'); assert.equal(bad({ dailyBudgetUsd: '150' }).dailyBudgetUsd, undefined) // Monatsgrenze 150 aus S0 assert.equal(bad({ dailyBudgetUsd: '0' }).dailyBudgetUsd, 'Tagesgrenze muss größer als 0 sein.') assert.equal(bad({ sendWindowStart: '16:00', sendWindowEnd: '09:00' }).sendWindowStart, 'Uhrzeit als HH:MM, Beginn vor Ende.'); assert.equal(bad({ regions: ' \n' }).regions, 'Mindestens eine Region, höchstens 30, je Zeile höchstens 64 Zeichen.') assert.equal(bad({ offerText: '' }).offerText, 'Angebotstext: 1 bis 400 Zeichen.'); assert.equal(bad({ offerText: 'x'.repeat(401) }).offerText, 'Angebotstext: 1 bis 400 Zeichen.'); assert.equal(bad({ offerText: 'x'.repeat(400) }).offerText, undefined) assert.equal(bad({ retentionDays: '5' }).retentionDays, 'Aufbewahrung: 7 bis 90 Tage.'); assert.equal(bad({ retentionDays: '6' }).retentionDays, 'Aufbewahrung: 7 bis 90 Tage.'); assert.equal(bad({ retentionDays: '91' }).retentionDays, 'Aufbewahrung: 7 bis 90 Tage.') assert.equal(bad({ retentionDays: '7' }).retentionDays, undefined); assert.equal(bad({ retentionDays: '90' }).retentionDays, undefined); assert.equal(bad({ warnPct: 'x' }).warnPct, 'Warnschwelle: ganze Zahl von 1 bis 100.') assert.equal(bad({ runsPerNight: '3' }).runsPerNight, 'Läufe pro Nacht: 1 oder 2.'); assert.equal(bad({ candidatesPerRun: '21' }).candidatesPerRun, 'Kandidaten pro Lauf: 1 bis 20.') assert.equal(F.validateForm(F.settingsToForm(S0)).ok, true); assert.deepEqual(F.ROOM_CHOICES.map((r) => r.id), S0.activeRooms) }) ``` - [ ] **Step 2: Scheitern sehen** — `cd $WK && timeout 300 node --max-old-space-size=1024 --test tests/unit/workersSettingsForm.test.mjs`. Expected: FAIL `Could not resolve …/workersSettingsForm.js`. - [ ] **Step 3: Implementieren** `src/lib/workersSettingsForm.js` nach den Regeln oben; Feldregeln als Datentabelle `RULES` (Typ, Grenzen, Fehlertext), `formToPatch` vergleicht typisiert mit `original` (Listen für Räume reihenfolgeunabhängig, in `ROOM_CHOICES`-Reihenfolge ausgegeben; `regions` reihenfolgetreu) und lässt `READONLY_KEYS` immer weg. - [ ] **Step 4: Bestehen sehen** — gleicher Befehl. Expected: `# pass 3`, `# fail 0`. - [ ] **Step 5: Commit** ```bash cd $WK && git add src/lib/workersSettingsForm.js tests/unit/workersSettingsForm.test.mjs git commit -m "Workers P2: Formularlogik der Einstellungen (Prüfung, Patch nur Geändertes)" -m "Co-Authored-By: Claude Opus 5.5 " ``` --- ### P2-T17: Kennzahlen, Worker-Karte, Worker-Liste **Files:** - Create: `src/components/workers/WorkersKpis.jsx`, `src/components/workers/WorkerCard.jsx`, `src/components/workers/WorkerList.jsx` - Test: `tests/unit/workersComponents.test.mjs` **Interfaces:** - Consumes: `Stat` (`../ui/Stat`), `Badge` (`../ui/Badge`), `kpiTiles`, `counterLines`, `tokenTotal`, `traceRows`, `workerStatusLabel`, `statusTone`, `formatUsd`, `formatCount` (`workersView.js`), `ROLES`, `FLOOR_TITLES`, `MODEL_IDS`, `roleOf`, `hatForWorker` (`workersRoster.js`), `drawWorker`, `setGrid` (`engine/sprites.js`), react-router `Link`. - Produces: ```jsx export default function WorkersKpis({ kpis }) //
+ Stat je Kachel export default function WorkerCard({ workerId, worker, trace, loading, error, onClose }) // worker = state.workers-Eintrag (status, task, counters); trace = Antwort von api.trace // Aufbau: wk-card · Porträt (Canvas, drawWorker) · Name (wk-name) · Rolle · Modell-Chip wk-chip wk-chip- mit MODEL_IDS-Text · Status-Badge // · „Macht gerade“ · Zähler heute · „Tokens heute“ · „Kosten heute“ · „Letzte Aktionen“ (wk-trace, bis 50 Zeilen, Zeit + Text + Link) export default function WorkerList({ workers, selectedId, onSelect }) // Liste aller Worker je Etage: