diff --git a/src/components/workers/engine/director.js b/src/components/workers/engine/director.js new file mode 100644 index 0000000..60fc464 --- /dev/null +++ b/src/components/workers/engine/director.js @@ -0,0 +1,259 @@ +// Regie des Hustler-Hochhauses, Teil 1: Agenten, Snapshot ohne Animation, Posen, Leerlauf, Zeichnen. +// Portiert aus entwuerfe/scene-turm.js (Agenten Z. 596-625, Update Z. 864-905, Zeichnen Z. 925-960). +// Schritte, Aufzugfahrten, Rückstau-Kappung und Wachhund ergänzt P2-T13. Kein React, kein Appwrite, kein window/document. + +import { ROLES, hatForWorker } from '../../../lib/workersRoster.js' +import { indexWorkers, cutBubble } from '../../../lib/workersEvents.js' +import { homeOf } from './layout.js' +import { trophyPaging } from './rooms.js' +import { drawWorker, drawBubble, drawSideBubble, drawAlertMark, drawPaper } from './sprites.js' + +export const BACKLOG_KEEP = 6 // je pushEvents animieren nur die neuesten so viele Ereignisse (T13) +export const GROUPS_PER_AGENT = 4 // je Worker höchstens so viele wartende Gruppen (T13) +export const IDLE_COFFEE_S = 25 // so lange trinkt ein Worker im Leerlauf Kaffee, dann schläft er +export const STEP_TIMEOUT_S = 45 // ein Schritt dauert höchstens so lange, dann greift der Wachhund (T13) +export const BUBBLE_MAX = 3 // höchstens so viele Sprechblasen gleichzeitig + +const PAGE_S = 6 // Pokal-Seiten blättern alle 6 Sekunden +const POP_S = 1.2 // so lange blinkt ein neuer Pokal +const BUBBLE_S = 2.2 // Standarddauer einer Sprechblase +const LANE = -2 // wer am Platz steht, wird 2 Einheiten höher gezeichnet (hinter dem Tisch) +const LANE_SPEED = 14 // Einheiten je Sekunde, mit der die Spur wechselt +const HOME_EPS = 0.5 // so nah am Heimatplatz gilt ein Worker als am Platz +const ALERT_H = 9 // Höhe des „!“ mit Umriss (sprites.js) + +// Blickrichtung am Heimatplatz (Vorlage FACE); alle anderen blicken nach rechts +const FACE_LEFT = new Set(['ali', 'hanna', 'sven', 'lea', 'paul', 'clara']) +const faceOf = (id) => (FACE_LEFT.has(id) ? -1 : 1) + +const isRecord = (v) => v !== null && typeof v === 'object' && !Array.isArray(v) +const clamp = (v, lo, hi) => (v < lo ? lo : v > hi ? hi : v) + +// Tiefe für die Zeichenreihenfolge: wer weiter unten steht, wird später (davor) gezeichnet +const depthOf = (a) => a.y + a.lane + a.bob + 13 * a.scale +const byDepth = (agents) => [...agents].sort((p, q) => depthOf(p) - depthOf(q)) + +// Pose nach Status: nicht angeschlossen schläft, Leerlauf trinkt Kaffee und schläft nach IDLE_COFFEE_S, Arbeit tippt +function poseFor(a) { + if (a.grey) return 'sleep' + if (a.status === 'leerlauf') return a.idleT >= IDLE_COFFEE_S ? 'sleep' : 'coffee' + if (a.status === 'arbeitet') return 'type' + return 'stand' +} + +// Läuft für den Worker eine Gruppe? Dann bestimmt die Gruppe die Pose, nicht der Status +const busy = (a) => a.groups > 0 + +// Steht der Worker an seinem Platz? (kein Schritt, nicht im Aufzug) +const atHome = (a) => a.frame === 0 && Math.abs(a.x - a.home.x) < HOME_EPS && Math.abs(a.y - a.home.y) < HOME_EPS + +// Spur: am Platz -2 (Otto steht immer vorn), unterwegs 0 +const laneTarget = (a) => (a.id !== 'otto' && atHome(a) ? LANE : 0) + +// Zähler als ganze Zahl >= 0; alles andere (NaN, Text, null, fehlend) ist kein Wert +const kpiOf = (kpis, key) => (isRecord(kpis) && Number.isFinite(kpis[key]) ? Math.max(0, Math.floor(kpis[key])) : null) + +/* createDirector({ layout, lift, fx, look, roles = ROLES, rnd = Math.random }) → { agents, byId, applyState(state), update(dt), pending(), boxes(), bubble(agent, text, dur) } + Ein Agent je Rolle (Rollen ohne Heimatplatz im Layout entfallen). applyState übernimmt einen Dienst-Zustand: der erste Snapshot setzt + alle Agenten auf ihren Platz, spätere bewegen nie und ändern Pose und Aufgabe nur bei Agenten ohne laufende Gruppe (groups = 0). + update(dt) tut bei look.paused nichts und läuft sonst ohne Obergrenze für dt (flush in T13 ruft es mit 0,25). rnd streut die Wipp-Phase. */ +export function createDirector({ layout, lift, fx, look, roles = ROLES, rnd = Math.random }) { + const agents = [] + const byId = Object.create(null) + let first = true // der erste Snapshot stellt alle Agenten an ihren Platz + let prevDeals = null // dealsMonth des vorigen Snapshots; null = noch keiner gesehen + let clock = 0 // Regie-Zeit in Sekunden (nur für das Wippen) + + for (const role of roles) { + const home = homeOf(layout, role.workerId) + if (!home) continue + const a = { + id: role.workerId, + name: role.name, + hat: hatForWorker(role.workerId), + scale: role.workerId === 'otto' ? 2 : 1, + x: home.x, + y: home.y, + floor: home.floor, + facing: faceOf(role.workerId), + frame: 0, + pose: 'stand', + carrying: false, + stackN: 0, + say: null, + bubT: 0, + grey: false, + alert: false, + status: 'wartet', + task: '', + counters: {}, + lane: 0, + bob: 0, + idleT: 0, + cheer: 0, + q: [], + cur: null, + stepT: 0, + groups: 0, + box: null, + home, + ph: rnd() * 6, // Phase des Wippens: nicht alle im Gleichschritt + } + a.lane = laneTarget(a) + agents.push(a) + byId[a.id] = a + } + + // Auf den Heimatplatz stellen (erster Snapshot, T13: reset) + function placeHome(a) { + a.x = a.home.x + a.y = a.home.y + a.floor = a.home.floor + a.facing = faceOf(a.id) + a.frame = 0 + a.carrying = false + a.stackN = 0 + a.bob = 0 + a.lane = laneTarget(a) + } + + // grey- und busyRooms aus den Agenten neu bilden (die Mengen bleiben dieselben Objekte) + function syncRooms() { + look.greyRooms.clear() + look.busyRooms.clear() + for (const a of agents) { + if (a.grey) look.greyRooms.add(a.home.room) + else if (a.status === 'arbeitet') look.busyRooms.add(a.home.room) + } + } + + // Eintrag aus indexWorkers (oder null = fehlt im Zustand) auf den Agenten übertragen + function adopt(a, w) { + a.status = w ? w.status : 'grau' + a.grey = a.status === 'grau' + a.alert = a.status === 'fehler' + a.counters = w ? w.counters : {} + if (!busy(a)) { + a.task = w ? w.task : '' + a.pose = poseFor(a) + } + } + + function applyKpis(kpis) { + const pult = kpiOf(kpis, 'pultOpen') + const deals = kpiOf(kpis, 'dealsMonth') + const bot = kpiOf(kpis, 'botLeads') + if (pult !== null) look.pultOpen = pult + if (bot !== null) look.botLeads = bot + if (deals !== null) { + if (prevDeals !== null && deals > prevDeals) look.trophyPop = POP_S + look.dealsMonth = deals + prevDeals = deals + } + } + + function applyState(state) { + const workers = indexWorkers(state) + for (const a of agents) adopt(a, Object.hasOwn(workers, a.id) ? workers[a.id] : null) + if (first) { + first = false + for (const a of agents) placeHome(a) + } + syncRooms() + applyKpis(isRecord(state) ? state.kpis : null) + look.paused = isRecord(state) && !!state.paused + } + + // Sprechblase zeigen; höchstens BUBBLE_MAX Worker gleichzeitig (der Sprecher selbst zählt nicht mit), sonst stillschweigend verworfen + function bubble(a, text, dur = BUBBLE_S) { + const str = cutBubble(text) + if (!a || str === '' || !agents.includes(a)) return false + let others = 0 + for (const o of agents) if (o.say && o !== a) others += 1 + if (others >= BUBBLE_MAX) return false + a.say = str + a.bubT = dur + return true + } + + function stepAgent(a, dt) { + a.idleT = a.status === 'leerlauf' ? a.idleT + dt : 0 + if (!busy(a)) a.pose = poseFor(a) + a.lane += clamp(laneTarget(a) - a.lane, -dt * LANE_SPEED, dt * LANE_SPEED) + a.bob = 0 + if (a.cheer > 0) { + a.cheer = Math.max(0, a.cheer - dt) + a.bob = Math.floor(clock * 8) % 2 ? -2 : 0 + } else if (a.pose === 'type' && atHome(a)) { + a.bob = Math.floor(clock * 3 + a.ph) % 2 ? -1 : 0 + } + if (a.bubT > 0) { + a.bubT -= dt + if (a.bubT <= 0) { + a.bubT = 0 + a.say = null + } + } + } + + function update(dt) { + if (look.paused || !Number.isFinite(dt) || dt <= 0) return + clock += dt + for (const a of agents) stepAgent(a, dt) + lift.update(dt) + fx.update(dt) + look.trophyPop = Math.max(0, look.trophyPop - dt) + look.vaultFlash = Math.max(0, look.vaultFlash - dt) + look.pageT += dt + if (look.pageT >= PAGE_S) { + look.pageT %= PAGE_S + look.trophyPage = (look.trophyPage + 1) % trophyPaging(look.dealsMonth, 0).pages + } + } + + // Trefferflächen der zuletzt gezeichneten Figuren in Zeichenreihenfolge (die letzte liegt oben), für pickAgent + function boxes() { + return byDepth(agents).filter((a) => a.box).map((a) => ({ id: a.id, box: a.box })) + } + + return { agents, byId, applyState, update, pending: () => 0, boxes, bubble } +} + +// ---------- Zeichnen ---------- + +// Eine Figur mit Stapel-Papieren und „!“; merkt die Trefferfläche in agent.box +function drawAgent(ctx, a, selected, t) { + const sc = a.scale + const x = Math.round(a.x) + const y = Math.round(a.y + a.lane + a.bob) + if (a.carrying && a.stackN > 1) { + const bx = x + (a.facing === -1 ? -2 : 8) * sc + const by = y + 6 * sc + (a.frame === 1 ? -1 : 0) + drawPaper(ctx, bx + 1, by - 1) + if (a.stackN > 4) drawPaper(ctx, bx + 2, by - 2) + } + a.box = drawWorker(ctx, x, y, { hat: a.hat, frame: a.frame, facing: a.facing, carrying: a.carrying, scale: sc, selected, pose: a.pose, grey: a.grey, t }) + // Das „!“ steht über dem Hut, bei Auswahl über dem Pfeil (Anker: Mitte der Unterkante) + if (a.alert) drawAlertMark(ctx, x + 6 * sc, y - 2 * sc - (selected ? 6 * sc : 0)) +} + +// Blase eines Agenten: Otto an der Oberkante der Welt seitlich, alle anderen mit der Spitze auf der Kopfoberkante +// (Hut, wie „!“ und Effekte), bei Auswahl und Fehler darüber, damit sie Pfeil und „!“ nicht verdeckt +function drawSay(ctx, a, selected) { + const sc = a.scale + const y = a.y + a.lane + a.bob + if (a.id === 'otto') { + drawSideBubble(ctx, a.say, { leftX: a.x, rightX: a.x + 12 * sc, y: y + 3 }) + } else if (a.x > -6 && a.x < 326) { + const lift = (selected ? 6 * sc : 0) + (a.alert ? ALERT_H + 1 : 0) + drawBubble(ctx, a.x + 6 * sc, y - 2 * sc - lift, a.say) + } +} + +/* Zeichnet alle Figuren von hinten nach vorn (nach depthOf sortiert), danach die Sprechblasen. + Setzt agent.box (für boxes()), zeichnet Auswahlpfeil bei selectedId und „!“ bei Fehler. t = Zeit in Sekunden. */ +export function drawAgents(ctx, director, selectedId, t) { + const order = byDepth(director.agents) + for (const a of order) drawAgent(ctx, a, a.id === selectedId, t) + for (const a of order) if (a.say) drawSay(ctx, a, a.id === selectedId) +} diff --git a/src/components/workers/engine/sprites.js b/src/components/workers/engine/sprites.js index 5060c55..3e189d8 100644 --- a/src/components/workers/engine/sprites.js +++ b/src/components/workers/engine/sprites.js @@ -253,6 +253,9 @@ function deviceRect(ctx, x, y, w, h, color) { ctx.fillRect(x, y, w, h) } +// Breite der Zeichenfläche in Geräte-Pixeln (ohne Canvas 960, wie die Welt bei Raster 3) +const canvasWidth = (ctx) => (ctx.canvas && ctx.canvas.width > 0 ? ctx.canvas.width : 960) + /* Sprechblase über einem Worker: (x, y) = Spitze der Blase (logisch). Maße wachsen mit dem Raster; die Blase wird in Geräte-Pixeln in die Breite von ctx.canvas geklemmt (Rand 2 bei k = 3, ohne Canvas 960). */ export function drawBubble(ctx, x, y, text) { @@ -270,7 +273,7 @@ export function drawBubble(ctx, x, y, text) { const bh = u(22) const tipX = Math.round(x * k + ox) const by = Math.round(y * k + oy) - bh - u(6) - const maxW = ctx.canvas && ctx.canvas.width > 0 ? ctx.canvas.width : 960 + const maxW = canvasWidth(ctx) const bx = Math.max(edge, Math.min(maxW - bw - edge, Math.round(tipX - bw / 2))) // Die Spitze bleibt am Kasten, auch wenn der Kasten wegen des Rands verschoben wurde const tailW = u(6) @@ -282,3 +285,38 @@ export function drawBubble(ctx, x, y, text) { ctx.fillStyle = PAL.ink ctx.fillText(str, bx + pad, by + u(5)) } + +/* Sprechblase neben einem Worker, für Otto an der Oberkante der Welt, wo über dem Kopf kein Platz ist (Vorlage: sideBubble). + leftX/rightX = linke und rechte Körperkante (logisch), y = Oberkante der Blase. Die Blase steht links vom Körper und ihre + Spitze zeigt nach rechts auf leftX; fehlt links der Platz (Rand 4), steht sie rechts davon mit der Spitze auf rightX, + in die Breite von ctx.canvas geklemmt. Maße und Schrift wachsen mit dem Raster. */ +export function drawSideBubble(ctx, text, { leftX, rightX, y }) { + const str = String(text ?? '') + if (str === '') return + const { k, ox, oy } = gridOf(ctx) + const f = k / 3 + const u = (n) => Math.max(1, Math.round(n * f)) + ctx.font = fontOf(500, 12 * f) + ctx.textBaseline = 'top' + ctx.textAlign = 'left' + const pad = u(7) + const edge = u(2) + const gap = u(9) // Blasenrand bis Körperkante: Rahmen, Spitze (8) und ein Pixel Luft + const bw = Math.ceil(ctx.measureText(str).width) + 2 * pad + const bh = u(22) + const by = Math.round(y * k + oy) + let bx = Math.round(leftX * k + ox) - gap - bw + const onLeft = bx >= u(4) + if (!onLeft) bx = Math.max(edge, Math.min(canvasWidth(ctx) - bw - edge, Math.round(rightX * k + ox) + gap)) + deviceRect(ctx, bx - edge, by - edge, bw + 2 * edge, bh + 2 * edge, PAL.o) + deviceRect(ctx, bx, by, bw, bh, PAL.w) + if (onLeft) { + deviceRect(ctx, bx + bw + edge, by + u(7), u(4), u(4), PAL.o) + deviceRect(ctx, bx + bw + u(6), by + u(8), u(2), u(2), PAL.o) + } else { + deviceRect(ctx, bx - u(6), by + u(7), u(4), u(4), PAL.o) + deviceRect(ctx, bx - u(8), by + u(8), u(2), u(2), PAL.o) + } + ctx.fillStyle = PAL.ink + ctx.fillText(str, bx + pad, by + u(5)) +} diff --git a/tests/unit/helpers/workersFixtures.mjs b/tests/unit/helpers/workersFixtures.mjs new file mode 100644 index 0000000..092dcfe --- /dev/null +++ b/tests/unit/helpers/workersFixtures.mjs @@ -0,0 +1,56 @@ +// Test-Hilfen für die Hustler-Regie: die 19 Worker-Kennungen, ein Dienst-Zustand (Übersicht §6.2) und Ereignisse. +// IDS kommt aus der gemeinsamen Roster-Fixture (nicht aus dem Code unter Test). +import { readFileSync } from 'node:fs' + +const roster = JSON.parse(readFileSync(new URL('../../fixtures/workers-roster.json', import.meta.url), 'utf8')) + +// Die 19 workerIds in Rollenreihenfolge +export const IDS = Object.freeze(roster.roles.map((r) => r.workerId)) + +// Wie im Dienst (P1-E9) können nur die acht Such-Worker nicht angeschlossen sein +const SEARCH_IDS = new Set(['rita', 'kim', 'pia', 'finn', 'ali', 'hanna', 'sven', 'lea']) + +const KPIS = Object.freeze({ + newAbosMonth: 3, + mrrNewEur: 60, + dealsMonth: 3, + openThreads: 7, + pultOpen: 2, + botLeads: 112, + apiTodayUsd: 5.1, + apiMonthUsd: 50, + dailyBudgetUsd: 6, + monthlyBudgetUsd: 150, +}) + +/** + * Zustand wie GET /api/workers/state mit 19 Workern. Standard: alle arbeiten und sind angeschlossen. + * connected: false ist nur für die Such-Worker erlaubt (sonst Fehler: der Dienst meldet alle anderen als angeschlossen). + */ +export function makeState({ statuses = {}, connected = {}, kpis = {}, paused = false, sendingHalted = false, sendingHaltedReason = '', lastEventTs } = {}) { + for (const [id, value] of Object.entries(connected)) { + if (value === false && !SEARCH_IDS.has(id)) throw new Error(`makeState: ${id} ist kein Such-Worker und kann nicht getrennt sein`) + } + return { + paused, + sendingHalted, + sendingHaltedReason, + lastEventTs: lastEventTs ?? null, + workers: IDS.map((workerId) => ({ + workerId, + status: statuses[workerId] ?? 'arbeitet', + task: '', + counters: {}, + connected: connected[workerId] ?? true, + })), + kpis: { ...KPIS, ...kpis }, + } +} + +// Ereignis mit eindeutiger id; ts zählt je Aufruf um eine Sekunde hoch (aufsteigend wie die Dienst-API) +const T0 = Date.parse('2026-10-17T20:00:00.000Z') +let counter = 0 +export function ev(kind, workerId, extra = {}) { + counter += 1 + return { id: `ev-${counter}`, ts: new Date(T0 + counter * 1000).toISOString(), workerId, kind, text: 'Text', ...extra } +} diff --git a/tests/unit/workersDirector.test.mjs b/tests/unit/workersDirector.test.mjs new file mode 100644 index 0000000..f34ef9a --- /dev/null +++ b/tests/unit/workersDirector.test.mjs @@ -0,0 +1,233 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { loadModule } from './load.mjs' +import { makeCtx } from './helpers/fakeCtx.mjs' +import { IDS, makeState } from './helpers/workersFixtures.mjs' + +// Regie 1: Agenten, Snapshot ohne Animation, Posen, Leerlauf, Zeichnen (Quelle: entwuerfe/scene-turm.js) +const D = await loadModule('src/components/workers/engine/director.js') +const { buildLayout, homeOf } = await loadModule('src/components/workers/engine/layout.js') +const { createLift } = await loadModule('src/components/workers/engine/lift.js') +const { createFx } = await loadModule('src/components/workers/engine/fx.js') +const { createLook } = await loadModule('src/components/workers/engine/rooms.js') +const { pickAgent } = await loadModule('src/components/workers/engine/camera.js') +const { PAL } = await loadModule('src/components/workers/engine/sprites.js') +const { createDirector, drawAgents } = D + +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 }) } +} +const step = (d, n, dt = 0.1) => { for (let i = 0; i < n; i++) d.update(dt) } +const rectsOf = (ctx, fill) => ctx.rects.filter((r) => r.fill === fill) +const draw = (d, selectedId = null, t = 1) => { const m = makeCtx(); drawAgents(m.ctx, d, selectedId, t); return m } +const depth = (a) => a.y + a.lane + a.bob + 13 * a.scale + +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('erster Snapshot stellt Agenten auf den Platz, auch wenn sie vorher woanders standen', () => { + const { d, layout } = setup(); for (const a of d.agents) { a.x = 5; a.y = 7; a.carrying = true; a.frame = 1 } + d.applyState(makeState()) + for (const a of d.agents) { const h = homeOf(layout, a.id); assert.deepEqual([a.x, a.y, a.carrying, a.frame, a.floor], [h.x, h.y, false, 0, h.floor], a.id) } + assert.deepEqual([d.byId.otto.lane, d.byId.bob.lane], [0, -2]) +}) + +test('nicht angeschlossen oder im Zustand fehlend ⇒ grau, schläft, Raum in look.greyRooms (Review Focus 4)', () => { + const { d, look } = setup(); const st = makeState({ connected: { kim: false } }); st.workers = st.workers.filter((w) => w.workerId !== 'pia') + d.applyState(st) + assert.deepEqual([d.byId.kim.grey, d.byId.kim.pose, d.byId.pia.grey, d.byId.pia.pose, d.byId.bob.grey], [true, 'sleep', true, 'sleep', false]) + assert.deepEqual([...look.greyRooms].sort(), ['kleinanzeigen', 'portale']) +}) + +test('Kennzahlen landen in look; neuer Pokal blinkt nur bei steigender Zahl, nicht beim ersten Snapshot', () => { + const { d, look } = setup(); d.applyState(makeState({ kpis: { pultOpen: 4, dealsMonth: 9, botLeads: 112 } })) + assert.deepEqual([look.pultOpen, look.dealsMonth, look.botLeads, look.trophyPop], [4, 9, 112, 0]) + d.applyState(makeState({ kpis: { pultOpen: 4, dealsMonth: 10, botLeads: 112 } })); assert.ok(look.trophyPop > 0) + d.applyState(makeState({ paused: true })); assert.equal(look.paused, true) + d.applyState(makeState({ sendingHalted: true, sendingHaltedReason: 'Zwei harte Bounces' })); assert.equal(look.paused, false) // Notbremse (H4) ist keine Pause: kein Schleier, die Meldung zeigt das Board +}) + +test('Leerlauf: erst Kaffee, nach 25 s Schlaf; Pokal-Seiten blättern alle 6 s', () => { + const { d, look } = setup(); d.applyState(makeState({ statuses: { kim: 'leerlauf' }, kpis: { dealsMonth: 9 } })) + const step = (n) => { for (let i = 0; i < n; i++) d.update(0.1) } + step(70); assert.equal(look.trophyPage, 1) // 7 s: erste Blätterung bei 6 s + step(60); assert.equal(look.trophyPage, 0); assert.equal(d.byId.kim.pose, 'coffee') // 13 s: zweite Blätterung bei 12 s + step(140); assert.equal(d.byId.kim.pose, 'sleep') // 27 s > 25 s +}) + +test('drawAgents: Trefferflächen für alle 19, jeder ist anklickbar', () => { + const { d } = setup(); d.applyState(makeState({ connected: { kim: false }, statuses: { rita: 'fehler' } })); const { ctx } = makeCtx(); drawAgents(ctx, d, 'bob', 1) + const boxes = d.boxes(); assert.equal(boxes.length, 19) + for (const { id, box } of boxes) assert.equal(pickAgent(boxes, box.x + box.w / 2, box.y + box.h / 2), id, id) +}) + +test('Konstanten der Regie', () => { + assert.deepEqual([D.BACKLOG_KEEP, D.GROUPS_PER_AGENT, D.IDLE_COFFEE_S, D.STEP_TIMEOUT_S, D.BUBBLE_MAX], [6, 4, 25, 45, 3]) +}) + +test('Agent hat alle Felder der Schnittstelle; Hut, Größe, Blickrichtung und Platz stimmen', () => { + const { d, layout } = setup(); d.applyState(makeState()) + const felder = ['id', 'name', 'hat', 'scale', 'x', 'y', 'floor', 'facing', 'frame', 'pose', 'carrying', 'stackN', 'say', 'bubT', 'grey', 'alert', 'status', 'task', 'counters', 'lane', 'bob', 'idleT', 'cheer', 'q', 'cur', 'stepT', 'groups', 'box', 'home'] + for (const a of d.agents) for (const f of felder) assert.ok(Object.hasOwn(a, f), `${a.id}.${f}`) + assert.deepEqual(d.agents.map((a) => a.id), [...IDS]) + assert.deepEqual([d.byId.otto.hat, d.byId.otto.scale, d.byId.bob.hat, d.byId.paul.hat, d.byId.kim.hat, d.byId.kim.scale], ['overseer', 2, 'opus', 'sonnet', 'haiku', 1]) + assert.deepEqual([d.byId.otto.name, d.byId.clara.name], ['Otto', 'Clara']) + assert.deepEqual(['ali', 'hanna', 'sven', 'lea', 'paul', 'clara'].map((id) => d.byId[id].facing), [-1, -1, -1, -1, -1, -1]) + assert.deepEqual(['otto', 'rita', 'bea', 'arne'].map((id) => d.byId[id].facing), [1, 1, 1, 1]) + assert.deepEqual(d.agents.map((a) => [a.floor, a.home]), d.agents.map((a) => [homeOf(layout, a.id).floor, homeOf(layout, a.id)])) + assert.deepEqual([d.byId.otto.lane, d.byId.bob.lane], [0, -2]) + assert.equal(d.byId.constructor, undefined) + assert.equal(d.pending(), 0) +}) + +test('spätere Snapshots bewegen nie; Status, Pose und Aufgabe nur bei Agenten ohne laufende Gruppe', () => { + const { d } = setup(); d.applyState(makeState()); const kim = d.byId.kim + kim.x = 77; kim.y = 66 + const st1 = makeState({ statuses: { kim: 'leerlauf' } }); st1.workers.find((w) => w.workerId === 'kim').task = 'Pause'; st1.workers.find((w) => w.workerId === 'kim').counters = { gelesen: 4 } + d.applyState(st1) + assert.deepEqual([kim.x, kim.y, kim.status, kim.pose, kim.task, kim.counters], [77, 66, 'leerlauf', 'coffee', 'Pause', { gelesen: 4 }]) + // laufende Gruppe: der Status gilt sofort, Pose und Aufgabe kommen erst nach dem Ende der Gruppe + kim.groups = 1; kim.pose = 'type'; kim.task = 'alt' + const st2 = makeState({ statuses: { kim: 'fehler' } }); st2.workers.find((w) => w.workerId === 'kim').task = 'neu' + d.applyState(st2) + assert.deepEqual([kim.status, kim.alert, kim.pose, kim.task], ['fehler', true, 'type', 'alt']) + d.update(0.05); assert.equal(kim.pose, 'type') + kim.groups = 0; d.update(0.05); assert.equal(kim.pose, 'stand') + d.applyState(st2); assert.equal(kim.task, 'neu') +}) + +test('unbrauchbarer Zustand wirft nicht: alle grau und schlafend, keine Pause', () => { + for (const bad of [null, undefined, {}, 'x', { workers: 'x' }, { workers: [null, 5, { workerId: 7 }] }]) { + const { d, look } = setup(); d.applyState(makeState({ paused: true })); d.applyState(bad) + assert.deepEqual([d.agents.every((a) => a.grey && a.pose === 'sleep' && a.status === 'grau'), look.paused, look.greyRooms.size], [true, false, 19], String(bad)) + } +}) + +test('busyRooms: Räume der arbeitenden Worker; graue und ruhende gehören nicht dazu', () => { + const { d, look } = setup(); d.applyState(makeState({ statuses: { kim: 'leerlauf', bea: 'wartet', paul: 'fehler' }, connected: { pia: false } })) + assert.equal(look.busyRooms.has('bruecke'), true) + for (const room of ['kleinanzeigen', 'portale', 'waage', 'pruefung']) assert.equal(look.busyRooms.has(room), false, room) + assert.equal(look.busyRooms.size, 19 - 4) +}) + +test('Kennzahlen: fehlende Werte lassen look stehen und lösen kein Blinken aus; Rückgang blinkt nicht', () => { + const { d, look } = setup(); d.applyState(makeState({ kpis: { pultOpen: 4, dealsMonth: 9, botLeads: 112 } })) + const bare = makeState(); delete bare.kpis; d.applyState(bare) + assert.deepEqual([look.pultOpen, look.dealsMonth, look.botLeads, look.trophyPop], [4, 9, 112, 0]) + d.applyState(makeState({ kpis: { dealsMonth: 'x', pultOpen: NaN, botLeads: null } })); assert.deepEqual([look.pultOpen, look.dealsMonth, look.botLeads, look.trophyPop], [4, 9, 112, 0]) + d.applyState(makeState({ kpis: { dealsMonth: 9 } })); assert.equal(look.trophyPop, 0) + d.applyState(makeState({ kpis: { dealsMonth: 9.8, pultOpen: -3, botLeads: 112.9 } })); assert.deepEqual([look.dealsMonth, look.pultOpen, look.botLeads, look.trophyPop], [9, 0, 112, 0]) // ganze Zahlen, nie negativ + d.applyState(makeState({ kpis: { dealsMonth: 2 } })); assert.deepEqual([look.dealsMonth, look.trophyPop], [2, 0]) + d.applyState(makeState({ kpis: { dealsMonth: 3 } })); assert.equal(look.trophyPop, 1.2) + // ein Zustand ohne Kennzahlen beim ersten Snapshot zählt nicht als erster Wert + const b = setup(); b.d.applyState(bare); b.d.applyState(makeState({ kpis: { dealsMonth: 9 } })); assert.equal(b.look.trophyPop, 0) +}) + +test('update: Pokal-Blinken und Tresor-Blitz zählen herunter; Pause friert alles ein', () => { + const { d, look, fx } = setup(); d.applyState(makeState({ kpis: { dealsMonth: 1 } })); d.applyState(makeState({ kpis: { dealsMonth: 2 } })); look.vaultFlash = 0.7 + let ended = 0; fx.add(0.4, () => {}, () => { ended += 1 }) + step(d, 5); assert.ok(look.trophyPop < 1.2 && look.trophyPop > 0.6); assert.ok(look.vaultFlash < 0.7 && look.vaultFlash > 0.1); assert.equal(ended, 1) + step(d, 20); assert.deepEqual([look.trophyPop, look.vaultFlash], [0, 0]) + const kim = d.byId.kim; d.applyState(makeState({ statuses: { kim: 'leerlauf' }, paused: true })); look.pageT = 0 + const before = [kim.idleT, look.pageT]; step(d, 100); assert.deepEqual([kim.idleT, look.pageT], before) + d.applyState(makeState({ statuses: { kim: 'leerlauf' }, paused: false })); step(d, 10); assert.ok(kim.idleT > 0.9) +}) + +test('update: ungültiges dt wird ignoriert; der Schlaf kehrt mit dem Status zurück', () => { + const { d } = setup(); d.applyState(makeState({ statuses: { kim: 'leerlauf' } })) + for (const dt of [NaN, undefined, 0, -1, Infinity]) d.update(dt) + assert.equal(d.byId.kim.idleT, 0) + step(d, 260); assert.equal(d.byId.kim.pose, 'sleep') + d.applyState(makeState({ statuses: { kim: 'arbeitet' } })); d.update(0.1); assert.deepEqual([d.byId.kim.idleT, d.byId.kim.pose], [0, 'type']) + d.applyState(makeState({ statuses: { kim: 'leerlauf' } })); step(d, 10); assert.equal(d.byId.kim.pose, 'coffee') +}) + +test('Wippen: nur wer am Platz tippt, wippt um einen Pixel; Jubel hüpft um zwei; unterwegs gilt die Spur 0', () => { + const { d } = setup(); d.applyState(makeState()); const seen = new Set() + for (let i = 0; i < 40; i++) { d.update(0.1); seen.add(d.byId.bob.bob) } + assert.deepEqual([...seen].sort(), [-1, 0]) + assert.equal(d.byId.arne.pose, 'type') + d.applyState(makeState({ statuses: { bob: 'wartet' } })); d.update(0.1); assert.equal(d.byId.bob.bob, 0) + d.byId.otto.cheer = 0.5; const jubel = new Set(); for (let i = 0; i < 4; i++) { d.update(0.1); jubel.add(d.byId.otto.bob) } + assert.ok(jubel.has(-2) && jubel.has(0)); step(d, 3); assert.equal(d.byId.otto.cheer, 0) + // Spur: am Platz -2 (außer Otto), unterwegs gleitet sie auf 0 + const kim = d.byId.kim; assert.equal(kim.lane, -2); kim.x += 30; kim.frame = 1; const unterwegs = new Set(); for (let i = 0; i < 20; i++) { d.update(0.1); unterwegs.add(kim.bob) } + assert.deepEqual([kim.lane, [...unterwegs]], [0, [0]]); kim.x -= 30; kim.frame = 0; d.update(0.05); assert.ok(kim.lane < 0 && kim.lane > -1) + step(d, 3); assert.equal(kim.lane, -2) +}) + +test('bubble: auf 20 Zeichen gekürzt, nach 2,2 s weg, höchstens drei fremde gleichzeitig, leer und unbekannt ignoriert', () => { + const { d } = setup(); d.applyState(makeState()) + assert.equal(d.bubble(d.byId.kim, 'Dies ist ein sehr langer Satz'), true); assert.equal(d.byId.kim.say, 'Dies ist ein sehr la') + assert.equal(d.bubble(d.byId.rita, 'Eins'), true); assert.equal(d.bubble(d.byId.pia, 'Zwei'), true) + assert.equal(d.bubble(d.byId.finn, 'Drei'), false); assert.equal(d.byId.finn.say, null) + assert.equal(d.bubble(d.byId.kim, 'Neu'), true); assert.equal(d.byId.kim.say, 'Neu') // der Sprecher selbst zählt nicht mit + assert.deepEqual([d.bubble(d.byId.kim, ''), d.bubble(d.byId.kim, 42), d.bubble(null, 'x')], [false, false, false]) + step(d, 20); assert.equal(d.byId.kim.say, 'Neu'); step(d, 5); assert.deepEqual(d.agents.filter((a) => a.say), []) + assert.equal(d.bubble(d.byId.finn, 'Jetzt'), true) + d.bubble(d.byId.ali, 'Lang', 5); step(d, 30); assert.equal(d.byId.ali.say, 'Lang'); step(d, 25); assert.equal(d.byId.ali.say, null) +}) + +test('drawAgents: y-sortiert, jede Figur genau einmal, Trefferfläche aus der Zeichnung', () => { + const { d } = setup(); d.applyState(makeState()); const m = draw(d) + const tiefen = d.boxes().map(({ id }) => depth(d.byId[id])) + assert.deepEqual(tiefen, [...tiefen].sort((a, b) => a - b)) + assert.equal(new Set(d.boxes().map((b) => b.id)).size, 19) + assert.ok(m.rects.length > 19 * 40) + const otto = d.byId.otto; assert.deepEqual([otto.box.w, otto.box.h], [24, 32]) + const e = setup(); assert.deepEqual(e.d.boxes(), []) // vor dem ersten Zeichnen gibt es keine Flächen +}) + +test('drawAgents: gezeichnet wird von hinten nach vorn, nicht in Listenreihenfolge', () => { + const kleine = (m) => m.rects.map((r, i) => (r.w === 3 && r.h === 3 ? i : -1)).filter((i) => i >= 0) // Zellen der normalen Figuren (Raster 3) + const grosse = (m) => m.rects.map((r, i) => (r.w === 6 && r.h === 6 ? i : -1)).filter((i) => i >= 0) // Zellen von Otto (Maßstab 2) + const { d } = setup(); d.applyState(makeState()) + const oben = draw(d); assert.ok(Math.max(...grosse(oben)) < Math.max(...kleine(oben)), 'Otto steht oben und wird zuerst gezeichnet') + d.byId.otto.y = 300 + const unten = draw(d); assert.ok(Math.min(...grosse(unten)) > Math.max(...kleine(unten)), 'Otto steht jetzt vor allen anderen') + assert.equal(d.boxes().at(-1).id, 'otto') +}) + +test('drawAgents: „!“ nur bei Fehler, Auswahlpfeil nur beim gewählten, Stapel nur bei mehreren Papieren', () => { + const { d } = setup(); d.applyState(makeState()) + assert.equal(rectsOf(draw(d), PAL.red).length, 0) + assert.equal(rectsOf(draw(d), PAL.ink).length, 0) + d.applyState(makeState({ statuses: { bea: 'fehler' } })); assert.ok(rectsOf(draw(d), PAL.red).length > 0) + const rot = rectsOf(draw(d), PAL.red), bea = d.byId.bea + assert.equal(Math.max(...rot.map((r) => r.y + r.h)) + 3, (bea.y + bea.lane - 2) * 3, 'Unterkante des „!“ (mit Umriss) steht auf der Kopfoberkante') + assert.equal(rectsOf(draw(d, 'bob'), PAL.ink).length, 9) + const papier = (n, carrying = true) => { d.byId.kim.carrying = carrying; d.byId.kim.stackN = n; return draw(d).rects.filter((r) => r.fill === PAL.g).length } + const eins = papier(1); assert.ok(papier(2) > eins); assert.ok(papier(5) > papier(2)); assert.equal(papier(5, false), papier(1, false)) +}) + +test('drawAgents: graue Figuren halbdurchsichtig, die Zeichnung lässt save/restore im Gleichgewicht', () => { + const { d } = setup(); d.applyState(makeState({ connected: { kim: false, pia: false } })); const m = draw(d) + assert.equal(m.alphas.filter((a) => a === 0.55).length, 2); assert.equal(m.counts.save, m.counts.restore) +}) + +test('drawAgents: Blasen zuletzt; Otto seitlich; außerhalb des Bildes keine Blase', () => { + const { d } = setup(); d.applyState(makeState()); d.bubble(d.byId.kim, 'Hallo'); d.bubble(d.byId.otto, 'Deal! Super!') + const m = draw(d); assert.deepEqual(m.texts.map((t) => t.text).sort(), ['Deal! Super!', 'Hallo']) + const weiss = m.rects.map((r, i) => [r, i]).filter(([r]) => r.fill === PAL.w && r.w > 20) + assert.equal(weiss.length, 2); for (const [, i] of weiss) assert.ok(m.rects.length - i <= 4 + 4, 'Blase liegt am Ende der Zeichnung') + // Otto: die Blase steht neben ihm, nicht über dem Kopf (Spitze liegt links von seiner linken Kante oder rechts von der rechten) + const otto = d.byId.otto, os = m.texts.find((t) => t.text === 'Deal! Super!'), kimText = m.texts.find((t) => t.text === 'Hallo') + assert.ok(os.x < otto.x * 3 - 20 || os.x > (otto.x + 24) * 3); assert.ok(os.y > otto.y * 3) + assert.ok(kimText.y < d.byId.kim.y * 3) + const kim = d.byId.kim, spitze = m.rects.at(-1); assert.equal(spitze.y + spitze.h, (kim.y + kim.lane - 2) * 3 + 1, 'Spitze steht auf der Kopfoberkante (Hut)') + d.byId.kim.x = -10; d.byId.rita.x = 330; d.bubble(d.byId.rita, 'Rechts'); const aus = draw(d); assert.deepEqual(aus.texts.map((t) => t.text).sort(), ['Deal! Super!']) +}) + +test('drawAgents: die Blase weicht dem „!“ und dem Auswahlpfeil aus', () => { + const { d } = setup(); d.applyState(makeState({ statuses: { kim: 'fehler' } })); d.bubble(d.byId.kim, 'Kim'); d.bubble(d.byId.rita, 'Rita') + const textY = (m, text) => m.texts.find((t) => t.text === text).y + const m = draw(d) + assert.equal(d.byId.kim.y, d.byId.rita.y) + assert.ok(textY(m, 'Rita') - textY(m, 'Kim') >= 27) // das „!“ ist 9 Einheiten hoch (Raster 3) + assert.equal(textY(m, 'Rita') - textY(draw(d, 'rita'), 'Rita'), 18) // der Pfeil nimmt 6 Einheiten +}) diff --git a/tests/unit/workersSprites.test.mjs b/tests/unit/workersSprites.test.mjs index 2b67549..ef72c32 100644 --- a/tests/unit/workersSprites.test.mjs +++ b/tests/unit/workersSprites.test.mjs @@ -138,3 +138,35 @@ test('drawBubble: Text und Maße folgen dem Raster, leerer Text zeichnet nichts, S.drawBubble(small.ctx, 20, 30, 'Hallo') assert.ok(Math.max(...small.rects.map((q) => q.x + q.w)) <= 100) }) +test('drawSideBubble: links neben dem Körper, Spitze nach rechts; am linken Rand rechts daneben, Spitze nach links', () => { + const l = makeCtx() + S.drawSideBubble(l.ctx, 'Hallo', { leftX: 100, rightX: 130, y: 10 }) + assert.deepEqual(l.texts.map((t) => t.text), ['Hallo']) + assert.ok(Math.max(...l.rects.map((r) => r.x + r.w)) <= 300, 'endet vor der linken Körperkante') + assert.ok(Math.max(...l.rects.map((r) => r.x + r.w)) >= 290, 'Spitze reicht bis an den Körper') + assert.equal(l.rects.length, 4) + const r = makeCtx() + S.drawSideBubble(r.ctx, 'Hallo', { leftX: 10, rightX: 40, y: 10 }) + assert.ok(Math.min(...r.rects.map((q) => q.x)) >= 120, 'beginnt hinter der rechten Körperkante') + assert.ok(Math.min(...r.rects.map((q) => q.x)) <= 124, 'Spitze reicht bis an den Körper') +}) + +test('drawSideBubble: Oberkante bei y, Maße und Schrift folgen dem Raster, rechts in die Bildbreite geklemmt, leer zeichnet nichts', () => { + const a = makeCtx() + S.drawSideBubble(a.ctx, 'Hallo', { leftX: 100, rightX: 130, y: 10 }) + const white = a.rects.find((q) => q.fill === S.PAL.w) + assert.deepEqual([white.y, white.h], [30, 22]) + assert.match(a.texts[0].font, /12px/) + const b = makeCtx() + S.setGrid(b.ctx, { k: 6 }) + S.drawSideBubble(b.ctx, 'Hallo', { leftX: 100, rightX: 130, y: 10 }) + assert.match(b.texts[0].font, /24px/) + assert.deepEqual([b.rects.find((q) => q.fill === S.PAL.w).y, b.rects.find((q) => q.fill === S.PAL.w).h], [60, 44]) + const small = makeCtx({ width: 120 }) + S.drawSideBubble(small.ctx, 'Hallo', { leftX: 10, rightX: 40, y: 10 }) + assert.ok(Math.max(...small.rects.map((q) => q.x + q.w)) <= 120) + const e = makeCtx() + S.drawSideBubble(e.ctx, '', { leftX: 100, rightX: 130, y: 10 }) + S.drawSideBubble(e.ctx, undefined, { leftX: 100, rightX: 130, y: 10 }) + assert.equal(e.rects.length, 0) +})