Kenso, 17.10.: "150 € im Monat max", "nenne es Hustler". Technische Namen (workers*) bleiben. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1528 lines
164 KiB
Markdown
1528 lines
164 KiB
Markdown
# 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**, sondern Claude bitten, `feature/workers` auf `origin/test` zu rebasen und Schritt 3 zu wiederholen.
|
||
3. **Letzter Test:** `cd /home/webklar/apps/ticket.webklar.com/dev-workers && free -m && timeout 600 node --max-old-space-size=1024 --test tests/*.test.mjs tests/unit/*.test.mjs` ⇒ `# fail 0`.
|
||
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 | head -3` ⇒ `HTTP/… 200`; 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. 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, öffnet den Prüfstand aus P2-T21 mit `?halted=1`.
|
||
7. **Zurücknehmen, falls nötig:** `git -C /home/webklar/apps/ticket.webklar.com/dev-workers revert --no-edit ALT..HEAD` (ALT aus Schritt 2; 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).
|