Files
tickte-system/docs/superpowers/plans/2026-10-17-workers-p2-hochhaus-woms.md
T

1535 lines
168 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 <noreply@anthropic.com>`.
- Testbefehl je Datei: `cd $WK && timeout 300 node --max-old-space-size=1024 --test tests/unit/<name>.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 <status>`) und die Beschriftung der Leinwand. Die Seite liegt unter `/hustler`; `/leads` und `/workers` leiten per `<Navigate replace>` dorthin um und behalten die Query (`/workers?tab=botleads&bot=<id>` → `/hustler?tab=botleads&bot=<id>`, Ü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 <noreply@anthropic.com>"
```
---
### 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 <noreply@anthropic.com>"
```
---
### 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<string>, 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 <noreply@anthropic.com>"
```
---
### 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: <Wert>') 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 <status>`. 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: <Wert>')` vor jeder Anfrage; Anfrage `GET /api/workers/pult?status=<Einträge mit Komma verbunden>` (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=<prospectId>`). `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 <noreply@anthropic.com>"
```
---
### 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 <noreply@anthropic.com>"
```
---
### 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 <noreply@anthropic.com>"
```
---
### 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 <noreply@anthropic.com>"
```
---
### 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 <noreply@anthropic.com>"
```
---
### 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 <noreply@anthropic.com>"
```
---
### 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 <noreply@anthropic.com>"
```
---
### 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<string>, busyRooms:Set<string>, 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 <noreply@anthropic.com>"
```
---
### 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 <noreply@anthropic.com>"
```
---
### 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 <noreply@anthropic.com>"
```
---
### 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 <noreply@anthropic.com>"
```
---
### 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 <noreply@anthropic.com>"
```
---
### 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 <noreply@anthropic.com>"
```
---
### 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 }) // <div className="stats wk-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-<hat> 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: <button data-wk-worker={id} aria-pressed aria-label="Name, Status">
```
Links aus der Spur nur über `safeLink`: extern `<a href target="_blank" rel="noopener noreferrer">`, intern `<Link to>`, sonst Text.
- [ ] **Step 1: Failing Quelltext-Test schreiben** (Muster `adminRoute.test.mjs`; `read(p)` = `readFileSync` relativ zu `src/components/workers/`)
```js
test('WorkerCard: Links nur über safeLink, extern mit rel noopener; deutsche Beschriftungen (Review Focus 5)', () => {
const src = read('WorkerCard.jsx')
assert.match(src, /safeLink\(/); assert.match(src, /rel="noopener noreferrer"/); assert.match(src, /target="_blank"/)
for (const t of ['Macht gerade', 'Tokens heute', 'Kosten heute', 'Letzte Aktionen']) assert.ok(src.includes(t), t)
assert.match(src, /MODEL_IDS/); assert.match(src, /wk-chip-/)
})
test('kein innerHTML in Komponenten und Engine', () => {
for (const dir of ['', 'engine/']) for (const f of readdirSync(new URL('../../src/components/workers/' + dir, import.meta.url))) { if (!/\.(jsx|js)$/.test(f)) continue; const s = read(dir + f); assert.doesNotMatch(s, /dangerouslySetInnerHTML|innerHTML/, dir + f) }
})
test('WorkersKpis nutzt Stat und kpiTiles; WorkerList: Knopf je Worker mit data-wk-worker, aria-pressed und Statustext', () => {
assert.match(read('WorkersKpis.jsx'), /kpiTiles\(/); assert.match(read('WorkersKpis.jsx'), /<Stat /)
const l = read('WorkerList.jsx'); assert.match(l, /data-wk-worker=/); assert.match(l, /aria-pressed=/); assert.match(l, /aria-label=/); assert.match(l, /FLOOR_TITLES/)
})
```
- [ ] **Step 2: Scheitern sehen** — `cd $WK && timeout 300 node --max-old-space-size=1024 --test tests/unit/workersComponents.test.mjs`. Expected: FAIL `ENOENT … WorkerCard.jsx`.
- [ ] **Step 3: Implementieren** die drei Komponenten
Klassen (alle in `workers.css`, P2-T20): `wk-kpis`, `wk-card`, `wk-portrait`, `wk-name`, `wk-role`, `wk-chip` mit `wk-chip-opus|sonnet|haiku|overseer`, `wk-facts`, `wk-counters`, `wk-trace`, `wk-trace-time`, `wk-list`, `wk-list-floor`, `wk-dot` mit `wk-dot-<status>`, `wk-hint`; Klassen mit Suffix stehen als Wörterbuch im Code (`CHIP = { opus: 'wk-chip-opus', … }`, `DOT = { arbeitet: 'wk-dot-arbeitet', … }`), damit der CSS-Test in P2-T20 sie findet; sonst nur globale Klassen (`btn`, `badge`, `msg`, `stats`). Karte: ohne `workerId` ⇒ Hinweis „Klicke eine Figur oder wähle einen Worker aus der Liste.“; `loading` ⇒ „Spur wird geladen …“; `error` ⇒ `<div className="msg err" role="alert">`; Modell-Chip-Text = `trace.worker.modelId`, sonst `MODEL_IDS[role.model]`; Kosten = `formatUsd(trace.today.costUsd)`; Porträt: `<canvas width=72 height=96>`, `useEffect` ⇒ `setGrid(ctx, { k: 4 })`, `drawWorker(ctx, 6, 8, { hat: hatForWorker(id), grey: status === 'grau', scale: 1 })`. Liste: Reihenfolge = `FLOORS` mit `FLOOR_TITLES`-Zwischenüberschrift, Statuspunkt aus `statusTone`.
- [ ] **Step 4: Bestehen sehen** — gleicher Befehl. Expected: `# pass 3`, `# fail 0`.
- [ ] **Step 5: Commit**
```bash
cd $WK && git add src/components/workers/WorkersKpis.jsx src/components/workers/WorkerCard.jsx src/components/workers/WorkerList.jsx tests/unit/workersComponents.test.mjs
git commit -m "Workers P2: Kennzahlen, Worker-Karte mit Spur und Worker-Liste" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```
---
### P2-T18: Einstellungs-Dialog (nur Admin)
**Files:**
- Create: `src/components/workers/WorkersSettingsDialog.jsx`
- Test: `tests/unit/workersComponents.test.mjs` (erweitern)
**Interfaces:**
- Consumes: `Dialog` (`../ui/Dialog`), `Field` (`../ui/Field`), `Button` (`../ui/Button`), `settingsToForm`, `validateForm`, `formToPatch`, `ROOM_CHOICES` (P2-T16), `api.settings()`, `api.saveSettings(patch)`.
- Produces: `export default function WorkersSettingsDialog({ api, onClose, onSaved })` — Dialogtitel „Hustler-Einstellungen“ (Übersicht §7, Namensregel); lädt die Einstellungen beim Öffnen („Wird geladen …“), zeigt Gruppen **Kosten** (Tagesgrenze (USD), Monatsgrenze (USD), Warnschwelle (%)), **Mails** (Unverlangte Mails pro Tag, Antwort-Mails pro Tag, Sendefenster von/bis, nur werktags), **Läufe** (Läufe pro Nacht, Kandidaten pro Lauf), **Räume** (je Suchraum ein Haken „aktiv“; darunter als reiner Text „Angeschlossen (meldet der Dienst): …“ aus `settings.connectedRooms`, nicht änderbar), **Regionen** (Textfeld, eine je Zeile), **Vorschau** (Freigabe: erste N / immer / nie, N, bisher verschickt als Text), **Angebot** (Angebotstext, Produkt als Text ohne Eingabe), **Fristen** (Pult veraltet nach, Aufbewahrung, Nachfassen nach, Erinnerung nach, Vorschau löschen nach, Bericht an). „Speichern“ ist gesperrt, solange der Patch leer ist oder Fehler bestehen; Fehler stehen am Feld (`aria-invalid`, Text darunter), Serverfehler als `<div className="msg err" role="alert">`; nach Erfolg `onSaved(neueEinstellungen)` und `onClose()`. Der Dialog kennt `paused` nicht.
**Block „Versand gestoppt“ (H4, A21):** Ist `settings.sendingHalted` wahr, steht oben im Dialog (über den Gruppen) ein Block mit der Überschrift „Versand gestoppt“, dem Grund aus `settings.sendingHaltedReason` als Textknoten (React-Text, nie HTML; ist er leer: „Grund nicht angegeben“) und dem Knopf „Versand wieder freigeben“. Der Knopf ruft `api.saveSettings({ sendingHalted: false })` (genau dieses eine Feld; der Dienst leert dabei den Grund) und danach `onSaved(neueEinstellungen)`; der Dialog bleibt offen, mischt die Antwort in sein `settings` ein (`{ ...settings, ...antwort }`; der Block verschwindet, weil `sendingHalted` nun falsch ist) und behält die Eingaben im Formular. Während des Aufrufs ist der Knopf gesperrt; ein Fehler steht als `<div className="msg err" role="alert">` im Block. Der Block liegt **außerhalb** von `formToPatch`/`validateForm`: „Speichern“ sendet ihn nie, und das Freigeben ändert den Patch-Zustand des Formulars nicht.
- [ ] **Step 1: Failing Test ergänzen**
```js
test('Einstellungs-Dialog: Dialog-Baustein, Formularlogik, kein paused, Admin-Beschriftungen, Notbremsen-Block', () => {
const s = read('WorkersSettingsDialog.jsx')
assert.match(s, /from '\.\.\/ui\/Dialog'/); assert.match(s, /formToPatch\(/); assert.match(s, /validateForm\(/); assert.match(s, /api\.saveSettings\(/)
assert.doesNotMatch(s, /paused/)
for (const t of ['Hustler-Einstellungen', 'Tagesgrenze (USD)', 'Monatsgrenze (USD)', 'Unverlangte Mails pro Tag', 'Angebotstext', 'Regionen', 'Speichern', 'Abbrechen']) assert.ok(s.includes(t), t)
assert.match(s, /disabled=\{/)
// H4/A21: Block „Versand gestoppt“ mit Freigabe, außerhalb des Formular-Patches
assert.match(s, /sendingHalted/); assert.ok(s.includes('Versand gestoppt')); assert.ok(s.includes('Versand wieder freigeben'))
assert.match(s, /api\.saveSettings\(\{ sendingHalted: false \}\)/); assert.match(s, /sendingHaltedReason/)
assert.doesNotMatch(s, /formToPatch\([^)]*sendingHalted/)
})
```
- [ ] **Step 2: Scheitern sehen** — `cd $WK && timeout 300 node --max-old-space-size=1024 --test tests/unit/workersComponents.test.mjs`. Expected: FAIL `ENOENT … WorkersSettingsDialog.jsx`.
- [ ] **Step 3: Implementieren** `WorkersSettingsDialog.jsx`
`Dialog wide` mit `as="form"`; Formularzustand `useState(settingsToForm(settings))`; `errors = validateForm(form).errors`; `patch = formToPatch(form, settings)`; `busy` während des Speicherns; Hinweis unter „Unverlangte Mails pro Tag“: „Höchstens 3 (Spec §8.1).“; Räume-Tabelle mit `ROOM_CHOICES` und `.check`-Haken. Der Notbremsen-Block (siehe Produces) hat eigenen Zustand (`releasing`, `releaseError`), steht nur bei `settings.sendingHalted` im Dialog und schreibt `api.saveSettings({ sendingHalted: false })` exakt so (der Quelltext-Test sucht diese Zeichenfolge); in der Quelldatei kommt das Wort `paused` nirgends vor, auch nicht in Kommentaren.
- [ ] **Step 4: Bestehen sehen** — gleicher Befehl. Expected: `# pass 4`, `# fail 0`.
- [ ] **Step 5: Commit**
```bash
cd $WK && git add src/components/workers/WorkersSettingsDialog.jsx tests/unit/workersComponents.test.mjs
git commit -m "Workers P2: Einstellungs-Dialog für Admins" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```
---
### P2-T19: Bühne und Board (`WorkersStage`, `WorkersBoard`)
**Files:**
- Create: `src/components/workers/WorkersStage.jsx`, `src/components/workers/WorkersBoard.jsx`
- Test: `tests/unit/workersComponents.test.mjs` (erweitern)
**Interfaces:**
- Consumes: `mountTower` (P2-T14), `useWorkersFeed`, `useWorkerTrace` (P2-T05), `reduceEvents`, `indexWorkers` (P2-T03), `WorkersKpis`, `WorkerCard`, `WorkerList`, `WorkersSettingsDialog`, `pauseLabel`, `EmptyState`, `Button`.
- Produces:
```jsx
// WorkersStage: forwardRef; Handle { pushEvents(events), locate(id), stats(), debug() }
export default forwardRef(function WorkersStage({ state, selectedId, onSelect }, ref) {...})
// WorkersBoard: Inhalt des Reiters „Hochhaus“, ohne Auth-Bezug
export default function WorkersBoard({ api, isAdmin, stageRef }) // stageRef optional (nur der Prüfstand reicht eine durch)
```
**Stage:** `<div className="wk-stage"><canvas className="wk-canvas" role="img" aria-label="Pixel-Hochhaus: Etagen und Räume der Hustler. Auswahl auch über die Liste."/>` plus `wk-zoom` mit „+“, „−“, „Ganz“. Mount-Effekt (Abhängigkeit: `reduceMotion`): `mountTower(canvas, { onSelect: (id) => onSelectRef.current(id), reducedMotion, coarsePointer: matchMedia('(pointer: coarse)').matches, fontsReady: document.fonts?.load('600 12px "Pixelify Sans"'), dpr: devicePixelRatio })`, `ResizeObserver` ⇒ `resize()`, `visibilitychange` ⇒ `setPaused(document.hidden)`, `matchMedia('(prefers-reduced-motion: reduce)')`-`change` ⇒ Zustand `reduceMotion` (Neu-Mount), Cleanup entfernt alles und ruft `destroy()`. Zweiter Effekt `[state, engineKey]` ⇒ `handle.setState(state)`, dritter `[selectedId]` ⇒ `handle.select(selectedId ?? null)`.
**Board:** Ohne übergebenes `stageRef` (die echte Seite übergibt keins) legt das Board selbst eins an: `const ownRef = useRef(null); const ref = stageRef ?? ownRef`; `<WorkersStage ref={ref} …>` und alle Zugriffe laufen über `ref` (sonst stürzt die Seite beim ersten Ereignis ab, der Prüfstand würde es nicht zeigen, weil er ein `stageRef` übergibt). `useWorkersFeed(api, { onEvents })`; `onEvents` ⇒ `setWorkers(w => reduceEvents(w, evs))` und `ref.current?.pushEvents(evs)`; bei neuem `state` `setWorkers(indexWorkers(state))`; `useWorkerTrace(api, selectedId)`. Zustände: nie geladen + Fehler ⇒ `EmptyState title="Der Hustler-Dienst ist noch nicht erreichbar"` mit Fehlertext und „Neu laden“ (Review Focus 1); nie geladen ohne Fehler ⇒ `<div className="loading">Wird geladen …</div>`; geladen mit Fehler ⇒ Bühne weiter sichtbar plus `<div className="msg warn" role="status">Verbindung gestört: … Es wird weiter versucht.</div>`. Kopfzeile `wk-toolbar`: Statustext („Läuft“ / „Angehalten: Es wird nichts gesendet und nichts gebaut.“), Pausenknopf `pauseLabel(state.paused)` für alle Mitarbeiter (sperrt während des Aufrufs; Fehler als `msg err`; danach `refresh()`), `isAdmin` ⇒ „Einstellungen“ (öffnet den Dialog). **Notbremse (H4, A21):** Ist `state.sendingHalted` wahr, steht zwischen Kopfzeile und `wk-main` (für alle Mitarbeiter, nicht nur Admins) eine Zeile `<div className="msg err" role="alert">` mit dem Text „Versand gestoppt: <Grund>. Ein Admin kann ihn in den Einstellungen freigeben.“; `<Grund>` = `state.sendingHaltedReason` als Textknoten, getrimmt und ohne abschließende Punkte (damit kein „..“ entsteht), leer ⇒ „Grund nicht angegeben“. Sie erscheint zusätzlich zu `msg warn` (Verbindung gestört), ersetzt es nicht, und verschwindet mit dem nächsten `state`, in dem `sendingHalted` falsch ist; `WorkersSettingsDialog` bekommt `onSaved={() => refresh()}`, damit die Zeile nach dem Freigeben sofort verschwindet. Layout: `wk-main` mit Bühne links und Seitenspalte (`WorkerCard`, darunter `WorkerList`) rechts.
- [ ] **Step 1: Failing Quelltext-Test ergänzen**
```js
test('WorkersStage: Aufräumen, Sichtbarkeit, Bewegungsreduktion', () => {
const s = read('WorkersStage.jsx')
assert.match(s, /\.destroy\(\)/); assert.match(s, /visibilitychange/); assert.match(s, /removeEventListener\('visibilitychange'/); assert.match(s, /prefers-reduced-motion: reduce/)
assert.match(s, /ResizeObserver/); assert.match(s, /disconnect\(\)/); assert.match(s, /fontsReady/); assert.match(s, /forwardRef/)
})
test('WorkersBoard: Zustände und Rechte (Review Focus 1)', () => {
const s = read('WorkersBoard.jsx')
assert.match(s, /Der Hustler-Dienst ist noch nicht erreichbar/); assert.match(s, /Verbindung gestört/); assert.match(s, /reduceEvents\(/); assert.match(s, /pauseLabel\(/)
assert.match(s, /isAdmin &&/); assert.match(s, /stageRef\s*(\?\?|\|\|)/); assert.match(s, /useRef\(/); assert.doesNotMatch(s, /useAuth|appwrite/)
// H4/A21: Notbremsen-Zeile für alle, Grund als Textknoten
assert.match(s, /state\.sendingHalted/); assert.match(s, /state\.sendingHaltedReason/); assert.match(s, /className="msg err"/); assert.match(s, /role="alert"/)
assert.ok(s.includes('Versand gestoppt: ')); assert.ok(s.includes('Ein Admin kann ihn in den Einstellungen freigeben.')); assert.ok(s.includes('Grund nicht angegeben'))
assert.match(s, /onSaved=\{/); assert.doesNotMatch(s, /dangerouslySetInnerHTML|innerHTML/)
})
```
- [ ] **Step 2: Scheitern sehen** — `cd $WK && timeout 300 node --max-old-space-size=1024 --test tests/unit/workersComponents.test.mjs`. Expected: FAIL `ENOENT … WorkersStage.jsx`.
- [ ] **Step 3: Implementieren** beide Komponenten nach den Angaben oben (Hooks-Regeln: Callback-Refs für `onSelect`/`onEvents`, damit der Mount-Effekt stabil bleibt). Die Texte der Notbremsen-Zeile stehen als zusammenhängende Literale in jeweils einer Zeile (`Versand gestoppt: ` und `Ein Admin kann ihn in den Einstellungen freigeben.` getrennt von `{Grund}` im JSX), weil der Quelltext-Test sie wörtlich sucht.
- [ ] **Step 4: Bestehen sehen** — gleicher Befehl. Expected: `# pass 6`, `# fail 0`.
- [ ] **Step 5: Commit**
```bash
cd $WK && git add src/components/workers/WorkersStage.jsx src/components/workers/WorkersBoard.jsx tests/unit/workersComponents.test.mjs
git commit -m "Workers P2: Bühne (StrictMode-fest) und Board mit Pausenschalter" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```
---
### P2-T20: Seite, Route, Menü, Stil, Schrift
**Files:**
- Create: `src/pages/WorkersPage.jsx`, `src/styles/workers.css`, `public/fonts/PixelifySans-Variable.woff2`, `public/fonts/OFL-PixelifySans.txt`
- Modify: `src/App.jsx`, `src/lib/navLinks.js`, `tests/unit/navLinks.test.mjs`
- Test: `tests/unit/workersRoute.test.mjs`, `tests/unit/workersCss.test.mjs`, `tests/unit/workersFont.test.mjs`, `tests/unit/navLinks.test.mjs`
**Interfaces:**
- Consumes: `WorkersBoard` (P2-T19), `workersApi` (P2-T04), `PageHeader`/`Tabs` (`../components/ui`), `pickTab` (P2-T02), `useAuth().isAdmin`.
- Produces: `export default function WorkersPage()` (Titel „Hustler“ als H1 und als Browser-Tab-Titel (`document.title = 'Hustler'` in einem Effekt, beim Verlassen der Seite wird der vorige Titel zurückgesetzt), Untertitel „Die Nachtschicht deiner Claude-Armee“; `const api = workersApi`; `WORKERS_TABS = [{ id: 'hochhaus', label: 'Hochhaus' }]` (P3 hängt `freigabe` an und passt `workersRoute.test.mjs` an, P3-E11; P6 hängt `botleads` an; kein Platzhalter-Reiter, P2-E7), Auswahl über `?tab=` mit `pickTab`, `Tabs` nur bei `WORKERS_TABS.length > 1`; Reiter `hochhaus` rendert `<WorkersBoard api={api} isAdmin={isAdmin} />` ohne `stageRef`); Route `/hustler` (lazy); `/leads` und `/workers` leiten über die kleine Komponente `HustlerRedirect` (`<Navigate to={`/hustler${search}`} replace />`, `search` aus `useLocation()`) auf `/hustler` um und behalten die Query; Menüeintrag `{ key: 'hustler', label: 'Hustler', href: '/hustler' }` an der Stelle von `leads`, ohne `paused`.
- [ ] **Step 1: Failing Tests schreiben**
`tests/unit/navLinks.test.mjs` anpassen: in beiden `deepEqual`-Listen `'leads'` durch `'hustler'` ersetzen (an derselben Stelle; die Schlüssel `leads` und `workers` kommen nicht mehr vor) und die Zeile `assert.equal(links.find((l) => l.key === 'leads').paused, true)` durch
```js
assert.deepEqual(links.find((l) => l.key === 'hustler'), { key: 'hustler', label: 'Hustler', href: '/hustler' })
assert.ok(!links.some((l) => l.key === 'leads' || l.key === 'workers'))
```
`tests/unit/workersRoute.test.mjs` (Quelltext von `src/App.jsx`):
```js
test('App: /hustler lazy hinter ProtectedRoute, /leads und /workers leiten mit erhaltener Query auf /hustler um, kein statischer Import', () => {
const s = readFileSync(new URL('../../src/App.jsx', import.meta.url), 'utf8')
assert.match(s, /const WorkersPage = lazy\(\(\) => import\('\.\/pages\/WorkersPage'\)\)/); assert.doesNotMatch(s, /import WorkersPage from/)
assert.match(s, /<Route path="\/hustler" element=\{<ProtectedRoute><Suspense[\s\S]*?<WorkersPage \/><\/Suspense><\/ProtectedRoute>\} \/>/)
assert.match(s, /<Route path="\/leads" element=\{<HustlerRedirect \/>\} \/>/); assert.match(s, /<Route path="\/workers" element=\{<HustlerRedirect \/>\} \/>/)
assert.match(s, /function HustlerRedirect\(\)[\s\S]*?useLocation\(\)[\s\S]*?<Navigate to=\{`\/hustler\$\{search\}`\} replace \/>/) // /workers?tab=botleads&bot=x → /hustler?tab=botleads&bot=x
assert.ok(s.indexOf('path="/hustler"') < s.indexOf('path="*"')); assert.ok(s.indexOf('path="/workers"') < s.indexOf('path="*"'))
})
test('WorkersPage: Titel Hustler (H1 und Tab-Titel), Reiter hochhaus (kein Platzhalter), Reiterleiste erst ab zwei Reitern, api-Instanz, Admin-Weitergabe', () => {
const s = readFileSync(new URL('../../src/pages/WorkersPage.jsx', import.meta.url), 'utf8')
assert.match(s, /title="Hustler"/); assert.match(s, /document\.title = 'Hustler'/)
assert.match(s, /id: 'hochhaus'/); assert.doesNotMatch(s, /id: 'freigabe'/); assert.match(s, /WORKERS_TABS\.length > 1/)
assert.match(s, /const api = workersApi/); assert.match(s, /pickTab\(/); assert.match(s, /isAdmin=\{isAdmin\}/)
})
```
`tests/unit/workersCss.test.mjs`:
```js
test('workers.css: nur wk-Klassen, @font-face, keine Hex-Farben, reduzierte Bewegung', () => {
const css = readFileSync(new URL('../../src/styles/workers.css', import.meta.url), 'utf8').replace(/\/\*[\s\S]*?\*\//g, '') // Kommentare vorher entfernen
for (const m of css.matchAll(/([^{}]+)\{/g)) { const pre = m[1].trim(); if (pre.startsWith('@')) continue; for (const sel of pre.split(',')) assert.ok(sel.trim().startsWith('.wk-'), sel) } // jede Regel beginnt mit einer wk-Klasse (Nachfahren dürfen globale Klassen nutzen)
assert.match(css, /@font-face\s*\{[^}]*font-family:\s*"Pixelify Sans"[^}]*PixelifySans-Variable\.woff2[^}]*font-display:\s*swap/s); assert.doesNotMatch(css, /#[0-9a-fA-F]{3,8}\b/)
assert.match(css, /prefers-reduced-motion/); assert.match(css, /@media \(max-width: 1100px\)/); assert.match(css, /@media \(max-width: 860px\)/)
})
test('jede im JSX verwendete wk-Klasse ist definiert', () => {
const css = readFileSync(new URL('../../src/styles/workers.css', import.meta.url), 'utf8'); const dir = new URL('../../src/components/workers/', import.meta.url)
for (const f of readdirSync(dir).filter((x) => x.endsWith('.jsx'))) for (const m of readFileSync(new URL(f, dir), 'utf8').matchAll(/(?<![\w-])wk-[a-z0-9-]+/g)) assert.ok(css.includes('.' + m[0]), `${f}: ${m[0]}`) // Lookbehind: das Attribut data-wk-worker ist keine Klasse
})
```
(Klassen mit dynamischem Suffix wie `wk-chip-${hat}` stehen in den Komponenten als Wörterbuch `{ opus: 'wk-chip-opus', … }`, damit der Test sie findet.)
`tests/unit/workersFont.test.mjs`:
```js
test('Schrift: woff2-Magic-Bytes, plausible Größe, OFL-Lizenztext', () => {
const buf = readFileSync(new URL('../../public/fonts/PixelifySans-Variable.woff2', import.meta.url)); assert.equal(buf.subarray(0, 4).toString('latin1'), 'wOF2'); assert.ok(buf.length > 10000 && buf.length < 400000, String(buf.length))
const lic = readFileSync(new URL('../../public/fonts/OFL-PixelifySans.txt', import.meta.url), 'utf8'); assert.match(lic, /SIL OPEN FONT LICENSE Version 1\.1/); assert.match(lic, /Pixelify Sans/)
})
```
- [ ] **Step 2: Scheitern sehen** — `cd $WK && timeout 300 node --max-old-space-size=1024 --test tests/unit/navLinks.test.mjs tests/unit/workersRoute.test.mjs tests/unit/workersCss.test.mjs tests/unit/workersFont.test.mjs`. Expected: FAIL (`navLinks`: `leads` noch vorhanden; übrige `ENOENT`).
- [ ] **Step 3: Implementieren**
(a) **Schrift besorgen** (einziger Netzschritt des Plans): neuen Ordner `D=$(mktemp -d "$TMPDIR/wk-font.XXXXXX")`; `curl -sfL --max-time 30 -A 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 Chrome/120 Safari/537.36' 'https://fonts.googleapis.com/css2?family=Pixelify+Sans:wght@400..700&display=swap' -o "$D/pixelify.css"`; die Adresse aus dem `/* latin */`-Block lesen mit `rg -A6 '^/\* latin \*/$' "$D/pixelify.css" | rg -o 'https://fonts\.gstatic\.com/[^)]+\.woff2'` (genau ein Treffer) und mit `curl -sfL --max-time 60 -o "$D/PixelifySans-Variable.woff2" '<url>'` laden; den Lizenztext (Open Font License 1.1 mit Copyright-Zeile „Copyright 2021 The Pixelify Sans Project Authors“) mit `curl -sfL --max-time 30 -o "$D/OFL-PixelifySans.txt" 'https://raw.githubusercontent.com/google/fonts/main/ofl/pixelifysans/OFL.txt'`; prüfen: `head -c 4 "$D/PixelifySans-Variable.woff2"` ist `wOF2`, `wc -c` zwischen 10 000 und 400 000, die Lizenz enthält „SIL OPEN FONT LICENSE Version 1.1“; erst dann `mkdir -p "$WK/public/fonts"` (der Ordner existiert noch nicht) und beide Dateien dorthin kopieren. Scheitert der Download (kein Netz), meldet der Implementierer BLOCKED mit dem Befehl, statt eine andere Quelle zu raten.
(b) `workers.css`: `@font-face { font-family: "Pixelify Sans"; src: url("/fonts/PixelifySans-Variable.woff2") format("woff2"); font-weight: 400 700; font-style: normal; font-display: swap; }`, Klassen der Komponenten (weiß, Haarlinien, `tabular-nums`, `.wk-main { display: grid; grid-template-columns: minmax(0, 1fr) 320px; gap: 32px }` mit `border-left: 1px solid var(--line)` an `.wk-side`, `.wk-canvas { display: block; width: 100%; aspect-ratio: 5 / 4; image-rendering: pixelated; cursor: pointer; touch-action: pan-y }`, `.wk-name { font-family: "Pixelify Sans", var(--font) }`, Chips mit Tokenpaaren `--orange`, `--blue`, `--green`, `--violet`), unter 1100 px einspaltig (Seitenspalte unten, Haarlinie oben), unter 860 px Zoomknöpfe größer (min. 36 px), `@media (prefers-reduced-motion: reduce)` ohne Übergänge; keine `@keyframes`, keine Kommentare mit geschweiften Klammern.
(c) `WorkersPage.jsx` (Muster `LegalPage.jsx`, `useSearchParams` für `?tab=` und `setSearchParams` beim Reiterwechsel, `page wk-page`, `<Tabs>` nur bei `WORKERS_TABS.length > 1`), `App.jsx`: `import { lazy, Suspense, … } from 'react'`, `useLocation` aus `react-router-dom` ergänzen, `const WorkersPage = lazy(() => import('./pages/WorkersPage'))`, Route `/hustler` wie im Test (Fallback `<div className="loading">Wird geladen …</div>`), `function HustlerRedirect() { const { search } = useLocation(); return <Navigate to={`/hustler${search}`} replace /> }` und die Routen `/leads` und `/workers` ⇒ `<HustlerRedirect />` (beide vor `path="*"`; die Route `/leads` hat bisher auf `/tickets` verwiesen); `navLinks.js`: Zeile `leads` ersetzen durch `{ key: 'hustler', label: 'Hustler', href: '/hustler' }` (Kommentar „Leads auf Eis“ entfällt).
- [ ] **Step 4: Bestehen sehen** — gleicher Befehl wie Step 2. Expected: `# fail 0`.
- [ ] **Step 5: Commit**
```bash
cd $WK && git add src/pages/WorkersPage.jsx src/styles/workers.css public/fonts/PixelifySans-Variable.woff2 public/fonts/OFL-PixelifySans.txt src/App.jsx src/lib/navLinks.js tests/unit/navLinks.test.mjs tests/unit/workersRoute.test.mjs tests/unit/workersCss.test.mjs tests/unit/workersFont.test.mjs
git commit -m "Workers P2: Seite /hustler (lazy), Menü „Hustler“ statt Leads, Stil und Schrift Pixelify Sans" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```
---
### P2-T21: Prüfstand und manuelle Playwright-Abnahme
**Files:**
- Create: `scripts/workers-harness/build.mjs`, `scripts/workers-harness/entry.jsx`, `scripts/workers-harness/index.html`, `scripts/workers-harness/scripted-api.js`, `scripts/workers-harness/shot.cjs`
- Test: `tests/unit/workersHarness.test.mjs`
**Interfaces:**
- Consumes: `WorkersBoard` mit Fake-API (kein Auth, kein Appwrite), `EVENT_KINDS`, `ROLES`.
- Produces:
```js
// scripted-api.js
export const SCRIPT // [{ at: Sekunden, event }], deckt alle 26 Arten und ≥ 15 Worker ab, erstes Ereignis bei ≥ 2 s
export function createScriptedApi({ now, halted = false }): { state, events, trace, pause, settings, saveSettings, pult, pultAction, botleads, prospect, saved } // gleiche Form wie createWorkersApi; `saved` = Liste der an saveSettings übergebenen Patches
```
`state()`: 19 Worker, `kleinanzeigen`/`portale`/`foren` (kim, pia, finn) nicht angeschlossen, gemischte Status, `kpis` wie in den Fixtures, `lastEventTs` = Basiszeit; `events(after)`: nur fällige, nie doppelte (Zeitstempel = Basiszeit + `at`); `trace(id)`: 12 Ereignisse, darunter eines mit `javascript:alert(1)` und eines mit `https://example.org/fund`, `today` mit Zählern, `tokens: { in, out }`, Kosten; `pause(p)` merkt den Zustand und `state().paused` folgt; `settings()` = Standardwerte aus Übersicht §4.1 (inkl. `privacyInteressentenLive: false`, `sendingHalted`, `sendingHaltedReason`); `state()` enthält `sendingHalted` und `sendingHaltedReason` (Standard `false` / `''`; mit `halted: true` gilt `true` / `'Zwei harte Bounces in den letzten 10 Mails.'`, H4); `saveSettings(patch)` merkt den Patch in `saved`, mit `sendingHalted: false` hebt es die Notbremse auf (`state()` und `settings()` melden danach `false` / `''`) und liefert die Einstellungen zurück. `build.mjs <outDir>`: esbuild-Bündel (`entry.jsx` → `harness.js`, `harness.css`, `index.html`; `jsx: 'automatic'`, `external: ['/fonts/*']`, Plattform Browser). `entry.jsx`: `StrictMode` + `MemoryRouter` + `WorkersBoard api={scripted} isAdmin stageRef={ref}` (der Prüfstand-URL-Parameter `?halted=1` erzeugt die Skript-API mit `halted: true`) und `window.__wk = { stage: ref, api: scripted }` nur im Prüfstand. `shot.cjs <distDir> <outDir>`: Muster `entwuerfe/shot.cjs` (gleicher Chromium-Pfad, gleiches `createRequire`, Sperre auf neue Ausgabeordner unter `$TMPDIR`), kleiner statischer Server auf 127.0.0.1 (liefert `distDir` und `/fonts/*` aus `public/fonts`).
- [ ] **Step 1: Failing Test schreiben** (`tests/unit/workersHarness.test.mjs`)
```js
const { createScriptedApi, SCRIPT } = await loadModule('scripts/workers-harness/scripted-api.js')
const { EVENT_KINDS } = await loadModule('src/lib/workersEvents.js')
const { SEARCH_ROOMS, roleOf } = await loadModule('src/lib/workersRoster.js')
test('Skript deckt alle 26 Ereignisarten und mindestens 15 Worker ab; erstes Ereignis nicht vor 2 s', () => {
assert.deepEqual(new Set(SCRIPT.map((s) => s.event.kind)), new Set(EVENT_KINDS)); assert.ok(new Set(SCRIPT.map((s) => s.event.workerId).filter((w) => w !== 'system')).size >= 15); assert.ok(Math.min(...SCRIPT.map((s) => s.at)) >= 2)
})
test('scripted api: 19 Worker (kim, pia, finn nicht angeschlossen); events liefert nur Fälliges, nie doppelt; Pause folgt', async () => {
let t = 0; const api = createScriptedApi({ now: () => t }); const s = await api.state()
assert.equal(s.workers.length, 19); assert.deepEqual(s.workers.filter((w) => !w.connected).map((w) => w.workerId).sort(), ['finn', 'kim', 'pia'])
assert.ok(s.workers.filter((w) => !w.connected).every((w) => SEARCH_ROOMS.includes(roleOf(w.workerId).room))) // Grau-Regel (P1-E9): nur Such-Räume können nicht angeschlossen sein, die anderen elf sind es immer
assert.equal((await api.events(null)).events.length, 0); t = 10000; const first = (await api.events(s.lastEventTs)).events; assert.ok(first.length > 0)
const again = (await api.events(first.at(-1).ts)).events; assert.equal(again.filter((e) => first.some((f) => f.id === e.id)).length, 0)
await api.pause(true); assert.equal((await api.state()).paused, true)
assert.equal(s.sendingHalted, false); assert.equal(s.sendingHaltedReason, '') // Standard: nicht gestoppt
const h = createScriptedApi({ now: () => 0, halted: true }); const hs = await h.state() // H4: gestoppt, Grund sichtbar, Freigabe über saveSettings
assert.equal(hs.sendingHalted, true); assert.match(hs.sendingHaltedReason, /Bounces/); assert.equal((await h.settings()).sendingHalted, true)
const saved = await h.saveSettings({ sendingHalted: false }); assert.equal(saved.sendingHalted, false); assert.equal(saved.sendingHaltedReason, '')
assert.deepEqual(h.saved, [{ sendingHalted: false }]); assert.equal((await h.state()).sendingHalted, false)
})
```
- [ ] **Step 2: Scheitern sehen** — `cd $WK && timeout 300 node --max-old-space-size=1024 --test tests/unit/workersHarness.test.mjs`. Expected: FAIL `Could not resolve …/scripted-api.js`.
- [ ] **Step 3: Implementieren** die fünf Dateien. `shot.cjs` prüft (Exit 1 bei Verstoß, am Ende JSON und `OK`):
1. keine `pageerror`/`console.error`; `document.fonts.check('600 12px "Pixelify Sans"')` ist wahr und die Schrift kam mit 200 vom Server;
2. Desktop 1280×900: 19 Knöpfe `[data-wk-worker]`; Klick auf jeden ⇒ `.wk-name` zeigt den Namen aus `ROLES`, `.wk-chip` den vollen Modellnamen;
3. sechs Figuren (otto, kim, rita, bea, bob, arne): `__wk.stage.current.locate(id)` liefert CSS-Pixel relativ zur Leinwand, geklickt wird an `boundingBox(.wk-canvas)` plus diese Werte ⇒ Karte zeigt dieselbe Figur;
4. Wahl von `kim`: `.wk-trace a[href^="javascript:"]` hat 0 Treffer, die https-Zeile ist ein Link mit `rel="noopener noreferrer"` und `target="_blank"`, die `javascript:`-Zeile steht als Text;
5. Pausenknopf: Text wechselt auf „Fortsetzen“ und zurück auf „Alles anhalten“; „Einstellungen“ öffnet einen Dialog mit dem Titel „Hustler-Einstellungen“ und dem Feld „Tagesgrenze (USD)“, „Abbrechen“ schließt ihn;
6. nach 20 s Skriptlauf: `stats().avgDrawMs` ≤ 25 (nur Warnung ausgeben, kein Abbruch, da software-gerendert), `debug().pending` ≥ 0;
7. Handy 390×844 (DPR 2): `document.documentElement.scrollWidth <= 390`, Zoom „+“ erhöht `stats().zoom`;
8. `page.emulateMedia({ reducedMotion: 'reduce' })` + Neuladen: nach 2 s `stats().frames <= 5`;
9. Bilder `desktop-start.png`, `desktop-nach-20s.png`, `handy.png`, `handy-zoom.png`, `desktop-gestoppt.png` im Ausgabeordner;
10. Notbremse (H4/A21), Seite mit `?halted=1` (Desktop 1280×900; das Bild `desktop-gestoppt.png` entsteht vor dem Freigeben): `.msg.err[role="alert"]` zeigt genau „Versand gestoppt: Zwei harte Bounces in den letzten 10 Mails. Ein Admin kann ihn in den Einstellungen freigeben.“ (der Punkt des Grundes nicht doppelt); „Einstellungen“ öffnet den Dialog mit dem Block „Versand gestoppt“ und dem Knopf „Versand wieder freigeben“; Klick darauf ⇒ binnen 2 s sind Block und Warnzeile verschwunden und `__wk.api.saved` ist genau `[{ sendingHalted: false }]`; ohne `?halted=1` gibt es weder Zeile noch Block.
- [ ] **Step 4: Test bestehen sehen** — `cd $WK && timeout 300 node --max-old-space-size=1024 --test tests/unit/workersHarness.test.mjs`. Expected: `# pass 2`, `# fail 0`.
- [ ] **Step 5: Abnahme-Lauf (Verifikation, Implementierer führt aus)**
```bash
cd $WK && free -m && B=$(mktemp -d "$TMPDIR/wk-harness.XXXXXX") && O=$(mktemp -d "$TMPDIR/wk-shots.XXXXXX")
systemd-run --scope -q -p MemoryMax=1500M -p MemorySwapMax=0 timeout 120 node --max-old-space-size=512 scripts/workers-harness/build.mjs "$B"
systemd-run --scope -q -p MemoryMax=700M -p MemorySwapMax=0 timeout 200 node --max-old-space-size=400 scripts/workers-harness/shot.cjs "$B" "$O"
```
Expected: letzte Zeile `OK`, `errors: []`. Danach die fünf PNGs mit dem Read-Werkzeug ansehen und festhalten: (a) 10 Etagen mit Schildern rechts, bei 1280 × 900 beim ersten Bild komplett sichtbar (Keller unten zu sehen) und die Raumtitel lesbar; ist die Bühne dort schmaler als `fitZoom`-Schwelle (720 px, die Seitenspalte nimmt 320 px + 32 px Abstand), wird die Spaltenbreite in `workers.css` verkleinert oder die Schwelle in P2-T08 samt Test angepasst, (b) Kleinanzeigen/Portale/Foren grau mit Schild, (c) 19 Figuren, Otto größer mit Kommandomütze, (d) Karte rechts mit Modell-Chip und Spur, (e) Handy ohne seitliches Scrollen, Zoom-Knöpfe sichtbar, (f) `desktop-gestoppt.png`: rote Zeile „Versand gestoppt: …“ zwischen Kopfzeile und Hochhaus, das Hochhaus selbst ohne Pausenschleier. Abweichungen werden im jeweiligen Engine-/CSS-Modul behoben (mit Test), dann erneut laufen lassen.
- [ ] **Step 6: Commit**
```bash
cd $WK && git add scripts/workers-harness/build.mjs scripts/workers-harness/entry.jsx scripts/workers-harness/index.html scripts/workers-harness/scripted-api.js scripts/workers-harness/shot.cjs tests/unit/workersHarness.test.mjs
git commit -m "Workers P2: Prüfstand mit Skript-API und Playwright-Abnahme" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```
---
### P2-T22: Gesamtlauf, Build und Chunk-Prüfung
**Files:**
- Test: keine neue Datei (Verifikation des Gesamtstands)
**Interfaces:**
- Consumes: alles aus P2-T01 bis P2-T21.
- Produces: nachgewiesen grüne Suite, lazy Chunk, Branch-Stand für Kenso.
- [ ] **Step 1: Gesamtlauf** — `cd $WK && free -m && timeout 600 node --max-old-space-size=1024 --test tests/*.test.mjs tests/unit/*.test.mjs`. Expected: `# fail 0` (Anzahl der Tests = bisherige plus die neuen `workers*`-Tests; Zahl für die Übergabe notieren).
- [ ] **Step 2: Build** (neuer leerer Ordner)
```bash
cd $WK && D=$(mktemp -d "$TMPDIR/wk-dist.XXXXXX") && systemd-run --scope -q -p MemoryMax=1500M -p MemorySwapMax=0 env NODE_OPTIONS=--max-old-space-size=1200 timeout 400 node_modules/.bin/vite build --outDir "$D" --emptyOutDir && echo "$D"
```
Expected: `✓ built`, kein Fehler.
- [ ] **Step 3: Chunk-Prüfung** (mit dem ausgegebenen `$D`): `ls "$D/assets" | rg WorkersPage` ⇒ je eine `.js` und eine `.css`; `rg -l 'noch nicht angeschlossen' "$D/assets"` ⇒ nur die WorkersPage-Datei, **nicht** `index-*.js`; `rg -l 'Pixelify' "$D/assets"` ⇒ nur die WorkersPage-CSS; `ls "$D/fonts"` ⇒ `PixelifySans-Variable.woff2`, `OFL-PixelifySans.txt`. Expected: wie beschrieben (Engine und Schrift liegen nicht im Haupt-Bundle).
- [ ] **Step 4: Branch-Stand festhalten** — `cd $WK && git status --short` (nur `?? node_modules`, ggf. `??`-Zeilen für nicht eingecheckte Plan-Dokumente) und `git log --oneline origin/test..HEAD` (nur Workers-Commits). Expected: keine geänderten Dateien; die Commit-Liste ist die Übergabe an Kenso.
- [ ] **Step 5: Kein Commit** (nur Verifikation). Fehlschläge werden im verursachenden Modul behoben und committet, danach beginnt P2-T22 von vorn.
---
## Betriebsanleitung (Kenso)
P2 braucht **keine** `.env`-, Compose- oder nginx-Änderung. Der Block `location /api/workers/` und der Container `webklar-workers` gehören zur Betriebsanleitung von P1; ohne sie zeigt die Seite „Der Hustler-Dienst ist noch nicht erreichbar“ (gewollt, kein Absturz).
1. **P1 prüfen (empfohlen vor P2):** `curl -s -o /dev/null -w '%{http_code}\n' https://ticket.webklar.com/api/workers/health` ⇒ `200`; `curl -s -o /dev/null -w '%{http_code}\n' https://ticket.webklar.com/api/workers/state` ⇒ `401` (ohne JWT). Anders ⇒ erst P1 in Betrieb nehmen; WOMS kann trotzdem schon live gehen.
2. **Stand merken:** `git -C /home/webklar/apps/ticket.webklar.com/dev-workers fetch origin && git -C /home/webklar/apps/ticket.webklar.com/dev-workers rev-parse origin/test` ⇒ SHA als `ALT` notieren (für das Zurücknehmen). Danach `git -C /home/webklar/apps/ticket.webklar.com/dev-workers merge-base --is-ancestor origin/test HEAD && echo OK`: Kommt kein `OK`, hat `test` inzwischen neue Commits; dann **nicht pushen und nie erzwingen** (kein `--force`), sondern Claude bitten, `origin/test` in `feature/workers` zusammenzuführen (`git fetch origin`, dann `git merge origin/test`; ein Rebase der 40 Commits ist nicht nötig und würde alle Commit-Nummern ändern) und danach Schritt 3 samt `vite build` zu wiederholen. **Stand der Endprüfung (2026-10-18): Es kommt kein `OK`.** `origin/test` steht auf `f9b0cbc` (Website-Statistik, 2026-10-17), der Branch ist 40 Commits davor und 1 dahinter, ein Push würde als Nicht-Fast-Forward abgelehnt und nichts ausliefern. `src/App.jsx` und `src/lib/navLinks.js` verschmelzen von selbst; es gibt genau einen Textkonflikt in `tests/unit/navLinks.test.mjs` (beide Seiten ändern dieselben `deepEqual`-Listen). Richtig ist die Reihenfolge `… 'customers', 'hustler', 'legal', 'statistik'` für Mitarbeiter und `… 'customers', 'hustler', 'finance', 'legal', 'statistik', 'serverpuls', 'admin'` für Admins. Eine Probezusammenführung in einer Kopie lief mit allen Tests grün und grünem Build (Stand 820ccd5: 993 Tests).
3. **Letzter Test:** `cd /home/webklar/apps/ticket.webklar.com/dev-workers && free -m && timeout 900 node --max-old-space-size=1024 --test --test-concurrency=2 tests/*.test.mjs tests/unit/*.test.mjs` ⇒ `# fail 0` (Stand nach den Endprüfung-Korrekturen: 996 Tests, rund 31 s). `--test-concurrency=2` ist Pflicht: ohne den Schalter startet Node bis zu 7 Prüfprozesse mit je bis zu 1 GB Speicher, und auf diesem Server hat genau so ein Lauf am 2026-10-04 den Speicher erschöpft (OOM-Kill). Vorher mit `free -m` prüfen, dass mindestens 2 GB frei sind.
4. **Live schalten (Push):** `git -C /home/webklar/apps/ticket.webklar.com/dev-workers push origin feature/workers:test`. Der Deploy-Webhook baut in 1–3 Minuten. Prüfen: `curl -sI https://ticket.webklar.com/fonts/PixelifySans-Variable.woff2 | grep -iE '^(HTTP|content-type)'` ⇒ `HTTP/… 200` **und** `content-type: font/woff2` (steht dort `text/html`, ist es nur die Startseite, die nginx bei fehlender Datei liefert, und die Schrift fehlt); das Build-Log des Ticket-Build-Containers (Name mit `docker ps --format '{{.Names}}' | rg -i ticket` finden, Muster `webklar-ticket-build`; `docker logs --tail 40 <name>`) endet ohne Fehler.
5. **Im Browser (als Mitarbeiter):** Menü zeigt „Hustler“ statt des grauen „Leads“; `https://ticket.webklar.com/leads` und `https://ticket.webklar.com/workers` springen auf `https://ticket.webklar.com/hustler` (eine angehängte Query wie `?tab=botleads` bleibt erhalten); Seitentitel (H1) und Browser-Tab zeigen „Hustler“; die Seite zeigt die sechs Kennzahlen, das Hochhaus mit 10 Etagen und 19 Figuren, ein Klick auf eine Figur öffnet rechts die Karte (Name, Modell, Zähler, Spur); es gibt vorerst nur den Bereich „Hochhaus“ ohne Reiterleiste (der Reiter „Freigabe“ kommt mit P3, dort P3-E11); auf dem Handy: Zoom-Knöpfe, kein seitliches Scrollen; ein Tipp auf einen Namen in der Liste holt die Karte nach oben ins Bild (der Name in der Karte hat dann den Fokus), „ד schließt sie und setzt den Fokus zurück auf den Listeneintrag. **Ausfall-Probe (solange P1 noch nicht läuft, also ohne `/api/workers/`-Block oder ohne Container):** statt des Hochhauses steht „Der Hustler-Dienst ist noch nicht erreichbar“ mit einer Meldung und dem Knopf „Neu laden“; sobald P1 läuft, zeigt ein Klick auf „Neu laden“ das Hochhaus ohne Seite neu zu laden. Fällt der Dienst später aus, bleibt das Hochhaus stehen und eine Zeile „Verbindung gestört: … Es wird weiter versucht.“ erscheint; ein hängender Abruf wird nach 20 s abgebrochen, die Seite friert nicht ein. Meldet der Dienst dauerhaft „401“, erscheint „Anmeldung abgelaufen. Bitte neu anmelden.“, und es wird höchstens alle 2 Minuten ein neues Anmelde-Token (JWT) erzeugt (Appwrite begrenzt das je Nutzer). Dieselben Fälle prüft der Prüfstand (Szenario F in `scripts/workers-harness/shot.cjs`) im Browser. Zu Beginn sind genau die acht Such-Räume grau mit „noch nicht angeschlossen“ (Netz-Radar, Kleinanzeigen, Portale, Foren & Fragen, Ärzte & Praxen, Handwerk, Selbstständige, Läden & Gastro); Brücke, Waage, Mail-Stube, Forum-Stube, Posteingang, Abschluss, die drei Werkstatt-Tische, Prüftisch und Archiv sind von Anfang an farbig (P1-E9). Das ist richtig: ein Such-Raum wird erst farbig, wenn der Dienst ihn als angeschlossen meldet (P3 für Netz-Radar, Kleinanzeigen, Portale, Foren; P6 für die Branchen-Räume).
6. **Pausenschalter (nur wenn P1 läuft):** „Alles anhalten“ ⇒ Hinweis „Angehalten …“ und heller Schleier im Hochhaus; danach „Fortsetzen“ — den Dienst **nicht** angehalten lassen. Als Admin: „Einstellungen“ öffnen (Titel „Hustler-Einstellungen“; unter „Kosten“ stehen im Standard Tagesgrenze 6 und Monatsgrenze 150, jeweils USD, [Kenso, 17.10.: max. 150 € im Monat]; die harte Grenze ist das Ausgabenlimit des Console-Workspaces, 150 $/Monat, Betriebsanleitung P1), nichts ändern, „Abbrechen“; als Nicht-Admin fehlt der Knopf. **Versand-Notbremse (H4):** Hat der Dienst den Versand wegen Bounces gestoppt (P4), zeigt das Board für alle Mitarbeiter eine rote Zeile „Versand gestoppt: <Grund>. Ein Admin kann ihn in den Einstellungen freigeben.“ und der Einstellungs-Dialog (nur Admin) oben den Block „Versand gestoppt“ mit dem Knopf „Versand wieder freigeben“. Den Knopf erst drücken, wenn die Ursache geklärt ist (Bounce-Adressen im Postfach prüfen, Sperrliste); er schickt nur `sendingHalted:false`, der Dienst löscht den Grund. Ohne Stopp sind Zeile und Block nicht zu sehen; wer sie vor dem Livegang sehen will, schaut sich das Bild `.superpowers/sdd/2026-10-17-workers-p2-hochhaus-woms/shots/desktop-gestoppt.png` an (Claude zeigt es auf Wunsch). Den Prüfstand mit `?halted=1` gibt es nur als automatisches Skript (`scripts/workers-harness/shot.cjs`, baut und beendet sich selbst); eine Seite zum Aufrufen im Browser gibt es nicht.
7. **Zurücknehmen, falls nötig:** `cd /home/webklar/apps/ticket.webklar.com/dev-workers && git revert --no-edit $(git rev-list --no-merges ALT..HEAD)` (ALT aus Schritt 2; nimmt alle eigenen Commits in der richtigen Reihenfolge zurück und lässt den Merge-Commit aus Schritt 2 stehen, denn `git revert` kann einen Merge-Commit nicht ohne `-m` zurücknehmen; die Konfliktlösung im Navigations-Test bleibt dann stehen, das betrifft nur den Test, nicht die Seite; macht auch die Dokumentations-Commits rückgängig, das schadet nicht), dann `git -C /home/webklar/apps/ticket.webklar.com/dev-workers push origin feature/workers:test`. Prüfen: `https://ticket.webklar.com/leads` zeigt wieder die Tickets (graues „Leads“ im Menü).
8. **Danach (Claude):** Memory-Eintrag zu WOMS-Workers aktualisieren (Stand live, Commit-SHA, Ergänzungen P2-E1 bis P2-E7 stehen in der Übersicht §11.1, die Notbremsen-Anzeige entspricht A21 und H4).
### Bekannte Einschränkung vor dem Livegang: Handy-Startansicht (Entscheidung für Kenso)
Auf dem Handy (390 px Breite) startet das Hochhaus vergrößert (Zoom 1,56, damit die Raumtitel lesbar sind). Folgen: Das Bild zeigt zuerst nur die oberen zwei Drittel (Prüfung, Pult, Tresor und Keller erreicht man durch Ziehen auf dem Bild oder mit „Ganz“), und ein Wischen, das auf dem Bild beginnt, verschiebt das Bild statt die Seite zu scrollen (die Seite lässt sich über Kennzahlen, Liste und Ränder scrollen; das Bild ist nur etwa 290 px hoch). Es gehen keine Daten und keine Funktion verloren, die Liste mit allen 19 Workern ist die Alternative. Das ist so im Plan beschlossen (Startzoom nach Breite, `touch-action: none` ab Zoom 1) und wird bewusst nicht ohne deine Entscheidung geändert. Zur Wahl:
1. So lassen (empfohlen für den Start): als bekannte Einschränkung hinnehmen.
2. Auf dem Handy mit dem ganzen Hochhaus starten (Zoom 1): Seite scrollt überall, aber die Raumtitel sind dann winzig (etwa 5 px).
3. Zoom 1,56 behalten, aber nur mit zwei Fingern oder nach „+“ verschieben, mit einem Finger scrollt die Seite (`touch-action: pan-y`): braucht eine kleine Änderung in `scene.js` plus Test.