148 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 /workers 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. - 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.
Ergänzung zur Übersicht (nur P2; vor Start in
…-00-uebersicht.md§7 nachtragen)
- E1 Fixture-Form
tests/fixtures/workers-roster.json:{ "floors": [<10 Etagen-Ids>], "roles": [{ "workerId", "name", "floor", "room", "model", "role", "caps" }] }, 19 Einträge in Reihenfolge der Übersicht §3,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. P1 legt dieselbe Datei byte-gleich ab (existiert sie dort schon, wird sie byte-identisch kopiert).- E2 JWT:
getJwt(opts?: { fresh?: boolean }) → Promise<string>; bei 401 ruftcreateWorkersApieinmalgetJwt({ fresh: true }). Neusrc/lib/workersClient.jsexportiert den verdrahtetenworkersApi.- E3 Zusätzliche Dateien:
src/lib/workersFeed.js,src/lib/workersSettingsForm.js,src/components/workers/{WorkersBoard,WorkerList}.jsx,engine/{camera,rooms}.js, Prüfstandscripts/workers-harness/.- E4
mountTower(canvas, opts):opts = { onSelect, reducedMotion?, coarsePointer?, fontsReady?, dpr?, raf?, caf?, now? }; der Handle hat zusätzlichresize(),zoomBy(faktor),resetView(),locate(workerId),stats(),debug().- E5 API-Formate, die §6.2 offenlässt:
state.workers[].countersundtrace.today.counters= Objekt{ <zaehler>: Zahl };trace.today.tokens= Zahl oder{ in, out }(P1 liefert{ in, out });trace.worker= ROLES-Eintrag plusmodelId(voller Modellname, P1-E2; fehlt er, nimmt WOMSMODEL_IDS[model]);trace.eventsin beliebiger Reihenfolge (P1: neueste zuerst; WOMS sortiert selbst absteigend);connectedRoomssetzt allein der Dienst (P3-T08); WOMS zeigt es nur an und sendet es nie (die Prüfung P1-T07 würde es annehmen, deshalb steht es inREADONLY_KEYS);link=https://…,http://…oder interner Pfad/…; fehlt ein Worker instate.workers, gilt er als nicht angeschlossen.- E6 Ereignis-
meta, das WOMS liest (alle optional):count(gelesen),score(bewertet, Zahl 0–100, neu: P3/P6 sollen es senden),classification(sortiert),round(pruefung_zurueck),paused(pause),by(gesendet).- E7 Abgrenzung und Reiter: Das Tagesziel („3 Erstkontakte, 1 Vorschau“, Spec §5) braucht Felder im
state, die §6.2 nicht liefert, und fehlt deshalb in P2.WorkersPage.jsxhältWORKERS_TABSmit dem einen Reiterhochhaussowie dieapi-Instanz (const api = workersApi); es gibt keinen Platzhalter-Reiter: P3 hängtfreigabean (P3-E8), P6botleads(Tab-Ids:hochhaus,freigabe,botleads; Links wie/workers?tab=botleads&bot=<id>laufen als interneLink; ein unbekannter?tab=fällt aufhochhauszurück). Die Reiterleiste (Tabs) erscheint erst ab zwei Reitern. Der Einstellungs-Dialog ändertconnectedRoomsnie.
Review Focus
- Dienst nicht erreichbar oder nginx-Block fehlt (HTML-Seite mit Status 200, 502, Netzfehler) → deutsche Meldung „Der Workers-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, Worker fehlt im Zustand, unbekannte Ereignisart oder unbekannte
workerId→ grauer Raum mit Schild „noch nicht angeschlossen“, schlafende Figur, keine vorgespielte Arbeit, kein Wurf. Tests: P2-T03, P2-T11, P2-T12, P2-T13. 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).
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, Patch) |
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) |
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, /leads → /workers), src/lib/navLinks.js (Eintrag workers), 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 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, 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' }) }) -
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 (E1). 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).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 3,# 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: 8.5, apiMonthUsd: 50, dailyBudgetUsd: 10, monthlyBudgetUsd: 250 } 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', `8,50${NB}$`]) assert.equal(t[0].sub, `60${NB}€ neu pro Monat`); assert.equal(t[5].sub, `von 10,00${NB}$`) assert.deepEqual(t.map((x) => x.tone), ['', '', '', 'warn', '', 'warn']) assert.equal(V.kpiTiles({ ...K, apiTodayUsd: 10 })[5].tone, 'neg'); assert.equal(V.kpiTiles({ ...K, apiTodayUsd: 7.9 })[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 }), prospect(id) } export const DOWN_TEXT, BAD_REPLY_TEXT, SESSION_TEXT, FORBIDDEN_TEXT // 'Der Workers-Dienst ist gerade nicht erreichbar.' · 'Der Workers-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: 11 }, () => ({ 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.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/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 Workers-Dienst antwortet nicht richtig.', 200]) // nginx liefert index.html assert.deepEqual(await run({ status: 404, body: undefined }), ['Der Workers-Dienst antwortet nicht richtig.', 404]) assert.deepEqual(await run({ status: 502, body: undefined }), ['Der Workers-Dienst ist gerade nicht erreichbar.', 502]) assert.deepEqual(await run(new TypeError('fetch failed')), ['Der Workers-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: {} }), ['Workers-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('pultAction: nur bekannte Aktionen, Pfad-Tricks abgewiesen', async () => { const f = fakeFetch(Array.from({ length: 6 }, () => ({ 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']) await api.pultAction('p1', a, a === 'reply' ? { text: 'x' } : undefined) assert.deepEqual(f.calls.map((c) => c.url.split('/').pop()), ['claim', 'sent', 'discard', 'done', 'reply', 'approve-preview']); assert.equal(f.calls[4].init.body, JSON.stringify({ text: 'x' })) }) 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.erroroderWorkers-Fehler <status>. ErlaubtepultAction-Namen:claim,sent,discard,done,reply,approve-preview.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 5,# 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 Workers-Dienst ist gerade nicht erreichbar.'), []] }); err.c.start(); await err.settle(); await err.advance(5000) assert.deepEqual(err.log.errors, ['Der Workers-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, lastEventTs } = {})⇒ Zustand mit 19workers({ workerId, status: statuses[id] ?? 'arbeitet', task: '', counters: {}, connected: connected[id] ?? true }) undkpis(Standard{ newAbosMonth: 3, mrrNewEur: 60, dealsMonth: 3, openThreads: 7, pultOpen: 2, botLeads: 112, apiTodayUsd: 8.5, apiMonthUsd: 50, dailyBudgetUsd: 10, monthlyBudgetUsd: 250 }, ü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) }) 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'] 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.);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..1000 Zeichen (Angebotstext: 1 bis 1000 Zeichen.);pultStaleDaysganz 1..30;followupDays/previewReminderDaysganz 1..60;retentionDaysganz 7..365 (Aufbewahrung: 7 bis 365 Tage.);previewDeleteDaysganz 7..365;reportEmaileinfache Adresse. Dezimalkomma wird akzeptiert (12,5). -
Step 1: Failing test schreiben (
const F = await loadModule('src/lib/workersSettingsForm.js'))const S0 = { paused: true, dailyBudgetUsd: 10, monthlyBudgetUsd: 250, 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', updatedAt: '2026-10-17T10:00:00Z', updatedBy: 'x' } test('unverändertes Formular ⇒ leerer Patch; Pflichtfelder 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), {}) }) 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: '300' }).dailyBudgetUsd, 'Die Tagesgrenze darf nicht über der Monatsgrenze liegen.'); 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 1000 Zeichen.'); assert.equal(bad({ retentionDays: '5' }).retentionDays, 'Aufbewahrung: 7 bis 365 Tage.'); 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 })— 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. -
Step 1: Failing Test ergänzen
test('Einstellungs-Dialog: Dialog-Baustein, Formularlogik, kein paused, Admin-Beschriftungen', () => { 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 ['Tagesgrenze (USD)', 'Monatsgrenze (USD)', 'Unverlangte Mails pro Tag', 'Angebotstext', 'Regionen', 'Speichern', 'Abbrechen']) assert.ok(s.includes(t), t) assert.match(s, /disabled=\{/) }) -
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. -
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 Workers. 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 Workers-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). 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 Workers-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/) }) -
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). -
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 „Workers“, Untertitel „Die Nachtschicht deiner Claude-Armee“;const api = workersApi;WORKERS_TABS = [{ id: 'hochhaus', label: 'Hochhaus' }](P3 hängtfreigabean, P6botleads; kein Platzhalter-Reiter), Auswahl über?tab=mitpickTab,Tabsnur beiWORKERS_TABS.length > 1; Reiterhochhausrendert<WorkersBoard api={api} isAdmin={isAdmin} />ohnestageRef); Route/workers(lazy),/leadsleitet auf/workersum; Menüeintrag{ key: 'workers', label: 'Workers', href: '/workers' }an der Stelle vonleads, ohnepaused. -
Step 1: Failing Tests schreiben
tests/unit/navLinks.test.mjsanpassen: in beidendeepEqual-Listen'leads'durch'workers'ersetzen und die Zeileassert.equal(links.find((l) => l.key === 'leads').paused, true)durchassert.deepEqual(links.find((l) => l.key === 'workers'), { key: 'workers', label: 'Workers', href: '/workers' })tests/unit/workersRoute.test.mjs(Quelltext vonsrc/App.jsx):test('App: /workers lazy hinter ProtectedRoute, /leads leitet 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="\/workers" element=\{<ProtectedRoute><Suspense[\s\S]*?<WorkersPage \/><\/Suspense><\/ProtectedRoute>\} \/>/) assert.match(s, /<Route path="\/leads" element=\{<Navigate to="\/workers" replace \/>\} \/>/) assert.ok(s.indexOf('path="/workers"') < s.indexOf('path="*"')) }) test('WorkersPage: 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, /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',const WorkersPage = lazy(() => import('./pages/WorkersPage')), Route wie im Test (Fallback<div className="loading">Wird geladen …</div>),/leads⇒/workers;navLinks.js: Zeileleadsersetzen durch{ key: 'workers', label: 'Workers', href: '/workers' }(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 /workers (lazy), Menü 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 }): { state, events, trace, pause, settings, saveSettings, pult, pultAction, botleads, prospect } // gleiche Form wie createWorkersApistate(): 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.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}undwindow.__wk = { stage: ref }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') 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.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) }) -
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 „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.pngim Ausgabeordner.
- 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 vier 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. 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 Workers-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 „Workers“ statt des grauen „Leads“;
https://ticket.webklar.com/leadsspringt auf/workers; 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); auf dem Handy: Zoom-Knöpfe, kein seitliches Scrollen. Zu Beginn sind fast alle Räume grau mit „noch nicht angeschlossen“: das ist richtig, sie werden erst farbig, wenn der Dienst sie als angeschlossen meldet (P3, P4, P6). - 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, nichts ändern, „Abbrechen“; als Nicht-Admin fehlt der Knopf.
- 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 E1–E7 in der Übersicht nachgezogen).