Workers-Pläne abgeglichen: Ergänzungen in der Übersicht, Harvey-Ideen H1–H5, Graue-Worker-Regel, Plattform-Folgeantworten (P4-T34)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
96a9226435
commit
740089c997
7 files changed
+1551
-668
No files matched your search
@@ -21,29 +21,24 @@
|
||||
- Takt: höchstens 30 Bilder/s (`FRAME_MS` 33), `dt` ≤ 0,05 s, höchstens 3 Sprechblasen gleichzeitig (≤ 20 Zeichen), höchstens 80 gleichzeitige Effekte, Canvas-Breite ≤ 2400 Geräte-Pixel.
|
||||
- Rückstau: je `pushEvents`-Aufruf animieren nur die neuesten 6 Ereignisse (`BACKLOG_KEEP`), ältere wirken still (nur Aufgabe/Pose); je Worker höchstens 4 wartende Gruppen; ein Schritt höchstens 45 s (`STEP_TIMEOUT_S`).
|
||||
- Polling: Zustand alle 15 000 ms, Ereignisse alle 5 000 ms (`limit=200`, höchstens 5 Seiten je Runde), Spur `limit=50`; nichts bei `document.visibilityState === 'hidden'`, sofortiger Abruf beim Zurückkehren; JWT 10 min gecacht (`createJwtCache`), Header `X-Appwrite-JWT`, Basis `/api/workers`.
|
||||
- Rechte: Pausenschalter für alle Mitarbeiter; `WorkersSettingsDialog` nur für `isAdmin`; `mailColdPerDay` ≤ 3 (Spec §8.1), `mailReplyCapPerDay` ≤ 15; der Dialog sendet nie `paused`.
|
||||
- Rechte: Pausenschalter für alle Mitarbeiter; `WorkersSettingsDialog` nur für `isAdmin`; `mailColdPerDay` ≤ 3 (Spec §8.1), `mailReplyCapPerDay` ≤ 15; der Dialog sendet nie `paused`; die Formulargrenzen sind gleich oder enger als die des Dienstes (`retentionDays` 7–90, `offerText` 1–400, `offerProductKey` nur `hosting`, A11). Die Bounce-Notbremse (H4) zeigt WOMS nur an und hebt sie nur für Admins auf: Dialog-Knopf „Versand wieder freigeben“ sendet genau `{ sendingHalted: false }`, nie als Teil des Formular-Patches; `sendingHalted`, `sendingHaltedReason` und `privacyInteressentenLive` sind schreibgeschützt (`READONLY_KEYS`, Übersicht §7).
|
||||
- Weißes Theme (Konzept 2026-10-04): keine Karten/Kästen, nur Haarlinien `var(--line)`, nur Tokens aus `global.css` (keine Hex-Farben in `workers.css`), Klassen mit Präfix `wk-`, Zahlen `tabular-nums`, Breakpoints 1100 px und 860 px, „ד statt Icons.
|
||||
- Schrift: Pixelify Sans (SIL OFL 1.1) als `public/fonts/PixelifySans-Variable.woff2` (Gewichte 400–700) plus Lizenztext `public/fonts/OFL-PixelifySans.txt`, `font-display: swap`, kein Google-Fonts-Link; Download nur in P2-T20, in einen neuen leeren Ordner, Prüfung der Magic Bytes `wOF2`.
|
||||
- Bewegung und Lebenszyklus: `prefers-reduced-motion` ⇒ keine Schleife, Standbild je Änderung; `visibilitychange` ⇒ `setPaused`; `mountTower`/`destroy` idempotent (React StrictMode: mount, destroy, mount).
|
||||
- Playwright/Chromium nur seriell unter `systemd-run --scope -q -p MemoryMax=700M -p MemorySwapMax=0`, vite-Build unter `MemoryMax=1500M` mit `NODE_OPTIONS=--max-old-space-size=1200`; Ausgabeordner immer neu unter `$TMPDIR`.
|
||||
- Keine `.env`-, Compose- oder nginx-Änderung in P2 (der `/api/workers/`-Block gehört zu P1); P2 schreibt nie nach Appwrite.
|
||||
|
||||
> **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 ruft `createWorkersApi` einmal `getJwt({ fresh: true })`. Neu `src/lib/workersClient.js` exportiert den verdrahteten `workersApi`.
|
||||
> - **E3 Zusätzliche Dateien:** `src/lib/workersFeed.js`, `src/lib/workersSettingsForm.js`, `src/components/workers/{WorkersBoard,WorkerList}.jsx`, `engine/{camera,rooms}.js`, Prüfstand `scripts/workers-harness/`.
|
||||
> - **E4 `mountTower(canvas, opts)`:** `opts = { onSelect, reducedMotion?, coarsePointer?, fontsReady?, dpr?, raf?, caf?, now? }`; der Handle hat zusätzlich `resize()`, `zoomBy(faktor)`, `resetView()`, `locate(workerId)`, `stats()`, `debug()`.
|
||||
> - **E5 API-Formate, die §6.2 offenlässt:** `state.workers[].counters` und `trace.today.counters` = Objekt `{ <zaehler>: Zahl }`; `trace.today.tokens` = Zahl oder `{ in, out }` (P1 liefert `{ in, out }`); `trace.worker` = ROLES-Eintrag plus `modelId` (voller Modellname, P1-E2; fehlt er, nimmt WOMS `MODEL_IDS[model]`); `trace.events` in beliebiger Reihenfolge (P1: neueste zuerst; WOMS sortiert selbst absteigend); `connectedRooms` setzt 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 in `READONLY_KEYS`); `link` = `https://…`, `http://…` oder interner Pfad `/…`; fehlt ein Worker in `state.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.jsx` hält `WORKERS_TABS` mit dem einen Reiter `hochhaus` sowie die `api`-Instanz (`const api = workersApi`); es gibt **keinen** Platzhalter-Reiter: P3 hängt `freigabe` an (P3-E8), P6 `botleads` (Tab-Ids: `hochhaus`, `freigabe`, `botleads`; Links wie `/workers?tab=botleads&bot=<id>` laufen als interne `Link`; ein unbekannter `?tab=` fällt auf `hochhaus` zurück). Die Reiterleiste (`Tabs`) erscheint erst ab zwei Reitern. Der Einstellungs-Dialog ändert `connectedRooms` nie.
|
||||
> **Ergänzungen E1–E7** (Fixture-Form, JWT, Zusatzdateien, `mountTower`-Optionen, API-Formate, Ereignis-`meta`, Reiter/Tagesziel) sind in der Übersicht nachgetragen: siehe Übersicht §11.1, P2-E1 bis P2-E7 (dort auch der Reiter `freigabe`, den P3 anhängt: P3-E11); „E1“ bis „E7“ in diesem Plan meinen diese Zeilen.
|
||||
|
||||
## Review Focus
|
||||
|
||||
1. Dienst nicht erreichbar oder nginx-Block fehlt (HTML-Seite mit Status 200, 502, Netzfehler) → deutsche Meldung „Der 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 (`kpiTiles` ohne Daten), P2-T04 (Fehlertexte), P2-T19 (Board-Zustände).
|
||||
2. Tab lange im Hintergrund, danach 200+ Ereignisse auf einmal → keine Animationsflut: höchstens 6 Gruppen laufen, der Rest wirkt still, alle Warteschlangen sind nach ≤ 60 s Simulationszeit leer; der Feed holt seitenweise (200, höchstens 5 je Runde). Tests: P2-T05, P2-T13.
|
||||
3. Dasselbe Ereignis kommt in zwei Abfragen (gleicher Zeitstempel am Cursor) und die Vorgeschichte vor dem Öffnen der Seite → nie doppelt abgespielt, Vorgeschichte nicht nachgespielt. Tests: P2-T03 (`takeNewEvents`), P2-T05 (Cursor aus dem Snapshot).
|
||||
4. Raum ohne Quelle, 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.
|
||||
4. Raum ohne Quelle (nur die acht Such-Räume aus `SEARCH_ROOMS` können es sein; der Dienst meldet alle anderen Worker von Anfang an als angeschlossen, P1-E9), Worker fehlt im Zustand, unbekannte Ereignisart oder unbekannte `workerId` → grauer Raum mit Schild „noch nicht angeschlossen“, schlafende Figur, keine vorgespielte Arbeit, kein Wurf. Tests: P2-T03, P2-T11, P2-T12, P2-T13.
|
||||
5. `link` im Ereignisstrom mit `javascript:`, `data:` oder `//fremder.host` → nur Text, nie ein `href`; externe Links mit `rel="noopener noreferrer"`. Tests: P2-T02 (`safeLink`), P2-T17 (Quelltext-Test der Karte).
|
||||
6. Bounce-Notbremse (H4): `state.sendingHalted` → rote Warnzeile im Board mit dem Grund als Textknoten (nie HTML); „Versand wieder freigeben“ nur im Admin-Dialog, sendet allein `{ sendingHalted: false }` (kein Patch aus dem Formular, kein `paused`), und die drei Felder `sendingHalted`, `sendingHaltedReason`, `privacyInteressentenLive` landen nie im Formular-Patch. Tests: P2-T16 (`READONLY_KEYS`, leerer Patch), P2-T18, P2-T19, P2-T21 (Prüfstand).
|
||||
7. Das Formular lässt nichts zu, was der Dienst ablehnt (sonst kommt eine Serverfehlermeldung statt der Feldmeldung): `retentionDays` 7–90, `offerText` 1–400 Zeichen. Test: P2-T16 (Grenzen 6/7/90/91 und 400/401).
|
||||
|
||||
## File Structure
|
||||
|
||||
@@ -57,7 +52,7 @@ Alles unter `$WK`. **Neu:**
|
||||
| `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) |
|
||||
| `src/lib/workersSettingsForm.js` | Formularlogik der Einstellungen (Umwandlung, Prüfung mit den Grenzen des Dienstes, Patch; schreibgeschützt: `READONLY_KEYS` inkl. `privacyInteressentenLive`, `sendingHalted`, `sendingHaltedReason`) |
|
||||
| `engine/sprites.js` | Raster `setGrid/px`, Palette, Figuren mit Posen/Grau, Text, Sprechblasen (Quelle `entwuerfe/sprites.js` Z. 7–142, 277–307) |
|
||||
| `engine/props.js` | Möbel/Requisiten und 7×7-Branchen-Icons (Quelle `sprites.js` Z. 146–273) |
|
||||
| `engine/layout.js` · `engine/camera.js` | Etagen/Räume/Plätze als Daten · Kamera, Zoom, Umkehr-Transformation, Treffer-Auswahl |
|
||||
@@ -65,7 +60,7 @@ Alles unter `$WK`. **Neu:**
|
||||
| `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/components/workers/{WorkersKpis,WorkerCard,WorkerList,WorkersSettingsDialog,WorkersStage,WorkersBoard}.jsx` | UI-Teile (Board = Inhalt des Reiters „Hochhaus“, ohne Auth, damit der Prüfstand ihn mounten kann; Board zeigt bei `sendingHalted` eine Warnzeile, der Einstellungs-Dialog hat den Block „Versand wieder freigeben“, H4) |
|
||||
| `src/pages/WorkersPage.jsx` · `src/styles/workers.css` | Seite mit `WORKERS_TABS` (zunächst nur `hochhaus`) und der `api`-Instanz · Stil (`wk-`, `@font-face`) |
|
||||
| `public/fonts/PixelifySans-Variable.woff2` · `public/fonts/OFL-PixelifySans.txt` | Schrift und Lizenz |
|
||||
| `scripts/workers-harness/{build.mjs,entry.jsx,index.html,scripted-api.js,shot.cjs}` | Prüfstand für die manuelle Playwright-Abnahme |
|
||||
@@ -90,6 +85,7 @@ Alles unter `$WK`. **Neu:**
|
||||
export const FLOORS // ['bruecke','anfragen','ohne_website','bewertung','gespraeche','werkstatt','pruefung','freigabe','tresor','keller']
|
||||
export const ROLES // 19 × { workerId, name, floor, room, model:'opus'|'sonnet'|'haiku', role, caps }
|
||||
export const MODEL_IDS // { opus:'claude-opus-5-5', sonnet:'claude-sonnet-5-5', haiku:'claude-haiku-5-5' }
|
||||
export const SEARCH_ROOMS // ['netz','kleinanzeigen','portale','foren','aerzte','handwerk','selbst','laden'] – Spiegel von `src/roles.js` des Dienstes (Räume der Etagen anfragen + ohne_website); nur diese Räume können „nicht angeschlossen“ (grau) sein, alle anderen Worker meldet der Dienst als angeschlossen (Übersicht §11.1 P1-E9)
|
||||
export const FLOOR_TITLES // id → 'Kommandobrücke' | 'Anfragen' | 'Ohne Website' | 'Bewertung' | 'Gespräche' | 'Werkstatt' | 'Prüfung' | 'Freigabe' | 'Tresor' | 'Keller'
|
||||
export const ROOM_TITLES // 21 Räume (19 Worker-Räume + 'pult','tresor') → Titel, Werte siehe Schritt 3
|
||||
export function hatForModel(model): 'opus'|'sonnet'|'haiku' // Kurzname oder voller Modellname, sonst 'haiku'
|
||||
@@ -103,7 +99,7 @@ Alles unter `$WK`. **Neu:**
|
||||
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 { ROLES, FLOORS, SEARCH_ROOMS, FLOOR_TITLES, ROOM_TITLES, MODEL_IDS, hatForModel, hatForWorker, roleOf } = await loadModule('src/lib/workersRoster.js')
|
||||
const fixture = JSON.parse(readFileSync(new URL('../fixtures/workers-roster.json', import.meta.url), 'utf8'))
|
||||
|
||||
test('Spiegel ist gleich der gemeinsamen Fixture', () => {
|
||||
@@ -126,14 +122,20 @@ Alles unter `$WK`. **Neu:**
|
||||
assert.equal(roleOf('zed'), null)
|
||||
assert.deepEqual(MODEL_IDS, { opus: 'claude-opus-5-5', sonnet: 'claude-sonnet-5-5', haiku: 'claude-haiku-5-5' })
|
||||
})
|
||||
test('SEARCH_ROOMS: festgenagelt wie im Dienst (P1-T05) und aus der Fixture ableitbar; nur diese Worker können grau sein (P1-E9)', () => {
|
||||
assert.deepEqual([...SEARCH_ROOMS], ['netz', 'kleinanzeigen', 'portale', 'foren', 'aerzte', 'handwerk', 'selbst', 'laden'])
|
||||
assert.deepEqual([...SEARCH_ROOMS], fixture.roles.filter((r) => ['anfragen', 'ohne_website'].includes(r.floor)).map((r) => r.room))
|
||||
assert.deepEqual(ROLES.filter((r) => SEARCH_ROOMS.includes(r.room)).map((r) => r.workerId), ['rita', 'kim', 'pia', 'finn', 'ali', 'hanna', 'sven', 'lea'])
|
||||
assert.equal(ROLES.filter((r) => !SEARCH_ROOMS.includes(r.room)).length, 11)
|
||||
})
|
||||
```
|
||||
- [ ] **Step 2: Test laufen lassen, Scheitern sehen**
|
||||
Einmalig: `cd $WK && git status --short` (keine geänderten Dateien; `??`-Zeilen für noch nicht eingecheckte Plan-Dokumente unter `docs/superpowers/plans/` sind erlaubt) und `ln -s ../build/node_modules node_modules` (erscheint als `?? node_modules`, nie committen).
|
||||
Run: `cd $WK && timeout 300 node --max-old-space-size=1024 --test tests/unit/workersRoster.test.mjs`
|
||||
Expected: FAIL, esbuild `Could not resolve …/src/lib/workersRoster.js`.
|
||||
- [ ] **Step 3: Implementieren** in `src/lib/workersRoster.js` und `tests/fixtures/workers-roster.json`
|
||||
Fixture und `ROLES` aus Übersicht §3 erzeugen (E1). Liegt `/home/webklar/apps/workers-dev/test/fixtures/workers-roster.json` (P1) schon vor, wird sie byte-identisch kopiert und `ROLES` danach ausgerichtet. `ROLES`/`FLOORS` mit `Object.freeze` (tief genug für `deepEqual` nach JSON-Rundlauf). `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`.
|
||||
Fixture und `ROLES` aus Übersicht §3 erzeugen (P2-E1): Form `{ "floors": [<10 Etagen-Ids>], "roles": [{ "workerId", "name", "floor", "room", "model", "role", "caps" }] }`, 19 Einträge in Tabellenreihenfolge, `model` = `opus|sonnet|haiku`, `role` = Spalte „Einzige Rolle“ wörtlich, `caps` = Spalte „Erlaubte Fähigkeiten“ (bei „wie ali“ aufgelöst), 2 Leerzeichen Einrückung, Zeilenumbruch am Ende. Liegt `/home/webklar/apps/workers-dev/test/fixtures/workers-roster.json` (P1) schon vor, wird sie byte-identisch kopiert und `ROLES` danach ausgerichtet. `ROLES`/`FLOORS` mit `Object.freeze` (tief genug für `deepEqual` nach JSON-Rundlauf). `SEARCH_ROOMS` = Räume der Etagen `anfragen` und `ohne_website` in `ROLES`-Reihenfolge, eingefroren (dieselbe Ableitung wie in P1-T05; WOMS berechnet `connected` nicht selbst, die Konstante dient Anzeige und Prüfstand). `ROOM_TITLES`: netz „Netz-Radar“, kleinanzeigen „Kleinanzeigen“, portale „Portale“, foren „Foren & Fragen“, aerzte „Ärzte & Praxen“, handwerk „Handwerk“, selbst „Selbstständige“, laden „Läden & Gastro“, waage „Waage“, mail „Mail-Stube“, forum „Forum-Stube“, posteingang „Posteingang“, abschluss „Abschluss“, tisch1/2/3 „Tisch 1/2/3“, pruefung „Prüftisch“, archiv „Archiv & Register“, bruecke „Brücke“, pult „Dein Pult“, tresor „Tresor“. `hatForModel`: Treffer auf `/opus|sonnet|haiku/` im Text, sonst `'haiku'`.
|
||||
- [ ] **Step 4: Test laufen lassen, Bestehen sehen** — gleicher Befehl wie Step 2. Expected: `# pass 4`, `# fail 0`.
|
||||
- [ ] **Step 5: Commit**
|
||||
```bash
|
||||
cd $WK && git add src/lib/workersRoster.js tests/fixtures/workers-roster.json tests/unit/workersRoster.test.mjs
|
||||
@@ -377,7 +379,10 @@ Alles unter `$WK`. **Neu:**
|
||||
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) }
|
||||
botleads({ room, q, page, id }), prospect(id) }
|
||||
// pult(status): ein Pult-Status, eine Komma-Liste ('offen,in_arbeit', Übersicht §7 und §6.2) oder ein Array; jeder Eintrag (getrimmt) muss in PULT_STATUS stehen,
|
||||
// sonst Error('Unbekannter Pult-Status: <Wert>') ohne Anfrage; leer = 'offen'. botleads reicht id schon hier durch (P6 ändert diese Datei nicht mehr).
|
||||
export const PULT_STATUS // ['offen','in_arbeit','gesendet','verworfen','veraltet','erledigt']
|
||||
export const DOWN_TEXT, BAD_REPLY_TEXT, SESSION_TEXT, FORBIDDEN_TEXT // 'Der 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-Cache
|
||||
@@ -396,11 +401,11 @@ Alles unter `$WK`. **Neu:**
|
||||
fn.calls = calls; return fn
|
||||
}
|
||||
test('URLs, Methoden, Header, Body', async () => {
|
||||
const f = fakeFetch(Array.from({ length: 11 }, () => ({ status: 200, body: {} })))
|
||||
const f = fakeFetch(Array.from({ length: 12 }, () => ({ status: 200, body: {} })))
|
||||
const api = createWorkersApi({ getJwt, fetchImpl: f })
|
||||
await api.state(); await api.events('2026-10-17T20:00:00.000+00:00'); await api.events(null, 500); await api.trace('kim'); await api.pause(true)
|
||||
await api.settings(); await api.saveSettings({ warnPct: 70 }); await api.pult(); await api.pultAction('p1', 'claim')
|
||||
await api.botleads({ room: 'netz', q: 'Bäcker', page: 2 }); await api.prospect('x/y')
|
||||
await api.botleads({ room: 'netz', q: 'Bäcker', page: 2 }); await api.botleads({ id: 'p1' }); await api.prospect('x/y')
|
||||
assert.deepEqual(f.calls.map((c) => [c.init.method || 'GET', c.url]), [
|
||||
['GET', '/api/workers/state'],
|
||||
['GET', '/api/workers/events?after=2026-10-17T20%3A00%3A00.000%2B00%3A00&limit=200'],
|
||||
@@ -408,7 +413,7 @@ Alles unter `$WK`. **Neu:**
|
||||
['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']])
|
||||
['GET', '/api/workers/botleads?room=netz&q=B%C3%A4cker&page=2'], ['GET', '/api/workers/botleads?id=p1'], ['GET', '/api/workers/prospect/x%2Fy']])
|
||||
assert.equal(f.calls[0].init.headers['X-Appwrite-JWT'], 'jwt-alt')
|
||||
assert.equal(f.calls[4].init.body, JSON.stringify({ paused: true })); assert.equal(f.calls[4].init.headers['Content-Type'], 'application/json')
|
||||
assert.equal(f.calls[6].init.body, JSON.stringify({ warnPct: 70 }))
|
||||
@@ -431,12 +436,20 @@ Alles unter `$WK`. **Neu:**
|
||||
assert.deepEqual(jwts, [false, true]); assert.equal(f.calls[1].init.headers['X-Appwrite-JWT'], 'jwt-neu')
|
||||
await assert.rejects(createWorkersApi({ getJwt, fetchImpl: fakeFetch([{ status: 401, body: {} }, { status: 401, body: {} }]) }).state(), { message: 'Anmeldung abgelaufen. Bitte neu anmelden.', status: 401 })
|
||||
})
|
||||
test('pult: ein Status, Komma-Liste oder Array (Übersicht §7); unbekannte Status werden abgewiesen, ohne Anfrage', async () => {
|
||||
const f = fakeFetch(Array.from({ length: 5 }, () => ({ status: 200, body: {} }))); const api = createWorkersApi({ getJwt, fetchImpl: f })
|
||||
await api.pult('offen,in_arbeit'); await api.pult(['gesendet', 'verworfen', 'erledigt']); await api.pult(' veraltet '); await api.pult(''); await api.pult('gesendet, verworfen , erledigt')
|
||||
assert.deepEqual(f.calls.map((c) => c.url), ['/api/workers/pult?status=offen,in_arbeit', '/api/workers/pult?status=gesendet,verworfen,erledigt', '/api/workers/pult?status=veraltet', '/api/workers/pult?status=offen', '/api/workers/pult?status=gesendet,verworfen,erledigt']) // Kommas bleiben unkodiert, Einträge getrimmt, leer = offen
|
||||
for (const bad of ['offen,../x', 'quatsch', ['offen', 'x y'], 'offen;in_arbeit', 'offen,,in_arbeit']) await assert.rejects(api.pult(bad), /Unbekannter Pult-Status/)
|
||||
assert.equal(f.calls.length, 5) // die fünf abgewiesenen Aufrufe haben nichts gesendet
|
||||
})
|
||||
test('pultAction: nur bekannte Aktionen, Pfad-Tricks abgewiesen', async () => {
|
||||
const f = fakeFetch(Array.from({ length: 6 }, () => ({ status: 200, body: {} }))); const api = createWorkersApi({ getJwt, fetchImpl: f })
|
||||
const f = fakeFetch(Array.from({ length: 7 }, () => ({ status: 200, body: {} }))); const api = createWorkersApi({ getJwt, fetchImpl: f })
|
||||
for (const bad of ['../x', 'delete', '']) await assert.rejects(api.pultAction('p1', bad), /Unbekannte Aktion/)
|
||||
assert.equal(f.calls.length, 0)
|
||||
for (const a of ['claim', 'sent', 'discard', 'done', 'reply', 'approve-preview']) 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' }))
|
||||
for (const a of ['claim', 'sent', 'discard', 'done', 'reply', 'approve-preview', 'paste']) await api.pultAction('p1', a, a === 'reply' || a === 'paste' ? { text: 'x' } : undefined)
|
||||
assert.deepEqual(f.calls.map((c) => c.url.split('/').pop()), ['claim', 'sent', 'discard', 'done', 'reply', 'approve-preview', 'paste']); assert.equal(f.calls[4].init.body, JSON.stringify({ text: 'x' }))
|
||||
assert.deepEqual([f.calls[6].url, f.calls[6].init.method, f.calls[6].init.body], ['/api/workers/pult/p1/paste', 'POST', JSON.stringify({ text: 'x' })]) // Plattform-Folge-Antwort einfügen (P4-T34)
|
||||
})
|
||||
test('limit wird geklemmt (Ereignisse 1..200, Spur 1..50)', async () => {
|
||||
const f = fakeFetch(Array.from({ length: 3 }, () => ({ status: 200, body: {} }))); const api = createWorkersApi({ getJwt, fetchImpl: f })
|
||||
@@ -446,8 +459,8 @@ Alles unter `$WK`. **Neu:**
|
||||
```
|
||||
- [ ] **Step 2: Scheitern sehen** — `cd $WK && timeout 300 node --max-old-space-size=1024 --test tests/unit/workersApi.test.mjs`. Expected: FAIL `Could not resolve …/workersApi.js`.
|
||||
- [ ] **Step 3: Implementieren**
|
||||
`src/lib/workersApi.js`: ein `request(path, { method, body })` nach Muster `financeRequest.js`: `fetchImpl` wirft ⇒ `DOWN_TEXT` (status 0); `getJwt` wirft ⇒ `SESSION_TEXT` (401); Antwort 401 ⇒ einmal `getJwt({ fresh: true })`, danach erneut 401 ⇒ `SESSION_TEXT`; JSON-Parsefehler ⇒ bei 502/503/504 `DOWN_TEXT`, sonst `BAD_REPLY_TEXT` (mit dem echten Status); 403 ohne `error` ⇒ `FORBIDDEN_TEXT`; sonstiger Fehlerstatus ⇒ `data.error` oder `Workers-Fehler <status>`. Erlaubte `pultAction`-Namen: `claim`, `sent`, `discard`, `done`, `reply`, `approve-preview`. `limit` hat den Standard (200 bzw. 50) nur bei `undefined` (nicht bei 0) und wird auf 1..200 (Ereignisse) bzw. 1..50 (Spur) geklemmt; leere Werte fallen aus der Query. `src/lib/workersClient.js` (nicht unter Node geladen, wie `serverpulsApi.js`): `account.createJWT` + `createJwtCache` (10 min), `getJwt = (o) => { if (o?.fresh) cache.invalidate(); return cache.get() }`, `export const workersApi = createWorkersApi({ getJwt })`.
|
||||
- [ ] **Step 4: Bestehen sehen** — gleicher Befehl. Expected: `# pass 5`, `# fail 0`.
|
||||
`src/lib/workersApi.js`: ein `request(path, { method, body })` nach Muster `financeRequest.js`: `fetchImpl` wirft ⇒ `DOWN_TEXT` (status 0); `getJwt` wirft ⇒ `SESSION_TEXT` (401); Antwort 401 ⇒ einmal `getJwt({ fresh: true })`, danach erneut 401 ⇒ `SESSION_TEXT`; JSON-Parsefehler ⇒ bei 502/503/504 `DOWN_TEXT`, sonst `BAD_REPLY_TEXT` (mit dem echten Status); 403 ohne `error` ⇒ `FORBIDDEN_TEXT`; sonstiger Fehlerstatus ⇒ `data.error` oder `Workers-Fehler <status>`. Erlaubte `pultAction`-Namen: `claim`, `sent`, `discard`, `done`, `reply`, `approve-preview`, `paste` (`paste` = Antwort des Kunden von einer Plattform einfügen, Körper `{ text }`, P4-T34). `pult(status = 'offen')`: String (an Kommas teilen, Einträge trimmen) oder Array; leer ⇒ `offen`; jeder Eintrag muss in `PULT_STATUS` stehen (`offen`, `in_arbeit`, `gesendet`, `verworfen`, `veraltet`, `erledigt`), sonst `Error('Unbekannter Pult-Status: <Wert>')` vor jeder Anfrage; Anfrage `GET /api/workers/pult?status=<Einträge mit Komma verbunden>` (das Komma bleibt unkodiert, die Einträge bestehen nur aus Kleinbuchstaben und `_`). `botleads({ room, q, page, id })`: Query-Reihenfolge `room`, `q`, `page`, `id`, leere Werte fallen weg (P6 braucht `id` für den Ereignislink `/workers?tab=botleads&bot=<prospectId>`). `limit` hat den Standard (200 bzw. 50) nur bei `undefined` (nicht bei 0) und wird auf 1..200 (Ereignisse) bzw. 1..50 (Spur) geklemmt; leere Werte fallen aus der Query. `src/lib/workersClient.js` (nicht unter Node geladen, wie `serverpulsApi.js`): `account.createJWT` + `createJwtCache` (10 min), `getJwt = (o) => { if (o?.fresh) cache.invalidate(); return cache.get() }`, `export const workersApi = createWorkersApi({ getJwt })`.
|
||||
- [ ] **Step 4: Bestehen sehen** — gleicher Befehl. Expected: `# pass 6`, `# fail 0`.
|
||||
- [ ] **Step 5: Commit**
|
||||
```bash
|
||||
cd $WK && git add src/lib/workersApi.js src/lib/workersClient.js tests/unit/workersApi.test.mjs
|
||||
@@ -918,7 +931,7 @@ Alles unter `$WK`. **Neu:**
|
||||
export function drawAgents(ctx, director, selectedId, t): void // y-sortiert, setzt agent.box, Blasen, Auswahlpfeil, „!“ bei alert
|
||||
```
|
||||
Agent: `{ id, name, hat, scale, x, y, floor, facing, frame, pose:'stand'|'type'|'coffee'|'sleep', carrying, stackN, say, bubT, grey, alert, status, task, counters, lane, bob, idleT, cheer, q, cur, stepT, groups, box, home }`.
|
||||
`tests/unit/helpers/workersFixtures.mjs`: `IDS` (die 19 workerIds in Rollenreihenfolge), `makeState({ statuses = {}, connected = {}, kpis = {}, paused = false, lastEventTs } = {})` ⇒ Zustand mit 19 `workers` (`{ workerId, status: statuses[id] ?? 'arbeitet', task: '', counters: {}, connected: connected[id] ?? true }`) und `kpis` (Standard `{ newAbosMonth: 3, mrrNewEur: 60, dealsMonth: 3, openThreads: 7, pultOpen: 2, botLeads: 112, apiTodayUsd: 8.5, apiMonthUsd: 50, dailyBudgetUsd: 10, monthlyBudgetUsd: 250 }`, überschrieben durch `kpis`), `ev(kind, workerId, extra)` ⇒ Ereignis mit eindeutiger `id`, `ts` (zählt je Aufruf um 1 s hoch), `text: 'Text'`.
|
||||
`tests/unit/helpers/workersFixtures.mjs`: `IDS` (die 19 workerIds in Rollenreihenfolge), `makeState({ statuses = {}, connected = {}, kpis = {}, paused = false, sendingHalted = false, sendingHaltedReason = '', lastEventTs } = {})` ⇒ Zustand (`paused`, `sendingHalted`, `sendingHaltedReason`, `lastEventTs`, wie Übersicht §6.2) mit 19 `workers` (`{ workerId, status: statuses[id] ?? 'arbeitet', task: '', counters: {}, connected: connected[id] ?? true }`; wie im Dienst (P1-E9) sind nur Such-Worker nicht angeschlossen: die Tests setzen `connected: false` ausschließlich für `rita`, `kim`, `pia`, `finn`, `ali`, `hanna`, `sven`, `lea`) und `kpis` (Standard `{ newAbosMonth: 3, mrrNewEur: 60, dealsMonth: 3, openThreads: 7, pultOpen: 2, botLeads: 112, apiTodayUsd: 8.5, apiMonthUsd: 50, dailyBudgetUsd: 10, monthlyBudgetUsd: 250 }`, überschrieben durch `kpis`), `ev(kind, workerId, extra)` ⇒ Ereignis mit eindeutiger `id`, `ts` (zählt je Aufruf um 1 s hoch), `text: 'Text'`.
|
||||
|
||||
- [ ] **Step 1: Failing test schreiben** (`tests/unit/workersDirector.test.mjs`)
|
||||
```js
|
||||
@@ -940,6 +953,7 @@ Alles unter `$WK`. **Neu:**
|
||||
assert.deepEqual([look.pultOpen, look.dealsMonth, look.botLeads, look.trophyPop], [4, 9, 112, 0])
|
||||
d.applyState(makeState({ kpis: { pultOpen: 4, dealsMonth: 10, botLeads: 112 } })); assert.ok(look.trophyPop > 0)
|
||||
d.applyState(makeState({ paused: true })); assert.equal(look.paused, true)
|
||||
d.applyState(makeState({ sendingHalted: true, sendingHaltedReason: 'Zwei harte Bounces' })); assert.equal(look.paused, false) // Notbremse (H4) ist keine Pause: kein Schleier, die Meldung zeigt das Board
|
||||
})
|
||||
test('Leerlauf: erst Kaffee, nach 25 s Schlaf; Pokal-Seiten blättern alle 6 s', () => {
|
||||
const { d, look } = setup(); d.applyState(makeState({ statuses: { kim: 'leerlauf' }, kpis: { dealsMonth: 9 } }))
|
||||
@@ -1161,21 +1175,27 @@ Alles unter `$WK`. **Neu:**
|
||||
- Produces:
|
||||
```js
|
||||
export const ROOM_CHOICES // die 8 Suchräume [{ id, title }] (Räume der Etagen anfragen und ohne_website, Rollenreihenfolge)
|
||||
export const READONLY_KEYS // ['paused','previewsSent','updatedAt','updatedBy','offerProductKey','connectedRooms']
|
||||
export const READONLY_KEYS // ['paused','previewsSent','updatedAt','updatedBy','offerProductKey','connectedRooms','privacyInteressentenLive','sendingHalted','sendingHaltedReason'] (Übersicht §7)
|
||||
export function settingsToForm(settings): form // Zahlen als Text, regions als Zeilen, Räume als Id-Listen
|
||||
export function validateForm(form): { ok, errors: { [feld]: 'Text' } }
|
||||
export function formToPatch(form, original): object // nur geänderte, getypte Felder; nie READONLY_KEYS
|
||||
```
|
||||
Regeln, gleich oder enger als die Prüfung des Dienstes (P1-T07; Fehlertext in Klammern): `dailyBudgetUsd` Zahl > 0 (`Tagesgrenze muss größer als 0 sein.`) und ≤ `monthlyBudgetUsd` (`Die Tagesgrenze darf nicht über der Monatsgrenze liegen.`); `monthlyBudgetUsd` > 0 (`Monatsgrenze muss größer als 0 sein.`); `warnPct` ganz 1..100 (`Warnschwelle: ganze Zahl von 1 bis 100.`); `mailColdPerDay` ganz 0..3 (`Höchstens 3 unverlangte Mails pro Tag.`); `mailReplyCapPerDay` ganz 0..15 (`Höchstens 15 Antwort-Mails pro Tag.`); `sendWindowStart`/`sendWindowEnd` `HH:MM`, Beginn vor Ende (`Uhrzeit als HH:MM, Beginn vor Ende.`); `runsPerNight` ganz 1..2 (`Läufe pro Nacht: 1 oder 2.`); `candidatesPerRun` ganz 1..20 (`Kandidaten pro Lauf: 1 bis 20.`); `regions` 1..30 Zeilen, je 1..64 Zeichen (`Mindestens eine Region, höchstens 30, je Zeile höchstens 64 Zeichen.`); `previewGate` ∈ `erste_n|immer|nie`; `previewGateN` ganz 0..1000; `offerText` 1..1000 Zeichen (`Angebotstext: 1 bis 1000 Zeichen.`); `pultStaleDays` ganz 1..30; `followupDays`/`previewReminderDays` ganz 1..60; `retentionDays` ganz 7..365 (`Aufbewahrung: 7 bis 365 Tage.`); `previewDeleteDays` ganz 7..365; `reportEmail` einfache Adresse. Dezimalkomma wird akzeptiert (`12,5`).
|
||||
Regeln, gleich oder enger als die Prüfung des Dienstes (P1-T07; Fehlertext in Klammern): `dailyBudgetUsd` Zahl > 0 (`Tagesgrenze muss größer als 0 sein.`) und ≤ `monthlyBudgetUsd` (`Die Tagesgrenze darf nicht über der Monatsgrenze liegen.`); `monthlyBudgetUsd` > 0 (`Monatsgrenze muss größer als 0 sein.`); `warnPct` ganz 1..100 (`Warnschwelle: ganze Zahl von 1 bis 100.`); `mailColdPerDay` ganz 0..3 (`Höchstens 3 unverlangte Mails pro Tag.`); `mailReplyCapPerDay` ganz 0..15 (`Höchstens 15 Antwort-Mails pro Tag.`); `sendWindowStart`/`sendWindowEnd` `HH:MM`, Beginn vor Ende (`Uhrzeit als HH:MM, Beginn vor Ende.`); `runsPerNight` ganz 1..2 (`Läufe pro Nacht: 1 oder 2.`); `candidatesPerRun` ganz 1..20 (`Kandidaten pro Lauf: 1 bis 20.`); `regions` 1..30 Zeilen, je 1..64 Zeichen (`Mindestens eine Region, höchstens 30, je Zeile höchstens 64 Zeichen.`); `previewGate` ∈ `erste_n|immer|nie`; `previewGateN` ganz 0..1000; `offerText` 1..400 Zeichen (`Angebotstext: 1 bis 400 Zeichen.`; 400 hält eine Kaltmail unter 180 Wörtern, H3/A11); `pultStaleDays` ganz 1..30; `followupDays`/`previewReminderDays` ganz 1..60; `retentionDays` ganz 7..90 (`Aufbewahrung: 7 bis 90 Tage.`; der Datenschutztext verspricht höchstens 90 Tage, A11); `previewDeleteDays` ganz 7..365; `reportEmail` einfache Adresse. Dezimalkomma wird akzeptiert (`12,5`). Schreibgeschützt (nie im Patch, nie im Formular bearbeitbar): `READONLY_KEYS`; `connectedRooms` setzt allein der Dienst (P3 pflegt die Anfragen-Räume, Kenso die Branchen-Räume per Skript, P2-E5), `privacyInteressentenLive` der Datenschutz-Schalter (A5), `sendingHalted` und `sendingHaltedReason` die Notbremse (H4; aufgehoben wird sie nur über den Knopf des Dialogs, P2-T18). `offerProductKey` ist ebenfalls nur Anzeige (der Dienst erlaubt nur `hosting`).
|
||||
|
||||
- [ ] **Step 1: Failing test schreiben** (`const F = await loadModule('src/lib/workersSettingsForm.js')`)
|
||||
```js
|
||||
const S0 = { paused: true, dailyBudgetUsd: 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', () => {
|
||||
pultStaleDays: 3, retentionDays: 90, followupDays: 7, previewReminderDays: 5, previewDeleteDays: 60, reportEmail: 'kenso.gri@gmail.com',
|
||||
privacyInteressentenLive: false, sendingHalted: false, sendingHaltedReason: '', updatedAt: '2026-10-17T10:00:00Z', updatedBy: 'x' }
|
||||
test('unverändertes Formular ⇒ leerer Patch; schreibgeschützte Felder (auch H4/A5) nie im Patch', () => {
|
||||
const f = F.settingsToForm(S0); assert.deepEqual(F.formToPatch(f, S0), {}); assert.equal(F.validateForm(f).ok, true)
|
||||
assert.deepEqual(F.formToPatch({ ...f, paused: false, previewsSent: 99, updatedBy: 'y', offerProductKey: 'x', connectedRooms: ['netz'] }, S0), {})
|
||||
assert.deepEqual(F.formToPatch({ ...f, privacyInteressentenLive: true, sendingHalted: true, sendingHaltedReason: 'x' }, S0), {}) // die drei neuen Felder: Änderungen ergeben {}
|
||||
assert.deepEqual([...F.READONLY_KEYS], ['paused', 'previewsSent', 'updatedAt', 'updatedBy', 'offerProductKey', 'connectedRooms', 'privacyInteressentenLive', 'sendingHalted', 'sendingHaltedReason'])
|
||||
const halted = { ...S0, sendingHalted: true, sendingHaltedReason: 'Zwei harte Bounces in den letzten 10 Mails.' } // Notbremse gesetzt: Formular bleibt gültig, Patch leer
|
||||
assert.deepEqual(F.formToPatch(F.settingsToForm(halted), halted), {}); assert.equal(F.validateForm(F.settingsToForm(halted)).ok, true)
|
||||
assert.deepEqual(F.formToPatch({ ...f, warnPct: '70', sendingHalted: true }, S0), { warnPct: 70 }) // ein echtes Feld nimmt die schreibgeschützten nicht mit
|
||||
})
|
||||
test('Patch enthält nur Geändertes, getypt; Dezimalkomma, Zeilen, Räume', () => {
|
||||
const f = { ...F.settingsToForm(S0), dailyBudgetUsd: '12,5', regions: 'Rheinland-Pfalz\n\n Hessen ', activeRooms: S0.activeRooms.filter((r) => r !== 'kleinanzeigen'), previewGate: 'immer' }
|
||||
@@ -1186,7 +1206,9 @@ Alles unter `$WK`. **Neu:**
|
||||
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({ offerText: '' }).offerText, 'Angebotstext: 1 bis 400 Zeichen.'); assert.equal(bad({ offerText: 'x'.repeat(401) }).offerText, 'Angebotstext: 1 bis 400 Zeichen.'); assert.equal(bad({ offerText: 'x'.repeat(400) }).offerText, undefined)
|
||||
assert.equal(bad({ retentionDays: '5' }).retentionDays, 'Aufbewahrung: 7 bis 90 Tage.'); assert.equal(bad({ retentionDays: '6' }).retentionDays, 'Aufbewahrung: 7 bis 90 Tage.'); assert.equal(bad({ retentionDays: '91' }).retentionDays, 'Aufbewahrung: 7 bis 90 Tage.')
|
||||
assert.equal(bad({ retentionDays: '7' }).retentionDays, undefined); assert.equal(bad({ retentionDays: '90' }).retentionDays, undefined); assert.equal(bad({ warnPct: 'x' }).warnPct, 'Warnschwelle: ganze Zahl von 1 bis 100.')
|
||||
assert.equal(bad({ runsPerNight: '3' }).runsPerNight, 'Läufe pro Nacht: 1 oder 2.'); assert.equal(bad({ candidatesPerRun: '21' }).candidatesPerRun, 'Kandidaten pro Lauf: 1 bis 20.')
|
||||
assert.equal(F.validateForm(F.settingsToForm(S0)).ok, true); assert.deepEqual(F.ROOM_CHOICES.map((r) => r.id), S0.activeRooms)
|
||||
})
|
||||
@@ -1258,20 +1280,25 @@ Alles unter `$WK`. **Neu:**
|
||||
**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): …“ aus `settings.connectedRooms`, nicht änderbar), **Regionen** (Textfeld, eine je Zeile), **Vorschau** (Freigabe: erste N / immer / nie, N, bisher verschickt als Text), **Angebot** (Angebotstext, Produkt als Text ohne Eingabe), **Fristen** (Pult veraltet nach, Aufbewahrung, Nachfassen nach, Erinnerung nach, Vorschau löschen nach, Bericht an). „Speichern“ ist gesperrt, solange der Patch leer ist oder Fehler bestehen; Fehler stehen am Feld (`aria-invalid`, Text darunter), Serverfehler als `<div className="msg err" role="alert">`; nach Erfolg `onSaved(neueEinstellungen)` und `onClose()`. Der Dialog kennt `paused` nicht.
|
||||
**Block „Versand gestoppt“ (H4, A21):** Ist `settings.sendingHalted` wahr, steht oben im Dialog (über den Gruppen) ein Block mit der Überschrift „Versand gestoppt“, dem Grund aus `settings.sendingHaltedReason` als Textknoten (React-Text, nie HTML; ist er leer: „Grund nicht angegeben“) und dem Knopf „Versand wieder freigeben“. Der Knopf ruft `api.saveSettings({ sendingHalted: false })` (genau dieses eine Feld; der Dienst leert dabei den Grund) und danach `onSaved(neueEinstellungen)`; der Dialog bleibt offen, mischt die Antwort in sein `settings` ein (`{ ...settings, ...antwort }`; der Block verschwindet, weil `sendingHalted` nun falsch ist) und behält die Eingaben im Formular. Während des Aufrufs ist der Knopf gesperrt; ein Fehler steht als `<div className="msg err" role="alert">` im Block. Der Block liegt **außerhalb** von `formToPatch`/`validateForm`: „Speichern“ sendet ihn nie, und das Freigeben ändert den Patch-Zustand des Formulars nicht.
|
||||
|
||||
- [ ] **Step 1: Failing Test ergänzen**
|
||||
```js
|
||||
test('Einstellungs-Dialog: Dialog-Baustein, Formularlogik, kein paused, Admin-Beschriftungen', () => {
|
||||
test('Einstellungs-Dialog: Dialog-Baustein, Formularlogik, kein paused, Admin-Beschriftungen, Notbremsen-Block', () => {
|
||||
const s = read('WorkersSettingsDialog.jsx')
|
||||
assert.match(s, /from '\.\.\/ui\/Dialog'/); assert.match(s, /formToPatch\(/); assert.match(s, /validateForm\(/); assert.match(s, /api\.saveSettings\(/)
|
||||
assert.doesNotMatch(s, /paused/)
|
||||
for (const t of ['Tagesgrenze (USD)', 'Monatsgrenze (USD)', 'Unverlangte Mails pro Tag', 'Angebotstext', 'Regionen', 'Speichern', 'Abbrechen']) assert.ok(s.includes(t), t)
|
||||
assert.match(s, /disabled=\{/)
|
||||
// H4/A21: Block „Versand gestoppt“ mit Freigabe, außerhalb des Formular-Patches
|
||||
assert.match(s, /sendingHalted/); assert.ok(s.includes('Versand gestoppt')); assert.ok(s.includes('Versand wieder freigeben'))
|
||||
assert.match(s, /api\.saveSettings\(\{ sendingHalted: false \}\)/); assert.match(s, /sendingHaltedReason/)
|
||||
assert.doesNotMatch(s, /formToPatch\([^)]*sendingHalted/)
|
||||
})
|
||||
```
|
||||
- [ ] **Step 2: Scheitern sehen** — `cd $WK && timeout 300 node --max-old-space-size=1024 --test tests/unit/workersComponents.test.mjs`. Expected: FAIL `ENOENT … WorkersSettingsDialog.jsx`.
|
||||
- [ ] **Step 3: Implementieren** `WorkersSettingsDialog.jsx`
|
||||
`Dialog wide` mit `as="form"`; Formularzustand `useState(settingsToForm(settings))`; `errors = validateForm(form).errors`; `patch = formToPatch(form, settings)`; `busy` während des Speicherns; Hinweis unter „Unverlangte Mails pro Tag“: „Höchstens 3 (Spec §8.1).“; Räume-Tabelle mit `ROOM_CHOICES` und `.check`-Haken.
|
||||
`Dialog wide` mit `as="form"`; Formularzustand `useState(settingsToForm(settings))`; `errors = validateForm(form).errors`; `patch = formToPatch(form, settings)`; `busy` während des Speicherns; Hinweis unter „Unverlangte Mails pro Tag“: „Höchstens 3 (Spec §8.1).“; Räume-Tabelle mit `ROOM_CHOICES` und `.check`-Haken. Der Notbremsen-Block (siehe Produces) hat eigenen Zustand (`releasing`, `releaseError`), steht nur bei `settings.sendingHalted` im Dialog und schreibt `api.saveSettings({ sendingHalted: false })` exakt so (der Quelltext-Test sucht diese Zeichenfolge); in der Quelldatei kommt das Wort `paused` nirgends vor, auch nicht in Kommentaren.
|
||||
- [ ] **Step 4: Bestehen sehen** — gleicher Befehl. Expected: `# pass 4`, `# fail 0`.
|
||||
- [ ] **Step 5: Commit**
|
||||
```bash
|
||||
@@ -1297,7 +1324,7 @@ Alles unter `$WK`. **Neu:**
|
||||
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."/>` plus `wk-zoom` mit „+“, „−“, „Ganz“. Mount-Effekt (Abhängigkeit: `reduceMotion`): `mountTower(canvas, { onSelect: (id) => onSelectRef.current(id), reducedMotion, coarsePointer: matchMedia('(pointer: coarse)').matches, fontsReady: document.fonts?.load('600 12px "Pixelify Sans"'), dpr: devicePixelRatio })`, `ResizeObserver` ⇒ `resize()`, `visibilitychange` ⇒ `setPaused(document.hidden)`, `matchMedia('(prefers-reduced-motion: reduce)')`-`change` ⇒ Zustand `reduceMotion` (Neu-Mount), Cleanup entfernt alles und ruft `destroy()`. Zweiter Effekt `[state, engineKey]` ⇒ `handle.setState(state)`, dritter `[selectedId]` ⇒ `handle.select(selectedId ?? null)`.
|
||||
**Board:** Ohne übergebenes `stageRef` (die echte Seite übergibt keins) legt das Board selbst eins an: `const ownRef = useRef(null); const ref = stageRef ?? ownRef`; `<WorkersStage ref={ref} …>` und alle Zugriffe laufen über `ref` (sonst stürzt die Seite beim ersten Ereignis ab, der Prüfstand würde es nicht zeigen, weil er ein `stageRef` übergibt). `useWorkersFeed(api, { onEvents })`; `onEvents` ⇒ `setWorkers(w => reduceEvents(w, evs))` und `ref.current?.pushEvents(evs)`; bei neuem `state` `setWorkers(indexWorkers(state))`; `useWorkerTrace(api, selectedId)`. Zustände: nie geladen + Fehler ⇒ `EmptyState title="Der 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>`. Kopfzeile `wk-toolbar`: Statustext („Läuft“ / „Angehalten: Es wird nichts gesendet und nichts gebaut.“), Pausenknopf `pauseLabel(state.paused)` für alle Mitarbeiter (sperrt während des Aufrufs; Fehler als `msg err`; danach `refresh()`), `isAdmin` ⇒ „Einstellungen“ (öffnet den Dialog). Layout: `wk-main` mit Bühne links und Seitenspalte (`WorkerCard`, darunter `WorkerList`) rechts.
|
||||
**Board:** Ohne übergebenes `stageRef` (die echte Seite übergibt keins) legt das Board selbst eins an: `const ownRef = useRef(null); const ref = stageRef ?? ownRef`; `<WorkersStage ref={ref} …>` und alle Zugriffe laufen über `ref` (sonst stürzt die Seite beim ersten Ereignis ab, der Prüfstand würde es nicht zeigen, weil er ein `stageRef` übergibt). `useWorkersFeed(api, { onEvents })`; `onEvents` ⇒ `setWorkers(w => reduceEvents(w, evs))` und `ref.current?.pushEvents(evs)`; bei neuem `state` `setWorkers(indexWorkers(state))`; `useWorkerTrace(api, selectedId)`. Zustände: nie geladen + Fehler ⇒ `EmptyState title="Der 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>`. Kopfzeile `wk-toolbar`: Statustext („Läuft“ / „Angehalten: Es wird nichts gesendet und nichts gebaut.“), Pausenknopf `pauseLabel(state.paused)` für alle Mitarbeiter (sperrt während des Aufrufs; Fehler als `msg err`; danach `refresh()`), `isAdmin` ⇒ „Einstellungen“ (öffnet den Dialog). **Notbremse (H4, A21):** Ist `state.sendingHalted` wahr, steht zwischen Kopfzeile und `wk-main` (für alle Mitarbeiter, nicht nur Admins) eine Zeile `<div className="msg err" role="alert">` mit dem Text „Versand gestoppt: <Grund>. Ein Admin kann ihn in den Einstellungen freigeben.“; `<Grund>` = `state.sendingHaltedReason` als Textknoten, getrimmt und ohne abschließende Punkte (damit kein „..“ entsteht), leer ⇒ „Grund nicht angegeben“. Sie erscheint zusätzlich zu `msg warn` (Verbindung gestört), ersetzt es nicht, und verschwindet mit dem nächsten `state`, in dem `sendingHalted` falsch ist; `WorkersSettingsDialog` bekommt `onSaved={() => refresh()}`, damit die Zeile nach dem Freigeben sofort verschwindet. Layout: `wk-main` mit Bühne links und Seitenspalte (`WorkerCard`, darunter `WorkerList`) rechts.
|
||||
|
||||
- [ ] **Step 1: Failing Quelltext-Test ergänzen**
|
||||
```js
|
||||
@@ -1310,10 +1337,14 @@ Alles unter `$WK`. **Neu:**
|
||||
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/)
|
||||
// H4/A21: Notbremsen-Zeile für alle, Grund als Textknoten
|
||||
assert.match(s, /state\.sendingHalted/); assert.match(s, /state\.sendingHaltedReason/); assert.match(s, /className="msg err"/); assert.match(s, /role="alert"/)
|
||||
assert.ok(s.includes('Versand gestoppt: ')); assert.ok(s.includes('Ein Admin kann ihn in den Einstellungen freigeben.')); assert.ok(s.includes('Grund nicht angegeben'))
|
||||
assert.match(s, /onSaved=\{/); assert.doesNotMatch(s, /dangerouslySetInnerHTML|innerHTML/)
|
||||
})
|
||||
```
|
||||
- [ ] **Step 2: Scheitern sehen** — `cd $WK && timeout 300 node --max-old-space-size=1024 --test tests/unit/workersComponents.test.mjs`. Expected: FAIL `ENOENT … WorkersStage.jsx`.
|
||||
- [ ] **Step 3: Implementieren** beide Komponenten nach den Angaben oben (Hooks-Regeln: Callback-Refs für `onSelect`/`onEvents`, damit der Mount-Effekt stabil bleibt).
|
||||
- [ ] **Step 3: Implementieren** beide Komponenten nach den Angaben oben (Hooks-Regeln: Callback-Refs für `onSelect`/`onEvents`, damit der Mount-Effekt stabil bleibt). Die Texte der Notbremsen-Zeile stehen als zusammenhängende Literale in jeweils einer Zeile (`Versand gestoppt: ` und `Ein Admin kann ihn in den Einstellungen freigeben.` getrennt von `{Grund}` im JSX), weil der Quelltext-Test sie wörtlich sucht.
|
||||
- [ ] **Step 4: Bestehen sehen** — gleicher Befehl. Expected: `# pass 6`, `# fail 0`.
|
||||
- [ ] **Step 5: Commit**
|
||||
```bash
|
||||
@@ -1332,7 +1363,7 @@ Alles unter `$WK`. **Neu:**
|
||||
|
||||
**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ängt `freigabe` an, P6 `botleads`; kein Platzhalter-Reiter), Auswahl über `?tab=` mit `pickTab`, `Tabs` nur bei `WORKERS_TABS.length > 1`; Reiter `hochhaus` rendert `<WorkersBoard api={api} isAdmin={isAdmin} />` ohne `stageRef`); Route `/workers` (lazy), `/leads` leitet auf `/workers` um; Menüeintrag `{ key: 'workers', label: 'Workers', href: '/workers' }` an der Stelle von `leads`, ohne `paused`.
|
||||
- Produces: `export default function WorkersPage()` (Titel „Workers“, Untertitel „Die Nachtschicht deiner Claude-Armee“; `const api = workersApi`; `WORKERS_TABS = [{ id: 'hochhaus', label: 'Hochhaus' }]` (P3 hängt `freigabe` an und passt `workersRoute.test.mjs` an, P3-E11; P6 hängt `botleads` an; kein Platzhalter-Reiter, P2-E7), Auswahl über `?tab=` mit `pickTab`, `Tabs` nur bei `WORKERS_TABS.length > 1`; Reiter `hochhaus` rendert `<WorkersBoard api={api} isAdmin={isAdmin} />` ohne `stageRef`); Route `/workers` (lazy), `/leads` leitet auf `/workers` um; Menüeintrag `{ key: 'workers', label: 'Workers', href: '/workers' }` an der Stelle von `leads`, ohne `paused`.
|
||||
|
||||
- [ ] **Step 1: Failing Tests schreiben**
|
||||
`tests/unit/navLinks.test.mjs` anpassen: in beiden `deepEqual`-Listen `'leads'` durch `'workers'` ersetzen und die Zeile `assert.equal(links.find((l) => l.key === 'leads').paused, true)` durch
|
||||
@@ -1401,23 +1432,30 @@ Alles unter `$WK`. **Neu:**
|
||||
```js
|
||||
// scripted-api.js
|
||||
export const SCRIPT // [{ at: Sekunden, event }], deckt alle 26 Arten und ≥ 15 Worker ab, erstes Ereignis bei ≥ 2 s
|
||||
export function createScriptedApi({ now }): { state, events, trace, pause, settings, saveSettings, pult, pultAction, botleads, prospect } // gleiche Form wie createWorkersApi
|
||||
export function createScriptedApi({ now, halted = false }): { state, events, trace, pause, settings, saveSettings, pult, pultAction, botleads, prospect, saved } // gleiche Form wie createWorkersApi; `saved` = Liste der an saveSettings übergebenen Patches
|
||||
```
|
||||
`state()`: 19 Worker, `kleinanzeigen`/`portale`/`foren` (kim, pia, finn) nicht angeschlossen, gemischte Status, `kpis` wie in den Fixtures, `lastEventTs` = Basiszeit; `events(after)`: nur fällige, nie doppelte (Zeitstempel = Basiszeit + `at`); `trace(id)`: 12 Ereignisse, darunter eines mit `javascript:alert(1)` und eines mit `https://example.org/fund`, `today` mit Zählern, `tokens: { in, out }`, Kosten; `pause(p)` merkt den Zustand und `state().paused` folgt; `settings()` = Standardwerte aus Übersicht §4.1. `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}` und `window.__wk = { stage: ref }` nur im Prüfstand. `shot.cjs <distDir> <outDir>`: Muster `entwuerfe/shot.cjs` (gleicher Chromium-Pfad, gleiches `createRequire`, Sperre auf neue Ausgabeordner unter `$TMPDIR`), kleiner statischer Server auf 127.0.0.1 (liefert `distDir` und `/fonts/*` aus `public/fonts`).
|
||||
`state()`: 19 Worker, `kleinanzeigen`/`portale`/`foren` (kim, pia, finn) nicht angeschlossen, gemischte Status, `kpis` wie in den Fixtures, `lastEventTs` = Basiszeit; `events(after)`: nur fällige, nie doppelte (Zeitstempel = Basiszeit + `at`); `trace(id)`: 12 Ereignisse, darunter eines mit `javascript:alert(1)` und eines mit `https://example.org/fund`, `today` mit Zählern, `tokens: { in, out }`, Kosten; `pause(p)` merkt den Zustand und `state().paused` folgt; `settings()` = Standardwerte aus Übersicht §4.1 (inkl. `privacyInteressentenLive: false`, `sendingHalted`, `sendingHaltedReason`); `state()` enthält `sendingHalted` und `sendingHaltedReason` (Standard `false` / `''`; mit `halted: true` gilt `true` / `'Zwei harte Bounces in den letzten 10 Mails.'`, H4); `saveSettings(patch)` merkt den Patch in `saved`, mit `sendingHalted: false` hebt es die Notbremse auf (`state()` und `settings()` melden danach `false` / `''`) und liefert die Einstellungen zurück. `build.mjs <outDir>`: esbuild-Bündel (`entry.jsx` → `harness.js`, `harness.css`, `index.html`; `jsx: 'automatic'`, `external: ['/fonts/*']`, Plattform Browser). `entry.jsx`: `StrictMode` + `MemoryRouter` + `WorkersBoard api={scripted} isAdmin stageRef={ref}` (der Prüfstand-URL-Parameter `?halted=1` erzeugt die Skript-API mit `halted: true`) und `window.__wk = { stage: ref, api: scripted }` nur im Prüfstand. `shot.cjs <distDir> <outDir>`: Muster `entwuerfe/shot.cjs` (gleicher Chromium-Pfad, gleiches `createRequire`, Sperre auf neue Ausgabeordner unter `$TMPDIR`), kleiner statischer Server auf 127.0.0.1 (liefert `distDir` und `/fonts/*` aus `public/fonts`).
|
||||
|
||||
- [ ] **Step 1: Failing Test schreiben** (`tests/unit/workersHarness.test.mjs`)
|
||||
```js
|
||||
const { createScriptedApi, SCRIPT } = await loadModule('scripts/workers-harness/scripted-api.js')
|
||||
const { EVENT_KINDS } = await loadModule('src/lib/workersEvents.js')
|
||||
const { SEARCH_ROOMS, roleOf } = await loadModule('src/lib/workersRoster.js')
|
||||
test('Skript deckt alle 26 Ereignisarten und mindestens 15 Worker ab; erstes Ereignis nicht vor 2 s', () => {
|
||||
assert.deepEqual(new Set(SCRIPT.map((s) => s.event.kind)), new Set(EVENT_KINDS)); assert.ok(new Set(SCRIPT.map((s) => s.event.workerId).filter((w) => w !== 'system')).size >= 15); assert.ok(Math.min(...SCRIPT.map((s) => s.at)) >= 2)
|
||||
})
|
||||
test('scripted api: 19 Worker (kim, pia, finn nicht angeschlossen); events liefert nur Fälliges, nie doppelt; Pause folgt', async () => {
|
||||
let t = 0; const api = createScriptedApi({ now: () => t }); const s = await api.state()
|
||||
assert.equal(s.workers.length, 19); assert.deepEqual(s.workers.filter((w) => !w.connected).map((w) => w.workerId).sort(), ['finn', 'kim', 'pia'])
|
||||
assert.ok(s.workers.filter((w) => !w.connected).every((w) => SEARCH_ROOMS.includes(roleOf(w.workerId).room))) // Grau-Regel (P1-E9): nur Such-Räume können nicht angeschlossen sein, die anderen elf sind es immer
|
||||
assert.equal((await api.events(null)).events.length, 0); t = 10000; const first = (await api.events(s.lastEventTs)).events; assert.ok(first.length > 0)
|
||||
const again = (await api.events(first.at(-1).ts)).events; assert.equal(again.filter((e) => first.some((f) => f.id === e.id)).length, 0)
|
||||
await api.pause(true); assert.equal((await api.state()).paused, true)
|
||||
assert.equal(s.sendingHalted, false); assert.equal(s.sendingHaltedReason, '') // Standard: nicht gestoppt
|
||||
const h = createScriptedApi({ now: () => 0, halted: true }); const hs = await h.state() // H4: gestoppt, Grund sichtbar, Freigabe über saveSettings
|
||||
assert.equal(hs.sendingHalted, true); assert.match(hs.sendingHaltedReason, /Bounces/); assert.equal((await h.settings()).sendingHalted, true)
|
||||
const saved = await h.saveSettings({ sendingHalted: false }); assert.equal(saved.sendingHalted, false); assert.equal(saved.sendingHaltedReason, '')
|
||||
assert.deepEqual(h.saved, [{ sendingHalted: false }]); assert.equal((await h.state()).sendingHalted, false)
|
||||
})
|
||||
```
|
||||
- [ ] **Step 2: Scheitern sehen** — `cd $WK && timeout 300 node --max-old-space-size=1024 --test tests/unit/workersHarness.test.mjs`. Expected: FAIL `Could not resolve …/scripted-api.js`.
|
||||
@@ -1430,7 +1468,8 @@ Alles unter `$WK`. **Neu:**
|
||||
6. nach 20 s Skriptlauf: `stats().avgDrawMs` ≤ 25 (nur Warnung ausgeben, kein Abbruch, da software-gerendert), `debug().pending` ≥ 0;
|
||||
7. Handy 390×844 (DPR 2): `document.documentElement.scrollWidth <= 390`, Zoom „+“ erhöht `stats().zoom`;
|
||||
8. `page.emulateMedia({ reducedMotion: 'reduce' })` + Neuladen: nach 2 s `stats().frames <= 5`;
|
||||
9. Bilder `desktop-start.png`, `desktop-nach-20s.png`, `handy.png`, `handy-zoom.png` im Ausgabeordner.
|
||||
9. Bilder `desktop-start.png`, `desktop-nach-20s.png`, `handy.png`, `handy-zoom.png`, `desktop-gestoppt.png` im Ausgabeordner;
|
||||
10. Notbremse (H4/A21), Seite mit `?halted=1` (Desktop 1280×900; das Bild `desktop-gestoppt.png` entsteht vor dem Freigeben): `.msg.err[role="alert"]` zeigt genau „Versand gestoppt: Zwei harte Bounces in den letzten 10 Mails. Ein Admin kann ihn in den Einstellungen freigeben.“ (der Punkt des Grundes nicht doppelt); „Einstellungen“ öffnet den Dialog mit dem Block „Versand gestoppt“ und dem Knopf „Versand wieder freigeben“; Klick darauf ⇒ binnen 2 s sind Block und Warnzeile verschwunden und `__wk.api.saved` ist genau `[{ sendingHalted: false }]`; ohne `?halted=1` gibt es weder Zeile noch Block.
|
||||
- [ ] **Step 4: Test bestehen sehen** — `cd $WK && timeout 300 node --max-old-space-size=1024 --test tests/unit/workersHarness.test.mjs`. Expected: `# pass 2`, `# fail 0`.
|
||||
- [ ] **Step 5: Abnahme-Lauf (Verifikation, Implementierer führt aus)**
|
||||
```bash
|
||||
@@ -1438,7 +1477,7 @@ Alles unter `$WK`. **Neu:**
|
||||
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 als `fitZoom`-Schwelle (720 px, die Seitenspalte nimmt 320 px + 32 px Abstand), wird die Spaltenbreite in `workers.css` verkleinert oder die Schwelle in P2-T08 samt Test angepasst, (b) Kleinanzeigen/Portale/Foren grau mit Schild, (c) 19 Figuren, Otto größer mit Kommandomütze, (d) Karte rechts mit Modell-Chip und Spur, (e) Handy ohne seitliches Scrollen, Zoom-Knöpfe sichtbar. Abweichungen werden im jeweiligen Engine-/CSS-Modul behoben (mit Test), dann erneut laufen lassen.
|
||||
Expected: letzte Zeile `OK`, `errors: []`. Danach die fünf PNGs mit dem Read-Werkzeug ansehen und festhalten: (a) 10 Etagen mit Schildern rechts, bei 1280 × 900 beim ersten Bild komplett sichtbar (Keller unten zu sehen) und die Raumtitel lesbar; ist die Bühne dort schmaler als `fitZoom`-Schwelle (720 px, die Seitenspalte nimmt 320 px + 32 px Abstand), wird die Spaltenbreite in `workers.css` verkleinert oder die Schwelle in P2-T08 samt Test angepasst, (b) Kleinanzeigen/Portale/Foren grau mit Schild, (c) 19 Figuren, Otto größer mit Kommandomütze, (d) Karte rechts mit Modell-Chip und Spur, (e) Handy ohne seitliches Scrollen, Zoom-Knöpfe sichtbar, (f) `desktop-gestoppt.png`: rote Zeile „Versand gestoppt: …“ zwischen Kopfzeile und Hochhaus, das Hochhaus selbst ohne Pausenschleier. Abweichungen werden im jeweiligen Engine-/CSS-Modul behoben (mit Test), dann erneut laufen lassen.
|
||||
- [ ] **Step 6: Commit**
|
||||
```bash
|
||||
cd $WK && git add scripts/workers-harness/build.mjs scripts/workers-harness/entry.jsx scripts/workers-harness/index.html scripts/workers-harness/scripted-api.js scripts/workers-harness/shot.cjs tests/unit/workersHarness.test.mjs
|
||||
@@ -1476,7 +1515,7 @@ P2 braucht **keine** `.env`-, Compose- oder nginx-Änderung. Der Block `location
|
||||
2. **Stand merken:** `git -C /home/webklar/apps/ticket.webklar.com/dev-workers fetch origin && git -C /home/webklar/apps/ticket.webklar.com/dev-workers rev-parse origin/test` ⇒ SHA als `ALT` notieren (für das Zurücknehmen). Danach `git -C /home/webklar/apps/ticket.webklar.com/dev-workers merge-base --is-ancestor origin/test HEAD && echo OK`: Kommt kein `OK`, hat `test` inzwischen neue Commits; dann **nicht pushen und nie erzwingen**, sondern Claude bitten, `feature/workers` auf `origin/test` zu rebasen und Schritt 3 zu wiederholen.
|
||||
3. **Letzter Test:** `cd /home/webklar/apps/ticket.webklar.com/dev-workers && free -m && timeout 600 node --max-old-space-size=1024 --test tests/*.test.mjs tests/unit/*.test.mjs` ⇒ `# fail 0`.
|
||||
4. **Live schalten (Push):** `git -C /home/webklar/apps/ticket.webklar.com/dev-workers push origin feature/workers:test`. Der Deploy-Webhook baut in 1–3 Minuten. Prüfen: `curl -sI https://ticket.webklar.com/fonts/PixelifySans-Variable.woff2 | head -3` ⇒ `HTTP/… 200`; das Build-Log des Ticket-Build-Containers (Name mit `docker ps --format '{{.Names}}' | rg -i ticket` finden, Muster `webklar-ticket-build`; `docker logs --tail 40 <name>`) endet ohne Fehler.
|
||||
5. **Im Browser (als Mitarbeiter):** Menü zeigt „Workers“ statt des grauen „Leads“; `https://ticket.webklar.com/leads` springt 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).
|
||||
6. **Pausenschalter (nur wenn P1 läuft):** „Alles anhalten“ ⇒ Hinweis „Angehalten …“ und heller Schleier im Hochhaus; danach „Fortsetzen“ — den Dienst **nicht** angehalten lassen. Als Admin: „Einstellungen“ öffnen, nichts ändern, „Abbrechen“; als Nicht-Admin fehlt der Knopf.
|
||||
5. **Im Browser (als Mitarbeiter):** Menü zeigt „Workers“ statt des grauen „Leads“; `https://ticket.webklar.com/leads` springt 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, dort P3-E11); auf dem Handy: Zoom-Knöpfe, kein seitliches Scrollen. Zu Beginn sind genau die acht Such-Räume grau mit „noch nicht angeschlossen“ (Netz-Radar, Kleinanzeigen, Portale, Foren & Fragen, Ärzte & Praxen, Handwerk, Selbstständige, Läden & Gastro); Brücke, Waage, Mail-Stube, Forum-Stube, Posteingang, Abschluss, die drei Werkstatt-Tische, Prüftisch und Archiv sind von Anfang an farbig (P1-E9). Das ist richtig: ein Such-Raum wird erst farbig, wenn der Dienst ihn als angeschlossen meldet (P3 für Netz-Radar, Kleinanzeigen, Portale, Foren; P6 für die Branchen-Räume).
|
||||
6. **Pausenschalter (nur wenn P1 läuft):** „Alles anhalten“ ⇒ Hinweis „Angehalten …“ und heller Schleier im Hochhaus; danach „Fortsetzen“ — den Dienst **nicht** angehalten lassen. Als Admin: „Einstellungen“ öffnen, nichts ändern, „Abbrechen“; als Nicht-Admin fehlt der Knopf. **Versand-Notbremse (H4):** Hat der Dienst den Versand wegen Bounces gestoppt (P4), zeigt das Board für alle Mitarbeiter eine rote Zeile „Versand gestoppt: <Grund>. Ein Admin kann ihn in den Einstellungen freigeben.“ und der Einstellungs-Dialog (nur Admin) oben den Block „Versand gestoppt“ mit dem Knopf „Versand wieder freigeben“. Den Knopf erst drücken, wenn die Ursache geklärt ist (Bounce-Adressen im Postfach prüfen, Sperrliste); er schickt nur `sendingHalted:false`, der Dienst löscht den Grund. Ohne Stopp sind Zeile und Block nicht zu sehen; wer sie vor dem Livegang sehen will, öffnet den Prüfstand aus P2-T21 mit `?halted=1`.
|
||||
7. **Zurücknehmen, falls nötig:** `git -C /home/webklar/apps/ticket.webklar.com/dev-workers revert --no-edit ALT..HEAD` (ALT aus Schritt 2; macht auch die Dokumentations-Commits rückgängig, das schadet nicht), dann `git -C /home/webklar/apps/ticket.webklar.com/dev-workers push origin feature/workers:test`. Prüfen: `https://ticket.webklar.com/leads` zeigt wieder die Tickets (graues „Leads“ im Menü).
|
||||
8. **Danach (Claude):** Memory-Eintrag zu WOMS-Workers aktualisieren (Stand live, Commit-SHA, Ergänzungen E1–E7 in der Übersicht nachgezogen).
|
||||
8. **Danach (Claude):** Memory-Eintrag zu WOMS-Workers aktualisieren (Stand live, Commit-SHA, Ergänzungen P2-E1 bis P2-E7 stehen in der Übersicht §11.1, die Notbremsen-Anzeige entspricht A21 und H4).
|
||||
Reference in new issue
Block a user