Kenso, 17.10.: "150 € im Monat max", "nenne es Hustler". Technische Namen (workers*) bleiben. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
164 KiB
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, Branchfeature/workers); Agenten pushen nie, Live nur durch Kenso (git push origin feature/workers:test);git addnur mit Dateinamen; Commit-Nachrichten enden mitCo-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äufenfree -m. - Stil wie WOMS: ES-Module, keine Semikolons, einfache Anführungszeichen, deutsche Kommentare, UI-Texte deutsch mit Umlauten, keine Emojis; Dateien unter
src/lib/undengine/importieren weder React noch Appwrite und greifen beim Import nicht aufwindow/documentzu (laden mitloadModule()austests/unit/load.mjs). - Keine neue npm-Abhängigkeit (kein PixiJS, kein Test-Framework);
package.jsonbleibt 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_MS33),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), Spurlimit=50; nichts beidocument.visibilityState === 'hidden', sofortiger Abruf beim Zurückkehren; JWT 10 min gecacht (createJwtCache), HeaderX-Appwrite-JWT, Basis/api/workers. - Rechte: Pausenschalter für alle Mitarbeiter;
WorkersSettingsDialognur fürisAdmin;mailColdPerDay≤ 3 (Spec §8.1),mailReplyCapPerDay≤ 15; der Dialog sendet niepaused; die Formulargrenzen sind gleich oder enger als die des Dienstes (retentionDays7–90,offerText1–400,offerProductKeynurhosting, 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,sendingHaltedReasonundprivacyInteressentenLivesind schreibgeschützt (READONLY_KEYS, Übersicht §7). - Weißes Theme (Konzept 2026-10-04): keine Karten/Kästen, nur Haarlinien
var(--line), nur Tokens ausglobal.css(keine Hex-Farben inworkers.css), Klassen mit Präfixwk-, Zahlentabular-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 Lizenztextpublic/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 ByteswOF2. - Bewegung und Lebenszyklus:
prefers-reduced-motion⇒ keine Schleife, Standbild je Änderung;visibilitychange⇒setPaused;mountTower/destroyidempotent (React StrictMode: mount, destroy, mount). - Playwright/Chromium nur seriell unter
systemd-run --scope -q -p MemoryMax=700M -p MemorySwapMax=0, vite-Build unterMemoryMax=1500MmitNODE_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;/leadsund/workersleiten 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: Sammlungenworkers*, Dienst/Containerwebklar-workers, Repoknso/workers-dienst, API/api/workers/*, Dateien und Komponenten (WorkersPage.jsx,workersApi.js,workersRoster.js…), CSS-Präfixwk-, UmgebungsvariablenWORKERS_*,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
monthlyBudgetUsd150 (150≈ 130 €, solange 1 € ≥ 1),dailyBudgetUsd6 (Ausreißerschutz, vorher 10) undwarnPct80. 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 Reiterfreigabe, den P3 anhängt: P3-E11); „E1“ bis „E7“ in diesem Plan meinen diese Zeilen.
Review Focus
- 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 (kpiTilesohne Daten), P2-T04 (Fehlertexte), P2-T19 (Board-Zustände). - 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.
- 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). - Raum ohne Quelle (nur die acht Such-Räume aus
SEARCH_ROOMSkönnen es sein; der Dienst meldet alle anderen Worker von Anfang an als angeschlossen, P1-E9), Worker fehlt im Zustand, unbekannte Ereignisart oder unbekannteworkerId→ grauer Raum mit Schild „noch nicht angeschlossen“, schlafende Figur, keine vorgespielte Arbeit, kein Wurf. Tests: P2-T03, P2-T11, P2-T12, P2-T13. linkim Ereignisstrom mitjavascript:,data:oder//fremder.host→ nur Text, nie einhref; externe Links mitrel="noopener noreferrer". Tests: P2-T02 (safeLink), P2-T17 (Quelltext-Test der Karte).- 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, keinpaused), und die drei FeldersendingHalted,sendingHaltedReason,privacyInteressentenLivelanden nie im Formular-Patch. Tests: P2-T16 (READONLY_KEYS, leerer Patch), P2-T18, P2-T19, P2-T21 (Prüfstand). - Das Formular lässt nichts zu, was der Dienst ablehnt (sonst kommt eine Serverfehlermeldung statt der Feldmeldung):
retentionDays7–90,offerText1–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:
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)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 unterdocs/superpowers/plans/sind erlaubt) undln -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.mjsExpected: FAIL, esbuildCould not resolve …/src/lib/workersRoster.js. -
Step 3: Implementieren in
src/lib/workersRoster.jsundtests/fixtures/workers-roster.jsonFixture undROLESaus Ü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 undROLESdanach ausgerichtet.ROLES/FLOORSmitObject.freeze(tief genug fürdeepEqualnach JSON-Rundlauf).SEARCH_ROOMS= Räume der Etagenanfragenundohne_websiteinROLES-Reihenfolge, eingefroren (dieselbe Ableitung wie in P1-T05; WOMS berechnetconnectednicht 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
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):
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'))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: FAILCould not resolve …/workersView.js. -
Step 3: Implementieren
src/lib/workersView.jsZahlen überIntl.NumberFormat('de-DE'); nurtypeof n === 'number' && Number.isFinite(n)zählt, sonst'–'; Zeichen vor$/€ist das geschützte Leerzeichen (Code 160, im TestNB).formatEurohne Nachkommastellen bei ganzen Zahlen.formatTimeüberIntl.DateTimeFormat('de-DE', { timeZone: 'Europe/Berlin', … }), „gleicher Tag“ = gleiches Berliner Datum wienow. Kachel-Tonapi:ratio = apiTodayUsd / dailyBudgetUsdnur beidailyBudgetUsd > 0: ≥ 1 ⇒'neg', ≥ 0,8 ⇒'warn', sonst''; Kachelpult'warn'beipultOpen > 0. Bekannte Zähler (Reihenfolge, Beschriftung):gelesen,gefunden,passend,bewertet,entwuerfe„Entwürfe“,gesendet,gebaut,geprueft„geprüft“.safeLinktrimmt, prüft/^https?:\/\//i(extern) oder/^\/(?!\/)/(intern).traceRowssortiert perDate.parseabsteigend, ungültige Zeiten zuletzt. -
Step 4: Bestehen sehen — gleicher Befehl. Expected:
# pass 9,# fail 0. -
Step 5: Commit
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,eventToStepslaut Übersicht §7):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 ZeichenSchritte:
{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 ausSPOTS) ·{op:'cheer',who}. -
Step 1: Failing test schreiben (
tests/unit/workersEvents.test.mjs,const E = await loadModule('src/lib/workersEvents.js'), Roster per zweitemloadModulefürROLES)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: BeiworkerId: 'system'fällt dertask-Schritt weg, weiltasknur 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: FAILCould not resolve …/workersEvents.js. -
Step 3: Implementieren
src/lib/workersEvents.jstakeNewEvents: Schlüssel =id, ohneidts|workerId|kind|text; ohne gültigestsverwerfen; Set auf 1000 Einträge begrenzen (ältesten zuerst löschen); Ergebnis stabil nachDate.parse(ts)aufsteigend.reduceEvents: nur Worker, die instateByWorkerstehen und inROLESvorkommen; ein Ereignis wirkt nur, wennDate.parse(ts) >= Date.parse(lastTs)(oderlastTsleer);graubleibtgrau; Status:leerlauf⇒'leerlauf',fehler⇒'fehler', jede andere Art ⇒'arbeitet',task = text,lastTs = ts; der Empfängertovonauftrag/uebergabewird (falls nicht grau und nicht neuer)'wartet'mittask = text; Rückgabe ist ein neues Objekt, Einträge als Kopien (Zusatzfelder wieconnected/countersbleiben).indexWorkers:status = connected === false ? 'grau' : status,lastTs = state.lastEventTs ?? null.eventToSteps: erst Schritte nach der Tabelle bilden, dann alle Schritte verwerfen, die auf unbekannteworkerId/Plätze zeigen (systemundzedfallen weg);metadarf Objekt, JSON-String odernullsein (P1 liefertnull, wenn leer);W=event.workerId,T=text:kind Schritte in dieser Reihenfolge auftrag task W; say W ${Name(to)}, los!; say toVerstandenleerlauf task W; pose W coffee gelesen pose W type; task W; say W ( meta.countZahl ?${count} gelesen:Gelesen)fund pose W type; task W; say W Neuer Fund!; fx paper at Wdoppelt task W; say W Schon bekannt; fx stampNo at Wuebergabe task W; give W→to bewertet pose W type; task W; say W ( meta.scoreZahl ?${score} Punkte:Bewertet)top3 task W; say W Top 3!; fx stampOk at Wbotlead task W; say W Abgelegt; fx drawer at Wentwurf pose W type; task W; say W Entwurf fertigpult task W; say W Zur Freigabe; fx envelope at W to pultgesendet task W; (W bekannt: say W Raus damit!; fx envelope at W to aussen) sonst: fx envelope at pult to ausseneingang task W; fx envelope at aussen to W; say W Antwort!sortiert task W; fx stampOk at W; say W (Beschriftung zu meta.classification: jaJa!, frageFrage, neinNein, abmeldenAbmeldung, abwesendAbwesend, unzustellbarUnzustellbar, beschwerdeBeschwerde!, unklarUnklar, sonstSortiert)bau_start pose W type; task W; say W Los geht’sbau_fertig task W; say W Fertig!; give W→paulpruefung_zurueck task W; fx stampNo at W; say W ( meta.roundZahl ?Runde ${round} zurück:Zurück); give W→topruefung_ok task W; fx stampOk at W; say W Passt!vorschau_link task W; say W Link raus; fx envelope at W to aussenangebot task W; say W Kauf-Link!; fx paper at Wdeal fx coin at aussen to tresor mit delay0, 0.4, 0.8; cheer otto; say ottoDeal! Super!gesperrt task W (entfällt bei system); fx stampNo at A; say AGesperrtmit A = W bekannt ? W : arnebudget_warnung task W; say otto Budget 80 %; fx alert at ottobudget_stopp task W; say otto Budget leer; fx alert at ottopause (keine) fehler task W; fx alert at W; say W Fehlerandere Art task W Alle
say-Texte laufen durchcutBubble.needsStateRefresh: wahr beipult,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
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;
createJwtCacheausserverpulsAuth.js(nurworkersClient.js). -
Produces (Übersicht §7):
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-CacheFehler sind
Errormitmessage(deutsch) undstatus. -
Step 1: Failing test schreiben (
tests/unit/workersApi.test.mjs)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: FAILCould not resolve …/workersApi.js. -
Step 3: Implementieren
src/lib/workersApi.js: einrequest(path, { method, body })nach MusterfinanceRequest.js:fetchImplwirft ⇒DOWN_TEXT(status 0);getJwtwirft ⇒SESSION_TEXT(401); Antwort 401 ⇒ einmalgetJwt({ fresh: true }), danach erneut 401 ⇒SESSION_TEXT; JSON-Parsefehler ⇒ bei 502/503/504DOWN_TEXT, sonstBAD_REPLY_TEXT(mit dem echten Status); 403 ohneerror⇒FORBIDDEN_TEXT; sonstiger Fehlerstatus ⇒data.erroroderHustler-Fehler <status>. ErlaubtepultAction-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 inPULT_STATUSstehen (offen,in_arbeit,gesendet,verworfen,veraltet,erledigt), sonstError('Unbekannter Pult-Status: <Wert>')vor jeder Anfrage; AnfrageGET /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-Reihenfolgeroom,q,page,id, leere Werte fallen weg (P6 brauchtidfür den Ereignislink/hustler?tab=botleads&bot=<prospectId>).limithat den Standard (200 bzw. 50) nur beiundefined(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, wieserverpulsApi.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
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:
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) HilfsfunktionmakeFeedHarness({ state, eventReplies, eventsHang })im Testfile: Fake-Timer (setTimer(fn, ms)/clearTimer,timersals Map,advance(ms)feuert fällige Timer in Zeitreihenfolge und wartet je Timer einensetImmediate),settle()(zweisetImmediate), Fake-apimitstate()(zählt inlog.state, gibtstatezurück, Standard{ lastEventTs: '2026-10-17T20:00:00.000Z', workers: [] }) undevents(after, limit)(hängt[after, limit]anlog.events; antwortet miteventReplies.shift() ?? [], einErrorin der Liste wird als Ablehnung zurückgegeben; miteventsHangkehrt der Aufruf nie zurück), Schaltervisible.v, Protokollelog.snapshots(je Aufruf vononSnapshotder Zustand),log.batches(jeonEventsdie Liste) undlog.errors(jeonError(e)der Eintrage ? e.message : null).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: FAILCould not resolve …/workersFeed.js. -
Step 3: Implementieren
createFeedController(Regeln): Zustand und Ereignisse laufen mit je einemsetTimernach Abschluss des vorherigen Abrufs (kein Intervall, kein Überlappen;inFlight.state/inFlight.events; je Schleife höchstens ein wartender Timer:visibilityChanged()undrefreshState()löschen ihn vor dem sofortigen Abruf, nach dem Abruf wird neu gesetzt); Start: sichtbar ⇒loadState(), bei ErfolgonSnapshot(state), beim ersten Erfolgcursor = state.lastEventTs || '1970-01-01T00:00:00.000Z'und danachloadEvents();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) undpages < 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 ⇒ sofortloadState()und (Cursor vorhanden)loadEvents();stop()löscht Timer, erhöht eine Generationszahl (späte Antworten ändern nichts),start()nachstop()beginnt neu.useWorkersFeed: Controller mit echten Timern unddocument.visibilityState,visibilitychange-Listener,onEventsüberuseRef(stabil), Cleanupstop()+ Listener entfernen;refresh=controller.refreshState().useWorkerTrace: lädt beiworkerId-Wechsel und alle 15 s bei sichtbarem Tab,createRequestGuardverwirft späte Antworten,workerIdnull ⇒{ trace: null }. -
Step 4: Bestehen sehen — gleicher Befehl. Expected:
# pass 8,# fail 0. -
Step 5: Commit
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.jsZ. 7–142 (Palette, Sprite-Karten,drawMap,drawWorker,drawSelect,drawPaper) und Z. 277–307 (label,drawBubble). ES5-IIFE ⇒ benannte ES-Exporte; die KonstanteSentfällt zugunsten des Rasters. -
Produces:
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:[] }:Proxynach Mustersmoke.js(fillRect/fillTextaufzeichnen mit aktuellemfillStyle/font,fillTextmit Nicht-String wirft,measureText⇒{ width: länge*7 },globalAlpha-Zuweisungen inalphas,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')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: FAILCould not resolve …/engine/sprites.js. -
Step 3: Implementieren
engine/sprites.jsundtests/unit/helpers/fakeCtx.mjsPortieren wie oben. Neu gegenüber der Vorlage: (1) Raster überctx.wkGrid; (2) Posen:standundtypezeichnen gleich (das Wippen macht die Regie),sleepersetzt 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 mitt),coffeezeichnet eine 3×3-Tasse an der Hand und 1 Dampfpixel (flackert mitt); (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-„!“ inPAL.redmit Umriss.drawBubbleklemmtbxin[2, ctx.canvas.width - bw - 2](ohnectx.canvasStandard 960). -
Step 4: Bestehen sehen — gleicher Befehl. Expected:
# pass 5,# fail 0. -
Step 5: Commit
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,HATaussprites.js. Quelle:entwuerfe/sprites.jsZ. 146–273. -
Produces:
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'))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) })(
PALaussprites.jsper eigenemloadModuleim Test importieren.) -
Step 2: Scheitern sehen —
cd $WK && timeout 300 node --max-old-space-size=1024 --test tests/unit/workersProps.test.mjs. Expected: FAILCould not resolve …/engine/props.js. -
Step 3: Implementieren
engine/props.jsDie elf Vorlagenfunktionen (drawDeskbisdrawScale) 1:1 portieren (pxaussprites.js);drawTray,drawSign,drawIconundICONSsind neu;drawMonitorbekommton(aus ⇒ nur Rahmen und Schirm, keine Zeilen, kein Blinken).ICONSals 7×7-Zeichenkarten (aerzteKreuz,handwerkHammer,selbstPerson,ladenMarkise,netzFunkwellen,kleinanzeigenUmschlag mit @-Punkt,portaleZeilenliste,forenSprechblase);drawIconzeichnetxincolor,ainPAL.gold. Entfällt aus der Vorlage:drawConveyor,drawZone. -
Step 4: Bestehen sehen — gleicher Befehl. Expected:
# pass 3,# fail 0. -
Step 5: Commit
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:
// 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: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: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: FAILCould not resolve …/layout.js. -
Step 3: Implementieren
layout.js: Etage 0top6 /floorY40, Etagei ≥ 1:top = 41 + 23*(i-1),floorY = top + 22;fy(layout, i) = floorY − 14,cabTop(layout, i) = floorY − 21(Kabinenhöhe als Zahl,layout.jsimportiertlift.jsnicht). Räume je Etage ausroles(Reihenfolge der Rollen) plus{ id:'pult' }infreigabeund{ id:'tresor' }intresor; 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)mitbreite24 für Otto, sonst 12;y = fy=floorY - 14, OttofloorY - 27.ROOM_METAaus 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 ausTEAM(Suche-Räumeradarbzw. eigener Branchenschlüssel, Gespräche/Brücke/Pultfreigabe, Tischewerkstatt, Waagebewertung).spotswie im Test.camera.js: Sichtfeld =W/zoom × H/zoom;zoomAtsetztzoom = clamp(zoom*factor, 1, 3)undx = wx - fx*W/zoom'(mitwxvorher ausviewToWorld), danachclampCamera;fitZoom = w >= 720 ? 1 : min(2.2, 720/w);pickAgentprüft von hinten nach vorn die umpadvergrößerten Kästen. -
Step 4: Bestehen sehen — gleicher Befehl wie Step 2. Expected:
# pass 8,# fail 0. -
Step 5: Commit
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.jsZ. 366–393 (addFx/updateFx/drawFx/envelope). -
Produces:
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 Weltkoordinatendraw(ctx, progress 0..1, effect);onEndgenau einmal beim Ablauf;clear()ruft keinonEnd; überMAX_FXhinaus wird der neue Effekt verworfen und seinonEndsofort gerufen. -
Step 1: Failing test schreiben (
const F = await loadModule('…/engine/fx.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: FAILCould not resolve …/engine/fx.js. -
Step 3: Implementieren
engine/fx.jsLebensdauern:paper0,9 s (Bogen vonfromnachto),envelope3,4 s (Bogen mit Höhe 12, wiesentOut),coin0,7 s (Bogen, 14 hoch),stampOk/stampNo1,2 s (Stempel hüpft überfrom),sparkle1,0 s,alert1,5 s (blinkendes „!“ überfrom),drawer0,9 s (Papier fliegt vonfromnachto, Ende kürzer 3 px). Jeder Effekt zeichnet nur mitpx/label. -
Step 4: Bestehen sehen — gleicher Befehl. Expected:
# pass 3,# fail 0. -
Step 5: Commit
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.jsZ. 316–330 (Kabine), 354–357, 466–492 (Zustandsautomat). -
Produces:
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 }mitst:queued → open → boarded → arrived → out, außerdemcancelled; die Übergängeboardedundoutsetzt der Fahrgast (Regie), alle anderen der Aufzug.state:idle|toPick|boarding|closing|riding|unloading. Während der Fahrt folgtreq.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,cabTopauslayout.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: FAILCould not resolve …/engine/lift.js. -
Step 3: Implementieren
engine/lift.jsZustandsautomat 1:1 aus der Vorlage (Z. 466–492) mitcabMove(speed110) undfollow; neu:cancel(agent)markiert Anfragen des Agentencancelled, entfernt sie aus der Warteschlange und setzt bei der laufenden Anfragereq = null,state = 'idle';reset()macht dasselbe für alle;updateüberspringtcancelled. Startpositiony = cabTop(layout, 2).drawBack/drawFrontaus Z. 316–330 (Kabine 20 × 21 bei x 18, Seil, Lampe), Etagenlampen zeichnetbuilding.jsüberat(i). -
Step 4: Bestehen sehen — gleicher Befehl. Expected:
# pass 3,# fail 0. -
Step 5: Commit
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.jsZ. 99–314. -
Produces:
// 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.pausedRaumtabelle (
kind⇒ Inhalt): bruecke Konsole mit 2 Monitoren, Tafel, Fenster, Pflanze · radardrawRadar+ Tisch · inbox Tisch, Monitor,drawTray· feed Tisch, Monitor mit Zeilen · board Tisch, Monitor, Sprechblasen-Tafel · scout Tisch in Teamfarbe, Monitor, Branchen-Icon · waagedrawScale, 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ßerdrawMailboxmit 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 auslook.busyRooms; Räume inlook.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)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: FAILCould not resolve …/engine/rooms.js. -
Step 3: Implementieren
engine/rooms.js,engine/building.jsEinedrawRoom(ctx, room, floor, t, look)jekind(Tabelle oben) über ein ObjektROOM_DRAWERS[kind], alle Koordinaten relativ zuroom.x/floor.floorY; Schriften nur überlabel. Räume mitworkerIdnull (pult,tresor) werden nie grau.trophyPaging:pages = max(1, ceil(total/perPage)),pageauf0..pages-1geklemmt,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 plusFLOOR_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
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:
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 alertAgent:
{ 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 19workers({ workerId, status: statuses[id] ?? 'arbeitet', task: '', counters: {}, connected: connected[id] ?? true }; wie im Dienst (P1-E9) sind nur Such-Worker nicht angeschlossen: die Tests setzenconnected: falseausschließlich fürrita,kim,pia,finn,ali,hanna,sven,lea) undkpis(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 durchkpis),ev(kind, workerId, extra)⇒ Ereignis mit eindeutigerid,ts(zählt je Aufruf um 1 s hoch),text: 'Text'. -
Step 1: Failing test schreiben (
tests/unit/workersDirector.test.mjs)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: FAILCould not resolve …/engine/director.js. -
Step 3: Implementieren
engine/director.js(Teil 1) undtests/unit/helpers/workersFixtures.mjsapplyState(state):workers = indexWorkers(state); je Agentw= Eintrag oder fehlend ⇒grey;grey = status === 'grau';look.greyRoomsaus den Räumen grauer Agenten;look.busyRooms= Räume mit Statusarbeitet; Kennzahlen nachlook; steigtkpis.dealsMonthgegenüber dem vorigen Snapshot (nicht beim ersten),look.trophyPop = 1.2;look.paused = !!state.paused. Pose-RegelposeFor(a): grau ⇒sleep;leerlauf⇒idleT >= 25 ? sleep : coffee;arbeitet⇒type; sonststand;alert = status === 'fehler'. Der erste Snapshot setzt alle Agenten aufhomeOf; spätere Snapshots bewegen nie, sie setzen Status/Pose nur bei Agenten ohne laufende Gruppe (sonst nach Ende der Gruppe).update(dt): beilook.pausedsofort zurück; sonst Agenten (idleTwächst bei Statusleerlauf, Pose jedes Bild neu nachposeForfür Agenten ohne laufende Gruppe, Wippen, Blasen-Timer),lift.update,fx.update,look.trophyPop/vaultFlashherunterzählen,look.pageT += dt⇒ bei ≥ 6trophyPage = (trophyPage + 1) % pages.drawAgents: nachy + lane + bob + 13*scalesortieren,drawWorkermitpose/grey/t,agent.boxmerken, Stapel-Papiere und „!“ zeichnen, Blasen zuletzt (Otto seitlich wie in der Vorlage Z. 929–944).bubble(a, text):cutBubble, höchstensBUBBLE_MAXgleichzeitig, sonst stillschweigend verworfen.pushEvents,flush,reset,debugkommen erst in P2-T13; hier liefertpending()vorerst 0. -
Step 4: Bestehen sehen — gleicher Befehl. Expected:
# pass 5,# fail 0. -
Step 5: Commit
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,makeStateaus den Fixtures)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 functionbzw. Assertion-Fehler beipending). -
Step 3: Implementieren in
engine/director.jspushEvents(events): (1)workers = reduceEvents(workers, events), nur den Status auf die Agenten übertragen (agent.tasksetzen alleintask-Schritte und der Snapshot) undlook.busyRoomssowielook.greyRoomsaus den Agenten neu bilden (dieselbe Hilfsfunktion wie inapplyState); (2) je Ereignisops = eventToSteps(e), Besitzer der Gruppe =fromdes erstengive, sonstW(wenn inROLES), sonstotto; (3) die letztenBACKLOG_KEEPGruppen sind animiert, alle älteren still; zusätzlich wird eine Gruppe still, wenn der Besitzer grau ist oder schonGROUPS_PER_AGENTGruppen hält; (4) stille Gruppen führen sofort nurtask/poseaus, animierte werden anagent.qgehängt (groups++). Besitzer-Queue wie in der Vorlage (nextStep/stepAgent, Z. 864–883), jeder Op wird zu Schritten:tasksetztagent.task;sayruftbubble(Zielwho);posesetzt Pose undidleT = 0(bei grauen Agenten wirkungslos);cheersetztcheer = 2;fxruftspawnFxmitfrom = posOf(at),to = posOf(to)(Agent: Kopfmitte, Platz:layout.spots;drawerohneto⇒spots.archiv; beicoin-Endelook.vaultFlash = 0.7);give= Folge: Dokument aufnehmen (carrying = true) → zum Empfänger (bei anderer EtagerideStep(vonEtage, nachEtage), Algorithmus unverändert aus der Vorlage Z. 435–464 mitlift.request, WartepositionenLIFT.slot0 + LIFT.slotStep * door[etage]++) → neben den Empfänger laufen (links oder rechts, je nach Seite, Abstand 12), Empfängerbubble('Danke!')→carrying = false→ Rückfahrt →walkTo(home.x)→ Blickrichtung undposeFor.rideStepbricht in jeder Phase beireq.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 alsSTEP_TIMEOUT_S, wirdlift.cancel(agent)gerufen, der Türzähler zurückgenommen, der Agent heimgesetzt und die Gruppe beendet.pending()= Summegroups+ laufende.flush(): wiederholtupdate(0.25)(höchstens 2000 Mal) bispending() === 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
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):
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 = 4raf/caf/nowStandard:globalThis.requestAnimationFrameusw. beim Aufruf (nie beim Import).onSelectkommt nur bei Klicks des Nutzers, nie beiselect().tests/unit/helpers/engineVm.mjs:createEngineSandbox({ cssWidth = 960, dpr = 1 } = {})bündeltscene.jsmit esbuild als IIFE (platform: 'neutral',globalName: 'WKEngine'), führt es invm.createContextaus (Sandbox ohnewindow/document, nurconsole,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=Setder verschiedenen gezeichneten Texte): Fake-Canvas (getContext⇒ Proxy-Kontext nachsmoke.js,addEventListener/removeEventListenerführen Buch,getBoundingClientRect⇒{ left:0, top:0, width: cssWidth, height: cssWidth*0.8 },style,width,height),opts(extra)⇒ Optionen mitraf/caf/now/dpraus dem Sandbox-Takt,tick(s)rücktnowum 1000/60 ms je Schritt vor und führt fälligeraf-Rückrufe aus. -
Step 1: Failing tests schreiben (
tests/unit/workersScene.test.mjs; Imports:createEngineSandboxaus./helpers/engineVm.mjs,makeState,ev,IDSaus./helpers/workersFixtures.mjs)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: FAILCould not resolve …/engine/scene.js. -
Step 3: Implementieren
engine/scene.jsundtests/unit/helpers/engineVm.mjsRegeln:resize()liestcanvas.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; StartzoomfitZoom(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 alleFRAME_MS(dazwischen nur neu planen),dt = min(DT_MAX, Δt),director.update(dt)vor dem Zeichnen;stats()misstavgDrawMsmitnow(). Eingabe:click⇒viewToWorldaus Klickposition ⇒pickAgent(boxes, x, y, pad)mitpad = (coarsePointer ? 10 : 4) / (cssW / (320/zoom))⇒onSelect(id|null);pointerdown/move/upverschieben die Kamera (panBy) nur beizoom > 1, Bewegung überDRAG_PXunterdrückt den nächstenclick;wheelzoomt nur mitctrlKey(passive: false);style.touchAction='none'bei Zoom > 1, sonst'pan-y'.select(id)setzt die Markierung und zentriert bei Zoom > 1.setStateundpushEventszeichnen sofort ein Bild (drawOnce), damit Klick undlocatefrische Trefferflächen haben.locate(id)⇒ Mitte der Trefferfläche (Weltkoordinaten der letzten Zeichnung, durch die Kamera in CSS-Pixel umgerechnet) odernullaußerhalb der Ansicht.reducedMotion: keinraf;setState/pushEvents/select/resize/zoomByrufendirector.flush()(nur beipushEvents) 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
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 Sekundesec: alle 26 Arten ausEVENT_KINDSverteilt auf die Sekunden 3, 8, 13, … (eine Art alle 5 s, in der Reihenfolge der folgenden Liste, Arbeiter passend zur Art:gelesenkim,fundrita,doppeltarne,uebergaberita→bea,bewertetbea,top3bea,botleadarne,entwurfmax,pultclara,gesendetmax,eingangnora,sortiertnora,bau_startbob,bau_fertigbob,pruefung_zurueckpaul→bob (meta.round1),pruefung_okpaul,vorschau_linkmax,angebottheo,dealsystem,gesperrtsystem,budget_warnungotto,budget_stoppotto,pausesystem,fehlerben,auftragotto→kim,leerlaufpia); zusätzlich ab Sekunde 100 ein Stoß von 120uebergabe-Ereignissen auf einmal.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
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);workersSettingsaus Übersicht §4.1. -
Produces:
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_KEYSRegeln, gleich oder enger als die Prüfung des Dienstes (P1-T07; Fehlertext in Klammern):
dailyBudgetUsdZahl > 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).);warnPctganz 1..100 (Warnschwelle: ganze Zahl von 1 bis 100.);mailColdPerDayganz 0..3 (Höchstens 3 unverlangte Mails pro Tag.);mailReplyCapPerDayganz 0..15 (Höchstens 15 Antwort-Mails pro Tag.);sendWindowStart/sendWindowEndHH:MM, Beginn vor Ende (Uhrzeit als HH:MM, Beginn vor Ende.);runsPerNightganz 1..2 (Läufe pro Nacht: 1 oder 2.);candidatesPerRunganz 1..20 (Kandidaten pro Lauf: 1 bis 20.);regions1..30 Zeilen, je 1..64 Zeichen (Mindestens eine Region, höchstens 30, je Zeile höchstens 64 Zeichen.);previewGate∈erste_n|immer|nie;previewGateNganz 0..1000;offerText1..400 Zeichen (Angebotstext: 1 bis 400 Zeichen.; 400 hält eine Kaltmail unter 180 Wörtern, H3/A11);pultStaleDaysganz 1..30;followupDays/previewReminderDaysganz 1..60;retentionDaysganz 7..90 (Aufbewahrung: 7 bis 90 Tage.; der Datenschutztext verspricht höchstens 90 Tage, A11);previewDeleteDaysganz 7..365;reportEmaileinfache Adresse. Dezimalkomma wird akzeptiert (12,5). Schreibgeschützt (nie im Patch, nie im Formular bearbeitbar):READONLY_KEYS;connectedRoomssetzt allein der Dienst (P3 pflegt die Anfragen-Räume, Kenso die Branchen-Räume per Skript, P2-E5),privacyInteressentenLiveder Datenschutz-Schalter (A5),sendingHaltedundsendingHaltedReasondie Notbremse (H4; aufgehoben wird sie nur über den Knopf des Dialogs, P2-T18).offerProductKeyist ebenfalls nur Anzeige (der Dienst erlaubt nurhosting). -
Step 1: Failing test schreiben (
const F = await loadModule('src/lib/workersSettingsForm.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: FAILCould not resolve …/workersSettingsForm.js. -
Step 3: Implementieren
src/lib/workersSettingsForm.jsnach den Regeln oben; Feldregeln als DatentabelleRULES(Typ, Grenzen, Fehlertext),formToPatchvergleicht typisiert mitoriginal(Listen für Räume reihenfolgeunabhängig, inROOM_CHOICES-Reihenfolge ausgegeben;regionsreihenfolgetreu) und lässtREADONLY_KEYSimmer weg. -
Step 4: Bestehen sehen — gleicher Befehl. Expected:
# pass 3,# fail 0. -
Step 5: Commit
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-routerLink. -
Produces:
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)=readFileSyncrelativ zusrc/components/workers/)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: FAILENOENT … 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-chipmitwk-chip-opus|sonnet|haiku|overseer,wk-facts,wk-counters,wk-trace,wk-trace-time,wk-list,wk-list-floor,wk-dotmitwk-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: ohneworkerId⇒ 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, sonstMODEL_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 =FLOORSmitFLOOR_TITLES-Zwischenüberschrift, Statuspunkt ausstatusTone. -
Step 4: Bestehen sehen — gleicher Befehl. Expected:
# pass 3,# fail 0. -
Step 5: Commit
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): …“ aussettings.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 ErfolgonSaved(neueEinstellungen)undonClose(). Der Dialog kenntpausednicht. Block „Versand gestoppt“ (H4, A21): Istsettings.sendingHaltedwahr, steht oben im Dialog (über den Gruppen) ein Block mit der Überschrift „Versand gestoppt“, dem Grund aussettings.sendingHaltedReasonals Textknoten (React-Text, nie HTML; ist er leer: „Grund nicht angegeben“) und dem Knopf „Versand wieder freigeben“. Der Knopf ruftapi.saveSettings({ sendingHalted: false })(genau dieses eine Feld; der Dienst leert dabei den Grund) und danachonSaved(neueEinstellungen); der Dialog bleibt offen, mischt die Antwort in seinsettingsein ({ ...settings, ...antwort }; der Block verschwindet, weilsendingHaltednun 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 vonformToPatch/validateForm: „Speichern“ sendet ihn nie, und das Freigeben ändert den Patch-Zustand des Formulars nicht. -
Step 1: Failing Test ergänzen
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: FAILENOENT … WorkersSettingsDialog.jsx. -
Step 3: Implementieren
WorkersSettingsDialog.jsxDialog widemitas="form"; FormularzustanduseState(settingsToForm(settings));errors = validateForm(form).errors;patch = formToPatch(form, settings);busywährend des Speicherns; Hinweis unter „Unverlangte Mails pro Tag“: „Höchstens 3 (Spec §8.1).“; Räume-Tabelle mitROOM_CHOICESund.check-Haken. Der Notbremsen-Block (siehe Produces) hat eigenen Zustand (releasing,releaseError), steht nur beisettings.sendingHaltedim Dialog und schreibtapi.saveSettings({ sendingHalted: false })exakt so (der Quelltext-Test sucht diese Zeichenfolge); in der Quelldatei kommt das Wortpausednirgends vor, auch nicht in Kommentaren. -
Step 4: Bestehen sehen — gleicher Befehl. Expected:
# pass 4,# fail 0. -
Step 5: Commit
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:
// 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."/>pluswk-zoommit „+“, „−“, „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⇒ ZustandreduceMotion(Neu-Mount), Cleanup entfernt alles und ruftdestroy(). Zweiter Effekt[state, engineKey]⇒handle.setState(state), dritter[selectedId]⇒handle.select(selectedId ?? null). Board: Ohne übergebenesstageRef(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 überref(sonst stürzt die Seite beim ersten Ereignis ab, der Prüfstand würde es nicht zeigen, weil er einstageRefübergibt).useWorkersFeed(api, { onEvents });onEvents⇒setWorkers(w => reduceEvents(w, evs))undref.current?.pushEvents(evs); bei neuemstatesetWorkers(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>. Kopfzeilewk-toolbar: Statustext („Läuft“ / „Angehalten: Es wird nichts gesendet und nichts gebaut.“), PausenknopfpauseLabel(state.paused)für alle Mitarbeiter (sperrt während des Aufrufs; Fehler alsmsg err; danachrefresh()),isAdmin⇒ „Einstellungen“ (öffnet den Dialog). Notbremse (H4, A21): Iststate.sendingHaltedwahr, steht zwischen Kopfzeile undwk-main(für alle Mitarbeiter, nicht nur Admins) eine Zeile<div className="msg err" role="alert">mit dem Text „Versand gestoppt: . Ein Admin kann ihn in den Einstellungen freigeben.“;<Grund>=state.sendingHaltedReasonals Textknoten, getrimmt und ohne abschließende Punkte (damit kein „..“ entsteht), leer ⇒ „Grund nicht angegeben“. Sie erscheint zusätzlich zumsg warn(Verbindung gestört), ersetzt es nicht, und verschwindet mit dem nächstenstate, in demsendingHaltedfalsch ist;WorkersSettingsDialogbekommtonSaved={() => refresh()}, damit die Zeile nach dem Freigeben sofort verschwindet. Layout:wk-mainmit Bühne links und Seitenspalte (WorkerCard, darunterWorkerList) rechts. -
Step 1: Failing Quelltext-Test ergänzen
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: FAILENOENT … 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:undEin 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
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ängtfreigabean und passtworkersRoute.test.mjsan, P3-E11; P6 hängtbotleadsan; kein Platzhalter-Reiter, P2-E7), Auswahl über?tab=mitpickTab,Tabsnur beiWORKERS_TABS.length > 1; Reiterhochhausrendert<WorkersBoard api={api} isAdmin={isAdmin} />ohnestageRef); Route/hustler(lazy);/leadsund/workersleiten über die kleine KomponenteHustlerRedirect(<Navigate to={/hustler${search}} replace />,searchaususeLocation()) auf/hustlerum und behalten die Query; Menüeintrag{ key: 'hustler', label: 'Hustler', href: '/hustler' }an der Stelle vonleads, ohnepaused. -
Step 1: Failing Tests schreiben
tests/unit/navLinks.test.mjsanpassen: in beidendeepEqual-Listen'leads'durch'hustler'ersetzen (an derselben Stelle; die Schlüsselleadsundworkerskommen nicht mehr vor) und die Zeileassert.equal(links.find((l) => l.key === 'leads').paused, true)durchassert.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 vonsrc/App.jsx):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: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: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:leadsnoch vorhanden; übrigeENOENT). -
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 mitrg -A6 '^/\* latin \*/$' "$D/pixelify.css" | rg -o 'https://fonts\.gstatic\.com/[^)]+\.woff2'(genau ein Treffer) und mitcurl -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“) mitcurl -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"istwOF2,wc -czwischen 10 000 und 400 000, die Lizenz enthält „SIL OPEN FONT LICENSE Version 1.1“; erst dannmkdir -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 }mitborder-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(MusterLegalPage.jsx,useSearchParamsfür?tab=undsetSearchParamsbeim Reiterwechsel,page wk-page,<Tabs>nur beiWORKERS_TABS.length > 1),App.jsx:import { lazy, Suspense, … } from 'react',useLocationausreact-router-domergänzen,const WorkersPage = lazy(() => import('./pages/WorkersPage')), Route/hustlerwie im Test (Fallback<div className="loading">Wird geladen …</div>),function HustlerRedirect() { const { search } = useLocation(); return <Navigate to={/hustler${search}} replace /> }und die Routen/leadsund/workers⇒<HustlerRedirect />(beide vorpath="*"; die Route/leadshat bisher auf/ticketsverwiesen);navLinks.js: Zeileleadsersetzen 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
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:
WorkersBoardmit Fake-API (kein Auth, kein Appwrite),EVENT_KINDS,ROLES. -
Produces:
// 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 Patchesstate(): 19 Worker,kleinanzeigen/portale/foren(kim, pia, finn) nicht angeschlossen, gemischte Status,kpiswie in den Fixtures,lastEventTs= Basiszeit;events(after): nur fällige, nie doppelte (Zeitstempel = Basiszeit +at);trace(id): 12 Ereignisse, darunter eines mitjavascript:alert(1)und eines mithttps://example.org/fund,todaymit Zählern,tokens: { in, out }, Kosten;pause(p)merkt den Zustand undstate().pausedfolgt;settings()= Standardwerte aus Übersicht §4.1 (inkl.privacyInteressentenLive: false,sendingHalted,sendingHaltedReason);state()enthältsendingHaltedundsendingHaltedReason(Standardfalse/''; mithalted: truegilttrue/'Zwei harte Bounces in den letzten 10 Mails.', H4);saveSettings(patch)merkt den Patch insaved, mitsendingHalted: falsehebt es die Notbremse auf (state()undsettings()melden danachfalse/'') 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=1erzeugt die Skript-API mithalted: true) undwindow.__wk = { stage: ref, api: scripted }nur im Prüfstand.shot.cjs <distDir> <outDir>: Musterentwuerfe/shot.cjs(gleicher Chromium-Pfad, gleichescreateRequire, Sperre auf neue Ausgabeordner unter$TMPDIR), kleiner statischer Server auf 127.0.0.1 (liefertdistDirund/fonts/*auspublic/fonts). -
Step 1: Failing Test schreiben (
tests/unit/workersHarness.test.mjs)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: FAILCould not resolve …/scripted-api.js. -
Step 3: Implementieren die fünf Dateien.
shot.cjsprüft (Exit 1 bei Verstoß, am Ende JSON undOK):- keine
pageerror/console.error;document.fonts.check('600 12px "Pixelify Sans"')ist wahr und die Schrift kam mit 200 vom Server; - Desktop 1280×900: 19 Knöpfe
[data-wk-worker]; Klick auf jeden ⇒.wk-namezeigt den Namen ausROLES,.wk-chipden vollen Modellnamen; - sechs Figuren (otto, kim, rita, bea, bob, arne):
__wk.stage.current.locate(id)liefert CSS-Pixel relativ zur Leinwand, geklickt wird anboundingBox(.wk-canvas)plus diese Werte ⇒ Karte zeigt dieselbe Figur; - Wahl von
kim:.wk-trace a[href^="javascript:"]hat 0 Treffer, die https-Zeile ist ein Link mitrel="noopener noreferrer"undtarget="_blank", diejavascript:-Zeile steht als Text; - 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;
- nach 20 s Skriptlauf:
stats().avgDrawMs≤ 25 (nur Warnung ausgeben, kein Abbruch, da software-gerendert),debug().pending≥ 0; - Handy 390×844 (DPR 2):
document.documentElement.scrollWidth <= 390, Zoom „+“ erhöhtstats().zoom; page.emulateMedia({ reducedMotion: 'reduce' })+ Neuladen: nach 2 sstats().frames <= 5;- Bilder
desktop-start.png,desktop-nach-20s.png,handy.png,handy-zoom.png,desktop-gestoppt.pngim Ausgabeordner; - Notbremse (H4/A21), Seite mit
?halted=1(Desktop 1280×900; das Bilddesktop-gestoppt.pngentsteht 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.savedist genau[{ sendingHalted: false }]; ohne?halted=1gibt es weder Zeile noch Block.
- keine
-
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)
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 alsfitZoom-Schwelle (720 px, die Seitenspalte nimmt 320 px + 32 px Abstand), wird die Spaltenbreite inworkers.cssverkleinert 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
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 neuenworkers*-Tests; Zahl für die Übergabe notieren). -
Step 2: Build (neuer leerer Ordner)
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.jsund eine.css;rg -l 'noch nicht angeschlossen' "$D/assets"⇒ nur die WorkersPage-Datei, nichtindex-*.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) undgit 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).
- 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. - 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 alsALTnotieren (für das Zurücknehmen). Danachgit -C /home/webklar/apps/ticket.webklar.com/dev-workers merge-base --is-ancestor origin/test HEAD && echo OK: Kommt keinOK, hattestinzwischen neue Commits; dann nicht pushen und nie erzwingen, sondern Claude bitten,feature/workersauforigin/testzu rebasen und Schritt 3 zu wiederholen. - 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. - 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 mitdocker ps --format '{{.Names}}' | rg -i ticketfinden, Musterwebklar-ticket-build;docker logs --tail 40 <name>) endet ohne Fehler. - Im Browser (als Mitarbeiter): Menü zeigt „Hustler“ statt des grauen „Leads“;
https://ticket.webklar.com/leadsundhttps://ticket.webklar.com/workersspringen aufhttps://ticket.webklar.com/hustler(eine angehängte Query wie?tab=botleadsbleibt 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). - 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: . 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. - 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), danngit -C /home/webklar/apps/ticket.webklar.com/dev-workers push origin feature/workers:test. Prüfen:https://ticket.webklar.com/leadszeigt wieder die Tickets (graues „Leads“ im Menü). - 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).