Workers P2: Ereignis-Logik (Dedup, reduceEvents, eventToSteps)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
79be67706c
commit
3386e91916
2 files changed
+530
No files matched your search
@@ -0,0 +1,289 @@
|
|||||||
|
// Ereignis-Logik der Hustler-Seite: Deduplizierung, Zustand aus Ereignissen, Animationsschritte (Übersicht §5 und §7).
|
||||||
|
// Kein React, kein Appwrite, kein window/document: unter Node testbar (loadModule).
|
||||||
|
import { ROLES, roleOf } from './workersRoster.js'
|
||||||
|
|
||||||
|
// Ereignisarten in der Reihenfolge der Übersicht §5 (geschlossene Liste, 26 Arten)
|
||||||
|
export const EVENT_KINDS = Object.freeze([
|
||||||
|
'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',
|
||||||
|
])
|
||||||
|
|
||||||
|
// Schrittarten der Regie und Plätze ohne Worker, an denen Effekte beginnen oder enden
|
||||||
|
export const STEP_OPS = Object.freeze(['task', 'say', 'pose', 'give', 'fx', 'cheer'])
|
||||||
|
export const SPOTS = Object.freeze(['pult', 'tresor', 'aussen', 'archiv'])
|
||||||
|
|
||||||
|
// Höchstens so viele Ereignis-Schlüssel merkt sich takeNewEvents (älteste fallen zuerst heraus)
|
||||||
|
const SEEN_MAX = 1000
|
||||||
|
// Sprechblasen zeigen höchstens so viele Zeichen
|
||||||
|
const BUBBLE_MAX = 20
|
||||||
|
|
||||||
|
// Arten, nach denen der Zustand (Zähler, Pult-Badge, Budget) frisch geholt werden muss
|
||||||
|
const REFRESH_KINDS = new Set(['pult', 'gesendet', 'deal', 'botlead', 'gesperrt', 'budget_warnung', 'budget_stopp', 'pause', 'top3', 'angebot'])
|
||||||
|
|
||||||
|
// Beschriftung des Stempels nach meta.classification
|
||||||
|
const CLASSIFICATION_LABEL = Object.freeze({
|
||||||
|
ja: 'Ja!',
|
||||||
|
frage: 'Frage',
|
||||||
|
nein: 'Nein',
|
||||||
|
abmelden: 'Abmeldung',
|
||||||
|
abwesend: 'Abwesend',
|
||||||
|
unzustellbar: 'Unzustellbar',
|
||||||
|
beschwerde: 'Beschwerde!',
|
||||||
|
unklar: 'Unklar',
|
||||||
|
})
|
||||||
|
|
||||||
|
const WORKER_IDS = new Set(ROLES.map((r) => r.workerId))
|
||||||
|
|
||||||
|
function isWorker(value) {
|
||||||
|
return typeof value === 'string' && WORKER_IDS.has(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPlace(value) {
|
||||||
|
return isWorker(value) || (typeof value === 'string' && SPOTS.includes(value))
|
||||||
|
}
|
||||||
|
|
||||||
|
function isNum(n) {
|
||||||
|
return typeof n === 'number' && Number.isFinite(n)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Zeitstempel als Millisekunden; kein Text oder nicht lesbar -> NaN
|
||||||
|
function msOf(ts) {
|
||||||
|
return typeof ts === 'string' ? Date.parse(ts) : NaN
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRecord(v) {
|
||||||
|
return v !== null && typeof v === 'object' && !Array.isArray(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sprechblasen-Text auf höchstens 20 Zeichen kürzen; alles außer Text ergibt ''. */
|
||||||
|
export function cutBubble(text) {
|
||||||
|
return typeof text === 'string' ? text.slice(0, BUBBLE_MAX) : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ereignisse, deren Schlüssel noch nicht in `seen` steht, aufsteigend nach ts (stabil).
|
||||||
|
* Schlüssel = id, ohne id `ts|workerId|kind|text`. Ohne gültiges ts wird das Ereignis verworfen.
|
||||||
|
* `seen` wird ergänzt und auf die jüngsten 1000 Schlüssel begrenzt (Review Focus 3).
|
||||||
|
*/
|
||||||
|
export function takeNewEvents(seen, events) {
|
||||||
|
if (!Array.isArray(events)) return []
|
||||||
|
const valid = []
|
||||||
|
for (const e of events) {
|
||||||
|
if (!isRecord(e)) continue
|
||||||
|
const ms = msOf(e.ts)
|
||||||
|
if (Number.isNaN(ms)) continue
|
||||||
|
valid.push({ e, ms })
|
||||||
|
}
|
||||||
|
valid.sort((a, b) => a.ms - b.ms)
|
||||||
|
const fresh = []
|
||||||
|
for (const { e } of valid) {
|
||||||
|
const key = typeof e.id === 'string' && e.id !== '' ? e.id : `${e.ts}|${e.workerId}|${e.kind}|${e.text}`
|
||||||
|
if (seen.has(key)) continue
|
||||||
|
seen.add(key)
|
||||||
|
fresh.push(e)
|
||||||
|
}
|
||||||
|
// Set behält die Einfügereihenfolge: die ältesten Schlüssel zuerst entfernen
|
||||||
|
for (const key of seen) {
|
||||||
|
if (seen.size <= SEEN_MAX) break
|
||||||
|
seen.delete(key)
|
||||||
|
}
|
||||||
|
return fresh
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Größter gültiger Zeitstempel aus `events` und `prev` (als ursprünglicher Text); sonst `prev`. */
|
||||||
|
export function latestTs(events, prev = null) {
|
||||||
|
let best = prev
|
||||||
|
let bestMs = msOf(prev)
|
||||||
|
if (Array.isArray(events)) {
|
||||||
|
for (const e of events) {
|
||||||
|
const ms = isRecord(e) ? msOf(e.ts) : NaN
|
||||||
|
if (Number.isNaN(ms)) continue
|
||||||
|
if (Number.isNaN(bestMs) || ms > bestMs) {
|
||||||
|
best = e.ts
|
||||||
|
bestMs = ms
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zustand der Dienst-API (`state.workers`) -> Tabelle nach workerId. Ein Worker mit connected false ist grau;
|
||||||
|
* lastTs ist der Stand des Schnappschusses (`lastEventTs`): ältere Ereignisse wirken nicht mehr.
|
||||||
|
*/
|
||||||
|
export function indexWorkers(state) {
|
||||||
|
const list = isRecord(state) && Array.isArray(state.workers) ? state.workers : []
|
||||||
|
const lastTs = isRecord(state) && typeof state.lastEventTs === 'string' ? state.lastEventTs : null
|
||||||
|
const entries = []
|
||||||
|
for (const w of list) {
|
||||||
|
if (!isRecord(w) || typeof w.workerId !== 'string' || w.workerId === '') continue
|
||||||
|
const connected = w.connected !== false
|
||||||
|
const status = !connected ? 'grau' : typeof w.status === 'string' ? w.status : 'leerlauf'
|
||||||
|
entries.push([w.workerId, {
|
||||||
|
status,
|
||||||
|
task: typeof w.task === 'string' ? w.task : '',
|
||||||
|
lastTs,
|
||||||
|
connected,
|
||||||
|
counters: isRecord(w.counters) ? w.counters : {},
|
||||||
|
}])
|
||||||
|
}
|
||||||
|
return Object.fromEntries(entries)
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusOf(kind) {
|
||||||
|
if (kind === 'leerlauf') return 'leerlauf'
|
||||||
|
if (kind === 'fehler') return 'fehler'
|
||||||
|
return 'arbeitet'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ein Ereignis wirkt nur auf einen Worker, der nicht grau ist und nicht schon etwas Neueres erlebt hat
|
||||||
|
function takesEffect(entry, ms) {
|
||||||
|
if (!isRecord(entry) || entry.status === 'grau') return false
|
||||||
|
const last = msOf(entry.lastTs)
|
||||||
|
return Number.isNaN(last) || ms >= last
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Neuer Zustand nach `events` (Eingabe bleibt unverändert). Nur Worker, die im Zustand stehen und im Roster
|
||||||
|
* vorkommen, ändern sich; grau bleibt grau. Der Empfänger `to` von auftrag/uebergabe wartet danach mit dem Text.
|
||||||
|
*/
|
||||||
|
export function reduceEvents(stateByWorker, events) {
|
||||||
|
const entries = isRecord(stateByWorker) ? Object.entries(stateByWorker).filter(([, entry]) => isRecord(entry)) : []
|
||||||
|
const out = Object.fromEntries(entries.map(([id, entry]) => [id, { ...entry }]))
|
||||||
|
const live = (id) => isWorker(id) && Object.hasOwn(out, id)
|
||||||
|
if (!Array.isArray(events)) return out
|
||||||
|
for (const e of events) {
|
||||||
|
if (!isRecord(e)) continue
|
||||||
|
const ms = msOf(e.ts)
|
||||||
|
if (Number.isNaN(ms)) continue
|
||||||
|
const text = typeof e.text === 'string' ? e.text : ''
|
||||||
|
if (live(e.workerId) && takesEffect(out[e.workerId], ms)) {
|
||||||
|
out[e.workerId] = { ...out[e.workerId], status: statusOf(e.kind), task: text, lastTs: e.ts }
|
||||||
|
}
|
||||||
|
if ((e.kind === 'auftrag' || e.kind === 'uebergabe') && e.to !== e.workerId && live(e.to) && takesEffect(out[e.to], ms)) {
|
||||||
|
out[e.to] = { ...out[e.to], status: 'wartet', task: text, lastTs: e.ts }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// meta darf Objekt, JSON-Text oder null sein (P1 liefert null, wenn leer)
|
||||||
|
function metaOf(meta) {
|
||||||
|
if (isRecord(meta)) return meta
|
||||||
|
if (typeof meta === 'string') {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(meta)
|
||||||
|
if (isRecord(parsed)) return parsed
|
||||||
|
} catch {
|
||||||
|
// kein JSON: wie leer behandeln
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const task = (who, text) => ({ op: 'task', who, text })
|
||||||
|
const say = (who, text) => ({ op: 'say', who, text: cutBubble(text) })
|
||||||
|
const pose = (who, p) => ({ op: 'pose', who, pose: p })
|
||||||
|
const give = (from, to) => ({ op: 'give', from, to })
|
||||||
|
const fx = (name, at, to) => (to === undefined ? { op: 'fx', fx: name, at } : { op: 'fx', fx: name, at, to })
|
||||||
|
|
||||||
|
// Schritte der Tabelle (Übersicht §7) für ein Ereignis, noch ungeprüft gegen Roster und Plätze
|
||||||
|
function rawSteps(e) {
|
||||||
|
const W = e.workerId
|
||||||
|
const T = typeof e.text === 'string' ? e.text : ''
|
||||||
|
const meta = metaOf(e.meta)
|
||||||
|
switch (e.kind) {
|
||||||
|
case 'auftrag': {
|
||||||
|
const steps = [task(W, T)]
|
||||||
|
if (isWorker(e.to)) steps.push(say(W, `${roleOf(e.to).name}, los!`), say(e.to, 'Verstanden'))
|
||||||
|
return steps
|
||||||
|
}
|
||||||
|
case 'leerlauf':
|
||||||
|
return [task(W, T), pose(W, 'coffee')]
|
||||||
|
case 'gelesen':
|
||||||
|
return [pose(W, 'type'), task(W, T), say(W, isNum(meta.count) ? `${meta.count} gelesen` : 'Gelesen')]
|
||||||
|
case 'fund':
|
||||||
|
return [pose(W, 'type'), task(W, T), say(W, 'Neuer Fund!'), fx('paper', W)]
|
||||||
|
case 'doppelt':
|
||||||
|
return [task(W, T), say(W, 'Schon bekannt'), fx('stampNo', W)]
|
||||||
|
case 'uebergabe':
|
||||||
|
return [task(W, T), give(W, e.to)]
|
||||||
|
case 'bewertet':
|
||||||
|
return [pose(W, 'type'), task(W, T), say(W, isNum(meta.score) ? `${meta.score} Punkte` : 'Bewertet')]
|
||||||
|
case 'top3':
|
||||||
|
return [task(W, T), say(W, 'Top 3!'), fx('stampOk', W)]
|
||||||
|
case 'botlead':
|
||||||
|
return [task(W, T), say(W, 'Abgelegt'), fx('drawer', W)]
|
||||||
|
case 'entwurf':
|
||||||
|
return [pose(W, 'type'), task(W, T), say(W, 'Entwurf fertig')]
|
||||||
|
case 'pult':
|
||||||
|
return [task(W, T), say(W, 'Zur Freigabe'), fx('envelope', W, 'pult')]
|
||||||
|
case 'gesendet':
|
||||||
|
// Max sendet selbst; bei `system` hat ein Mensch am Pult gesendet
|
||||||
|
return isWorker(W) ? [task(W, T), say(W, 'Raus damit!'), fx('envelope', W, 'aussen')] : [fx('envelope', 'pult', 'aussen')]
|
||||||
|
case 'eingang':
|
||||||
|
return [task(W, T), fx('envelope', 'aussen', W), say(W, 'Antwort!')]
|
||||||
|
case 'sortiert': {
|
||||||
|
const c = meta.classification
|
||||||
|
const label = typeof c === 'string' && Object.hasOwn(CLASSIFICATION_LABEL, c) ? CLASSIFICATION_LABEL[c] : 'Sortiert'
|
||||||
|
return [task(W, T), fx('stampOk', W), say(W, label)]
|
||||||
|
}
|
||||||
|
case 'bau_start':
|
||||||
|
return [pose(W, 'type'), task(W, T), say(W, 'Los geht’s')]
|
||||||
|
case 'bau_fertig':
|
||||||
|
return [task(W, T), say(W, 'Fertig!'), give(W, 'paul')]
|
||||||
|
case 'pruefung_zurueck':
|
||||||
|
return [task(W, T), fx('stampNo', W), say(W, isNum(meta.round) ? `Runde ${meta.round} zurück` : 'Zurück'), give(W, e.to)]
|
||||||
|
case 'pruefung_ok':
|
||||||
|
return [task(W, T), fx('stampOk', W), say(W, 'Passt!')]
|
||||||
|
case 'vorschau_link':
|
||||||
|
return [task(W, T), say(W, 'Link raus'), fx('envelope', W, 'aussen')]
|
||||||
|
case 'angebot':
|
||||||
|
return [task(W, T), say(W, 'Kauf-Link!'), fx('paper', W)]
|
||||||
|
case 'deal':
|
||||||
|
return [
|
||||||
|
...[0, 0.4, 0.8].map((delay) => ({ op: 'fx', fx: 'coin', at: 'aussen', to: 'tresor', delay })),
|
||||||
|
{ op: 'cheer', who: 'otto' },
|
||||||
|
say('otto', 'Deal! Super!'),
|
||||||
|
]
|
||||||
|
case 'gesperrt': {
|
||||||
|
// Bei `system` stempelt Arne
|
||||||
|
const stamper = isWorker(W) ? W : 'arne'
|
||||||
|
return [task(W, T), fx('stampNo', stamper), say(stamper, 'Gesperrt')]
|
||||||
|
}
|
||||||
|
case 'budget_warnung':
|
||||||
|
return [task(W, T), say('otto', 'Budget 80 %'), fx('alert', 'otto')]
|
||||||
|
case 'budget_stopp':
|
||||||
|
return [task(W, T), say('otto', 'Budget leer'), fx('alert', 'otto')]
|
||||||
|
case 'pause':
|
||||||
|
return []
|
||||||
|
case 'fehler':
|
||||||
|
return [task(W, T), fx('alert', W), say(W, 'Fehler')]
|
||||||
|
default:
|
||||||
|
return [task(W, T)]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Zeigt der Schritt nur auf bekannte Worker und Plätze? (Review Focus 4)
|
||||||
|
function stepIsValid(s) {
|
||||||
|
switch (s.op) {
|
||||||
|
case 'give':
|
||||||
|
return isWorker(s.from) && isWorker(s.to)
|
||||||
|
case 'fx':
|
||||||
|
return isPlace(s.at) && (s.to === undefined || isPlace(s.to))
|
||||||
|
default:
|
||||||
|
return isWorker(s.who)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Animationsschritte eines Ereignisses; Schritte zu unbekannten Workern oder Plätzen entfallen. */
|
||||||
|
export function eventToSteps(event) {
|
||||||
|
if (!isRecord(event)) return []
|
||||||
|
return rawSteps(event).filter(stepIsValid)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Muss nach diesen Ereignissen der Zustand frisch geholt werden? */
|
||||||
|
export function needsStateRefresh(events) {
|
||||||
|
return Array.isArray(events) && events.some((e) => isRecord(e) && REFRESH_KINDS.has(e.kind))
|
||||||
|
}
|
||||||
@@ -0,0 +1,241 @@
|
|||||||
|
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
|
||||||
|
})
|
||||||
Reference in new issue
Block a user