]*type="button"/g) || []).length)
- assert.doesNotMatch(s, /window\.confirm|confirm\(/)
-})
-
-// Endprüfung P3 (W3): Nach „Anhalten“ schließt der Dialog im selben Takt, in dem pausing beginnt und den Knopf der Leiste sperrt;
-// useDialogFocus kann den Fokus dann nicht zurückgeben (focus() auf einem gesperrten Knopf tut nichts) und er bliebe auf .
-// Das Board gibt ihn deshalb selbst zurück, sobald pausing endet. Das echte Verhalten prüft der Prüfstand (shot.cjs, Szenario I).
-test('WorkersBoard: der Fokus kehrt nach dem Umschalten auf den Knopf der Leiste zurück, sobald er wieder bedienbar ist (W3)', () => {
- const s = read('WorkersBoard.jsx')
- assert.match(s, /const toolbarRef = useRef\(null\)/); assert.match(s, /const refocusPauseRef = useRef\(false\)/)
- assert.match(s, //)
- assert.match(s, //)
- // die Markierung wird vor setPausing(true) gesetzt, der Fokus erst im Effekt nach dem Ende von pausing (sonst wäre der Knopf noch gesperrt)
- assert.match(s, /if \(pausing\) return\s*refocusPauseRef\.current = true\s*setPausing\(true\)/)
- const effect = s.match(/useEffect\(\(\) => \{\s*if \(pausing \|\| !refocusPauseRef\.current\) return([\s\S]*?)\}, \[pausing\]\)/)
- assert.ok(effect, 'Effekt mit [pausing]')
- assert.match(effect[1], /refocusPauseRef\.current = false/)
- // nur wenn der Fokus verloren ist (auf oder nirgends): wer inzwischen woanders ist, wird nicht gestört
- assert.match(effect[1], /const active = document\.activeElement\s*if \(active && active !== document\.body\) return/)
- assert.match(effect[1], /toolbarRef\.current\?\.querySelector\('\[data-wk-pause\]'\)/); assert.match(effect[1], /button\.focus\(\)/)
- // Hook vor der frühen Rückgabe
- assert.ok(s.indexOf('[data-wk-pause]') < s.indexOf('if (!loaded || !state)'))
- // das Gesperrt-Sein des Knopfes bleibt (kein Doppelklick während der Anfrage)
- assert.match(s, /disabled=\{pausing\}/)
-})
diff --git a/tests/unit/workersCss.test.mjs b/tests/unit/workersCss.test.mjs
index 59109a3..47b6186 100644
--- a/tests/unit/workersCss.test.mjs
+++ b/tests/unit/workersCss.test.mjs
@@ -1,32 +1,63 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync, readdirSync } from 'node:fs'
+import path from 'node:path'
+import { fileURLToPath } from 'node:url'
-test('workers.css: nur wk-Klassen, @font-face, keine Hex-Farben, reduzierte Bewegung', () => {
- const css = readFileSync(new URL('../../src/styles/workers.css', import.meta.url), 'utf8').replace(/\/\*[\s\S]*?\*\//g, '') // Kommentare vorher entfernen
- for (const m of css.matchAll(/([^{}]+)\{/g)) { const pre = m[1].trim(); if (pre.startsWith('@')) continue; for (const sel of pre.split(',')) assert.ok(sel.trim().startsWith('.wk-'), sel) } // jede Regel beginnt mit einer wk-Klasse (Nachfahren dürfen globale Klassen nutzen)
- assert.match(css, /@font-face\s*\{[^}]*font-family:\s*"Pixelify Sans"[^}]*PixelifySans-Variable\.woff2[^}]*font-display:\s*swap/s); assert.doesNotMatch(css, /#[0-9a-fA-F]{3,8}\b/)
- assert.match(css, /prefers-reduced-motion/); assert.match(css, /@media \(max-width: 1100px\)/); assert.match(css, /@media \(max-width: 860px\)/)
+// workers.css ist seit T11 (Pixelwelt) auf die weißen Bausteine gekürzt, die noch gebraucht werden: Einstellungen-Dialog (WorkersSettingsDialog)
+// und Interessenten-Panel (ProspectPanel). Hochhaus, Pult und Keller haben ihre Stile in src/styles/hustler/ (eigene Tests: hustler*.test.mjs).
+const stylesDir = fileURLToPath(new URL('../../src/styles/', import.meta.url))
+const srcDir = fileURLToPath(new URL('../../src/', import.meta.url))
+const stripped = (file) => readFileSync(path.join(stylesDir, file), 'utf8').replace(/\/\*[\s\S]*?\*\//g, '') // Kommentare vorher entfernen
+const walk = (dir) => readdirSync(dir, { withFileTypes: true }).flatMap((e) => (e.isDirectory() ? walk(path.join(dir, e.name)) : [path.join(dir, e.name)]))
+const ohneKommentare = (code) => code.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|\s)\/\/.*$/gm, '$1')
+
+// Klassen der Regeln (jede Regel, auch in @media) und Klassen im Code (ohne Kommentare; `wk-set-${name}` ist eine Kennung, keine Klasse;
+// das Attribut data-wk-worker hat ein Zeichen davor und zählt nicht)
+const definierteKlassen = (css) => new Set([...css.matchAll(/\.(wk-[a-z0-9-]+)/g)].map((m) => m[1]))
+const benutzteKlassen = () => {
+ const out = new Map()
+ for (const f of walk(srcDir).filter((x) => /\.jsx?$/.test(x))) {
+ for (const m of ohneKommentare(readFileSync(f, 'utf8')).matchAll(/(? {
+ const css = stripped('workers.css')
+ let regeln = 0
+ for (const m of css.matchAll(/([^{}]+)\{/g)) {
+ const pre = m[1].trim()
+ if (pre.startsWith('@')) continue
+ regeln += 1
+ for (const sel of pre.split(',')) assert.ok(sel.trim().startsWith('.wk-'), sel) // jede Regel beginnt mit einer wk-Klasse (Nachfahren dürfen globale Klassen nutzen)
+ }
+ assert.ok(regeln >= 15, `nur ${regeln} Regeln gelesen`)
+ assert.doesNotMatch(css, /#[0-9a-fA-F]{3,8}\b/)
+ assert.doesNotMatch(css, /@font-face|@import|url\(/) // die Schrift steht in hustler/schrift.css (workersFont.test.mjs)
+ assert.match(css, /@media \(max-width: 860px\)/)
+ assert.doesNotMatch(css, /@media \(max-width: 1100px\)/)
+ // keine Bewegung in dieser Datei, also braucht sie auch kein prefers-reduced-motion
+ assert.doesNotMatch(css, /\b(?:animation|transition)\s*:|@keyframes/)
})
-test('jede im JSX verwendete wk-Klasse ist definiert', () => {
- const css = readFileSync(new URL('../../src/styles/workers.css', import.meta.url), 'utf8'); const dir = new URL('../../src/components/workers/', import.meta.url)
- for (const f of readdirSync(dir).filter((x) => x.endsWith('.jsx'))) for (const m of readFileSync(new URL(f, dir), 'utf8').matchAll(/(? {
- const css = readFileSync(new URL('../../src/styles/workers.css', import.meta.url), 'utf8').replace(/\/\*[\s\S]*?\*\//g, '')
- const rule = (selector) => css.match(new RegExp(`${selector.replace(/[.~]/g, '\\$&')}\\s*\\{([^}]*)\\}`))?.[1] ?? ''
- assert.match(rule('.wk-zoom'), /bottom:\s*8px/); assert.match(rule('.wk-zoom'), /right:\s*8px/); assert.doesNotMatch(rule('.wk-zoom'), /\btop:/)
- assert.match(rule('.wk-toolbar ~ .msg'), /margin-bottom:\s*16px/)
-})
-test('workers.css: die Karte bleibt beim Anspringen unter der festen Leiste der Handy-Ansicht (scroll-margin-top)', () => {
- const css = readFileSync(new URL('../../src/styles/workers.css', import.meta.url), 'utf8').replace(/\/\*[\s\S]*?\*\//g, '')
- assert.match(css, /\.wk-card\s*\{[^}]*scroll-margin-top:\s*12px/)
- const phone = css.slice(css.indexOf('@media (max-width: 860px)'))
- assert.match(phone, /\.wk-card\s*\{\s*scroll-margin-top:\s*64px/) // 52 px Leiste + Luft
+
+test('jede wk-Klasse im Code hat eine Regel in workers.css, und keine Regel steht ohne Nutzer', () => {
+ const definiert = definierteKlassen(stripped('workers.css'))
+ const benutzt = benutzteKlassen()
+ assert.ok(definiert.size >= 10 && benutzt.size >= 10, `${definiert.size} Regel-Klassen, ${benutzt.size} benutzte Klassen`)
+ for (const [klasse, dateien] of benutzt) assert.ok(definiert.has(klasse), `${klasse} (${[...dateien].join(', ')}) hat keine Regel`)
+ for (const klasse of definiert) assert.ok(benutzt.has(klasse), `.${klasse} wird nirgends im Code benutzt`)
+ // die Nutzer sind genau die zwei weißen Dialoge
+ const nutzer = new Set([...benutzt.values()].flatMap((s) => [...s]))
+ assert.deepEqual([...nutzer].sort(), ['components/workers/ProspectPanel.jsx', 'components/workers/WorkersSettingsDialog.jsx'])
})
+
test('workers.css: die globale Regel .page section + section (36 px) verlängert den Abstand der Panel-Abschnitte nicht (W4)', () => {
- const global = readFileSync(new URL('../../src/styles/global.css', import.meta.url), 'utf8').replace(/\/\*[\s\S]*?\*\//g, '')
- const css = readFileSync(new URL('../../src/styles/workers.css', import.meta.url), 'utf8').replace(/\/\*[\s\S]*?\*\//g, '')
+ const global = stripped('global.css')
+ const css = stripped('workers.css')
assert.match(global, /\.page section \+ section\s*\{\s*margin-top:\s*36px/) // die Regel, gegen die das Panel sich behaupten muss: Spezifität (0,1,2)
const m = css.match(/(\.wk-prospect [^{}]*\.wk-prospect-section \+ \.wk-prospect-section)\s*\{([^}]*)\}/)
assert.ok(m, 'Regel für zwei benachbarte Abschnitte im Panel')
@@ -38,60 +69,13 @@ test('workers.css: die globale Regel .page section + section (36 px) verlängert
assert.doesNotMatch(css.match(/\.wk-prospect-section\s*\{([^}]*)\}/)[1], /margin-top/)
})
-// ---- Bot-Leads (Endprüfung P6: C5, W1, W2) ----
-const stripped = (file) => readFileSync(new URL('../../src/styles/' + file, import.meta.url), 'utf8').replace(/\/\*[\s\S]*?\*\//g, '')
-const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
-// Rumpf der Regel mit genau diesem Selektor am Anfang der Regel (weder Teil eines längeren Selektors noch einer Gruppe mit Komma)
-const body = (css, selector) => css.match(new RegExp(`(?:^|[{}])\\s*${escapeRe(selector)}\\s*\\{([^}]*)\\}`))?.[1] ?? null
-const decl = (rule, prop) => rule?.match(new RegExp(`(?:^|[;\\s])${prop}\\s*:\\s*([^;]+)`))?.[1].trim() ?? null
-// Spezifität (ids, Klassen/Attribute/Pseudoklassen, Elemente); Vergleich von links nach rechts
-const specificity = (selector) => {
- const s = selector.replace(/\[[^\]]*\]/g, '.a')
- const ids = (s.match(/#[\w-]+/g) || []).length
- const classes = (s.match(/\.[\w-]+|:(?!:)[\w-]+/g) || []).length
- const elements = (s.replace(/\.[\w-]+|#[\w-]+|:+[\w-]+/g, ' ').match(/(?:^|[\s>+~])[a-z][\w-]*/gi) || []).length
- return [ids, classes, elements]
-}
-const beats = (a, b) => { for (let i = 0; i < 3; i++) if (a[i] !== b[i]) return a[i] > b[i]; return false }
-const luminance = (hex) => {
- const c = [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16) / 255).map((v) => (v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4))
- return 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2]
-}
-const contrast = (a, b) => { const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x); return (hi + 0.05) / (lo + 0.05) }
-const token = (name) => stripped('global.css').match(new RegExp(`${name}:\\s*(#[0-9a-fA-F]{6})`))?.[1]
-
-test('workers.css: gekürzte Zeilen der Bot-Leads bleiben lesbar (Text >= 4,5:1 auf dem Seiten- und dem Zeilengrund), grau über --text-2 statt --text-3 (C5)', () => {
- const css = stripped('workers.css')
- const rule = body(css, '.wk-bl-cut td')
- assert.ok(rule, 'Regel .wk-bl-cut td')
- assert.equal(decl(rule, 'color'), 'var(--text-2)')
- assert.doesNotMatch(css, /\.wk-bl-cut[^{]*\{[^}]*--text-3/) // keine Regel der gekürzten Zeile greift zu --text-3
- for (const ground of ['--bg', '--bg-subtle']) {
- const ratio = contrast(token('--text-2'), token(ground))
- assert.ok(ratio >= 4.5, `${ground}: ${ratio.toFixed(2)}:1`)
- }
-})
-
-test('workers.css: am Handy sind die Blätterknöpfe, „Alle anzeigen“ und „Erneut versuchen“ der Bot-Leads 40 px hoch, ohne die globale .btn-Regel zu ändern (C5, W1)', () => {
- const css = stripped('workers.css')
- const phone = css.slice(css.indexOf('@media (max-width: 860px)'))
- for (const selector of ['.wk-bl-pager .btn', '.wk-bl-single .btn', '.wk-bl-retry .btn']) {
- assert.equal(decl(body(phone, selector), 'min-height'), '40px', selector)
- // min-height schlägt height: 32px der globalen .btn-Regel, und die Spezifität (0,2,0) schlägt .btn (0,1,0): kein !important nötig
- assert.ok(beats(specificity(selector), specificity('.btn')), selector)
- assert.doesNotMatch(body(phone, selector), /!important/)
- }
- assert.equal(decl(body(stripped('global.css'), '.btn'), 'height'), '32px') // das, wogegen die Knöpfe sich behaupten
- assert.equal(body(css, '.btn'), null) // keine globale Änderung aus der Hustler-Datei
-})
-
-test('workers.css: der Ort in der Bot-Leads-Tabelle bricht nicht mitten im Wort (break-word schlägt die globale Regel mit anywhere) (W2)', () => {
+test('workers.css: Einstellungen-Dialog behält Trennlinien und den roten Block „Versand gestoppt“ (Tokens aus global.css)', () => {
const css = stripped('workers.css')
const global = stripped('global.css')
- const wanted = '.wk-bl .wk-bl-table td .cell-sub'
- const globalSelector = '.table td .cell-sub'
- assert.equal(decl(body(global, globalSelector), 'overflow-wrap'), 'anywhere') // die globale Regel, die der Bot-Leads-Ort nicht erben soll
- assert.equal(decl(body(css, wanted), 'overflow-wrap'), 'break-word')
- // streng größer, nicht gleich: bei Gleichstand entschiede die Reihenfolge der Stylesheets im Bündel
- assert.ok(beats(specificity(wanted), specificity(globalSelector)), `${wanted} ${specificity(wanted)} gegen ${specificity(globalSelector)}`)
+ const rule = (selector) => css.match(new RegExp(`${selector.replace(/[.~]/g, '\\$&')}\\s*\\{([^}]*)\\}`))?.[1] ?? ''
+ assert.match(rule('.wk-set-group'), /border-top:\s*1px solid var\(--line\)/)
+ assert.match(rule('.wk-halt'), /border-top:\s*1px solid var\(--danger\)/); assert.match(rule('.wk-halt'), /border-bottom:\s*1px solid var\(--danger\)/)
+ assert.match(rule('.wk-halt .wk-set-title'), /color:\s*var\(--red-fg\)/)
+ // jedes verwendete Token gibt es in global.css
+ for (const m of css.matchAll(/var\((--[\w-]+)\)/g)) assert.match(global, new RegExp(`${m[1]}\\s*:`), m[1])
})
diff --git a/tests/unit/workersDirector.test.mjs b/tests/unit/workersDirector.test.mjs
deleted file mode 100644
index 7b3b1c5..0000000
--- a/tests/unit/workersDirector.test.mjs
+++ /dev/null
@@ -1,640 +0,0 @@
-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, ev } from './helpers/workersFixtures.mjs'
-
-// Regie 1: Agenten, Snapshot ohne Animation, Posen, Leerlauf, Zeichnen; Regie 2: Schritte, Aufzugfahrten, Rückstau, Wachhund (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, LIFT } = 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
-})
-
-// ---------- Regie 2: Schritte, Aufzugfahrten, Rückstau, Wachhund (P2-T13) ----------
-
-// Simulationszeit in Sekunden, bis keine Gruppe mehr läuft (höchstens max)
-const run = (d, max = 60) => {
- let t = 0
- while (d.pending() > 0 && t < max) {
- d.update(0.05)
- t += 0.05
- }
- return t
-}
-const at = (layout, id) => homeOf(layout, id)
-
-test('Übergabe über zwei Etagen: Rita bringt Bea ein Dokument, fährt Aufzug, steht danach wieder zuhause', () => {
- const { d, lift, layout } = setup()
- d.applyState(makeState())
- d.pushEvents([ev('uebergabe', 'rita', { to: 'bea', text: 'Rita bringt 2 Funde zu Bea' })])
- assert.ok(d.pending() >= 1)
- let ride = false
- let t = 0
- while (d.pending() > 0 && t < 60) {
- d.update(0.05)
- t += 0.05
- if (lift.state !== 'idle') ride = true
- }
- assert.ok(ride)
- assert.ok(t < 40, String(t))
- const h = at(layout, 'rita')
- assert.deepEqual([Math.round(d.byId.rita.x), Math.round(d.byId.rita.y), d.byId.rita.carrying, lift.state], [h.x, h.y, false, 'idle'])
- assert.equal(d.byId.rita.task, 'Rita bringt 2 Funde zu Bea')
-})
-
-test('gleiche Etage: kein Aufzug', () => {
- const { d, lift } = setup()
- d.applyState(makeState())
- d.pushEvents([ev('uebergabe', 'kim', { to: 'pia' })])
- let ride = false
- for (let i = 0; i < 1200 && d.pending() > 0; i++) {
- d.update(0.05)
- if (lift.state !== 'idle') ride = true
- }
- assert.equal(ride, false)
- assert.equal(d.pending(), 0)
-})
-
-test('Rückstau: 300 Ereignisse ⇒ höchstens 6 Gruppen, Rest still, nach ≤ 60 s ruhig, letzte Aufgaben stehen (Review Focus 2)', () => {
- const { d, lift } = setup()
- d.applyState(makeState())
- d.pushEvents(Array.from({ length: 300 }, (_, i) => ev('uebergabe', IDS[i % 19], { to: IDS[(i + 3) % 19], text: `Aufgabe ${i}` })))
- assert.ok(d.pending() <= 6, String(d.pending()))
- const t = run(d)
- assert.ok(t < 60, String(t))
- assert.equal(d.pending(), 0)
- assert.equal(lift.state, 'idle')
- for (let k = 0; k < 19; k++) {
- const last = 299 - ((299 - k) % 19)
- assert.equal(d.byId[IDS[k]].task, `Aufgabe ${last}`, IDS[k])
- }
- d.pushEvents(Array.from({ length: 10 }, (_, i) => ev('fund', 'kim', { text: `K${i}` })))
- assert.ok(d.byId.kim.groups <= 4)
-})
-
-test('graue, fremde und unbekannte Ereignisse: keine Arbeit, kein Wurf (Review Focus 4)', () => {
- const { d } = setup()
- d.applyState(makeState({ connected: { kim: false } }))
- d.pushEvents([ev('gelesen', 'kim'), ev('fund', 'zed'), ev('neueArt', 'rita'), ev('uebergabe', 'kim', { to: 'bea' })])
- assert.equal(d.byId.kim.say, null)
- assert.equal(d.byId.kim.pose, 'sleep')
- assert.equal(d.byId.kim.groups, 0)
-})
-
-test('Blasen: höchstens drei gleichzeitig', () => {
- const { d } = setup()
- d.applyState(makeState())
- d.pushEvents(['kim', 'rita', 'pia', 'finn', 'ali'].map((w) => ev('fund', w)))
- d.update(0.05)
- assert.ok(d.agents.filter((a) => a.say).length <= 3)
-})
-
-test('Pause friert ein, Fortsetzen läuft weiter', () => {
- const { d } = setup()
- d.applyState(makeState({ paused: true }))
- d.pushEvents([ev('uebergabe', 'kim', { to: 'pia' })])
- const x0 = d.byId.kim.x
- for (let i = 0; i < 400; i++) d.update(0.05)
- assert.equal(d.byId.kim.x, x0)
- d.applyState(makeState({ paused: false }))
- for (let i = 0; i < 40; i++) d.update(0.05)
- assert.notEqual(d.byId.kim.x, x0)
-})
-
-test('Aufzug zurückgesetzt mitten in der Fahrt: Fahrgast erholt sich, Türzähler 0; hängender Aufzug: Wachhund nach 45 s', () => {
- const a = setup()
- a.d.applyState(makeState())
- a.d.pushEvents([ev('uebergabe', 'rita', { to: 'bea' })])
- let g = 0
- while (a.lift.state !== 'riding' && g++ < 400) a.d.update(0.05)
- assert.equal(a.lift.state, 'riding')
- a.lift.reset()
- assert.ok(run(a.d) < 10)
- assert.deepEqual(a.d.debug().door, new Array(10).fill(0))
- const layout = buildLayout()
- const fx = createFx()
- const look = createLook()
- const stuck = { y: 0, state: 'idle', request: (ag, f, t) => ({ a: ag, from: f, to: t, st: 'queued' }), update() {}, cancel() {}, reset() {}, at: () => false, drawBack() {}, drawFront() {} }
- const d = createDirector({ layout, lift: stuck, fx, look })
- d.applyState(makeState())
- d.pushEvents([ev('uebergabe', 'rita', { to: 'bea' })])
- const t = run(d, 80)
- assert.ok(t >= 45 && t < 60, String(t))
- assert.deepEqual(d.debug().door, new Array(10).fill(0))
-})
-
-test('flush: alles sofort ruhig, alle zuhause; Deal löst Münzen und Jubel aus', () => {
- const { d, lift, fx, layout } = setup()
- d.applyState(makeState())
- d.pushEvents(Array.from({ length: 20 }, (_, i) => ev('uebergabe', IDS[i % 19], { to: IDS[(i + 5) % 19] })))
- d.flush()
- assert.equal(d.pending(), 0)
- assert.equal(lift.state, 'idle')
- assert.equal(fx.size(), 0)
- for (const a of d.agents) {
- const h = at(layout, a.id)
- assert.deepEqual([Math.round(a.x), Math.round(a.y)], [h.x, h.y], a.id)
- }
- d.pushEvents([ev('deal', 'system')])
- d.update(0.1)
- assert.ok(fx.size() >= 3)
- assert.ok(d.byId.otto.cheer > 0)
- run(d)
- for (let i = 0; i < 100; i++) d.update(0.1)
- assert.equal(fx.size(), 0)
-})
-
-// Aufzug-Attrappe: request liefert, was der Test vorgibt; alles Übrige tut nichts
-const stubLift = (request, extra = {}) => ({ y: 0, state: 'idle', request, update() {}, cancel() {}, reset() {}, at: () => false, drawBack() {}, drawFront() {}, ...extra })
-const withLift = (lift) => {
- const layout = buildLayout()
- const fx = createFx()
- const look = createLook()
- return { layout, fx, look, d: createDirector({ layout, lift, fx, look, rnd: () => 0.5 }) }
-}
-// Mittelpunkt aller gezeichneten Rechtecke in Welteinheiten (Raster 3)
-const centerOf = (rects) => ({
- x: rects.reduce((s, r) => s + r.x + r.w / 2, 0) / rects.length / 3,
- y: rects.reduce((s, r) => s + r.y + r.h / 2, 0) / rects.length / 3,
-})
-const dist = (p, q) => Math.hypot(p.x - q.x, p.y - q.y)
-
-test('Aufzug antwortet cancelled: Fahrt entfällt, der Agent steht sofort wieder zuhause, Türzähler 0', () => {
- const calls = []
- const lift = stubLift((a, from, to) => {
- calls.push([a.id, from, to])
- return { a, from, to, st: 'cancelled' }
- })
- const { d, layout } = withLift(lift)
- d.applyState(makeState())
- d.pushEvents([ev('uebergabe', 'rita', { to: 'bea', text: 'Rita bringt Bea Funde' })])
- assert.ok(run(d) < 5)
- assert.deepEqual(calls, [['rita', 1, 3]])
- const h = at(layout, 'rita')
- assert.deepEqual([d.byId.rita.x, d.byId.rita.y, d.byId.rita.floor, d.byId.rita.carrying, d.byId.rita.frame], [h.x, h.y, h.floor, false, 0])
- assert.deepEqual(d.debug().door, new Array(10).fill(0))
- assert.deepEqual([d.byId.rita.task, d.byId.bea.say, d.pending()], ['Rita bringt Bea Funde', null, 0])
-})
-
-test('Wachhund: ruft lift.cancel für den Agenten, stellt ihn heim und beendet die Gruppe', () => {
- const cancelled = []
- const lift = stubLift((a, from, to) => ({ a, from, to, st: 'queued' }), { cancel: (a) => cancelled.push(a.id) })
- const { d, layout } = withLift(lift)
- d.applyState(makeState())
- d.pushEvents([ev('uebergabe', 'rita', { to: 'bea' }), ev('fund', 'rita')])
- assert.equal(d.byId.rita.groups, 2)
- const t = run(d, 200)
- assert.deepEqual(cancelled, ['rita'])
- assert.ok(t >= 45 && t < 60, String(t))
- const h = at(layout, 'rita')
- assert.deepEqual([d.byId.rita.x, d.byId.rita.y, d.byId.rita.carrying, d.byId.rita.groups, d.byId.rita.cur], [h.x, h.y, false, 0, null])
-})
-
-test('Wartende vor dem Aufzug stehen nebeneinander (Türzähler zählt mit) und werden beim Einsteigen ausgetragen', () => {
- const { d } = setup()
- d.applyState(makeState())
- d.pushEvents([ev('uebergabe', 'rita', { to: 'bea' }), ev('uebergabe', 'kim', { to: 'max' })])
- let most = 0
- const slots = { rita: false, kim: false }
- for (let i = 0; i < 1200 && d.pending() > 0; i++) {
- d.update(0.05)
- most = Math.max(most, d.debug().door[1])
- if (d.byId.rita.x === LIFT.slot0) slots.rita = true
- if (d.byId.kim.x === LIFT.slot0 + LIFT.slotStep) slots.kim = true
- }
- assert.equal(most, 2)
- assert.deepEqual(slots, { rita: true, kim: true })
- assert.deepEqual(d.debug().door, new Array(10).fill(0))
-})
-
-test('Snapshot während einer Gruppe: Aufgabe und Pose kommen erst am Gruppenende aus der Tabelle', () => {
- const { d } = setup()
- d.applyState(makeState())
- d.pushEvents([ev('uebergabe', 'kim', { to: 'pia', text: 'Kim bringt Pia Funde' })])
- for (let i = 0; i < 20; i++) d.update(0.05)
- const st = makeState({ statuses: { kim: 'leerlauf' } })
- st.workers.find((w) => w.workerId === 'kim').task = 'Snapshot-Aufgabe'
- d.applyState(st)
- assert.deepEqual([d.byId.kim.status, d.byId.kim.task], ['leerlauf', 'Kim bringt Pia Funde'])
- run(d)
- assert.deepEqual([d.byId.kim.task, d.byId.kim.pose, d.byId.kim.groups], ['Snapshot-Aufgabe', 'coffee', 0])
-})
-
-test('stille Gruppe für einen Worker mit laufender Gruppe: seine Aufgabe kommt am Gruppenende aus der Tabelle', () => {
- const { d } = setup()
- d.applyState(makeState())
- d.pushEvents([ev('uebergabe', 'kim', { to: 'pia', text: 'Kim bringt Pia Funde' })])
- d.update(0.05)
- d.pushEvents([ev('gelesen', 'kim', { text: 'Liest Mails' }), ...Array.from({ length: 6 }, (_, i) => ev('fund', 'rita', { text: `R${i}` }))])
- assert.equal(d.byId.kim.task, 'Kim bringt Pia Funde')
- run(d)
- assert.equal(d.byId.kim.task, 'Liest Mails')
-})
-
-test('pro Worker höchstens 4 wartende Gruppen: die neuesten bleiben animiert, die Aufgabe des neuesten gewinnt', () => {
- const { d } = setup()
- d.applyState(makeState())
- d.pushEvents(Array.from({ length: 10 }, (_, i) => ev('fund', 'kim', { text: `K${i}` })))
- assert.equal(d.byId.kim.groups, 4)
- assert.equal(d.byId.kim.task, 'K5')
- d.pushEvents([ev('fund', 'kim', { text: 'K10' })])
- assert.equal(d.byId.kim.groups, 4)
- run(d)
- assert.equal(d.byId.kim.task, 'K10')
-})
-
-test('pushEvents vor dem ersten Snapshot und mit kaputten Eingaben: nichts passiert, kein Wurf', () => {
- const { d, look } = setup()
- d.pushEvents([ev('fund', 'kim')])
- assert.deepEqual([d.pending(), d.agents.some((a) => a.grey), look.greyRooms.size, d.byId.kim.status], [0, false, 0, 'wartet'])
- d.applyState(makeState())
- for (const bad of [null, undefined, 'x', 5, {}, [], [null, 5, 'x', {}, { kind: 'fund' }]]) d.pushEvents(bad)
- assert.equal(d.pending(), 0)
-})
-
-test('Ereignisse ändern den Status sofort (Fehler-Zeichen, Leerlauf, Räume), die Aufgabe setzt erst der Schritt', () => {
- const { d, look } = setup()
- d.applyState(makeState())
- d.pushEvents([ev('fehler', 'rita', { text: 'Suche kaputt' }), ev('leerlauf', 'kim', { text: 'Pause' })])
- assert.deepEqual([d.byId.rita.status, d.byId.rita.alert, d.byId.kim.status], ['fehler', true, 'leerlauf'])
- assert.deepEqual([look.busyRooms.has('netz'), look.busyRooms.has('kleinanzeigen'), look.busyRooms.has('portale')], [false, false, true])
- assert.equal(d.byId.rita.task, '')
- d.update(0.05)
- assert.deepEqual([d.byId.rita.task, d.byId.kim.task, d.byId.kim.pose], ['Suche kaputt', 'Pause', 'coffee'])
-})
-
-test('wer schläft, wird für seine Gruppe wach; danach gilt wieder der Status', () => {
- const { d } = setup()
- d.applyState(makeState({ statuses: { kim: 'leerlauf' } }))
- for (let i = 0; i < 300; i++) d.update(0.1)
- assert.equal(d.byId.kim.pose, 'sleep')
- d.pushEvents([ev('doppelt', 'kim')])
- d.update(0.05)
- assert.deepEqual([d.byId.kim.pose, d.byId.kim.say], ['type', 'Schon bekannt'])
- run(d)
- assert.equal(d.byId.kim.pose, 'type')
-})
-
-test('Übergabe an einen nicht angeschlossenen Worker entfällt: niemand läuft, nur die Aufgabe steht', () => {
- const { d, lift } = setup()
- d.applyState(makeState({ connected: { kim: false } }))
- const x0 = d.byId.pia.x
- d.pushEvents([ev('uebergabe', 'pia', { to: 'kim', text: 'Pia an Kim' })])
- for (let i = 0; i < 20; i++) d.update(0.05)
- assert.equal(d.pending(), 0)
- assert.deepEqual([d.byId.pia.x, d.byId.pia.task, d.byId.kim.say, d.byId.kim.pose, lift.state], [x0, 'Pia an Kim', null, 'sleep', 'idle'])
-})
-
-test('Übergabe auf einer Etage: Dokument in der Hand, Giver steht neben dem Empfänger (Abstand 12), blickt ihn an, Empfänger dankt', () => {
- const { d, layout } = setup()
- d.applyState(makeState())
- const kim = d.byId.kim
- const pia = d.byId.pia
- const bea = d.byId.bea
- d.pushEvents([ev('uebergabe', 'kim', { to: 'pia' }), ev('uebergabe', 'pia', { to: 'kim' })])
- let carried = false
- const seen = {}
- for (let i = 0; i < 800 && d.pending() > 0; i++) {
- d.update(0.05)
- if (kim.carrying) carried = true
- if (!seen.kim && pia.say === 'Danke!') seen.kim = { x: kim.x, facing: kim.facing, carrying: kim.carrying }
- if (!seen.pia && kim.say === 'Danke!') seen.pia = { x: pia.x, facing: pia.facing, carrying: pia.carrying }
- }
- assert.equal(carried, true)
- assert.deepEqual(seen.kim, { x: at(layout, 'pia').x - 12, facing: 1, carrying: false })
- assert.deepEqual(seen.pia, { x: at(layout, 'kim').x + 12, facing: -1, carrying: false })
- assert.equal(bea.say, null)
- for (const a of [kim, pia]) assert.deepEqual([a.x, a.y, a.facing], [at(layout, a.id).x, at(layout, a.id).y, 1])
-})
-
-test('Effekte: Deal wirft drei Münzen von außen zum Tresor (Versatz 0, 0,4 und 0,8 s), der Tresor blitzt beim Ankommen, Otto jubelt', () => {
- const calls = []
- const fx = { add: (life, draw, onEnd, delay) => calls.push({ life, draw, onEnd, delay }), update() {}, clear() {}, size: () => calls.length }
- const layout = buildLayout()
- const look = createLook()
- const d = createDirector({ layout, lift: createLift(layout), fx, look, rnd: () => 0.5 })
- d.applyState(makeState())
- d.pushEvents([ev('deal', 'system')])
- d.update(0.05)
- assert.deepEqual(calls.map((c) => c.delay), [0, 0.4, 0.8])
- assert.deepEqual([look.vaultFlash, d.byId.otto.say], [0, 'Deal! Super!'])
- calls[2].onEnd()
- assert.equal(look.vaultFlash, 0.7)
- const at0 = makeCtx()
- calls[0].draw(at0.ctx, 0, { t: 0, life: calls[0].life })
- const at1 = makeCtx()
- calls[0].draw(at1.ctx, 1, { t: calls[0].life, life: calls[0].life })
- assert.ok(dist(centerOf(at0.rects), layout.spots.aussen) < 4)
- assert.ok(dist(centerOf(at1.rects), layout.spots.tresor) < 4)
-})
-
-test('Effekte: Ablegen ohne Ziel fliegt vom Kopf des Workers zum Archiv', () => {
- const calls = []
- const fx = { add: (life, draw, onEnd, delay) => calls.push({ life, draw, onEnd, delay }), update() {}, clear() {}, size: () => calls.length }
- const layout = buildLayout()
- const d = createDirector({ layout, lift: createLift(layout), fx, look: createLook(), rnd: () => 0.5 })
- d.applyState(makeState())
- d.pushEvents([ev('botlead', 'arne')])
- for (let i = 0; i < 60 && calls.length === 0; i++) d.update(0.05)
- assert.equal(calls.length, 1)
- const arne = d.byId.arne
- const start = makeCtx()
- calls[0].draw(start.ctx, 0, { t: 0, life: calls[0].life })
- const end = makeCtx()
- calls[0].draw(end.ctx, 1, { t: calls[0].life, life: calls[0].life })
- assert.ok(dist(centerOf(start.rects), { x: arne.x + 6, y: arne.y + arne.lane - 2 }) < 5)
- assert.ok(dist(centerOf(end.rects), layout.spots.archiv) < 6)
-})
-
-test('flush läuft auch bei Pause durch und lässt alle zuhause', () => {
- const { d, layout, look } = setup()
- d.applyState(makeState({ paused: true }))
- d.pushEvents([ev('uebergabe', 'rita', { to: 'bea', text: 'Rita bringt Bea Funde' })])
- assert.equal(d.pending(), 1)
- d.flush()
- const h = at(layout, 'rita')
- assert.deepEqual([d.pending(), look.paused, d.byId.rita.x, d.byId.rita.y], [0, true, h.x, h.y])
- assert.equal(d.byId.rita.task, 'Rita bringt Bea Funde') // die Schritte sind durchgelaufen, nicht nur verworfen
-})
-
-test('reset: Warteschlangen leer, Aufzug frei, Türzähler 0, Effekte und Blasen weg, alle zuhause', () => {
- const { d, lift, fx, layout } = setup()
- d.applyState(makeState())
- d.pushEvents([ev('uebergabe', 'rita', { to: 'bea' }), ev('fund', 'kim')])
- let g = 0
- while (lift.state !== 'riding' && g++ < 400) d.update(0.05)
- assert.equal(lift.state, 'riding')
- fx.add(5, () => {})
- assert.ok(d.pending() > 0)
- d.reset()
- assert.deepEqual([d.pending(), lift.state, fx.size()], [0, 'idle', 0])
- assert.deepEqual(d.debug(), { door: new Array(10).fill(0), groups: 0 })
- for (const a of d.agents) {
- const h = at(layout, a.id)
- assert.deepEqual([a.x, a.y, a.carrying, a.say, a.q.length, a.cur], [h.x, h.y, false, null, 0, null], a.id)
- }
- const x = d.byId.rita.x
- for (let i = 0; i < 100; i++) d.update(0.05)
- assert.deepEqual([d.byId.rita.x, d.pending(), lift.state], [x, 0, 'idle'])
-})
-
-test('graue Worker bekommen keine vorgespielte Arbeit: weder Aufgabe noch Pose noch Blase noch Gruppe', () => {
- const { d } = setup()
- d.applyState(makeState({ connected: { kim: false } }))
- d.pushEvents([ev('fund', 'kim', { text: 'Fund' }), ev('gelesen', 'kim', { text: 'Gelesen' }), ev('auftrag', 'otto', { to: 'kim', text: 'Kim, los' })])
- for (let i = 0; i < 100; i++) d.update(0.05)
- assert.deepEqual([d.byId.kim.task, d.byId.kim.pose, d.byId.kim.say, d.byId.kim.groups, d.byId.kim.status], ['', 'sleep', null, 0, 'grau'])
- assert.equal(d.byId.otto.task, 'Kim, los')
-})
-
-test('Fahrgast steht beim Einsteigen schon in der Kabine, auch der hohe Otto, und kommt danach heim', () => {
- const { d, lift, layout } = setup()
- d.applyState(makeState())
- const otto = d.byId.otto
- d.pushEvents([ev('uebergabe', 'otto', { to: 'rita' })])
- let boarded = null
- for (let i = 0; i < 1200 && d.pending() > 0; i++) {
- d.update(0.05)
- if (!boarded && lift.state === 'closing') boarded = [otto.x, otto.y, otto.y === lift.y + 7]
- }
- assert.deepEqual(boarded, [LIFT.cabWx, boarded[1], true])
- const h = at(layout, 'otto')
- assert.deepEqual([otto.x, otto.y, otto.floor, lift.state, d.pending()], [h.x, h.y, h.floor, 'idle', 0])
-})
-
-test('Übergabe vom Aufzug aus: der Giver blickt den Empfänger an, auch wenn er dafür zurücklaufen muss', () => {
- const { d, layout } = setup()
- d.applyState(makeState())
- const bea = d.byId.bea
- const rita = d.byId.rita
- d.pushEvents([ev('uebergabe', 'bea', { to: 'rita' })])
- let seen = null
- for (let i = 0; i < 1200 && d.pending() > 0; i++) {
- d.update(0.05)
- if (!seen && rita.say === 'Danke!') seen = [bea.x, bea.facing]
- }
- assert.deepEqual(seen, [at(layout, 'rita').x - 12, 1])
- assert.ok(seen[0] < LIFT.exitX) // der Weg führt vom Ausgang des Schachts nach links zurück
-})
diff --git a/tests/unit/workersFont.test.mjs b/tests/unit/workersFont.test.mjs
index 9cbfcc5..156a649 100644
--- a/tests/unit/workersFont.test.mjs
+++ b/tests/unit/workersFont.test.mjs
@@ -1,8 +1,38 @@
import test from 'node:test'
import assert from 'node:assert/strict'
-import { readFileSync } from 'node:fs'
+import { existsSync, readFileSync, readdirSync } from 'node:fs'
+import path from 'node:path'
+import { fileURLToPath } from 'node:url'
test('Schrift: woff2-Magic-Bytes, plausible Größe, OFL-Lizenztext', () => {
const buf = readFileSync(new URL('../../public/fonts/PixelifySans-Variable.woff2', import.meta.url)); assert.equal(buf.subarray(0, 4).toString('latin1'), 'wOF2'); assert.ok(buf.length > 10000 && buf.length < 400000, String(buf.length))
const lic = readFileSync(new URL('../../public/fonts/OFL-PixelifySans.txt', import.meta.url), 'utf8'); assert.match(lic, /SIL OPEN FONT LICENSE Version 1\.1/); assert.match(lic, /Pixelify Sans/)
})
+
+// Seit T11 (Pixelwelt) steht die Einbindung von Pixelify Sans neben Press Start 2P in styles/hustler/schrift.css; workers.css hat keine Schrift mehr.
+const stylesDir = fileURLToPath(new URL('../../src/styles/', import.meta.url))
+const walk = (dir) => readdirSync(dir, { withFileTypes: true }).flatMap((e) => (e.isDirectory() ? walk(path.join(dir, e.name)) : [path.join(dir, e.name)]))
+const ohneKommentare = (css) => css.replace(/\/\*[\s\S]*?\*\//g, '')
+const fontFaces = () => walk(stylesDir)
+ .filter((f) => f.endsWith('.css'))
+ .flatMap((f) => [...ohneKommentare(readFileSync(f, 'utf8')).matchAll(/@font-face\s*\{[^}]*\}/g)].map((m) => ({ datei: path.relative(stylesDir, f), regel: m[0] })))
+
+test('Pixelify Sans: genau eine @font-face-Regel in src/styles, in hustler/schrift.css (selbst gehostet, swap, Gewichte 400-700)', () => {
+ const pixelify = fontFaces().filter((r) => /font-family:\s*["']Pixelify Sans["']/.test(r.regel))
+ assert.equal(pixelify.length, 1, 'genau eine Einbindung')
+ assert.equal(pixelify[0].datei, 'hustler/schrift.css')
+ const regel = pixelify[0].regel
+ assert.match(regel, /src:\s*url\(["']?\/fonts\/PixelifySans-Variable\.woff2["']?\)\s*format\(["']woff2["']\)/)
+ assert.match(regel, /font-display:\s*swap/)
+ assert.match(regel, /font-weight:\s*400 700/)
+ assert.doesNotMatch(regel, /googleapis|gstatic|https?:/)
+ // workers.css bindet keine Schrift mehr ein
+ assert.doesNotMatch(ohneKommentare(readFileSync(path.join(stylesDir, 'workers.css'), 'utf8')), /@font-face/)
+})
+
+test('jede selbst gehostete Schrift in src/styles liegt unter public/fonts und hat dort ihren Lizenztext', () => {
+ const dateien = fontFaces().flatMap((r) => [...r.regel.matchAll(/url\(\s*["']?(\/fonts\/[\w.-]+)["']?\s*\)/g)].map((m) => m[1]))
+ assert.ok(dateien.length >= 2, 'mindestens Press Start 2P und Pixelify Sans')
+ for (const datei of dateien) assert.ok(existsSync(fileURLToPath(new URL('../../public' + datei, import.meta.url))), `${datei} fehlt in public/`)
+ for (const lizenz of ['OFL-PressStart2P.txt', 'OFL-PixelifySans.txt']) assert.ok(existsSync(fileURLToPath(new URL('../../public/fonts/' + lizenz, import.meta.url))), lizenz)
+})
diff --git a/tests/unit/workersFx.test.mjs b/tests/unit/workersFx.test.mjs
deleted file mode 100644
index 8171fbe..0000000
--- a/tests/unit/workersFx.test.mjs
+++ /dev/null
@@ -1,162 +0,0 @@
-import test from 'node:test'
-import assert from 'node:assert/strict'
-import { loadModule } from './load.mjs'
-import { makeCtx } from './helpers/fakeCtx.mjs'
-
-// Effekt-System: Verzögerung, Lebensdauer, Obergrenze und die acht benannten Effekte
-const F = await loadModule('src/components/workers/engine/fx.js')
-const { PAL } = await loadModule('src/components/workers/engine/sprites.js')
-
-test('Verzögerung, Lebensdauer, onEnd genau einmal', () => {
- const fx = F.createFx(), log = []; fx.add(1, (c, p) => log.push(['draw', p]), () => log.push(['end']), 0.5)
- const { ctx } = makeCtx(); fx.update(0.4); fx.draw(ctx); assert.equal(log.length, 0)
- fx.update(0.4); fx.draw(ctx); assert.equal(log.at(-1)[0], 'draw')
- fx.update(1); assert.deepEqual(log.at(-1), ['end']); fx.update(1); assert.equal(log.filter((l) => l[0] === 'end').length, 1); assert.equal(fx.size(), 0)
-})
-test('Obergrenze 80: Überschuss wird verworfen und meldet onEnd; clear ruft kein onEnd', () => {
- const fx = F.createFx(); let ended = 0
- for (let i = 0; i < 200; i++) fx.add(5, () => {}, () => { ended++ })
- assert.equal(fx.size(), F.MAX_FX); assert.equal(ended, 120); fx.clear(); assert.equal(fx.size(), 0); assert.equal(ended, 120)
-})
-test('jeder Effektname zeichnet und endet in höchstens 6 s', () => {
- for (const name of F.FX_NAMES) { const fx = F.createFx(), { ctx, rects } = makeCtx(); let ended = false
- F.spawnFx(fx, name, { from: { x: 50, y: 100 }, to: { x: 150, y: 60 }, onEnd: () => { ended = true } })
- for (let i = 0; i < 60; i++) { fx.update(0.1); fx.draw(ctx) }
- assert.ok(rects.length > 0, name); assert.equal(fx.size(), 0, name); assert.ok(ended, name) }
-})
-
-// ---- Ergänzungen: Verhalten, das die drei Tests oben nicht berühren ----
-
-const K = 3 // Standardraster der Engine (kein setGrid): Geräte-Pixel = 3 × logische Einheiten
-const LIFE = { paper: 0.9, envelope: 3.4, coin: 0.7, stampOk: 1.2, stampNo: 1.2, sparkle: 1.0, alert: 1.5, drawer: 0.9 }
-
-// Umschließendes Rechteck aller Rechtecke in logischen Einheiten (null ohne Rechtecke)
-function bounds(rects) {
- if (!rects.length) return null
- const x0 = Math.min(...rects.map((r) => r.x)) / K, y0 = Math.min(...rects.map((r) => r.y)) / K
- const x1 = Math.max(...rects.map((r) => r.x + r.w)) / K, y1 = Math.max(...rects.map((r) => r.y + r.h)) / K
- return { x0, y0, x1, y1, cx: (x0 + x1) / 2, cy: (y0 + y1) / 2 }
-}
-// Ein Bild des Effekts nach `at` Sekunden (frischer Kontext)
-function shot(name, opts, at) {
- const fx = F.createFx(), { ctx, rects } = makeCtx()
- F.spawnFx(fx, name, opts)
- if (at > 0) fx.update(at)
- fx.draw(ctx)
- return { b: bounds(rects), rects, fx }
-}
-const FROM = { x: 40, y: 100 }
-
-test('FX_NAMES und MAX_FX wie vereinbart, die Liste ist eingefroren', () => {
- assert.deepEqual([...F.FX_NAMES], ['paper', 'envelope', 'coin', 'stampOk', 'stampNo', 'sparkle', 'alert', 'drawer'])
- assert.ok(Object.isFrozen(F.FX_NAMES)); assert.equal(F.MAX_FX, 80)
-})
-test('Lebensdauer je Effekt: kurz vor dem Ende noch da, kurz danach weg und onEnd genau einmal', () => {
- for (const name of F.FX_NAMES) {
- const fx = F.createFx(); let ended = 0
- F.spawnFx(fx, name, { from: FROM, to: { x: 140, y: 100 }, onEnd: () => { ended++ } })
- fx.update(LIFE[name] - 0.05); assert.equal(fx.size(), 1, name); assert.equal(ended, 0, name)
- fx.update(0.1); assert.equal(fx.size(), 0, name); assert.equal(ended, 1, name)
- fx.update(1); assert.equal(ended, 1, name)
- }
-})
-test('draw bekommt Fortschritt 0..1 (nie darüber) und den Effekt mit Zeit und Lebensdauer', () => {
- const fx = F.createFx(), { ctx } = makeCtx(), seen = []
- fx.add(1, (c, p, f) => seen.push([p, f.t, f.life]))
- fx.draw(ctx); for (let i = 0; i < 9; i++) { fx.update(0.1); fx.draw(ctx) }
- assert.equal(seen.length, 10); assert.deepEqual(seen[0], [0, 0, 1])
- for (let i = 1; i < seen.length; i++) assert.ok(seen[i][0] >= seen[i - 1][0] && seen[i][0] <= 1)
- assert.ok(seen.at(-1)[0] > 0.85)
- const solo = F.createFx(); let p = null; solo.add(0, (c, q) => { p = q }); solo.draw(ctx); assert.equal(p, 1) // Lebensdauer 0: kein NaN
-})
-test('update ignoriert dt, das keine positive endliche Zahl ist (kein NaN im Zeitstand)', () => {
- const fx = F.createFx(); let ended = 0; fx.add(1, () => {}, () => { ended++ })
- fx.update(NaN); fx.update(-5); fx.update(0); fx.update(Infinity); fx.update(undefined)
- assert.equal(fx.size(), 1); fx.update(1.1); assert.equal(fx.size(), 0); assert.equal(ended, 1)
-})
-test('ungültige Lebensdauer und Verzögerung laufen höchstens bis zum nächsten update', () => {
- const fx = F.createFx(); fx.add(NaN, () => {}); fx.add(-3, () => {}); fx.add(1, () => {}, undefined, NaN); fx.add(1, () => {}, undefined, -2)
- assert.equal(fx.size(), 4); fx.update(0.1); assert.equal(fx.size(), 2)
-})
-test('ein onEnd darf neue Effekte anlegen und clear() rufen; clear mitten im update wirft nicht', () => {
- const fx = F.createFx(), log = []
- fx.add(0.5, () => {}, () => { log.push('a'); fx.add(0.5, () => {}, () => log.push('b')) })
- fx.update(0.6); assert.equal(fx.size(), 1); assert.deepEqual(log, ['a'])
- fx.update(0.6); assert.deepEqual(log, ['a', 'b']); assert.equal(fx.size(), 0)
- fx.add(0.5, () => {}, () => { log.push('c'); fx.clear() }); fx.add(5, () => {}, () => log.push('nie'))
- fx.update(0.6); assert.equal(fx.size(), 0); assert.deepEqual(log, ['a', 'b', 'c'])
-})
-test('wirft ein onEnd, laufen die übrigen trotzdem und der erste Fehler kommt danach', () => {
- const fx = F.createFx(); let second = 0
- fx.add(0.1, () => {}, () => { throw new Error('boom') }); fx.add(0.1, () => {}, () => { second++ }); fx.add(5, () => {})
- assert.throws(() => fx.update(0.2), /boom/); assert.equal(second, 1); assert.equal(fx.size(), 1)
-})
-test('voll: ein verworfener Effekt meldet onEnd sofort (nicht erst beim nächsten update)', () => {
- const fx = F.createFx(); for (let i = 0; i < F.MAX_FX; i++) fx.add(5, () => {})
- let ended = 0; F.spawnFx(fx, 'coin', { from: FROM, onEnd: () => { ended++ } })
- assert.equal(ended, 1); assert.equal(fx.size(), F.MAX_FX)
-})
-test('spawnFx: Verzögerung zeichnet erst danach; unbekannter Name wirft nicht und meldet onEnd sofort', () => {
- const fx = F.createFx(), { ctx, rects } = makeCtx()
- F.spawnFx(fx, 'paper', { from: FROM, to: { x: 140, y: 100 }, delay: 0.5 })
- fx.update(0.4); fx.draw(ctx); assert.equal(rects.length, 0)
- fx.update(0.3); fx.draw(ctx); assert.ok(rects.length > 0)
- let ended = 0; const empty = F.createFx()
- for (const bad of ['gibtsnicht', '', undefined, null, 'constructor', '__proto__']) F.spawnFx(empty, bad, { from: FROM, onEnd: () => { ended++ } })
- assert.equal(ended, 6); assert.equal(empty.size(), 0)
- F.spawnFx(empty, 'sparkle'); assert.equal(empty.size(), 1) // ohne from/to und ohne Optionen: Standardpunkt statt Wurf
-})
-test('Bogenflug: Papier 8, Brief 12, Münze 14 Einheiten über der Geraden, Start und Ziel liegen auf der Geraden', () => {
- const to = { x: 140, y: 100 } // gleiche Höhe: jede Abweichung in y ist der Bogen
- for (const [name, high] of [['paper', 8], ['envelope', 12], ['coin', 14]]) {
- const start = shot(name, { from: FROM, to }, 0).b, mid = shot(name, { from: FROM, to }, LIFE[name] / 2).b, end = shot(name, { from: FROM, to }, LIFE[name] - 0.001).b
- assert.ok(Math.abs(start.cy - mid.cy - high) <= 1, `${name} Bogenhöhe ${start.cy - mid.cy}`)
- assert.ok(Math.abs(start.cy - end.cy) <= 1, `${name} Ende auf der Geraden`)
- assert.ok(Math.abs(start.cx - FROM.x) <= 1, `${name} Start bei from`); assert.ok(Math.abs(mid.cx - 90) <= 1, `${name} Mitte`); assert.ok(Math.abs(end.cx - to.x) <= 1, `${name} Ende bei to`)
- }
-})
-test('Bogenflug folgt auch einem schrägen Weg (Ziel oben rechts)', () => {
- const to = { x: 140, y: 60 }, start = shot('paper', { from: FROM, to }, 0).b, end = shot('paper', { from: FROM, to }, 0.899).b
- assert.ok(end.cx - start.cx >= 98); assert.ok(start.cy - end.cy >= 38)
-})
-test('Briefumschlag ist violett mit weißem Fenster, Münze golden, Papier grau-weiß', () => {
- const fills = (name) => new Set(shot(name, { from: FROM, to: { x: 140, y: 100 } }, 0.2).rects.map((r) => r.fill))
- assert.ok(fills('envelope').has('#7a5cd6') && fills('envelope').has(PAL.w))
- assert.ok(fills('coin').has(PAL.gold2)); assert.ok(fills('paper').has(PAL.g) && fills('paper').has(PAL.w))
-})
-test('Schubladen-Papier endet 3 Einheiten vor dem Ziel, das normale Papier erreicht es', () => {
- const to = { x: 140, y: 100 }, d = shot('drawer', { from: FROM, to }, 0.899).b, p = shot('paper', { from: FROM, to }, 0.899).b
- assert.ok(Math.abs(d.cx - (to.x - 3)) <= 1, `drawer ${d.cx}`); assert.ok(Math.abs(p.cx - to.x) <= 1, `paper ${p.cx}`)
- // liegt das Ziel näher als 3 Einheiten, bleibt das Blatt beim Start (kein Rückwärtsflug, kein NaN)
- const near = shot('drawer', { from: FROM, to: { x: 41, y: 100 } }, 0.899).b; assert.ok(Math.abs(near.cx - FROM.x) <= 1)
- const same = shot('drawer', { from: FROM, to: FROM }, 0.4).b; assert.ok(Number.isFinite(same.cx))
-})
-test('Stempel: grün für OK, rot für Nein, hüpft über dem Punkt und landet darauf', () => {
- for (const [name, color, other] of [['stampOk', PAL.green, PAL.red], ['stampNo', PAL.red, PAL.green]]) {
- const heights = []
- for (let i = 0; i < 12; i++) {
- const s = shot(name, { from: FROM }, i * 0.1); assert.ok(s.b.y1 <= FROM.y + 0.01, `${name} bleibt über from`)
- assert.ok(s.rects.some((r) => r.fill === color), name); assert.ok(!s.rects.some((r) => r.fill === other), name); heights.push(s.b.y1)
- }
- assert.ok(Math.min(...heights) < FROM.y - 3, `${name} hüpft`); assert.ok(Math.abs(shot(name, { from: FROM }, 1.199).b.y1 - FROM.y) <= 1, `${name} landet`)
- assert.ok(Math.abs(shot(name, { from: FROM }, 0.5).b.cx - FROM.x) <= 1, `${name} mittig`)
- }
-})
-test('Funkeln: Goldpixel wachsen nach außen und bleiben um from', () => {
- const early = shot('sparkle', { from: FROM }, 0.05).b, late = shot('sparkle', { from: FROM }, 0.95).b
- assert.ok(late.x1 - late.x0 > early.x1 - early.x0 + 6); assert.ok(Math.abs(late.cx - FROM.x) <= 1)
- assert.ok(shot('sparkle', { from: FROM }, 0.5).rects.every((r) => r.fill === PAL.gold || r.fill === '#fff3c4'))
-})
-test('Warnzeichen: blinkt, beginnt sichtbar, steht mit der Unterkante auf from und ist rot', () => {
- const on = []
- for (let i = 0; i < 30; i++) on.push(shot('alert', { from: FROM }, i * 0.05).rects.length > 0)
- assert.equal(on[0], true); assert.ok(on.some((v) => !v)); assert.ok(on.filter(Boolean).length >= 10)
- const b = shot('alert', { from: FROM }, 0).b; assert.equal(b.y1, FROM.y); assert.ok(Math.abs(b.cx - FROM.x) <= 1)
- assert.ok(shot('alert', { from: FROM }, 0).rects.some((r) => r.fill === PAL.red))
-})
-test('mit nur to oder nur from werden die Eintragspunkte ersetzt (kein Wurf, kein NaN)', () => {
- for (const name of F.FX_NAMES) {
- const a = shot(name, { to: { x: 90, y: 50 } }, 0).b, c = shot(name, { from: { x: 90, y: 50 } }, 0).b
- assert.ok(a && Number.isFinite(a.cx) && Number.isFinite(a.cy), name); assert.ok(c && Number.isFinite(c.cx) && Number.isFinite(c.cy), name)
- }
-})
diff --git a/tests/unit/workersLayout.test.mjs b/tests/unit/workersLayout.test.mjs
deleted file mode 100644
index d63d769..0000000
--- a/tests/unit/workersLayout.test.mjs
+++ /dev/null
@@ -1,120 +0,0 @@
-import test from 'node:test'
-import assert from 'node:assert/strict'
-import { loadModule } from './load.mjs'
-
-// Hochhaus-Layout als Daten: 10 Etagen, 19 Plätze, Räume nach Rolle, feste Orte für Effekte
-const { buildLayout, homeOf, roomById, floorIndex, fy, cabTop, WORLD, ROOM_META, FLOOR_PITCH, BRIDGE_H } = await loadModule('src/components/workers/engine/layout.js')
-const { ROLES, FLOORS, FLOOR_TITLES, ROOM_TITLES } = await loadModule('src/lib/workersRoster.js')
-const { TEAM } = await loadModule('src/components/workers/engine/sprites.js')
-
-const layout = buildLayout()
-const rooms = (id) => layout.floors.find((f) => f.id === id).rooms.map((r) => [r.id, r.x, r.w])
-const role = (workerId, floor, room) => ({ workerId, name: workerId, floor, room, model: 'haiku', role: '', caps: [] })
-
-test('10 Etagen in FLOORS-Reihenfolge mit festen Höhen, Welt 320×256', () => {
- assert.deepEqual(layout.floors.map((f) => f.id), [...FLOORS])
- assert.deepEqual([WORLD.w, WORLD.h], [320, 256])
- assert.deepEqual(layout.floors.map((f) => [f.top, f.floorY]), [[6, 40], [41, 63], [64, 86], [87, 109], [110, 132], [133, 155], [156, 178], [179, 201], [202, 224], [225, 247]])
- assert.equal(fy(layout, 3), 95)
- assert.equal(cabTop(layout, 3), 88)
-})
-
-test('Etagen tragen Index, Titel und Höhe (Brücke 34, sonst 22 plus Bodenlinie = Raster 23)', () => {
- assert.equal(BRIDGE_H, 34)
- assert.equal(FLOOR_PITCH, 23)
- layout.floors.forEach((f, i) => {
- assert.equal(f.index, i)
- assert.equal(f.title, FLOOR_TITLES[f.id])
- assert.equal(f.h, i === 0 ? 34 : 22, f.id)
- assert.equal(f.floorY - f.top, f.h, f.id)
- })
- assert.equal(layout.floors[1].top - layout.floors[0].floorY, 1)
- assert.equal(floorIndex(layout, 'tresor'), 8)
- assert.equal(floorIndex(layout, 'gibtsnicht'), -1)
-})
-
-test('Räume je Etage', () => {
- assert.deepEqual(rooms('anfragen'), [['netz', 42, 46], ['kleinanzeigen', 88, 46], ['portale', 134, 46], ['foren', 180, 48]])
- assert.deepEqual(rooms('gespraeche').map((r) => r[0]), ['mail', 'forum', 'posteingang', 'abschluss'])
- assert.deepEqual(rooms('werkstatt'), [['tisch1', 42, 62], ['tisch2', 104, 62], ['tisch3', 166, 62]])
- for (const id of ['bruecke', 'bewertung', 'pruefung', 'freigabe', 'tresor', 'keller']) {
- assert.deepEqual(rooms(id).map((r) => [r[1], r[2]]), [[42, 186]], id)
- }
-})
-
-test('alle Räume füllen die Breite 42..228 lückenlos', () => {
- for (const f of layout.floors) {
- let x = 42
- for (const r of f.rooms) {
- assert.equal(r.x, x, `${f.id}/${r.id}`)
- x += r.w
- }
- assert.equal(x, 228, f.id)
- }
- // andere Raumzahlen teilen gleichmäßig, der letzte Raum nimmt den Rest
- const zwei = buildLayout([role('a', 'anfragen', 'netz'), role('b', 'anfragen', 'portale')], ['anfragen'])
- assert.deepEqual(zwei.floors[0].rooms.map((r) => [r.x, r.w]), [[42, 93], [135, 93]])
- const fuenf = buildLayout(['netz', 'kleinanzeigen', 'portale', 'foren', 'aerzte'].map((room, i) => role(`w${i}`, 'anfragen', room)), ['anfragen'])
- assert.deepEqual(fuenf.floors[0].rooms.map((r) => [r.x, r.w]), [[42, 37], [79, 37], [116, 37], [153, 37], [190, 38]])
-})
-
-test('19 Worker haben genau einen Platz im eigenen Raum; Freigabe und Tresor ohne Worker', () => {
- for (const r of ROLES) {
- const h = homeOf(layout, r.workerId)
- const room = roomById(layout, r.room)
- const wide = r.workerId === 'otto' ? 24 : 12
- assert.ok(h.x >= room.x && h.x + wide <= room.x + room.w, r.workerId)
- assert.equal(h.floor, FLOORS.indexOf(r.floor))
- assert.equal(h.room, r.room)
- }
- assert.equal(Object.keys(layout.homes).length, 19)
- assert.equal(homeOf(layout, 'otto').y, 13)
- assert.equal(homeOf(layout, 'kim').y, 49)
- assert.deepEqual(layout.floors.filter((f) => f.rooms.some((r) => !r.workerId)).map((f) => f.id), ['freigabe', 'tresor'])
- assert.deepEqual(layout.spots, { pult: { x: 135, y: 186 }, tresor: { x: 163, y: 213 }, aussen: { x: 324, y: 212 }, archiv: { x: 148, y: 231 } })
-})
-
-test('Platz zentriert in der Raumbreite (Otto 24 breit, sonst 12)', () => {
- assert.deepEqual(homeOf(layout, 'otto'), { x: 123, y: 13, floor: 0, room: 'bruecke' })
- assert.deepEqual(homeOf(layout, 'rita'), { x: 59, y: 49, floor: 1, room: 'netz' })
- assert.equal(homeOf(layout, 'arne').y, 247 - 14)
-})
-
-test('Räume tragen Art, Titel, Teamfarbe und Worker aus den Tabellen', () => {
- assert.deepEqual(roomById(layout, 'kleinanzeigen'), { id: 'kleinanzeigen', kind: 'inbox', title: ROOM_TITLES.kleinanzeigen, team: 'radar', x: 88, w: 46, workerId: 'kim' })
- assert.equal(roomById(layout, 'pult').workerId, null)
- assert.equal(roomById(layout, 'tresor').workerId, null)
- assert.equal(roomById(layout, 'gibtsnicht'), null)
- assert.equal(roomById(layout, 'constructor'), null)
-})
-
-test('ROOM_META: genau die 21 Räume aus ROOM_TITLES, bekannte Arten, Teamfarbe aus TEAM', () => {
- assert.deepEqual(Object.keys(ROOM_META).sort(), Object.keys(ROOM_TITLES).sort())
- const kinds = ['bruecke', 'radar', 'inbox', 'feed', 'board', 'scout', 'waage', 'mail', 'schreiber', 'sortier', 'vertrag', 'tisch', 'pruefung', 'pult', 'tresor', 'archiv']
- assert.deepEqual([...new Set(Object.values(ROOM_META).map((m) => m.kind))].sort(), [...kinds].sort())
- for (const [id, m] of Object.entries(ROOM_META)) assert.ok(Object.hasOwn(TEAM, m.team), `${id}: ${m.team}`)
- const kindTeam = (id) => [ROOM_META[id].kind, ROOM_META[id].team]
- assert.deepEqual(['netz', 'kleinanzeigen', 'portale', 'foren'].map(kindTeam), [['radar', 'radar'], ['inbox', 'radar'], ['feed', 'radar'], ['board', 'radar']])
- assert.deepEqual(['aerzte', 'handwerk', 'selbst', 'laden'].map(kindTeam), [['scout', 'aerzte'], ['scout', 'handwerk'], ['scout', 'selbst'], ['scout', 'laden']])
- assert.deepEqual(['mail', 'forum', 'posteingang', 'abschluss'].map(kindTeam), [['mail', 'freigabe'], ['schreiber', 'freigabe'], ['sortier', 'freigabe'], ['vertrag', 'freigabe']])
- assert.deepEqual(['tisch1', 'tisch2', 'tisch3'].map(kindTeam), [['tisch', 'werkstatt'], ['tisch', 'werkstatt'], ['tisch', 'werkstatt']])
- assert.deepEqual(['waage', 'pruefung', 'bruecke', 'pult', 'tresor', 'archiv'].map(kindTeam), [['waage', 'bewertung'], ['pruefung', 'pruefung'], ['bruecke', 'freigabe'], ['pult', 'freigabe'], ['tresor', 'tresor'], ['archiv', 'archiv']])
-})
-
-test('unbekannte Kennung gibt null, auch bei Namen aus dem Prototyp', () => {
- assert.equal(homeOf(layout, 'niemand'), null)
- assert.equal(homeOf(layout, 'constructor'), null)
- assert.equal(homeOf(layout, '__proto__'), null)
-})
-
-test('neue Räume kommen als Daten; unbekannter Raum oder unbekannte Etage wirft', () => {
- assert.throws(() => buildLayout([role('x', 'anfragen', 'zz')], ['anfragen']), /Unbekannter Raum: zz/)
- assert.throws(() => buildLayout([role('x', 'oben', 'netz')], ['anfragen']), /Unbekannte Etage: oben/)
- assert.throws(() => buildLayout([role('x', 'anfragen', 'constructor')], ['anfragen']), /Unbekannter Raum: constructor/)
-})
-
-test('Orte für Effekte fehlen (null), wenn die Etage nicht im Layout ist', () => {
- const klein = buildLayout([role('x', 'anfragen', 'netz')], ['anfragen'])
- assert.deepEqual(klein.spots, { pult: null, tresor: null, aussen: null, archiv: null })
- assert.equal(klein.floors.length, 1)
-})
diff --git a/tests/unit/workersLift.test.mjs b/tests/unit/workersLift.test.mjs
deleted file mode 100644
index 27566a6..0000000
--- a/tests/unit/workersLift.test.mjs
+++ /dev/null
@@ -1,228 +0,0 @@
-import test from 'node:test'
-import assert from 'node:assert/strict'
-import { loadModule } from './load.mjs'
-import { makeCtx } from './helpers/fakeCtx.mjs'
-
-// Aufzug: Warteschlange, Zustandsautomat, Abbruch/Reset und Kabine (Quelle: entwuerfe/scene-turm.js)
-const { createLift, LIFT } = await loadModule('src/components/workers/engine/lift.js')
-const { buildLayout, cabTop } = await loadModule('src/components/workers/engine/layout.js')
-const { PAL } = await loadModule('src/components/workers/engine/sprites.js')
-const { FLOORS } = await loadModule('src/lib/workersRoster.js')
-
-const layout = buildLayout()
-function until(lift, fn, max = 12) { let t = 0; while (!fn() && t < max) { lift.update(0.05); t += 0.05 } return t }
-// Schritt für Schritt bis zur Bedingung, mit Sicherung gegen Endlosschleifen bei einem Fehler im Aufzug
-function stepUntil(step, fn, max = 400) { for (let i = 0; i < max && !fn(); i++) step(); assert.ok(fn(), 'Bedingung nicht erreicht') }
-test('Fahrt 1 → 8: queued, open, boarded, arrived, out; Fahrgast folgt der Kabine; danach idle', () => {
- const lift = createLift(layout), a = { x: 0, y: 0, frame: 1 }, req = lift.request(a, 1, 8)
- assert.equal(req.st, 'queued')
- let t = until(lift, () => req.st === 'open'); assert.ok(lift.at(1)); assert.equal(lift.state, 'boarding')
- req.st = 'boarded'; t += until(lift, () => req.st === 'arrived'); assert.ok(lift.at(8)); assert.equal(a.y, cabTop(layout, 8) + 7); assert.equal(a.x, LIFT.cabWx)
- assert.ok(t < 12); req.st = 'out'; lift.update(0.05); assert.equal(lift.state, 'idle')
-})
-test('FIFO: zweite Anfrage wartet bis die erste ausgestiegen ist', () => {
- const lift = createLift(layout), r1 = lift.request({}, 1, 4), r2 = lift.request({}, 5, 2)
- until(lift, () => r1.st === 'open'); r1.st = 'boarded'; until(lift, () => r1.st === 'arrived'); assert.equal(r2.st, 'queued')
- r1.st = 'out'; until(lift, () => r2.st === 'open'); assert.equal(r2.st, 'open')
-})
-test('cancel und reset geben die Kabine frei (kein Festhängen)', () => {
- const lift = createLift(layout), a = {}, r1 = lift.request(a, 7, 1), r2 = lift.request({}, 2, 3)
- lift.update(0.05); lift.cancel(a); assert.equal(r1.st, 'cancelled'); until(lift, () => r2.st === 'open'); assert.equal(r2.st, 'open')
- lift.reset(); assert.equal(r2.st, 'cancelled'); assert.equal(lift.state, 'idle')
-})
-
-// ---- Ergänzungen: Verhalten, das die drei Tests oben nicht berühren ----
-
-test('LIFT-Werte wie vereinbart und cabH stimmt mit der Kabinenhöhe des Layouts überein', () => {
- assert.deepEqual({ ...LIFT }, { cabWx: 22, exitX: 50, slot0: 52, slotStep: 14, speed: 110, cabH: 21 })
- layout.floors.forEach((f, i) => assert.equal(f.floorY - cabTop(layout, i), LIFT.cabH))
-})
-
-test('Start: Kabine steht auf Etage 2 im Leerlauf; at() gilt bis 1,5 Einheiten Abstand und ist bei ungültigem Index false', () => {
- const lift = createLift(layout)
- assert.equal(lift.y, cabTop(layout, 2)); assert.equal(lift.state, 'idle')
- layout.floors.forEach((f, i) => assert.equal(lift.at(i), i === 2, `at(${i})`))
- lift.y = cabTop(layout, 2) + 1.4; assert.ok(lift.at(2))
- lift.y = cabTop(layout, 2) - 1.4; assert.ok(lift.at(2))
- lift.y = cabTop(layout, 2) + 1.5; assert.ok(!lift.at(2))
- for (const i of [-1, 10, 99, 2.5, NaN, '2', undefined, null]) assert.equal(lift.at(i), false)
-})
-
-test('Layout mit nur zwei Etagen: Startetage ist die letzte vorhandene (kein Wurf)', () => {
- const small = buildLayout([], FLOORS.slice(0, 2)), lift = createLift(small)
- assert.equal(lift.y, cabTop(small, 1)); assert.ok(lift.at(1))
-})
-
-test('Zustandsfolge einer Fahrt; das Schließen dauert 0,3 s', () => {
- const lift = createLift(layout), a = { x: 0, y: 0, frame: 1 }, req = lift.request(a, 4, 6), seen = [lift.state]
- const step = () => { lift.update(0.05); if (seen.at(-1) !== lift.state) seen.push(lift.state) }
- stepUntil(step, () => req.st === 'open')
- assert.equal(lift.state, 'boarding')
- req.st = 'boarded'
- step(); assert.equal(lift.state, 'closing')
- for (let i = 0; i < 5; i++) step()
- assert.equal(lift.state, 'closing') // 0,25 s Tür
- step(); step(); assert.equal(lift.state, 'riding') // 0,35 s
- stepUntil(step, () => req.st === 'arrived')
- assert.equal(lift.state, 'unloading')
- step(); assert.equal(lift.state, 'unloading') // wartet auf den Fahrgast
- req.st = 'out'; step()
- assert.deepEqual(seen, ['idle', 'toPick', 'boarding', 'closing', 'riding', 'unloading', 'idle'])
- assert.equal(req.st, 'out')
-})
-
-test('Kabine fährt mit 110 Einheiten pro Sekunde, nach oben und nach unten', () => {
- const lift = createLift(layout), y0 = lift.y
- lift.request({}, 8, 1)
- for (let i = 0; i < 20; i++) lift.update(0.05) // 1 s
- assert.ok(Math.abs(lift.y - (y0 + 110)) < 1e-9, `nach 1 s ${lift.y - y0}`)
- const up = createLift(layout)
- up.request({}, 0, 1)
- for (let i = 0; i < 4; i++) up.update(0.05) // 0,2 s
- assert.ok(Math.abs(up.y - (y0 - 22)) < 1e-9, `nach 0,2 s ${up.y - y0}`)
- for (let i = 0; i < 6; i++) up.update(0.05) // Etage 0 liegt 46 Einheiten höher: erreicht, kein Überschwingen
- assert.equal(up.y, cabTop(layout, 0)); assert.equal(up.state, 'boarding')
-})
-
-test('Fahrgast bleibt bis zum Schließen unberührt, folgt dann der Kabine und wird beim Aussteigen losgelassen', () => {
- const lift = createLift(layout), a = { x: 12, y: 34, frame: 1 }, req = lift.request(a, 5, 1)
- until(lift, () => req.st === 'open')
- assert.deepEqual(a, { x: 12, y: 34, frame: 1 }) // Anfahren und Einsteigen: die Regie bewegt ihn
- a.x = LIFT.cabWx; a.y = lift.y + 7; a.frame = 0 // die Regie hat ihn in die Kabine gestellt (Schritt 0, Blick nach vorn)
- req.st = 'boarded'
- let followed = 0
- stepUntil(() => {
- lift.update(0.05)
- if (lift.state === 'closing' || lift.state === 'riding') {
- if (a.y !== lift.y + 7 || a.x !== LIFT.cabWx || a.frame !== 0) assert.fail(`Fahrgast ${a.x}/${a.y}/${a.frame} bei Kabine ${lift.y}`)
- followed++
- }
- }, () => req.st === 'arrived')
- assert.ok(followed > 10, `folgte nur ${followed}-mal`)
- assert.equal(a.y, cabTop(layout, 1) + 7)
- a.x = 77; a.y = 66; a.frame = 1 // Aussteigen: die Regie setzt ihn selbst
- lift.update(0.05); lift.update(0.05)
- assert.deepEqual(a, { x: 77, y: 66, frame: 1 })
-})
-
-test('Fahrt auf derselben Etage öffnet sofort und schließt ab', () => {
- const lift = createLift(layout), req = lift.request({}, 2, 2)
- lift.update(0.05); assert.equal(req.st, 'open')
- req.st = 'boarded'; until(lift, () => req.st === 'arrived'); assert.equal(req.st, 'arrived')
- req.st = 'out'; lift.update(0.05); assert.equal(lift.state, 'idle')
-})
-
-test('cancel während der Fahrt: Kabine frei, Fahrgast wird nicht mehr bewegt, nächste Anfrage kommt dran', () => {
- const lift = createLift(layout), a = { x: LIFT.cabWx, y: 0, frame: 0 }, r1 = lift.request(a, 2, 9), r2 = lift.request({}, 3, 4)
- until(lift, () => r1.st === 'open'); r1.st = 'boarded'
- until(lift, () => lift.state === 'riding'); lift.update(0.05)
- lift.cancel(a)
- assert.equal(r1.st, 'cancelled'); assert.equal(lift.state, 'idle')
- const before = { ...a }
- lift.update(0.05); assert.deepEqual(a, before)
- until(lift, () => r2.st === 'open'); assert.equal(r2.st, 'open'); assert.equal(lift.state, 'boarding')
- assert.deepEqual(a, before)
-})
-
-test('cancel einer wartenden Anfrage: nie bedient, die übrigen bleiben in Reihenfolge; mehrere Anfragen eines Agenten fallen alle', () => {
- const lift = createLift(layout), a2 = {}
- const r1 = lift.request({}, 1, 4), r2 = lift.request(a2, 5, 2), r3 = lift.request({}, 6, 3), r4 = lift.request(a2, 7, 3)
- lift.cancel(a2)
- assert.deepEqual([r1.st, r2.st, r3.st, r4.st], ['queued', 'cancelled', 'queued', 'cancelled'])
- until(lift, () => r1.st === 'open'); r1.st = 'boarded'; until(lift, () => r1.st === 'arrived'); r1.st = 'out'
- const seen = new Set()
- until(lift, () => { seen.add(r2.st).add(r4.st); return r3.st === 'open' })
- assert.equal(r3.st, 'open')
- assert.deepEqual([...seen], ['cancelled'])
-})
-
-test('cancel eines unbekannten oder bereits ausgestiegenen Agenten ändert nichts', () => {
- const lift = createLift(layout), a = {}, r = lift.request(a, 2, 3)
- until(lift, () => r.st === 'open'); r.st = 'boarded'; until(lift, () => r.st === 'arrived'); r.st = 'out'
- lift.cancel({}); lift.cancel(undefined); assert.equal(r.st, 'out'); assert.equal(lift.state, 'unloading')
- lift.cancel(a); assert.equal(r.st, 'out') // ausgestiegen bleibt ausgestiegen
- lift.update(0.05); assert.equal(lift.state, 'idle')
-})
-
-test('update überspringt von außen abgebrochene Anfragen (wartend und laufend)', () => {
- const lift = createLift(layout)
- const r1 = lift.request({}, 1, 4), r2 = lift.request({}, 5, 2), r3 = lift.request({}, 6, 3)
- r2.st = 'cancelled'
- until(lift, () => r1.st === 'open')
- r1.st = 'cancelled' // laufende Anfrage von außen abgebrochen: kein Festhängen im Zustand boarding
- lift.update(0.05)
- assert.equal(lift.state, 'toPick')
- until(lift, () => r3.st === 'open'); assert.equal(r3.st, 'open')
- assert.equal(r2.st, 'cancelled'); assert.equal(r1.st, 'cancelled')
-})
-
-test('reset bricht Wartende und die laufende Anfrage ab, ausgestiegene bleiben; danach fährt der Aufzug wieder', () => {
- const lift = createLift(layout), r1 = lift.request({}, 1, 4), r2 = lift.request({}, 5, 2), r3 = lift.request({}, 6, 3)
- until(lift, () => r1.st === 'open'); r1.st = 'boarded'; until(lift, () => r1.st === 'arrived'); r1.st = 'out'; lift.update(0.05)
- until(lift, () => r2.st === 'open')
- lift.reset()
- assert.deepEqual([r1.st, r2.st, r3.st], ['out', 'cancelled', 'cancelled']); assert.equal(lift.state, 'idle')
- lift.update(0.05); assert.equal(lift.state, 'idle') // Warteschlange ist leer
- const r4 = lift.request({}, 3, 5); until(lift, () => r4.st === 'open'); assert.equal(r4.st, 'open')
-})
-
-test('ungültige Anfragen (Etage, Agent) kommen abgebrochen zurück und blockieren nichts', () => {
- const lift = createLift(layout)
- for (const [a, from, to] of [[{}, -1, 3], [{}, 1, 10], [{}, 1.5, 2], [{}, '1', 2], [{}, 1], [{}, NaN, 2], [null, 1, 2], [undefined, 1, 2], ['x', 1, 2]]) {
- const r = lift.request(a, from, to)
- assert.equal(r.st, 'cancelled', JSON.stringify([from, to]))
- assert.equal(r.a, a)
- }
- lift.update(0.05); assert.equal(lift.state, 'idle')
- const ok = lift.request({}, 1, 2); until(lift, () => ok.st === 'open'); assert.equal(ok.st, 'open')
-})
-
-test('unbrauchbares dt (0, negativ, NaN, Infinity, kein Zahlentyp) ändert nichts', () => {
- const lift = createLift(layout), r = lift.request({}, 8, 1), y0 = lift.y
- for (const dt of [0, -1, NaN, Infinity, undefined, '0.05']) lift.update(dt)
- assert.equal(lift.state, 'idle'); assert.equal(lift.y, y0); assert.equal(r.st, 'queued')
-})
-
-// ---- Kabine zeichnen (Raster 3, kein setGrid: logische Einheit = 3 Geräte-Pixel) ----
-
-const K = 3
-const rect = (x, y, w, h, fill) => ({ x: x * K, y: y * K, w: w * K, h: h * K, fill })
-
-test('drawBack: Seil von oben bis zur Kabine, Rückwand mit Handlauf und Fußleiste', () => {
- const lift = createLift(layout), { ctx, rects } = makeCtx(), cy = cabTop(layout, 2)
- lift.drawBack(ctx)
- assert.deepEqual(rects, [
- rect(28, 9, 1, cy - 9, PAL.metal2),
- rect(18, cy, 20, 21, '#f1f3f8'),
- rect(19, cy + 11, 18, 1, '#cfd5df'),
- rect(19, cy + 18, 18, 2, '#e1e5ec'),
- ])
-})
-
-test('drawFront: Glasschleier, Rahmen, Lampe und Aufhängung; save/restore gleichen sich aus', () => {
- const lift = createLift(layout), { ctx, rects, counts, alphas } = makeCtx(), cy = cabTop(layout, 2)
- lift.drawFront(ctx)
- assert.deepEqual(rects, [
- rect(19, cy + 1, 18, 19, '#ffffff'),
- rect(18, cy, 20, 1, PAL.metal2),
- rect(18, cy + 20, 20, 1, PAL.metal2),
- rect(18, cy, 1, 21, PAL.metal2),
- rect(37, cy, 1, 21, PAL.metal2),
- rect(24, cy + 1, 8, 1, '#ffe9a8'),
- rect(26, cy - 2, 4, 2, PAL.metal2),
- ])
- assert.deepEqual(alphas, [0.18]); assert.equal(counts.save, 1); assert.equal(counts.restore, 1)
-})
-
-test('Kabine wird an der gerundeten Fahrposition gezeichnet und bleibt im Schacht (x 18..38)', () => {
- const lift = createLift(layout)
- lift.request({}, 8, 1)
- lift.update(0.05); lift.update(0.05); lift.update(0.05)
- assert.ok(!Number.isInteger(lift.y), `Testvoraussetzung: Kabine zwischen Rastern (y ${lift.y})`)
- const cy = Math.round(lift.y), back = makeCtx(), front = makeCtx()
- lift.drawBack(back.ctx); lift.drawFront(front.ctx)
- assert.deepEqual(back.rects[1], rect(18, cy, 20, 21, '#f1f3f8'))
- assert.deepEqual(back.rects[0], rect(28, 9, 1, cy - 9, PAL.metal2))
- assert.deepEqual(front.rects[1], rect(18, cy, 20, 1, PAL.metal2))
- for (const r of [...back.rects.slice(1), ...front.rects]) assert.ok(r.x >= 18 * K && r.x + r.w <= 38 * K, JSON.stringify(r))
-})
diff --git a/tests/unit/workersProps.test.mjs b/tests/unit/workersProps.test.mjs
deleted file mode 100644
index 8a79e96..0000000
--- a/tests/unit/workersProps.test.mjs
+++ /dev/null
@@ -1,82 +0,0 @@
-import test from 'node:test'
-import assert from 'node:assert/strict'
-import { loadModule } from './load.mjs'
-import { makeCtx } from './helpers/fakeCtx.mjs'
-
-// Engine-Requisiten: Möbel, Briefkasten, Tresor, Waage, Schild und die 7×7-Branchen-Icons
-const P = await loadModule('src/components/workers/engine/props.js')
-const { PAL } = await loadModule('src/components/workers/engine/sprites.js')
-const { SEARCH_ROOMS } = await loadModule('src/lib/workersRoster.js')
-
-test('alle Requisiten zeichnen ohne Wurf und mindestens ein Rechteck', () => {
- const calls = { desk: (c) => P.drawDesk(c, 0, 0, 18, '#f00'), monitor: (c) => P.drawMonitor(c, 0, 0, 1, '#7fb2ff'), cabinet: (c) => P.drawCabinet(c, 0, 0, 3), plant: (c) => P.drawPlant(c, 0, 0),
- coin: (c) => P.drawCoin(c, 0, 0, 0.3), trophy: (c) => P.drawTrophy(c, 0, 0), stampOk: (c) => P.drawStamp(c, 0, 0, true), stampNo: (c) => P.drawStamp(c, 0, 0, false),
- radar: (c) => P.drawRadar(c, 0, 0, 1), mailbox: (c) => P.drawMailbox(c, 0, 0, 2), vault: (c) => P.drawVault(c, 0, 0, 1), scale: (c) => P.drawScale(c, 0, 0, 2), tray: (c) => P.drawTray(c, 0, 0, true) }
- for (const [name, fn] of Object.entries(calls)) { const { ctx, rects } = makeCtx(); fn(ctx); assert.ok(rects.length >= 1, name) }
-})
-test('Briefkasten-Abzeichen nur bei count > 0; Monitor aus zeigt keine Inhaltszeilen', () => {
- const red = (n) => { const { ctx, rects } = makeCtx(); P.drawMailbox(ctx, 0, 0, n); return rects.filter((r) => r.fill === PAL.red).length }
- assert.equal(red(0), 0); assert.ok(red(3) >= 1)
- const lines = (on) => { const { ctx, rects } = makeCtx(); P.drawMonitor(ctx, 0, 0, 1, '#abcdef', on); return rects.filter((r) => r.fill === '#abcdef').length }
- assert.ok(lines(true) >= 2); assert.equal(lines(false), 0)
-})
-test('ICONS: genau die 8 Suchräume, je 7 Zeilen à 7 Zeichen; drawSign schreibt den Text', () => {
- assert.deepEqual(Object.keys(P.ICONS).sort(), ['aerzte', 'foren', 'handwerk', 'kleinanzeigen', 'laden', 'netz', 'portale', 'selbst'])
- for (const [k, rows] of Object.entries(P.ICONS)) { assert.equal(rows.length, 7, k); for (const r of rows) assert.match(r, /^[.xa]{7}$/, k) }
- const { ctx, texts, rects } = makeCtx(); P.drawSign(ctx, 0, 0, 40, 'noch nicht angeschlossen'); assert.equal(texts[0].text, 'noch nicht angeschlossen'); assert.ok(rects.length >= 2)
- const t = makeCtx(); P.drawIcon(t.ctx, 'aerzte', 0, 0, '#3fb6b2'); assert.ok(t.rects.length >= 5)
-})
-
-// ---- Ergänzungen: Verhalten, das die drei Tests oben nicht berühren ----
-
-test('ICONS: Schlüssel gleich SEARCH_ROOMS, eingefroren, jedes Icon hat Linien und die Zeichen stehen nur in .xa', () => {
- assert.deepEqual([...SEARCH_ROOMS].sort(), Object.keys(P.ICONS).sort())
- assert.ok(Object.isFrozen(P.ICONS))
- for (const [k, rows] of Object.entries(P.ICONS)) {
- assert.ok(Object.isFrozen(rows), k)
- assert.ok(rows.join('').includes('x'), k)
- }
-})
-test('drawIcon: x in der Farbe, a in Gold, nichts außerhalb von 7 × 7; unbekannter Name zeichnet nichts und wirft nicht', () => {
- const { ctx, rects } = makeCtx(); P.drawIcon(ctx, 'handwerk', 10, 20, '#3fb6b2')
- assert.ok(rects.some((r) => r.fill === '#3fb6b2')); assert.ok(rects.some((r) => r.fill === PAL.gold))
- assert.ok(rects.every((r) => r.fill === '#3fb6b2' || r.fill === PAL.gold))
- assert.ok(rects.every((r) => r.x >= 30 && r.x + r.w <= 30 + 21 && r.y >= 60 && r.y + r.h <= 60 + 21))
- for (const name of ['gibt-es-nicht', 'constructor', '__proto__', 'toString', undefined, null]) {
- const e = makeCtx(); assert.doesNotThrow(() => P.drawIcon(e.ctx, name, 0, 0, '#000'), String(name)); assert.equal(e.rects.length, 0, String(name))
- }
-})
-test('drawIcon: jedes der acht Icons zeichnet sichtbar (mindestens 5 Rechtecke)', () => {
- for (const name of Object.keys(P.ICONS)) { const { ctx, rects } = makeCtx(); P.drawIcon(ctx, name, 0, 0, '#123456'); assert.ok(rects.length >= 5, name) }
-})
-test('drawMonitor: Standard ist an; aus hat kein Blinken, an blinkt mit t', () => {
- const draw = (t, on) => { const { ctx, rects } = makeCtx(); if (on === undefined) P.drawMonitor(ctx, 0, 0, t, '#abcdef'); else P.drawMonitor(ctx, 0, 0, t, '#abcdef', on); return JSON.stringify(rects) }
- assert.equal(draw(0.2), draw(0.2, true))
- assert.notEqual(draw(0.2, true), draw(0.7, true))
- assert.equal(draw(0.2, false), draw(0.7, false))
- const off = makeCtx(); P.drawMonitor(off.ctx, 0, 0, 1, '#abcdef', false); assert.ok(off.rects.length >= 2) // Rahmen und Schirm bleiben
-})
-test('drawTray: voll zeigt Papier über dem Rand, beide bleiben in 10 × 4', () => {
- const draw = (filled) => { const { ctx, rects } = makeCtx(); P.drawTray(ctx, 5, 7, filled); return rects }
- const full = draw(true); const empty = draw(false)
- assert.ok(full.some((r) => r.fill === PAL.w)); assert.ok(!empty.some((r) => r.fill === PAL.w))
- for (const rs of [full, empty]) assert.ok(rs.every((r) => r.x >= 15 && r.x + r.w <= 15 + 30 && r.y >= 21 && r.y + r.h <= 21 + 12))
- assert.equal(Math.max(...full.map((r) => r.x + r.w)) - Math.min(...full.map((r) => r.x)), 30)
-})
-test('drawSign: dunkler Rand, weiße Fläche, Text mittig im Schild', () => {
- const { ctx, rects, texts } = makeCtx(); P.drawSign(ctx, 4, 6, 40, 'Ablage')
- assert.equal(rects[0].fill, PAL.o); assert.ok(rects.some((r) => r.fill === PAL.w))
- const border = rects[0]; assert.deepEqual([border.x, border.y, border.w], [12, 18, 120])
- assert.ok(rects.slice(1).every((r) => r.x > border.x && r.x + r.w < border.x + border.w && r.y > border.y && r.y + r.h < border.y + border.h))
- assert.equal(texts.length, 1); assert.equal(texts[0].x, (4 + 20) * 3)
- assert.ok(texts[0].y > border.y && texts[0].y < border.y + border.h)
-})
-test('drawStamp: ok grün, nein rot; drawCoin dreht sich mit t; drawScale kippt mit tilt', () => {
- const fills = (fn) => { const { ctx, rects } = makeCtx(); fn(ctx); return new Set(rects.map((r) => r.fill)) }
- assert.ok(fills((c) => P.drawStamp(c, 0, 0, true)).has(PAL.green)); assert.ok(!fills((c) => P.drawStamp(c, 0, 0, true)).has(PAL.red))
- assert.ok(fills((c) => P.drawStamp(c, 0, 0, false)).has(PAL.red)); assert.ok(!fills((c) => P.drawStamp(c, 0, 0, false)).has(PAL.green))
- const coinW = (t) => { const { ctx, rects } = makeCtx(); P.drawCoin(ctx, 0, 0, t); return rects[0].w }
- assert.notEqual(coinW(0), coinW(1 / 6))
- const pans = (tilt) => { const { ctx, rects } = makeCtx(); P.drawScale(ctx, 0, 0, tilt); return rects.filter((r) => r.w === 15 && r.h === 6).map((r) => r.y) }
- assert.equal(pans(0).length, 2); assert.notDeepEqual(pans(2), pans(-2))
-})
diff --git a/tests/unit/workersRooms.test.mjs b/tests/unit/workersRooms.test.mjs
deleted file mode 100644
index 62d06cd..0000000
--- a/tests/unit/workersRooms.test.mjs
+++ /dev/null
@@ -1,336 +0,0 @@
-import test from 'node:test'
-import assert from 'node:assert/strict'
-import { loadModule } from './load.mjs'
-import { makeCtx } from './helpers/fakeCtx.mjs'
-
-// Räume nach kind, Pokal-Seiten, graue Räume und Gebäudehülle (Quelle: entwuerfe/scene-turm.js Z. 99-314)
-const R = await loadModule('src/components/workers/engine/rooms.js')
-const B = await loadModule('src/components/workers/engine/building.js')
-const { buildLayout, ROOM_META } = await loadModule('src/components/workers/engine/layout.js')
-const { PAL, TEAM } = await loadModule('src/components/workers/engine/sprites.js')
-const { FLOOR_TITLES, ROOM_TITLES } = await loadModule('src/lib/workersRoster.js')
-
-const layout = buildLayout()
-const draw = (look, t = 1) => {
- const m = makeCtx()
- R.drawRooms(m.ctx, layout, t, look)
- return m
-}
-const SIGN = 'noch nicht angeschlossen'
-const looks = (patch = {}) => Object.assign(R.createLook(), patch)
-const count = (rects, fill) => rects.filter((r) => r.fill === fill).length
-// Pokale sind die 7 × 3 großen Goldflächen (k = 3: 21 × 9 Geräte-Pixel)
-const trophies = (look) => draw(look).rects.filter((r) => r.fill === PAL.gold && r.w === 21 && r.h === 9).length
-
-test('trophyPaging: 8 je Seite, Seite wird geklemmt', () => {
- assert.deepEqual(R.trophyPaging(0, 0), { shown: 0, page: 0, pages: 1, from: 0 })
- assert.deepEqual(R.trophyPaging(8, 0), { shown: 8, page: 0, pages: 1, from: 0 })
- assert.deepEqual(R.trophyPaging(9, 1), { shown: 1, page: 1, pages: 2, from: 8 })
- assert.deepEqual(R.trophyPaging(9, 5), { shown: 1, page: 1, pages: 2, from: 8 })
- assert.deepEqual(R.trophyPaging(25, -1), { shown: 8, page: 0, pages: 4, from: 0 })
-})
-
-test('alle 21 Räume zeichnen, jeder Titel erscheint', () => {
- const { texts, rects } = draw(R.createLook())
- assert.ok(rects.length > 300)
- for (const title of Object.values(ROOM_TITLES)) assert.ok(texts.some((x) => x.text === title), title)
-})
-
-test('graue Räume tragen das Schild, sonst keins (Review Focus 4)', () => {
- const look = R.createLook()
- look.greyRooms = new Set(['kleinanzeigen', 'portale'])
- assert.equal(draw(look).texts.filter((x) => x.text === SIGN).length, 2)
- assert.equal(draw(R.createLook()).texts.filter((x) => x.text === SIGN).length, 0)
-})
-
-test('Pult-Zahl, Pokal- und Bot-Lead-Zähler', () => {
- const look = R.createLook()
- look.pultOpen = 7
- look.dealsMonth = 9
- look.botLeads = 112
- const t = draw(look).texts.map((x) => x.text)
- assert.ok(t.includes('7'))
- assert.ok(t.includes('9 Deals'))
- assert.ok(t.includes('112 Bot-Leads'))
- assert.ok(!draw(R.createLook()).texts.some((x) => x.text === '7'))
-})
-
-test('Gebäude: Etagenschilder, Lampen, Pausenschleier', () => {
- const m = makeCtx()
- B.drawTower(m.ctx, layout, 1, { at: (i) => i === 3 })
- for (const title of Object.values(FLOOR_TITLES)) assert.ok(m.texts.some((x) => x.text === title), title)
- const on = makeCtx()
- const look = R.createLook()
- look.paused = true
- B.drawPauseVeil(on.ctx, look)
- assert.ok(on.texts.some((x) => x.text === 'Pausiert'))
- const off = makeCtx()
- B.drawPauseVeil(off.ctx, R.createLook())
- assert.equal(off.texts.length, 0)
- B.drawOutside(makeCtx().ctx)
-})
-
-// ---- Ergänzungen: Verhalten, das die fünf Tests oben nicht berühren ----
-
-test('createLook: Ausgangszustand ohne gemeinsame Mengen zwischen zwei Aufrufen', () => {
- const a = R.createLook()
- const b = R.createLook()
- assert.ok(a.greyRooms instanceof Set && a.busyRooms instanceof Set)
- assert.equal(a.greyRooms.size + a.busyRooms.size, 0)
- assert.deepEqual({ ...a, greyRooms: 0, busyRooms: 0 }, { greyRooms: 0, busyRooms: 0, pultOpen: 0, dealsMonth: 0, botLeads: 0, trophyPage: 0, pageT: 0, trophyPop: 0, vaultFlash: 0, paused: false })
- a.busyRooms.add('netz')
- assert.equal(b.busyRooms.size, 0)
-})
-
-test('ROOM_DRAWERS: eine Zeichnung für jede Raumart des Layouts, eingefroren', () => {
- const kinds = new Set(Object.values(ROOM_META).map((m) => m.kind))
- assert.equal(kinds.size, 16)
- for (const kind of kinds) assert.equal(typeof R.ROOM_DRAWERS[kind], 'function', kind)
- assert.ok(Object.isFrozen(R.ROOM_DRAWERS))
-})
-
-test('jeder Raum zeichnet sichtbaren Inhalt über Tint und Titel hinaus (kein leerer Raum)', () => {
- // Rechtecke je Raum zählen: Raum für Raum in ein eigenes Layout mit nur dieser Etage
- for (const floor of layout.floors) {
- for (const room of floor.rooms) {
- const single = { ...layout, floors: [{ ...floor, rooms: [room] }] }
- const m = makeCtx()
- R.drawRooms(m.ctx, single, 1, R.createLook())
- assert.ok(m.rects.length >= 5, room.id)
- }
- }
-})
-
-test('trophyPaging: ungültige Werte fallen auf eine leere erste Seite zurück', () => {
- const empty = { shown: 0, page: 0, pages: 1, from: 0 }
- assert.deepEqual(R.trophyPaging(NaN, NaN), empty)
- assert.deepEqual(R.trophyPaging(undefined, undefined), empty)
- assert.deepEqual(R.trophyPaging(-4, 2), empty)
- assert.deepEqual(R.trophyPaging(16, 1), { shown: 8, page: 1, pages: 2, from: 8 })
- assert.deepEqual(R.trophyPaging(5, 0, 2), { shown: 2, page: 0, pages: 3, from: 0 })
-})
-
-test('Pokalbrett zeigt die gewählte Seite; der neueste Pokal blinkt bei trophyPop', () => {
- assert.equal(trophies(looks()), 0)
- assert.equal(trophies(looks({ dealsMonth: 3 })), 3)
- assert.equal(trophies(looks({ dealsMonth: 20 })), 8)
- assert.equal(trophies(looks({ dealsMonth: 9, trophyPage: 1 })), 1)
- assert.equal(trophies(looks({ dealsMonth: 9, trophyPage: 7 })), 1)
- // floor(0.25 * 10) = 2 -> gerade: sichtbar; floor(0.15 * 10) = 1 -> ungerade: der neueste Pokal fehlt
- assert.equal(trophies(looks({ dealsMonth: 3, trophyPop: 0.25 })), 3)
- assert.equal(trophies(looks({ dealsMonth: 3, trophyPop: 0.15 })), 2)
-})
-
-test('Tresor blitzt Gold bei vaultFlash, sonst nicht', () => {
- const gold = (look) => draw(look).alphas.filter((a) => a > 0.1).length
- assert.ok(gold(looks({ vaultFlash: 0.7 })) > gold(looks()))
-})
-
-test('Pult-Zahl: 0 zeigt keine Zahl, ab 100 steht 99+', () => {
- const nums = (n) => draw(looks({ pultOpen: n })).texts.map((x) => x.text).filter((x) => /^\d+\+?$/.test(x))
- assert.deepEqual(nums(0), [])
- assert.deepEqual(nums(120), ['99+'])
- assert.deepEqual(nums(99), ['99'])
-})
-
-test('Zähler zeigen immer ihre Zahl, auch 0', () => {
- const t = draw(R.createLook()).texts.map((x) => x.text)
- assert.ok(t.includes('0 Deals'))
- assert.ok(t.includes('0 Bot-Leads'))
-})
-
-test('Monitore laufen nur in Räumen aus busyRooms', () => {
- const lines = (look) => count(draw(look).rects, TEAM.aerzte)
- const idle = lines(looks())
- const busy = lines(looks({ busyRooms: new Set(['aerzte']) }))
- assert.ok(busy > idle)
- // ein anderer Raum arbeitet: Ärzte & Praxen bleibt dunkel
- assert.equal(lines(looks({ busyRooms: new Set(['handwerk']) })), idle)
-})
-
-test('graue Räume: Monitor aus trotz busyRooms, pult und tresor werden nie grau', () => {
- const lines = (look) => count(draw(look).rects, TEAM.aerzte)
- const grey = looks({ greyRooms: new Set(['aerzte']), busyRooms: new Set(['aerzte']) })
- const greyIdle = looks({ greyRooms: new Set(['aerzte']) })
- assert.equal(lines(grey), lines(greyIdle))
- const never = looks({ greyRooms: new Set(['pult', 'tresor', 'gibt-es-nicht']) })
- assert.equal(draw(never).texts.filter((x) => x.text === SIGN).length, 0)
-})
-
-test('Schild steht innerhalb des grauen Raums und in der Etage seiner Figur', () => {
- const look = looks({ greyRooms: new Set(['kleinanzeigen']) })
- const m = draw(look)
- const sign = m.texts.find((x) => x.text === SIGN)
- const floor = layout.floors.find((f) => f.id === 'anfragen')
- const room = floor.rooms.find((r) => r.id === 'kleinanzeigen')
- // Mitte des Raums (k = 3, align center), Zeile innerhalb der Etage
- assert.equal(sign.x, Math.round((room.x + room.w / 2) * 3))
- assert.ok(sign.y >= floor.top * 3 && sign.y < floor.floorY * 3)
-})
-
-test('unbekannte Raumart zeichnet nur Titel und Tint, ohne Wurf', () => {
- const odd = { ...layout, floors: layout.floors.map((f) => ({ ...f, rooms: f.rooms.map((r) => ({ ...r, kind: 'gibt-es-nicht' })) })) }
- const m = makeCtx()
- assert.doesNotThrow(() => R.drawRooms(m.ctx, odd, 1, R.createLook()))
- assert.ok(m.texts.some((x) => x.text === ROOM_TITLES.netz))
-})
-
-test('drawRooms hält save/restore im Gleichgewicht', () => {
- const m = draw(looks({ greyRooms: new Set(['netz']), pultOpen: 3, dealsMonth: 2 }))
- assert.equal(m.counts.save, m.counts.restore)
-})
-
-test('Gebäude: Bodenlinie liegt auf dem Boden der Tresor-Etage (y 224)', () => {
- const tresor = layout.floors.find((f) => f.id === 'tresor')
- assert.equal(tresor.floorY, 224)
- const m = makeCtx()
- B.drawOutside(m.ctx)
- // k = 3: volle Breite 960, eine logische Einheit hoch, auf y 672
- assert.ok(m.rects.some((r) => r.x === 0 && r.y === 224 * 3 && r.w === 960 && r.h === 3))
- // Erde unter der Bodenlinie, bis zum Weltrand
- assert.ok(m.rects.some((r) => r.y === 225 * 3 && r.h === (256 - 225) * 3))
-})
-
-test('Gebäude: Lampe der Etage mit Kabine leuchtet grün, keine ohne Kabine', () => {
- const green = (at) => {
- const m = makeCtx()
- B.drawTower(m.ctx, layout, 1, { at })
- return count(m.rects, PAL.green)
- }
- const none = green(() => false)
- assert.equal(green((i) => i === 3), none + 1)
- assert.equal(green((i) => i === 0 || i === 9), none + 2)
-})
-
-test('Gebäude: Etagenschilder rechts außen bei x 234, in der Zeile ihrer Etage', () => {
- const m = makeCtx()
- B.drawTower(m.ctx, layout, 1, { at: () => false })
- for (const floor of layout.floors) {
- const sign = m.texts.find((x) => x.text === floor.title)
- assert.equal(sign.x, 234 * 3, floor.id)
- assert.ok(sign.y >= floor.top * 3 && sign.y < floor.floorY * 3, floor.id)
- }
-})
-
-test('Gebäude: Hülle reicht bis zum Fundament der letzten Etage', () => {
- const m = makeCtx()
- B.drawTower(m.ctx, layout, 1, { at: () => false })
- const last = layout.floors[layout.floors.length - 1]
- const bottom = Math.max(...m.rects.map((r) => (r.y + r.h) / 3))
- assert.equal(bottom, last.floorY + 2)
- assert.ok(m.rects.every((r) => r.x >= 0 && (r.x + r.w) / 3 <= 320))
-})
-
-test('Pausenschleier: 45 % Weiß über der ganzen Welt, Aufrufe gleichen sich aus', () => {
- const m = makeCtx()
- B.drawPauseVeil(m.ctx, looks({ paused: true }))
- assert.ok(m.alphas.includes(0.45))
- const veil = m.rects.find((r) => r.fill === PAL.w)
- assert.ok(veil.x <= 0 && veil.y <= 0 && veil.x + veil.w >= 960 && veil.y + veil.h >= 768)
- assert.equal(m.counts.save, m.counts.restore)
- const off = makeCtx()
- B.drawPauseVeil(off.ctx, R.createLook())
- assert.equal(off.rects.length, 0)
-})
-
-test('Gebäude: Layout ohne Etagen zeichnet nichts und wirft nicht', () => {
- const m = makeCtx()
- assert.doesNotThrow(() => B.drawTower(m.ctx, { floors: [], homes: {}, spots: {} }, 1, { at: () => false }))
- assert.doesNotThrow(() => R.drawRooms(m.ctx, { floors: [], homes: {}, spots: {} }, 1, R.createLook()))
-})
-
-// ---- Fix-Runde 1: Zeichnung und Effektorte, graue Räume ----
-
-// Rechtecke der Raumzeichnung, die den Mittelpunkt der Einheitszelle (x, y) überdecken; die Tönung, die den ganzen Raum füllt, zählt nicht
-const hitsAt = (floorId, spot, look = R.createLook()) => {
- const floor = layout.floors.find((f) => f.id === floorId)
- const full = Math.max(...floor.rooms.map((r) => r.w)) * 3
- const m = makeCtx()
- R.drawRooms(m.ctx, { ...layout, floors: [floor] }, 1, look)
- const cx = spot.x * 3 + 1
- const cy = spot.y * 3 + 1
- return m.rects.filter((r) => r.w < full && cx >= r.x && cx < r.x + r.w && cy >= r.y && cy < r.y + r.h)
-}
-
-test('Zeichnung trifft den Spot: Briefkasten am Pult, Spitze genau bei spots.pult', () => {
- const spot = layout.spots.pult
- assert.ok(hitsAt('freigabe', spot).some((r) => r.fill === TEAM.freigabe), 'Briefkasten unter dem Spot')
- // Spitze: eine Zeile darüber ist kein Briefkasten mehr
- assert.ok(!hitsAt('freigabe', { x: spot.x, y: spot.y - 1 }).some((r) => r.fill === TEAM.freigabe))
-})
-
-test('Zeichnung trifft den Spot: Tresortür in der Mitte von spots.tresor', () => {
- const hit = hitsAt('tresor', layout.spots.tresor)
- assert.ok(hit.some((r) => r.fill === PAL.metal2 && r.w === 22 * 3 && r.h === 20 * 3), 'Tresorkörper unter dem Spot')
- // die 6 × 6 große Türplatte (x + 8 ... x + 14) umschließt den Spot
- assert.ok(hit.some((r) => r.fill === PAL.metal && r.w === 6 * 3 && r.h === 6 * 3), 'Türplatte unter dem Spot')
-})
-
-test('Zeichnung trifft den Spot: Schublade eines Aktenschranks bei spots.archiv, nicht auf Arne', () => {
- const spot = layout.spots.archiv
- const hit = hitsAt('keller', spot)
- // oberste Schubladenfront (10 × 5) des ersten Aktenschranks
- assert.ok(hit.some((r) => r.fill === PAL.metal && r.w === 10 * 3 && r.h === 5 * 3), 'Schubladenfront unter dem Spot')
- assert.ok(hit.some((r) => r.fill === PAL.metal2 && r.w === 12 * 3), 'Schrankkorpus unter dem Spot')
- // Arnes Platz (Körper 12 breit, 14 hoch, Hut ab y - 2) liegt nicht auf dem Spot
- const arne = layout.homes.arne
- assert.ok(!(spot.x >= arne.x && spot.x < arne.x + 12 && spot.y >= arne.y - 2 && spot.y < arne.y + 14))
-})
-
-test('grauer Raum: Teamfarbton bleibt, das Schild ersetzt den Titel (kein Platz für beides)', () => {
- const floor = layout.floors.find((f) => f.id === 'anfragen')
- const room = floor.rooms.find((r) => r.id === 'kleinanzeigen')
- assert.equal(room.w, 46)
- const run = (look) => {
- const m = makeCtx()
- R.drawRooms(m.ctx, { ...layout, floors: [{ ...floor, rooms: [room] }] }, 1, look)
- return m
- }
- const normal = run(looks())
- const grey = run(looks({ greyRooms: new Set(['kleinanzeigen']) }))
- const tintRects = (m) => m.rects.filter((r) => r.fill === TEAM[room.team] && r.w === room.w * 3 && r.h === floor.h * 3)
- assert.equal(tintRects(normal).length, 1)
- assert.equal(tintRects(grey).length, 1, 'Teamfarbton auch im grauen Raum')
- assert.ok(grey.alphas.includes(0.1) && grey.alphas.includes(0.45), 'Teamfarbton 0,10, darüber die Grautönung 0,45')
- assert.ok(normal.texts.some((x) => x.text === room.title))
- assert.ok(!grey.texts.some((x) => x.text === room.title), 'im grauen Raum steht das Schild an der Stelle des Titels')
- assert.equal(grey.texts.filter((x) => x.text === SIGN).length, 1)
- assert.equal(normal.texts.filter((x) => x.text === SIGN).length, 0)
-})
-
-test('Schilder: drawRooms mit signs: false lässt sie aus (Grautönung bleibt), drawSigns zeichnet genau diese Schilder an dieselbe Stelle', () => {
- const look = looks({ greyRooms: new Set(['kleinanzeigen', 'portale']) })
- const withSigns = draw(look)
- const m = makeCtx()
- R.drawRooms(m.ctx, layout, 1, look, { signs: false })
- assert.equal(m.texts.filter((x) => x.text === SIGN).length, 0)
- assert.equal(m.rects.length < withSigns.rects.length, true)
- assert.ok(m.alphas.includes(0.45), 'die Grautönung gehört weiter zum Raum')
- assert.deepEqual(m.texts.filter((x) => x.text !== SIGN).map((x) => x.text), withSigns.texts.filter((x) => x.text !== SIGN).map((x) => x.text))
- const s = makeCtx()
- R.drawSigns(s.ctx, layout, look)
- assert.deepEqual(s.texts.map((x) => x.text), [SIGN, SIGN])
- assert.deepEqual(s.texts.map((x) => [x.x, x.y]), withSigns.texts.filter((x) => x.text === SIGN).map((x) => [x.x, x.y]))
- const none = makeCtx()
- R.drawSigns(none.ctx, layout, R.createLook())
- assert.equal(none.rects.length + none.texts.length, 0)
- const noWorker = makeCtx()
- R.drawSigns(noWorker.ctx, layout, looks({ greyRooms: new Set(['pult', 'tresor']) })) // Räume ohne Worker werden nie grau
- assert.equal(noWorker.texts.length, 0)
-})
-
-test('Raumtitel: über den Möbeln gezeichnet (ein Monitor deckt sie nicht zu) und mit hellem Rand', () => {
- const floor = layout.floors.find((f) => f.id === 'gespraeche')
- const room = floor.rooms.find((r) => r.id === 'mail')
- const m = makeCtx()
- R.drawRooms(m.ctx, { ...layout, floors: [{ ...floor, rooms: [room] }] }, 1, R.createLook())
- assert.equal(m.strokes.length, 1)
- assert.equal(m.strokes[0].text, room.title)
- assert.ok(m.strokes[0].style.startsWith('rgba(255, 255, 255'))
- assert.equal(m.strokes[0].rects, m.rects.length, 'nach dem Titel kommt keine Fläche mehr: Möbel, Tisch und Monitor liegen darunter')
- assert.ok(m.rects.length > 20)
- assert.equal(m.texts.filter((x) => x.text === room.title).length, 1)
- const grey = makeCtx()
- R.drawRooms(grey.ctx, { ...layout, floors: [{ ...floor, rooms: [{ ...room, id: 'kleinanzeigen' }] }] }, 1, looks({ greyRooms: new Set(['kleinanzeigen']) }))
- assert.equal(grey.strokes.length, 0, 'im grauen Raum steht kein Titel')
-})
diff --git a/tests/unit/workersScene.test.mjs b/tests/unit/workersScene.test.mjs
deleted file mode 100644
index fdee318..0000000
--- a/tests/unit/workersScene.test.mjs
+++ /dev/null
@@ -1,620 +0,0 @@
-import test from 'node:test'
-import assert from 'node:assert/strict'
-import { createEngineSandbox } from './helpers/engineVm.mjs'
-import { makeState, ev, IDS } from './helpers/workersFixtures.mjs'
-
-// Szene: mountTower (Größe, 30-fps-Schleife, Eingabe, Kamera, Lebenszyklus) in einer vm-Sandbox ohne window/document
-
-const sel = []
-const open = async (o = {}, extra = {}) => {
- const sb = await createEngineSandbox(o)
- const h = sb.mountTower(sb.canvas, sb.opts({ onSelect: (id) => sel.push(id), ...extra }))
- return { sb, h }
-}
-
-test('Handle hat die Methoden aus Übersicht §7 und E4; Mount zeichnet sofort', async () => {
- const { sb, h } = await open()
- for (const m of ['destroy', 'setState', 'pushEvents', 'select', 'setPaused', 'flush', 'resize', 'zoomBy', 'resetView', 'locate', 'stats', 'debug']) {
- assert.equal(typeof h[m], 'function', m)
- }
- assert.ok(sb.counts.fillRect > 100)
- h.destroy()
-})
-
-test('Größe: Breite·dpr (max. 2400), Höhe 4/5 davon', async () => {
- const a = await open({ cssWidth: 960, dpr: 1 })
- assert.deepEqual([a.sb.canvas.width, a.sb.canvas.height], [960, 768])
- a.h.destroy()
- const b = await open({ cssWidth: 1500, dpr: 2 })
- assert.deepEqual([b.sb.canvas.width, b.sb.canvas.height], [2400, 1920])
- b.h.destroy()
-})
-
-test('Takt: höchstens 30 Bilder je Sekunde', async () => {
- const { sb, h } = await open()
- h.setState(makeState())
- const f0 = h.stats().frames
- sb.tick(1)
- const f = h.stats().frames - f0
- assert.ok(f >= 28 && f <= 32, String(f))
- h.destroy()
-})
-
-test('Lebenszyklus: destroy doppelt, nichts danach, Listener weg; StrictMode mount → destroy → mount', async () => {
- const sb = await createEngineSandbox()
- const a = sb.mountTower(sb.canvas, sb.opts({}))
- a.destroy()
- a.destroy()
- const n = sb.counts.fillRect
- sb.tick(1)
- assert.equal(sb.counts.fillRect, n)
- assert.equal(Object.values(sb.listeners).flat().length, 0)
- const b = sb.mountTower(sb.canvas, sb.opts({}))
- sb.tick(0.5)
- assert.ok(sb.counts.fillRect > n)
- a.setState(makeState())
- a.pushEvents([ev('fund', 'kim')])
- b.destroy()
- assert.equal(Object.values(sb.listeners).flat().length, 0)
-})
-
-test('fontsReady: Ablehnung oder Zerstörung davor lässt nichts mehr zeichnen', async () => {
- let resolve
- const p = new Promise((r) => { resolve = r })
- const { sb, h } = await open({}, { fontsReady: p })
- h.destroy()
- const n = sb.counts.fillRect
- resolve()
- await new Promise((r) => setImmediate(r))
- assert.equal(sb.counts.fillRect, n)
- // das abgelehnte Versprechen erst nach dem Sandbox-Aufbau erzeugen, sonst „unhandled rejection“
- const sb2 = await createEngineSandbox()
- sb2.mountTower(sb2.canvas, sb2.opts({ fontsReady: Promise.reject(new Error('x')) })).destroy()
-})
-
-test('reducedMotion: keine Schleife, jede Änderung zeichnet ein Standbild und beruhigt die Regie', async () => {
- const { sb, h } = await open({}, { reducedMotion: true })
- assert.equal(sb.rafPending(), 0)
- const n = sb.counts.fillRect
- h.setState(makeState())
- h.pushEvents(Array.from({ length: 30 }, (_, i) => ev('uebergabe', IDS[i % 19], { to: IDS[(i + 4) % 19] })))
- assert.ok(sb.counts.fillRect > n)
- assert.equal(h.debug().pending, 0)
- assert.equal(sb.rafPending(), 0)
- h.destroy()
-})
-
-test('setPaused hält die Schleife an und setzt sie fort', async () => {
- const { sb, h } = await open()
- h.setPaused(true)
- sb.tick(0.2)
- const n = sb.counts.fillRect
- sb.tick(1)
- assert.equal(sb.counts.fillRect, n)
- h.setPaused(false)
- sb.tick(0.5)
- assert.ok(sb.counts.fillRect > n)
- h.destroy()
-})
-
-test('Klick trifft über die Umkehr-Transformation, auch gezoomt und verschoben', async () => {
- const { sb, h } = await open()
- h.setState(makeState())
- sel.length = 0
- for (const id of ['otto', 'ben', 'arne']) {
- const p = h.locate(id)
- sb.click(p.x, p.y)
- assert.equal(sel.at(-1), id)
- }
- h.zoomBy(2.5)
- h.select('ben')
- const p = h.locate('ben')
- assert.ok(p)
- sb.click(p.x, p.y)
- assert.equal(sel.at(-1), 'ben')
- assert.equal(h.stats().zoom, 2.5)
- h.resetView()
- assert.equal(h.stats().zoom, 1)
- sel.length = 0
- h.select('kim')
- assert.equal(sel.length, 0)
- h.destroy()
-})
-
-test('Ziehen verschiebt bei Zoom > 1 und unterdrückt den Klick', async () => {
- const { sb, h } = await open()
- h.setState(makeState())
- h.zoomBy(2)
- h.select('bea')
- const before = h.locate('bea')
- sel.length = 0
- sb.pointer('pointerdown', 400, 300)
- sb.pointer('pointermove', 460, 300)
- sb.pointer('pointerup', 460, 300)
- sb.click(460, 300)
- assert.equal(sel.length, 0)
- const after = h.locate('bea')
- // 60 CSS-Pixel nach rechts gezogen
- assert.ok(Math.abs(after.x - (before.x + 60)) < 2)
- assert.ok(Math.abs(after.y - before.y) < 1e-6)
- h.destroy()
-})
-
-// ---------- Größe und Startansicht ----------
-
-test('resize() folgt der Bühne; unter 10 CSS-Pixeln wird nicht gemessen', async () => {
- const { sb, h } = await open({ cssWidth: 960 })
- sb.setCssWidth(500)
- h.resize()
- assert.deepEqual([sb.canvas.width, sb.canvas.height], [500, 400])
- sb.setCssWidth(5)
- h.resize()
- assert.deepEqual([sb.canvas.width, sb.canvas.height], [500, 400])
- h.destroy()
-})
-
-test('unsichtbare Bühne (Breite unter 10): nichts wird gezeichnet, bis resize() sie misst', async () => {
- const { sb, h } = await open({ cssWidth: 5 })
- h.setState(makeState())
- sb.tick(1)
- assert.equal(sb.counts.fillRect, 0)
- assert.equal(h.stats().frames, 0)
- sb.setCssWidth(960)
- h.resize()
- assert.deepEqual([sb.canvas.width, sb.canvas.height], [960, 768])
- assert.ok(sb.counts.fillRect > 100)
- h.destroy()
-})
-
-test('Startansicht: breite Bühne Zoom 1, schmale fitZoom; die Touch-Aktion folgt dem Zoom', async () => {
- const wide = await open({ cssWidth: 960 })
- assert.equal(wide.h.stats().zoom, 1)
- assert.equal(wide.sb.canvas.style.touchAction, 'pan-y')
- wide.h.destroy()
- const narrow = await open({ cssWidth: 375 })
- narrow.h.setState(makeState())
- assert.ok(Math.abs(narrow.h.stats().zoom - 560 / 375) < 1e-9)
- assert.equal(narrow.sb.canvas.style.touchAction, 'none')
- // alle Figuren der oberen Etagen liegen in der Ansicht (die untere Hälfte des Turms ist bei Zoom > 1 abgeschnitten)
- const upper = narrow.h.debug().agents.filter((a) => a.y < 120)
- assert.ok(upper.length >= 10)
- for (const a of upper) assert.ok(narrow.h.locate(a.id), a.id)
- narrow.h.resetView()
- assert.equal(narrow.h.stats().zoom, 1)
- assert.equal(narrow.sb.canvas.style.touchAction, 'pan-y')
- narrow.h.destroy()
-})
-
-test('Startansicht auf schmaler Bühne: alle Räume (x 42..228) liegen in der Breite', async () => {
- const { sb, h } = await open({ cssWidth: 375, record: true })
- const k = sb.canvas.width / (320 / h.stats().zoom)
- const backWalls = sb.rects.filter((r) => Math.abs(r.w - 186 * k) <= 1)
- assert.ok(backWalls.length >= 10, 'Rückwand und Bodenstreifen jeder Etage sind 186 Einheiten breit')
- for (const r of backWalls) assert.ok(r.x >= 0 && r.x + r.w <= sb.canvas.width, JSON.stringify(r))
- h.destroy()
-})
-
-test('resize() zieht den Startausschnitt nach, bis der Nutzer die Kamera bewegt hat', async () => {
- const { sb, h } = await open({ cssWidth: 375 })
- assert.ok(h.stats().zoom > 1.4)
- sb.setCssWidth(960)
- h.resize()
- assert.equal(h.stats().zoom, 1)
- sb.setCssWidth(375)
- h.resize()
- h.zoomBy(2)
- const z = h.stats().zoom
- sb.setCssWidth(960)
- h.resize()
- assert.equal(h.stats().zoom, z)
- h.destroy()
-})
-
-// ---------- Zeichnen ----------
-
-test('Zeichenreihenfolge: Weiß, Außenwelt, Turm, Räume, Aufzug hinten, Figuren, Schilder, Aufzug vorn, Pausenschleier', async () => {
- const { sb, h } = await open({ record: true })
- const state = makeState({ paused: true, connected: { kim: false } })
- h.setState(state)
- sb.rects.length = 0
- sb.texts.length = 0
- h.setState(state) // genau ein Bild
- const white = sb.rects[0]
- assert.deepEqual([white.x, white.y, white.w, white.h, white.fill], [0, 0, 960, 768, '#ffffff'])
- const rectN = (pred, what) => {
- const r = sb.rects.find(pred)
- assert.ok(r, what)
- return r.n
- }
- const textN = (text) => {
- const t = sb.texts.find((x) => x.text === text)
- assert.ok(t, text)
- return t.n
- }
- const outside = rectN((r) => r.fill === '#f7f4f0', 'Erde der Außenwelt')
- const tower = rectN((r) => r.fill === '#e4e8ef', 'Aufzugschacht')
- const rooms = textN('Netz-Radar') // Raumtitel
- const liftBack = rectN((r) => r.fill === '#f1f3f8', 'Rückwand der Kabine')
- const figure = rectN((r) => r.alpha === 0.55, 'graue Figur')
- const sign = textN('noch nicht angeschlossen') // das Schild liegt über dem Hut der schlafenden Figur (sonst deckt der Hut den Text zu)
- const liftFront = rectN((r) => r.fill === '#ffe9a8', 'Lampe der Kabine')
- const veil = textN('Pausiert')
- assert.deepEqual([outside, tower, rooms, liftBack, figure, sign, liftFront, veil].toSorted((a, b) => a - b), [outside, tower, rooms, liftBack, figure, sign, liftFront, veil])
- assert.equal(sb.texts.filter((t) => t.text === 'noch nicht angeschlossen').length, 1, 'genau ein Schild, nicht zweimal')
- assert.equal(sb.texts.at(-1).text, 'Pausiert')
- h.destroy()
-})
-
-test('Effekte liegen über dem Aufzug vorn und unter dem Pausenschleier', async () => {
- const { sb, h } = await open({ record: true })
- h.setState(makeState())
- h.pushEvents([ev('vorschau_link', 'max')])
- sb.tick(2) // erst Sprechblase, dann fliegt der Brief (Lebensdauer 3,4 s)
- assert.ok(h.debug().fx > 0)
- sb.rects.length = 0
- sb.texts.length = 0
- h.setState(makeState({ paused: true })) // genau ein Bild, der Brief ist noch unterwegs
- const lamp = sb.rects.find((r) => r.fill === '#ffe9a8')
- const mail = sb.rects.filter((r) => r.fill === '#7a5cd6' && r.w === 21 && r.h === 15).at(-1)
- const veil = sb.texts.find((t) => t.text === 'Pausiert')
- assert.ok(lamp && mail && veil)
- assert.ok(lamp.n < mail.n && mail.n < veil.n, [lamp.n, mail.n, veil.n].join(' < '))
- h.destroy()
-})
-
-test('Pausenschleier nur bei pausiertem Dienst', async () => {
- const { sb, h } = await open()
- h.setState(makeState())
- assert.equal(sb.seenTexts.has('Pausiert'), false)
- h.setState(makeState({ paused: true }))
- assert.equal(sb.seenTexts.has('Pausiert'), true)
- h.destroy()
-})
-
-test('stats().avgDrawMs misst die Zeit eines Bildes mit der Uhr der Szene', async () => {
- const sb = await createEngineSandbox()
- let t = 0
- const h = sb.mountTower(sb.canvas, sb.opts({ reducedMotion: true, now: () => { t += 2; return t } }))
- h.setState(makeState())
- const s = h.stats()
- assert.equal(s.frames, 2) // Mount und setState
- assert.equal(s.avgDrawMs, 2)
- h.destroy()
-})
-
-test('Schleife treibt die Regie: eine Übergabe über sieben Etagen läuft in der Szene zu Ende', async () => {
- const { sb, h } = await open()
- h.setState(makeState())
- const arne = () => h.debug().agents.find((a) => a.id === 'arne')
- const home = { x: arne().x, y: arne().y, floor: arne().floor }
- h.pushEvents([ev('uebergabe', 'arne', { to: 'bea' })])
- assert.equal(h.debug().pending, 1)
- let away = false
- for (let s = 0; s < 40 && h.debug().pending > 0; s++) {
- sb.tick(1)
- if (Math.abs(arne().x - home.x) > 10 || arne().floor !== home.floor) away = true
- }
- assert.ok(away)
- assert.equal(h.debug().pending, 0)
- sb.tick(2)
- assert.equal(h.debug().lift, 'idle')
- assert.deepEqual({ x: arne().x, y: arne().y, floor: arne().floor }, home)
- h.destroy()
-})
-
-test('dt wird nach einem Stillstand auf DT_MAX gekappt', async () => {
- const { sb, h } = await open()
- h.setState(makeState())
- h.pushEvents([ev('uebergabe', 'arne', { to: 'bea' })])
- sb.tick(1) // 0,4 s Aufnehmen, dann unterwegs zum Aufzug
- const at = () => h.debug().agents.find((a) => a.id === 'arne')
- const before = { x: at().x, y: at().y }
- sb.jump(60) // die Uhr springt, ohne dass ein Bild kommt
- sb.tick(2 / 60) // ein Bild mit Δt = 60 s
- const moved = Math.hypot(at().x - before.x, at().y - before.y)
- assert.ok(moved > 0 && moved <= 46 * sb.engine.DT_MAX + 1e-6, String(moved))
- h.destroy()
-})
-
-// ---------- Eingabe ----------
-
-test('Klick ins Leere wählt ab (onSelect(null))', async () => {
- const { sb, h } = await open()
- h.setState(makeState())
- sel.length = 0
- const p = h.locate('otto')
- sb.click(p.x, p.y)
- sb.click(5, 5)
- assert.deepEqual(sel, ['otto', null])
- h.destroy()
-})
-
-test('coarsePointer weitet die Trefferfläche von 4 auf 10 CSS-Pixel', async () => {
- for (const [coarsePointer, expected] of [[false, null], [true, 'arne']]) {
- const { sb, h } = await open({}, { coarsePointer })
- h.setState(makeState())
- sel.length = 0
- const p = h.locate('arne')
- sb.click(p.x + 25, p.y) // die Figur ist 12 Einheiten = 36 CSS-Pixel breit, 25 > 18 + 4 und 25 < 18 + 10
- assert.equal(sel.at(-1), expected, String(coarsePointer))
- h.destroy()
- }
-})
-
-test('zoomBy zoomt um die Mitte der Ansicht', async () => {
- const { h } = await open()
- h.setState(makeState())
- const before = h.locate('bea')
- h.zoomBy(1.4)
- const after = h.locate('bea')
- assert.ok(Math.abs(after.x - (480 + (before.x - 480) * 1.4)) < 1, JSON.stringify(after))
- assert.ok(Math.abs(after.y - (384 + (before.y - 384) * 1.4)) < 1, JSON.stringify(after))
- h.destroy()
-})
-
-test('Strg+Rad zoomt um den Zeiger, ohne Strg bleibt das Rad der Seite; Schritt begrenzt, passive: false', async () => {
- const { sb, h } = await open()
- h.setState(makeState())
- const free = sb.wheel(480, 384, -100)
- assert.equal(free.defaultPrevented, false)
- assert.equal(h.stats().zoom, 1)
- const p = h.locate('arne')
- const e = sb.wheel(p.x, p.y, -100, { ctrlKey: true })
- assert.equal(e.defaultPrevented, true)
- assert.ok(Math.abs(h.stats().zoom - sb.engine.ZOOM_STEP) < 1e-9)
- const q = h.locate('arne')
- assert.ok(Math.abs(q.x - p.x) < 1 && Math.abs(q.y - p.y) < 1, 'der Punkt unter dem Zeiger bleibt stehen')
- sb.wheel(p.x, p.y, 100, { ctrlKey: true })
- assert.ok(Math.abs(h.stats().zoom - 1) < 1e-9)
- sb.wheel(480, 384, -1e6, { ctrlKey: true })
- assert.ok(Math.abs(h.stats().zoom - sb.engine.ZOOM_STEP ** 2) < 1e-9)
- const wheel = sb.listeners.wheel[0]
- assert.equal(sb.listenerOptions.get(wheel).passive, false)
- h.destroy()
-})
-
-test('Ziehen bei Zoom 1 verschiebt nichts, unterdrückt aber den Klick; kleine Bewegung und die rechte Maustaste', async () => {
- const { sb, h } = await open()
- h.setState(makeState())
- const before = h.locate('bea')
- sel.length = 0
- sb.pointer('pointerdown', 400, 300)
- sb.pointer('pointermove', 460, 300)
- sb.pointer('pointerup', 460, 300)
- sb.click(before.x, before.y)
- assert.equal(sel.length, 0)
- assert.deepEqual(h.locate('bea'), before)
- // der folgende Klick gilt wieder
- sb.click(before.x, before.y)
- assert.deepEqual(sel, ['bea'])
- // bis DRAG_PX Wanderung bleibt ein Klick
- sb.pointer('pointerdown', before.x, before.y)
- sb.pointer('pointermove', before.x + sb.engine.DRAG_PX, before.y)
- sb.pointer('pointerup', before.x + sb.engine.DRAG_PX, before.y)
- sb.click(before.x, before.y)
- assert.deepEqual(sel, ['bea', 'bea'])
- // die rechte Maustaste startet kein Ziehen
- sb.pointer('pointerdown', 400, 300, { button: 2 })
- sb.pointer('pointermove', 460, 300)
- sb.click(before.x, before.y)
- assert.deepEqual(sel, ['bea', 'bea', 'bea'])
- h.destroy()
-})
-
-test('Zeiger-Erfassung beim Ziehen; ein Fehler dabei stört das Ziehen nicht', async () => {
- const { sb, h } = await open()
- h.setState(makeState())
- h.zoomBy(2)
- h.select('bea')
- const calls = []
- sb.canvas.setPointerCapture = (id) => calls.push(['set', id])
- sb.canvas.releasePointerCapture = (id) => calls.push(['release', id])
- sb.pointer('pointerdown', 400, 300)
- sb.pointer('pointercancel', 400, 300)
- assert.deepEqual(calls, [['set', 1], ['release', 1]])
- sb.canvas.setPointerCapture = () => { throw new Error('InvalidPointerId') }
- sb.canvas.releasePointerCapture = () => { throw new Error('InvalidPointerId') }
- const before = h.locate('bea')
- sb.pointer('pointerdown', 400, 300)
- sb.pointer('pointermove', 450, 300)
- sb.pointer('pointerup', 450, 300)
- assert.ok(Math.abs(h.locate('bea').x - (before.x + 50)) < 2)
- h.destroy()
-})
-
-test('select() von außen holt die Figur bei Zoom > 1 in die Mitte, der Klick selbst verschiebt die Ansicht nicht', async () => {
- const { sb, h } = await open()
- h.setState(makeState())
- h.zoomBy(3)
- h.select('rita')
- const mid = h.locate('rita')
- assert.ok(Math.abs(mid.x - 480) < 1 && Math.abs(mid.y - 384) < 1, JSON.stringify(mid))
- sb.pointer('pointerdown', 400, 300)
- sb.pointer('pointermove', 450, 300)
- sb.pointer('pointerup', 450, 300)
- sb.click(450, 300) // gilt als Ende des Ziehens
- const dragged = h.locate('rita')
- assert.ok(Math.abs(dragged.x - (mid.x + 50)) < 2)
- sel.length = 0
- sb.click(dragged.x, dragged.y)
- assert.deepEqual(sel, ['rita'])
- h.select('rita') // so antwortet die Seite auf onSelect
- assert.deepEqual(h.locate('rita'), dragged)
- h.select('rita') // jetzt von außen (Liste): in die Mitte
- const again = h.locate('rita')
- assert.ok(Math.abs(again.x - 480) < 1 && Math.abs(again.y - 384) < 1)
- h.destroy()
-})
-
-test('locate: null außerhalb der Ansicht und für Unbekanntes', async () => {
- const { h } = await open()
- h.setState(makeState())
- h.zoomBy(3)
- h.select('otto')
- assert.ok(h.locate('otto'))
- assert.equal(h.locate('arne'), null)
- for (const id of ['niemand', '__proto__', 'constructor', '', 42, null, undefined]) assert.equal(h.locate(id), null, String(id))
- h.destroy()
-})
-
-// ---------- Lebenszyklus und Sonderfälle ----------
-
-test('nach destroy ist jeder Aufruf wirkungslos', async () => {
- const { sb, h } = await open()
- h.setState(makeState())
- h.destroy()
- const n = sb.counts.fillRect
- const zoom = h.stats().zoom
- h.setState(makeState({ paused: true }))
- h.pushEvents([ev('fund', 'kim')])
- h.select('kim')
- h.zoomBy(2)
- h.resetView()
- h.resize()
- h.setPaused(true)
- h.setPaused(false)
- sb.click(100, 100)
- assert.equal(sb.counts.fillRect, n)
- assert.equal(h.stats().zoom, zoom)
- assert.equal(h.locate('kim'), null)
- assert.equal(sb.rafPending(), 0)
-})
-
-test('Standbild: das Aufblitzen des Tresors nach einem Deal bleibt nicht stehen', async () => {
- const { sb, h } = await open({ record: true }, { reducedMotion: true })
- h.setState(makeState())
- sb.rects.length = 0
- h.pushEvents([ev('deal', 'system'), ev('deal', 'system')])
- assert.ok(sb.rects.length > 100)
- assert.equal(sb.rects.filter((r) => r.fill === '#e8b730' && r.alpha > 0 && r.alpha < 0.5).length, 0)
- h.destroy()
-})
-
-test('setPaused(true) bestellt keinen Bildrückruf mehr, setPaused(false) genau einen', async () => {
- const { sb, h } = await open()
- assert.equal(sb.rafPending(), 1)
- h.setPaused(true)
- assert.equal(sb.rafPending(), 0)
- h.setPaused(true)
- h.setPaused(false)
- h.setPaused(false)
- assert.equal(sb.rafPending(), 1)
- h.destroy()
- assert.equal(sb.rafPending(), 0)
-})
-
-test('ohne wirksames caf läuft nach Pause und Fortsetzen nie eine zweite Schleife', async () => {
- const { sb, h } = await open({}, { caf: () => {} })
- h.setPaused(true)
- h.setPaused(false) // der alte Rückruf steht noch in der Warteschlange
- assert.equal(sb.rafPending(), 2)
- sb.tick(0.1)
- assert.equal(sb.rafPending(), 1)
- const f0 = h.stats().frames
- sb.tick(1)
- const f = h.stats().frames - f0
- assert.ok(f >= 28 && f <= 32, String(f))
- h.destroy()
-})
-
-test('setPaused im Standbild startet keine Schleife', async () => {
- const { sb, h } = await open({}, { reducedMotion: true })
- h.setPaused(true)
- h.setPaused(false)
- assert.equal(sb.rafPending(), 0)
- h.destroy()
-})
-
-test('ohne raf (Option und global) bleibt es beim Standbild; die Standardwerte werden erst beim Aufruf gelesen', async () => {
- const sb = await createEngineSandbox()
- const h = sb.mountTower(sb.canvas, { onSelect() {} })
- assert.ok(sb.counts.fillRect > 100)
- assert.equal(sb.rafPending(), 0)
- h.setState(makeState())
- h.destroy()
-})
-
-test('fontsReady erfüllt: danach wird einmal neu gezeichnet', async () => {
- const sb = await createEngineSandbox()
- const h = sb.mountTower(sb.canvas, sb.opts({ fontsReady: Promise.resolve() }))
- const n = sb.counts.fillRect
- await new Promise((r) => setImmediate(r))
- assert.ok(sb.counts.fillRect > n)
- h.destroy()
-})
-
-test('Canvas ohne 2D-Kontext: ein Handle, das nichts tut', async () => {
- const sb = await createEngineSandbox()
- sb.canvas.getContext = () => null
- const h = sb.mountTower(sb.canvas, sb.opts({}))
- h.setState(makeState())
- h.pushEvents([ev('fund', 'kim')])
- h.select('kim')
- h.zoomBy(2)
- h.resetView()
- h.resize()
- h.setPaused(true)
- assert.equal(h.locate('kim'), null)
- assert.equal(h.stats().frames, 0)
- assert.equal(h.debug().pending, 0)
- h.destroy()
- assert.equal(sb.counts.fillRect, 0)
- assert.equal(sb.rafPending(), 0)
- assert.equal(Object.values(sb.listeners).flat().length, 0)
-})
-
-test('debug() und stats(): Form', async () => {
- const { sb, h } = await open()
- h.setState(makeState({ connected: { kim: false } }))
- sb.tick(1)
- const d = h.debug()
- assert.equal(d.agents.length, 19)
- assert.deepEqual(Object.keys(d.agents[0]).sort(), ['floor', 'grey', 'id', 'pose', 'x', 'y'])
- assert.equal(d.agents.find((a) => a.id === 'kim').grey, true)
- assert.deepEqual([typeof d.pending, typeof d.lift, typeof d.fx], ['number', 'string', 'number'])
- const s = h.stats()
- assert.deepEqual(Object.keys(s).sort(), ['avgDrawMs', 'frames', 'zoom'])
- assert.ok(s.frames > 25)
- h.destroy()
-})
-
-test('flush(): Rückkehr in den Tab beruhigt die Regie (alles heim, Aufzug und Effekte leer) und zeichnet; nach destroy wirkungslos', async () => {
- const { sb, h } = await open()
- h.setState(makeState())
- const arne = () => h.debug().agents.find((a) => a.id === 'arne')
- const home = { x: arne().x, y: arne().y, floor: arne().floor }
- h.pushEvents([ev('uebergabe', 'arne', { to: 'bea' })])
- sb.tick(3)
- assert.ok(h.debug().pending > 0)
- h.setPaused(true)
- const n = sb.counts.fillRect
- h.setPaused(false)
- h.flush()
- assert.ok(sb.counts.fillRect > n)
- assert.equal(h.debug().pending, 0)
- assert.equal(h.debug().lift, 'idle')
- assert.equal(h.debug().fx, 0)
- assert.deepEqual({ x: arne().x, y: arne().y, floor: arne().floor }, home)
- h.pushEvents([ev('uebergabe', 'arne', { to: 'bea' })])
- assert.equal(h.debug().pending, 1)
- h.destroy()
- const m = sb.counts.fillRect
- h.flush()
- assert.equal(sb.counts.fillRect, m)
- assert.equal(h.debug().pending, 1)
-})
-
-test('flush() im Standbild zeichnet ein Standbild ohne Bildrückruf; ohne 2D-Kontext tut es nichts', async () => {
- const { sb, h } = await open({}, { reducedMotion: true })
- h.setState(makeState())
- const n = sb.counts.fillRect
- h.flush()
- assert.ok(sb.counts.fillRect > n)
- assert.equal(sb.rafPending(), 0)
- h.destroy()
- const bare = await createEngineSandbox()
- bare.canvas.getContext = () => null
- const g = bare.mountTower(bare.canvas, bare.opts({}))
- g.flush()
- assert.equal(bare.counts.fillRect, 0)
-})
diff --git a/tests/unit/workersSmoke.test.mjs b/tests/unit/workersSmoke.test.mjs
deleted file mode 100644
index 7de7ace..0000000
--- a/tests/unit/workersSmoke.test.mjs
+++ /dev/null
@@ -1,118 +0,0 @@
-import test from 'node:test'
-import assert from 'node:assert/strict'
-import { createEngineSandbox } from './helpers/engineVm.mjs'
-import { makeState, ev, IDS } from './helpers/workersFixtures.mjs'
-import { loadModule } from './load.mjs'
-
-// Rauchtest der Engine ohne Browser (Muster: entwuerfe/smoke.js): 19 Figuren sind anklickbar, 150 simulierte Sekunden
-// mit allen 26 Ereignisarten und einem Stoß von 120 Übergaben laufen ohne Wurf und beruhigen sich danach wieder.
-
-const { EVENT_KINDS } = await loadModule('src/lib/workersEvents.js')
-
-// Eine Art alle 5 s ab Sekunde 3, in dieser Reihenfolge: [Art, Arbeiter, Zusatzfelder]
-const PLAN = [
- ['gelesen', 'kim'],
- ['fund', 'rita'],
- ['doppelt', 'arne'],
- ['uebergabe', 'rita', { to: 'bea' }],
- ['bewertet', 'bea'],
- ['top3', 'bea'],
- ['botlead', 'arne'],
- ['entwurf', 'max'],
- ['pult', 'clara'],
- ['gesendet', 'max'],
- ['eingang', 'nora'],
- ['sortiert', 'nora'],
- ['bau_start', 'bob'],
- ['bau_fertig', 'bob'],
- ['pruefung_zurueck', 'paul', { to: 'bob', meta: { round: 1 } }],
- ['pruefung_ok', 'paul'],
- ['vorschau_link', 'max'],
- ['angebot', 'theo'],
- ['deal', 'system'],
- ['gesperrt', 'system'],
- ['budget_warnung', 'otto'],
- ['budget_stopp', 'otto'],
- ['pause', 'system'],
- ['fehler', 'ben'],
- ['auftrag', 'otto', { to: 'kim' }],
- ['leerlauf', 'pia'],
-]
-const FIRST_SEC = 3
-const EVERY_SEC = 5
-const BURST_SEC = 100
-const BURST_SIZE = 120
-
-// Die Ereignisse für Sekunde `sec`: höchstens eine Art aus dem Plan, ab Sekunde 100 zusätzlich der Stoß (einmalig)
-function scripted(sec) {
- const out = []
- const slot = (sec - FIRST_SEC) / EVERY_SEC
- if (Number.isInteger(slot) && slot >= 0 && slot < PLAN.length) {
- const [kind, who, extra] = PLAN[slot]
- out.push(ev(kind, who, extra))
- }
- if (sec === BURST_SEC) {
- for (let i = 0; i < BURST_SIZE; i++) out.push(ev('uebergabe', IDS[i % IDS.length], { to: IDS[(i + 4) % IDS.length] }))
- }
- return out
-}
-
-const OFF = { kim: false, pia: false, finn: false }
-
-test('alle 19 Worker sind anklickbar (Auswahl liefert die richtige workerId)', async () => {
- const sb = await createEngineSandbox()
- const picked = []
- const h = sb.mountTower(sb.canvas, sb.opts({ onSelect: (id) => picked.push(id) }))
- h.setState(makeState())
- for (const id of IDS) {
- const p = h.locate(id)
- assert.ok(p, id)
- sb.click(p.x, p.y)
- assert.equal(picked.at(-1), id)
- }
- assert.deepEqual([...new Set(picked)].sort(), [...IDS].sort())
- h.destroy()
-})
-
-test('150 s mit allen 26 Ereignisarten: kein Wurf, alle endlich und im Bild, danach ruhig', async () => {
- const sb = await createEngineSandbox()
- const h = sb.mountTower(sb.canvas, sb.opts({ onSelect() {} }))
- h.setState(makeState({ connected: OFF }))
- const fed = []
- for (let s = 0; s < 150; s++) {
- const evs = scripted(s)
- if (evs.length) {
- fed.push(...evs)
- h.pushEvents(evs)
- }
- if (s % 15 === 0) {
- h.setState(makeState({ connected: OFF, kpis: { pultOpen: s % 7, dealsMonth: 3 + Math.floor(s / 40), botLeads: 112 + s } }))
- }
- sb.tick(1)
- // alle zwei Sekunden ein Raster von Klicks über die ganze Leinwand
- if (s % 2 === 0) {
- for (let x = 10; x < 960; x += 40) {
- for (let y = 10; y < 768; y += 40) sb.click(x, y)
- }
- }
- }
- // der Plan deckt die geschlossene Liste vollständig ab, der Stoß kam genau einmal
- assert.equal(PLAN.length, 26)
- assert.deepEqual([...new Set(fed.map((e) => e.kind))].sort(), [...EVENT_KINDS].sort())
- assert.equal(fed.length, PLAN.length + BURST_SIZE)
- assert.equal(fed.filter((e) => e.kind === 'uebergabe').length, BURST_SIZE + 1)
- // danach 90 ruhige Sekunden: Warteschlangen, Aufzug und Effekte laufen leer
- for (let s = 0; s < 90; s++) sb.tick(1)
- const dbg = h.debug()
- assert.equal(dbg.pending, 0)
- assert.equal(dbg.lift, 'idle')
- assert.equal(dbg.fx, 0)
- for (const a of dbg.agents) {
- assert.ok(Number.isFinite(a.x) && Number.isFinite(a.y) && a.x > -30 && a.x < 350 && a.floor >= 0 && a.floor <= 9, a.id)
- }
- assert.ok(sb.counts.fillRect > 100000)
- assert.ok(sb.counts.fillText > 1000)
- assert.ok(sb.seenTexts.has('noch nicht angeschlossen'))
- h.destroy()
- assert.equal(Object.values(sb.listeners).flat().length, 0)
-})
diff --git a/tests/unit/workersSprites.test.mjs b/tests/unit/workersSprites.test.mjs
deleted file mode 100644
index 9d081fc..0000000
--- a/tests/unit/workersSprites.test.mjs
+++ /dev/null
@@ -1,189 +0,0 @@
-import test from 'node:test'
-import assert from 'node:assert/strict'
-import { loadModule } from './load.mjs'
-import { makeCtx } from './helpers/fakeCtx.mjs'
-
-// Engine-Grundlagen: Raster, Figuren mit Posen, Text, Sprechblasen
-const S = await loadModule('src/components/workers/engine/sprites.js')
-
-test('px: gerundete Kanten ohne Naht, Standardraster 3, Versatz, Mindestgröße', () => {
- let { ctx, rects } = makeCtx(); S.setGrid(ctx, { k: 3.4, ox: 0, oy: 0 })
- S.px(ctx, 0, 0, 1, 1, '#000'); S.px(ctx, 1, 0, 1, 1, '#111'); S.px(ctx, 2, 0, 1, 1, '#222')
- assert.equal(rects[0].x + rects[0].w, rects[1].x); assert.equal(rects[1].x + rects[1].w, rects[2].x)
- ;({ ctx, rects } = makeCtx()); S.px(ctx, 1, 2, 3, 4, '#fff'); assert.deepEqual([rects[0].x, rects[0].y, rects[0].w, rects[0].h], [3, 6, 9, 12])
- ;({ ctx, rects } = makeCtx()); S.setGrid(ctx, { k: 2, ox: -10, oy: 0 }); S.px(ctx, 5, 0, 0.1, 1, '#fff'); assert.equal(rects[0].x, 0); assert.ok(rects[0].w >= 1)
-})
-test('label: Schriftgröße wächst mit dem Raster', () => {
- const { ctx, texts } = makeCtx(); S.setGrid(ctx, { k: 6 }); S.label(ctx, 'Hallo', 1, 1, { size: 11 }); assert.match(texts[0].font, /22px/)
-})
-test('drawWorker: Trefferfläche', () => {
- const { ctx } = makeCtx()
- assert.deepEqual(S.drawWorker(ctx, 10, 20, { hat: 'haiku' }), { x: 10, y: 18, w: 12, h: 16 })
- assert.equal(S.drawWorker(ctx, 10, 20, { hat: 'haiku', carrying: true }).w, 14)
- assert.deepEqual(S.drawWorker(ctx, 10, 20, { hat: 'overseer', scale: 2 }), { x: 10, y: 16, w: 24, h: 32 })
-})
-test('drawWorker: Hutfarbe zeigt das Modell, Augen je Pose, Grau mit Alpha', () => {
- for (const hat of ['opus', 'sonnet', 'haiku', 'overseer']) { const { ctx, rects } = makeCtx(); S.drawWorker(ctx, 0, 0, { hat }); assert.ok(rects.some((r) => r.fill === S.HAT[hat][0]), hat) }
- const eyes = (pose) => { const { ctx, rects } = makeCtx(); S.drawWorker(ctx, 0, 0, { hat: 'haiku', pose }); return rects.filter((r) => r.fill === S.PAL.e).length }
- assert.equal(eyes('stand'), 4); assert.equal(eyes('sleep'), 2)
- for (const pose of ['stand', 'type', 'coffee', 'sleep']) { const { ctx, rects } = makeCtx(); S.drawWorker(ctx, 0, 0, { hat: 'sonnet', pose, t: 1.3 }); assert.ok(rects.length > 20, pose) }
- const g = makeCtx(); S.drawWorker(g.ctx, 0, 0, { hat: 'opus', grey: true }); assert.ok(g.alphas.includes(0.55)); assert.equal(g.counts.save, g.counts.restore)
-})
-test('drawBubble bleibt im Bild (Spitze nahe am linken und rechten Rand, Raster 3, Breite 960)', () => {
- const l = makeCtx(); S.drawBubble(l.ctx, 2, 30, 'Hallo'); assert.ok(Math.min(...l.rects.map((r) => r.x)) >= 0)
- const r = makeCtx(); S.drawBubble(r.ctx, 318, 30, 'Hallo'); assert.ok(Math.max(...r.rects.map((q) => q.x + q.w)) <= 960)
-})
-
-// ---- Ergänzungen: Verhalten, das die fünf Tests oben nicht berühren ----
-
-test('Raster gilt je Kontext (WeakMap, kein Feld am Kontext); ohne setGrid gilt k = 3', () => {
- const a = makeCtx()
- const b = makeCtx()
- S.setGrid(a.ctx, { k: 6, ox: 1, oy: 2 })
- S.px(a.ctx, 1, 1, 1, 1, '#000')
- S.px(b.ctx, 1, 1, 1, 1, '#000')
- assert.deepEqual([a.rects[0].x, a.rects[0].y, a.rects[0].w, a.rects[0].h], [7, 8, 6, 6])
- assert.deepEqual([b.rects[0].x, b.rects[0].y, b.rects[0].w, b.rects[0].h], [3, 3, 3, 3])
- assert.equal('wkGrid' in a.ctx, false)
-})
-test('tint: Alpha nur um die Fläche, save und restore ausgeglichen', () => {
- const { ctx, rects, alphas, counts } = makeCtx()
- S.tint(ctx, 0, 0, 2, 2, '#ff0000', 0.25)
- assert.deepEqual([rects.length, rects[0].fill, rects[0].w], [1, '#ff0000', 6])
- assert.deepEqual(alphas, [0.25])
- assert.deepEqual(counts, { save: 1, restore: 1 })
-})
-test('label: Zahl wird zu Text, Hintergrund folgt der Ausrichtung', () => {
- const n = makeCtx()
- S.label(n.ctx, 42, 1, 1)
- assert.equal(n.texts[0].text, '42')
- const c = makeCtx()
- S.label(c.ctx, 'abcd', 10, 5, { size: 12, align: 'center', bg: '#ffffff' })
- // Breite 4 * 7 = 28, Mitte bei 30: Kasten von 30 - 14 - 4 bis + 28 + 8, Höhe 12 + 5
- assert.deepEqual([c.rects[0].x, c.rects[0].y, c.rects[0].w, c.rects[0].h, c.rects[0].fill], [12, 13, 36, 17, '#ffffff'])
- assert.equal(c.texts[0].x, 30)
-})
-test('label: halo zeichnet erst einen hellen Rand (strokeText), dann den Text darüber; ohne halo kein Rand', () => {
- const h = makeCtx()
- S.setGrid(h.ctx, { k: 3 })
- S.label(h.ctx, 'Mail-Stube', 2, 1, { size: 10, color: '#445566', halo: '#ffffff' })
- assert.equal(h.strokes.length, 1)
- assert.deepEqual([h.strokes[0].text, h.strokes[0].style], ['Mail-Stube', '#ffffff'])
- assert.deepEqual([h.strokes[0].x, h.strokes[0].y], [h.texts[0].x, h.texts[0].y], 'Rand und Text an derselben Stelle')
- assert.equal(h.strokes[0].texts, 0, 'der Rand kommt vor dem Text')
- assert.ok(h.strokes[0].width >= 2, 'mindestens ein Geräte-Pixel Rand rundum')
- const big = makeCtx()
- S.setGrid(big.ctx, { k: 6 })
- S.label(big.ctx, 'Mail-Stube', 2, 1, { size: 10, halo: '#ffffff' })
- assert.ok(big.strokes[0].width > h.strokes[0].width, 'der Rand wächst mit dem Raster')
- const plain = makeCtx()
- S.label(plain.ctx, 'Mail-Stube', 2, 1, { size: 10 })
- assert.equal(plain.strokes.length, 0)
-})
-test('drawWorker: Schlafen zeigt ein z über dem Hut, das mit t steigt; stehen nicht', () => {
- const zs = (pose, t) => {
- const { ctx, rects } = makeCtx()
- S.drawWorker(ctx, 20, 40, { hat: 'haiku', pose, t })
- return rects.filter((r) => r.fill === S.PAL.mute)
- }
- const z0 = zs('sleep', 0)
- const z1 = zs('sleep', 0.5)
- assert.equal(z0.length, 3)
- assert.equal(zs('stand', 0).length, 0)
- assert.ok(z1[0].y < z0[0].y, 'steigt')
- assert.ok(Math.max(...z0.map((r) => r.y + r.h)) <= (40 - 2) * 3, 'über dem Hut')
-})
-test('drawWorker: Kaffee zeichnet eine 3x3-Tasse und einen flackernden Dampfpixel', () => {
- const run = (pose, t) => {
- const { ctx, rects } = makeCtx()
- S.drawWorker(ctx, 0, 0, { hat: 'sonnet', pose, t })
- return rects
- }
- const cups = (rects) => rects.filter((r) => r.fill === S.PAL.metal2 && r.w === 9 && r.h === 9).length
- const steam = (rects) => rects.filter((r) => r.fill === S.PAL.g).length
- assert.equal(cups(run('coffee', 0)), 1)
- assert.equal(cups(run('stand', 0)), 0)
- assert.deepEqual([steam(run('coffee', 0)), steam(run('coffee', 0.4)), steam(run('stand', 0))], [1, 0, 0])
-})
-test('drawWorker: Grau tauscht Körper- und Hutfarben, Trefferfläche bleibt', () => {
- const grey = makeCtx()
- const hit = S.drawWorker(grey.ctx, 10, 20, { hat: 'opus', grey: true, carrying: true })
- assert.deepEqual(hit, S.drawWorker(makeCtx().ctx, 10, 20, { hat: 'opus', carrying: true }))
- const own = [S.PAL.b, S.PAL.s, S.PAL.h, S.HAT.opus[0], S.HAT.opus[1]]
- assert.ok(!grey.rects.some((r) => own.includes(r.fill)))
- assert.deepEqual([grey.alphas, grey.counts], [[0.55], { save: 1, restore: 1 }])
-})
-test('drawWorker: Auswahlpfeil nur bei selected, außerhalb der Graustufe', () => {
- const ink = (opts) => {
- const { ctx, rects, alphas } = makeCtx()
- S.drawWorker(ctx, 0, 0, { hat: 'haiku', ...opts })
- return [rects.filter((r) => r.fill === S.PAL.ink).length, alphas.length]
- }
- assert.deepEqual(ink({}), [0, 0])
- assert.deepEqual(ink({ selected: true }), [9, 0])
- assert.deepEqual(ink({ selected: true, grey: true }), [9, 1])
-})
-test('drawAlertMark: rotes 3x7-Ausrufezeichen mit Umriss, unten mittig bei (x, y)', () => {
- const { ctx, rects } = makeCtx()
- S.drawAlertMark(ctx, 50, 30)
- assert.deepEqual([...new Set(rects.map((r) => r.fill))].sort(), [S.PAL.o, S.PAL.red].sort())
- const red = rects.filter((r) => r.fill === S.PAL.red)
- const left = Math.min(...red.map((r) => r.x))
- const right = Math.max(...red.map((r) => r.x + r.w))
- const top = Math.min(...red.map((r) => r.y))
- const bottom = Math.max(...red.map((r) => r.y + r.h))
- assert.deepEqual([right - left, bottom - top], [9, 21])
- assert.ok(Math.abs((left + right) / 2 - 150) <= 1, 'mittig')
- assert.equal(Math.max(...rects.map((r) => r.y + r.h)), 90)
-})
-test('drawBubble: Text und Maße folgen dem Raster, leerer Text zeichnet nichts, Spitze außerhalb bleibt im Bild', () => {
- const a = makeCtx()
- S.drawBubble(a.ctx, 50, 40, 'Hallo')
- assert.deepEqual([a.texts.length, a.texts[0].text], [1, 'Hallo'])
- assert.match(a.texts[0].font, /12px/)
- const b = makeCtx()
- S.setGrid(b.ctx, { k: 6 })
- S.drawBubble(b.ctx, 50, 40, 'Hallo')
- assert.match(b.texts[0].font, /24px/)
- const e = makeCtx()
- S.drawBubble(e.ctx, 50, 40, '')
- assert.equal(e.rects.length, 0)
- const o = makeCtx()
- S.drawBubble(o.ctx, 330, 30, 'Hallo')
- assert.ok(Math.max(...o.rects.map((q) => q.x + q.w)) <= 960)
- const small = makeCtx({ width: 100 })
- 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)
-})