import test from 'node:test' import assert from 'node:assert/strict' import { loadModule } from './load.mjs' // Ereignis-Logik der Hustler-Seite: Deduplizierung, Zustandsableitung, Animationsschritte const E = await loadModule('src/lib/workersEvents.js') const { ROLES } = await loadModule('src/lib/workersRoster.js') const TS = '2026-10-17T20:00:00.000Z' const ev = (kind, workerId, extra = {}) => ({ id: `${kind}-${workerId}`, ts: TS, workerId, kind, text: 'Text', ...extra }) test('EVENT_KINDS: 26 Arten aus Übersicht §5', () => { assert.deepEqual([...E.EVENT_KINDS], ['auftrag', 'leerlauf', 'gelesen', 'fund', 'doppelt', 'uebergabe', 'bewertet', 'top3', 'botlead', 'entwurf', 'pult', 'gesendet', 'eingang', 'sortiert', 'bau_start', 'bau_fertig', 'pruefung_zurueck', 'pruefung_ok', 'vorschau_link', 'angebot', 'deal', 'gesperrt', 'budget_warnung', 'budget_stopp', 'pause', 'fehler']) assert.deepEqual([...E.STEP_OPS], ['task', 'say', 'pose', 'give', 'fx', 'cheer']) assert.deepEqual([...E.SPOTS], ['pult', 'tresor', 'aussen', 'archiv']) }) test('takeNewEvents: dedupliziert nach id, sortiert nach ts, Gedächtnis begrenzt (Review Focus 3)', () => { const seen = new Set() const a = { id: 'a', ts: '2026-10-17T20:00:02Z' } const b = { id: 'b', ts: '2026-10-17T20:00:01Z' } assert.deepEqual(E.takeNewEvents(seen, [a, b]).map((e) => e.id), ['b', 'a']) assert.deepEqual(E.takeNewEvents(seen, [a, b, { id: 'c', ts: '2026-10-17T20:00:03Z' }]).map((e) => e.id), ['c']) const noId = { ts: '2026-10-17T20:00:04Z', workerId: 'kim', kind: 'gelesen', text: 'x' } assert.equal(E.takeNewEvents(seen, [noId]).length, 1) assert.equal(E.takeNewEvents(seen, [noId]).length, 0) assert.deepEqual(E.takeNewEvents(seen, [null, {}, { id: 'z' }]), []) const big = new Set() E.takeNewEvents(big, Array.from({ length: 1500 }, (_, i) => ({ id: `i${i}`, ts: new Date(Date.UTC(2026, 9, 17, 0, 0, i)).toISOString() }))) assert.ok(big.size <= 1000) }) test('takeNewEvents: gleiche Zeitstempel bleiben in Eingangsreihenfolge, Gedächtnis verwirft die ältesten', () => { const seen = new Set() const same = ['x1', 'x2', 'x3'].map((id) => ({ id, ts: TS })) assert.deepEqual(E.takeNewEvents(seen, same).map((e) => e.id), ['x1', 'x2', 'x3']) // Zwei Aufrufe über der Grenze: die zuerst gemerkten Ids fallen heraus, die neuesten bleiben const big = new Set() E.takeNewEvents(big, Array.from({ length: 1200 }, (_, i) => ({ id: `i${i}`, ts: new Date(Date.UTC(2026, 9, 17, 0, 0, i)).toISOString() }))) assert.equal(big.has('i1199'), true) assert.equal(big.has('i0'), false) // Ein junges Ereignis bleibt bekannt; ein längst vergessenes gälte wieder als neu (das Gedächtnis ist begrenzt) assert.equal(E.takeNewEvents(big, [{ id: 'i1199', ts: new Date(Date.UTC(2026, 9, 17, 0, 19, 59)).toISOString() }]).length, 0) assert.equal(E.takeNewEvents(big, [{ id: 'i0', ts: new Date(Date.UTC(2026, 9, 17, 0, 0, 0)).toISOString() }]).length, 1) // Dasselbe Ereignis zweimal in einer Runde (Überlappung der Abfragen) zählt einmal assert.equal(E.takeNewEvents(new Set(), [same[0], { ...same[0] }]).length, 1) // Kein Array -> leer, Eingabe bleibt unverändert assert.deepEqual(E.takeNewEvents(new Set(), null), []) const input = [{ id: 'q', ts: '2026-10-17T20:00:02Z' }, { id: 'p', ts: '2026-10-17T20:00:01Z' }] E.takeNewEvents(new Set(), input) assert.deepEqual(input.map((e) => e.id), ['q', 'p']) }) test('latestTs: größter gültiger Zeitstempel', () => { assert.equal(E.latestTs([{ ts: '2026-10-17T20:00:01+00:00' }, { ts: '2026-10-17T20:00:05Z' }, { ts: 'kaputt' }], null), '2026-10-17T20:00:05Z') assert.equal(E.latestTs([], 'alt'), 'alt') assert.equal(E.latestTs([], null), null) // Ein älterer Stand wird nicht überschrieben assert.equal(E.latestTs([{ ts: '2026-10-17T20:00:01Z' }], '2026-10-17T20:00:09Z'), '2026-10-17T20:00:09Z') assert.equal(E.latestTs(null), null) }) test('reduceEvents: Status aus der Ereignisart, grau bleibt grau, Altes und Fremdes wird ignoriert (Review Focus 4)', () => { const base = { kim: { status: 'wartet', task: '', lastTs: null }, rita: { status: 'grau', task: '', lastTs: null }, bea: { status: 'arbeitet', task: 'alt', lastTs: '2026-10-17T20:00:09Z' }, } const copy = JSON.parse(JSON.stringify(base)) const out = E.reduceEvents(base, [ev('gelesen', 'kim', { text: 'Kim hat 3 Gesuche gelesen' }), ev('fund', 'rita'), ev('fehler', 'bea'), ev('pause', 'system'), ev('fund', 'zed'), ev('auftrag', 'otto', { to: 'kim' })]) assert.deepEqual(base, copy) assert.deepEqual(out.kim, { status: 'wartet', task: 'Text', lastTs: TS }) // zuletzt: Auftrag von Otto assert.equal(out.rita.status, 'grau') assert.equal(out.bea.status, 'arbeitet') assert.equal(out.bea.task, 'alt') // Ereignis älter als lastTs assert.equal(E.reduceEvents(base, [ev('leerlauf', 'kim')]).kim.status, 'leerlauf') assert.equal(E.reduceEvents(base, [ev('fehler', 'kim')]).kim.status, 'fehler') assert.equal(E.indexWorkers({ lastEventTs: TS, workers: [{ workerId: 'kim', status: 'arbeitet', task: 't', connected: false, counters: { a: 1 } }] }).kim.status, 'grau') }) test('reduceEvents: Empfänger, Zusatzfelder, Reihenfolge und Randfälle', () => { const base = { kim: { status: 'leerlauf', task: '', lastTs: null, connected: true, counters: { gelesen: 4 } }, rita: { status: 'grau', task: '', lastTs: null, connected: false }, bea: { status: 'arbeitet', task: 'alt', lastTs: '2026-10-17T20:00:09Z' }, } // Übergabe: Absender arbeitet, Empfänger wartet mit dem Text; Zusatzfelder bleiben const out = E.reduceEvents(base, [ev('uebergabe', 'kim', { to: 'bea' }), ev('uebergabe', 'kim', { to: 'rita', id: 'u2' })]) assert.equal(out.kim.status, 'arbeitet') assert.equal(out.kim.connected, true) assert.deepEqual(out.kim.counters, { gelesen: 4 }) assert.equal(out.bea.task, 'alt') // Empfänger mit neuerem lastTs bleibt unberührt assert.equal(out.bea.status, 'arbeitet') assert.equal(out.rita.status, 'grau') // grauer Empfänger bleibt grau assert.notEqual(out.kim, base.kim) // Einträge sind Kopien assert.notEqual(out.rita, base.rita) // Empfänger ohne Eintrag oder unbekannt: kein Wurf, kein neuer Eintrag const out2 = E.reduceEvents(base, [ev('auftrag', 'otto', { to: 'zed' }), ev('uebergabe', 'kim', { to: 'kim', id: 'self' })]) assert.equal('zed' in out2, false) assert.equal('otto' in out2, false) assert.equal(out2.kim.status, 'arbeitet') // Selbstadressierung macht aus dem Absender keinen Wartenden // Spätere Ereignisse gewinnen, ältere derselben Runde wirken nicht mehr const later = { id: 'l', ts: '2026-10-17T20:00:05Z', workerId: 'kim', kind: 'leerlauf', text: 'später' } const earlier = { id: 'e', ts: '2026-10-17T20:00:01Z', workerId: 'kim', kind: 'fund', text: 'früher' } const out3 = E.reduceEvents(base, [later, earlier]) assert.deepEqual([out3.kim.status, out3.kim.task, out3.kim.lastTs], ['leerlauf', 'später', '2026-10-17T20:00:05Z']) // Ereignis ohne gültiges ts wirkt nicht; kaputte Eingaben werfen nicht assert.equal(E.reduceEvents(base, [{ id: 'k', ts: 'kaputt', workerId: 'kim', kind: 'fehler', text: 'x' }]).kim.status, 'leerlauf') assert.deepEqual(E.reduceEvents({}, [ev('fund', 'kim')]), {}) assert.deepEqual(E.reduceEvents(null, null), {}) assert.doesNotThrow(() => E.reduceEvents(base, [null, 7, {}, { ts: TS }])) // Eigenschaften der Prototypkette sind keine Worker assert.deepEqual(E.reduceEvents({}, [ev('fund', 'constructor'), ev('fund', '__proto__')]), {}) // Kein Text: Aufgabe bleibt leer statt „undefined“ assert.equal(E.reduceEvents(base, [{ id: 'n', ts: TS, workerId: 'kim', kind: 'gelesen' }]).kim.task, '') }) test('indexWorkers: grau bei connected false, lastTs aus lastEventTs, Randfälle', () => { const idx = E.indexWorkers({ lastEventTs: TS, workers: [ { workerId: 'kim', status: 'arbeitet', task: 'liest', connected: true, counters: { gelesen: 2 } }, { workerId: 'rita', status: 'wartet', task: 'x', connected: false }, { workerId: 'bea', status: 'leerlauf' }, { task: 'ohne Kennung' }, null, ], }) assert.deepEqual(Object.keys(idx), ['kim', 'rita', 'bea']) assert.deepEqual(idx.kim, { status: 'arbeitet', task: 'liest', lastTs: TS, connected: true, counters: { gelesen: 2 } }) assert.equal(idx.rita.status, 'grau') assert.equal(idx.rita.connected, false) assert.deepEqual(idx.bea, { status: 'leerlauf', task: '', lastTs: TS, connected: true, counters: {} }) assert.deepEqual(E.indexWorkers(null), {}) assert.deepEqual(E.indexWorkers({}), {}) assert.equal(E.indexWorkers({ workers: [{ workerId: 'kim', status: 'wartet' }] }).kim.lastTs, null) }) test('eventToSteps: feste Zeilen', () => { assert.deepEqual(E.eventToSteps(ev('gelesen', 'kim', { meta: { count: 3 } })), [{ op: 'pose', who: 'kim', pose: 'type' }, { op: 'task', who: 'kim', text: 'Text' }, { op: 'say', who: 'kim', text: '3 gelesen' }]) assert.deepEqual(E.eventToSteps(ev('doppelt', 'arne')), [{ op: 'task', who: 'arne', text: 'Text' }, { op: 'say', who: 'arne', text: 'Schon bekannt' }, { op: 'fx', fx: 'stampNo', at: 'arne' }]) assert.deepEqual(E.eventToSteps(ev('uebergabe', 'rita', { to: 'bea' })), [{ op: 'task', who: 'rita', text: 'Text' }, { op: 'give', from: 'rita', to: 'bea' }]) assert.deepEqual(E.eventToSteps(ev('pruefung_zurueck', 'paul', { to: 'bob', meta: { round: 2 } })), [{ op: 'task', who: 'paul', text: 'Text' }, { op: 'fx', fx: 'stampNo', at: 'paul' }, { op: 'say', who: 'paul', text: 'Runde 2 zurück' }, { op: 'give', from: 'paul', to: 'bob' }]) assert.deepEqual(E.eventToSteps(ev('deal', 'system')), [0, 0.4, 0.8].map((delay) => ({ op: 'fx', fx: 'coin', at: 'aussen', to: 'tresor', delay })).concat([{ op: 'cheer', who: 'otto' }, { op: 'say', who: 'otto', text: 'Deal! Super!' }])) // system: kein task-Schritt, Arne stempelt assert.deepEqual(E.eventToSteps(ev('gesperrt', 'system')), [{ op: 'fx', fx: 'stampNo', at: 'arne' }, { op: 'say', who: 'arne', text: 'Gesperrt' }]) }) test('eventToSteps: Tabelle der übrigen Arten', () => { const steps = (kind, w, extra) => E.eventToSteps(ev(kind, w, extra)) assert.deepEqual(steps('auftrag', 'otto', { to: 'kim' }), [{ op: 'task', who: 'otto', text: 'Text' }, { op: 'say', who: 'otto', text: 'Kim, los!' }, { op: 'say', who: 'kim', text: 'Verstanden' }]) assert.deepEqual(steps('leerlauf', 'bob'), [{ op: 'task', who: 'bob', text: 'Text' }, { op: 'pose', who: 'bob', pose: 'coffee' }]) assert.deepEqual(steps('gelesen', 'kim'), [{ op: 'pose', who: 'kim', pose: 'type' }, { op: 'task', who: 'kim', text: 'Text' }, { op: 'say', who: 'kim', text: 'Gelesen' }]) assert.deepEqual(steps('fund', 'ali'), [{ op: 'pose', who: 'ali', pose: 'type' }, { op: 'task', who: 'ali', text: 'Text' }, { op: 'say', who: 'ali', text: 'Neuer Fund!' }, { op: 'fx', fx: 'paper', at: 'ali' }]) assert.deepEqual(steps('bewertet', 'bea', { meta: { score: 87 } }), [{ op: 'pose', who: 'bea', pose: 'type' }, { op: 'task', who: 'bea', text: 'Text' }, { op: 'say', who: 'bea', text: '87 Punkte' }]) assert.equal(steps('bewertet', 'bea').at(-1).text, 'Bewertet') assert.deepEqual(steps('top3', 'bea'), [{ op: 'task', who: 'bea', text: 'Text' }, { op: 'say', who: 'bea', text: 'Top 3!' }, { op: 'fx', fx: 'stampOk', at: 'bea' }]) assert.deepEqual(steps('botlead', 'arne'), [{ op: 'task', who: 'arne', text: 'Text' }, { op: 'say', who: 'arne', text: 'Abgelegt' }, { op: 'fx', fx: 'drawer', at: 'arne' }]) assert.deepEqual(steps('entwurf', 'max'), [{ op: 'pose', who: 'max', pose: 'type' }, { op: 'task', who: 'max', text: 'Text' }, { op: 'say', who: 'max', text: 'Entwurf fertig' }]) assert.deepEqual(steps('pult', 'clara'), [{ op: 'task', who: 'clara', text: 'Text' }, { op: 'say', who: 'clara', text: 'Zur Freigabe' }, { op: 'fx', fx: 'envelope', at: 'clara', to: 'pult' }]) assert.deepEqual(steps('gesendet', 'max'), [{ op: 'task', who: 'max', text: 'Text' }, { op: 'say', who: 'max', text: 'Raus damit!' }, { op: 'fx', fx: 'envelope', at: 'max', to: 'aussen' }]) assert.deepEqual(steps('gesendet', 'system'), [{ op: 'fx', fx: 'envelope', at: 'pult', to: 'aussen' }]) assert.deepEqual(steps('eingang', 'nora'), [{ op: 'task', who: 'nora', text: 'Text' }, { op: 'fx', fx: 'envelope', at: 'aussen', to: 'nora' }, { op: 'say', who: 'nora', text: 'Antwort!' }]) assert.deepEqual(steps('bau_start', 'bob'), [{ op: 'pose', who: 'bob', pose: 'type' }, { op: 'task', who: 'bob', text: 'Text' }, { op: 'say', who: 'bob', text: 'Los geht’s' }]) assert.deepEqual(steps('bau_fertig', 'ben'), [{ op: 'task', who: 'ben', text: 'Text' }, { op: 'say', who: 'ben', text: 'Fertig!' }, { op: 'give', from: 'ben', to: 'paul' }]) assert.deepEqual(steps('pruefung_ok', 'paul'), [{ op: 'task', who: 'paul', text: 'Text' }, { op: 'fx', fx: 'stampOk', at: 'paul' }, { op: 'say', who: 'paul', text: 'Passt!' }]) assert.equal(steps('pruefung_zurueck', 'paul', { to: 'bob' }).find((s) => s.op === 'say').text, 'Zurück') assert.deepEqual(steps('vorschau_link', 'max'), [{ op: 'task', who: 'max', text: 'Text' }, { op: 'say', who: 'max', text: 'Link raus' }, { op: 'fx', fx: 'envelope', at: 'max', to: 'aussen' }]) assert.deepEqual(steps('angebot', 'theo'), [{ op: 'task', who: 'theo', text: 'Text' }, { op: 'say', who: 'theo', text: 'Kauf-Link!' }, { op: 'fx', fx: 'paper', at: 'theo' }]) assert.deepEqual(steps('gesperrt', 'nora'), [{ op: 'task', who: 'nora', text: 'Text' }, { op: 'fx', fx: 'stampNo', at: 'nora' }, { op: 'say', who: 'nora', text: 'Gesperrt' }]) assert.deepEqual(steps('budget_warnung', 'otto'), [{ op: 'task', who: 'otto', text: 'Text' }, { op: 'say', who: 'otto', text: 'Budget 80 %' }, { op: 'fx', fx: 'alert', at: 'otto' }]) assert.deepEqual(steps('budget_stopp', 'otto'), [{ op: 'task', who: 'otto', text: 'Text' }, { op: 'say', who: 'otto', text: 'Budget leer' }, { op: 'fx', fx: 'alert', at: 'otto' }]) assert.deepEqual(steps('pause', 'system'), []) assert.deepEqual(steps('pause', 'otto'), []) assert.deepEqual(steps('fehler', 'bea'), [{ op: 'task', who: 'bea', text: 'Text' }, { op: 'fx', fx: 'alert', at: 'bea' }, { op: 'say', who: 'bea', text: 'Fehler' }]) }) test('eventToSteps: sortiert nach meta.classification', () => { const say = (classification) => E.eventToSteps(ev('sortiert', 'nora', { meta: classification === undefined ? null : { classification } })).find((s) => s.op === 'say').text const erwartet = { ja: 'Ja!', frage: 'Frage', nein: 'Nein', abmelden: 'Abmeldung', abwesend: 'Abwesend', unzustellbar: 'Unzustellbar', beschwerde: 'Beschwerde!', unklar: 'Unklar', irgendwas: 'Sortiert', constructor: 'Sortiert' } for (const [k, v] of Object.entries(erwartet)) assert.equal(say(k), v, k) assert.equal(say(undefined), 'Sortiert') assert.deepEqual(E.eventToSteps(ev('sortiert', 'nora', { meta: { classification: 'ja' } })).map((s) => s.op), ['task', 'fx', 'say']) }) test('eventToSteps: meta als Objekt, JSON-Text oder null', () => { const zeile = (meta) => E.eventToSteps(ev('gelesen', 'kim', { meta })).find((s) => s.op === 'say').text assert.equal(zeile({ count: 5 }), '5 gelesen') assert.equal(zeile('{"count":7}'), '7 gelesen') assert.equal(zeile(null), 'Gelesen') assert.equal(zeile(undefined), 'Gelesen') assert.equal(zeile('kein json'), 'Gelesen') assert.equal(zeile('[1,2]'), 'Gelesen') assert.equal(zeile({ count: '5' }), 'Gelesen') assert.equal(zeile({ count: NaN }), 'Gelesen') }) test('eventToSteps: jede Art liefert gültige Schritte, Blasen ≤ 20 Zeichen, Unbekanntes wird verworfen (Review Focus 4)', () => { const ids = new Set(ROLES.map((r) => r.workerId)) const known = (v) => ids.has(v) || E.SPOTS.includes(v) for (const kind of E.EVENT_KINDS) { for (const w of ['kim', 'system', 'zed']) { for (const s of E.eventToSteps(ev(kind, w, { to: 'bea', meta: { count: 99999999, score: 123456789, round: 2, classification: 'ja' } }))) { assert.ok(E.STEP_OPS.includes(s.op), kind) if (s.op === 'say') assert.ok(s.text.length <= 20, s.text) if (s.op === 'fx') { assert.ok(known(s.at), `${kind}/${w}: at=${s.at}`) if (s.to) assert.ok(known(s.to)) } else { for (const k of ['who', 'from', 'to']) if (s[k]) assert.ok(ids.has(s[k]), `${kind}/${w}: ${k}=${s[k]}`) } } } } assert.deepEqual(E.eventToSteps(ev('fund', 'zed')), []) assert.deepEqual(E.eventToSteps(ev('fund', 'system')), []) assert.deepEqual(E.eventToSteps(ev('neueArt', 'kim')), [{ op: 'task', who: 'kim', text: 'Text' }]) assert.deepEqual(E.eventToSteps(ev('neueArt', 'zed')), []) assert.deepEqual(E.eventToSteps(ev('uebergabe', 'rita', { to: 'zed' })), [{ op: 'task', who: 'rita', text: 'Text' }]) }) test('eventToSteps: kaputte Ereignisse werfen nicht, Text wird gekürzt statt verworfen', () => { for (const bad of [null, undefined, 7, 'x', [], {}]) assert.deepEqual(E.eventToSteps(bad), []) assert.deepEqual(E.eventToSteps({ kind: 'gelesen', workerId: 'kim' }), [{ op: 'pose', who: 'kim', pose: 'type' }, { op: 'task', who: 'kim', text: '' }, { op: 'say', who: 'kim', text: 'Gelesen' }]) const lang = 'x'.repeat(300) assert.equal(E.eventToSteps(ev('doppelt', 'arne', { text: lang }))[0].text, lang) // task-Text bleibt vollständig, nur Blasen werden gekürzt // Auftrag an eine unbekannte Kennung: nur Ottos Aufgabe, keine Blasen für Fremde assert.deepEqual(E.eventToSteps(ev('auftrag', 'otto', { to: 'zed' })).map((s) => s.op), ['task']) }) test('needsStateRefresh und cutBubble', () => { assert.equal(E.needsStateRefresh([ev('gelesen', 'kim'), ev('pult', 'clara')]), true) assert.equal(E.needsStateRefresh([ev('gelesen', 'kim'), ev('fund', 'kim')]), false) for (const k of ['pult', 'gesendet', 'deal', 'botlead', 'gesperrt', 'budget_warnung', 'budget_stopp', 'pause', 'top3', 'angebot']) assert.equal(E.needsStateRefresh([ev(k, 'otto')]), true, k) assert.equal(E.needsStateRefresh([]), false) assert.equal(E.needsStateRefresh(null), false) assert.equal(E.cutBubble('Dies ist ein sehr langer Satz!'), 'Dies ist ein sehr la') assert.equal(E.cutBubble(null), '') assert.equal(E.cutBubble('kurz'), 'kurz') assert.equal(E.cutBubble(12345), '') // nur Text wird gekürzt, alles andere ist leer })