Hustler-Pixelwelt T11: alte helle Oberfläche und Kanvas-Engine entfernt, workers.css gekürzt, Zusicherungen in die neuen Tests übertragen
- gelöscht: components/workers/engine/* (10 Dateien), WorkersStage, WorkersBoard, WorkerCard, WorkerList, WorkersKpis, die neun Engine-Tests samt workersSmoke (211 Tests) und die Helfer engineVm.mjs und fakeCtx.mjs (nur die Engine-Tests brauchten sie) - workers.css (310 -> 60 Zeilen) behält nur Einstellungen-Dialog und Interessenten-Panel; @font-face für Pixelify Sans steht jetzt in hustler/schrift.css - workersComponents: kein innerHTML und kein Kanvas rekursiv unter components/workers/**, jeder Knopf mit type, Links nur aus geprüften Feldern (externe mit noopener), keine Anmeldung in den Bildschirmen, alte Dateien weg und nirgends mehr importiert - workersCss: jede wk-Klasse im Code hat eine Regel, keine Regel ohne Nutzer; workersFont: Pixelify Sans genau einmal in schrift.css - hustlerAssets: Pixelify Sans in schrift.css erlaubt; hustlerDialog: Link-Ziele nur aus safeLink, Knöpfe mit type; hustlerSeite: Laden/Fehler/bereit am selben Einhängepunkt Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
4be9750f36
commit
ba007a325a
35 files changed
+277
-6495
No files matched your search
@@ -1,179 +0,0 @@
|
||||
// vm-Sandbox für die Szene des Hustler-Hochhauses (Muster: entwuerfe/smoke.js, nur für Tests).
|
||||
// scene.js wird mit esbuild zu einem IIFE gebündelt und in einem vm-Kontext ohne window/document ausgeführt:
|
||||
// der Kontext kennt nur console, Math, Date und JSON. Canvas, Zeichenkontext und Bildtakt sind Attrappen.
|
||||
|
||||
import vm from 'node:vm'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import esbuild from 'esbuild'
|
||||
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../..')
|
||||
const SCENE = path.join(repoRoot, 'src/components/workers/engine/scene.js')
|
||||
const STEP_MS = 1000 / 60
|
||||
|
||||
// Das Bündel ist für alle Sandboxen gleich: einmal bauen
|
||||
let bundle = null
|
||||
function bundleScene() {
|
||||
bundle ??= esbuild
|
||||
.build({ entryPoints: [SCENE], bundle: true, format: 'iife', globalName: 'WKEngine', platform: 'neutral', write: false })
|
||||
.then((result) => result.outputFiles[0].text)
|
||||
return bundle
|
||||
}
|
||||
|
||||
/**
|
||||
* createEngineSandbox({ cssWidth = 960, dpr = 1, record = false })
|
||||
* → { mountTower, engine, canvas, counts, seenTexts, rects, texts, listeners, listenerOptions,
|
||||
* opts(extra), tick(seconds), jump(seconds), rafPending(), setCssWidth(w),
|
||||
* click(cssX, cssY), pointer(type, cssX, cssY, extra), wheel(cssX, cssY, deltaY, { ctrlKey }) }
|
||||
* counts: { fillRect, fillText } zählt nur (es werden keine Aufrufe gespeichert, sonst sprengen tausende Bilder den Speicher)
|
||||
* seenTexts: Set der verschiedenen gezeichneten Texte
|
||||
* rects/texts: nur mit record: true, jedes fillRect { n, x, y, w, h, fill, alpha } und jedes fillText { n, text, x, y, font }
|
||||
* (n = laufende Nummer über beide Listen, damit die Reihenfolge der Aufrufe vergleichbar ist; alpha folgt save/restore)
|
||||
* listeners: Typ -> Liste der Rückrufe am Canvas; listenerOptions: Map Rückruf -> Optionen von addEventListener
|
||||
* tick(s): rückt die Uhr in Schritten von 1000/60 ms vor und führt fällige raf-Rückrufe aus; jump(s) rückt die Uhr ohne Rückrufe
|
||||
* (ein Stillstand); click/pointer/wheel schicken Ereignisse in CSS-Pixeln relativ zur Leinwand (Rand 0, Breite cssWidth, Höhe 4/5)
|
||||
*/
|
||||
export async function createEngineSandbox({ cssWidth = 960, dpr = 1, record = false } = {}) {
|
||||
const code = await bundleScene()
|
||||
|
||||
const counts = { fillRect: 0, fillText: 0 }
|
||||
const seenTexts = new Set()
|
||||
const rects = []
|
||||
const texts = []
|
||||
const listeners = {}
|
||||
const listenerOptions = new Map()
|
||||
const alphaStack = []
|
||||
let seq = 0
|
||||
let width = cssWidth
|
||||
|
||||
const target = { fillStyle: '#000000', font: '10px sans-serif', globalAlpha: 1, textAlign: 'left', textBaseline: 'alphabetic' }
|
||||
const ctx = new Proxy(target, {
|
||||
get(t, prop) {
|
||||
if (typeof prop === 'symbol') return undefined
|
||||
if (prop === 'save') return () => alphaStack.push(t.globalAlpha)
|
||||
if (prop === 'restore') return () => { if (alphaStack.length > 0) t.globalAlpha = alphaStack.pop() }
|
||||
if (prop === 'measureText') return (text) => ({ width: String(text).length * 7 })
|
||||
if (prop === 'fillRect') {
|
||||
return (x, y, w, h) => {
|
||||
counts.fillRect += 1
|
||||
if (record) rects.push({ n: seq++, x, y, w, h, fill: t.fillStyle, alpha: t.globalAlpha })
|
||||
}
|
||||
}
|
||||
if (prop === 'fillText') {
|
||||
return (text, x, y) => {
|
||||
if (typeof text !== 'string') throw new Error('fillText mit Nicht-String: ' + text)
|
||||
counts.fillText += 1
|
||||
seenTexts.add(text)
|
||||
if (record) texts.push({ n: seq++, text, x, y, font: t.font })
|
||||
}
|
||||
}
|
||||
if (prop === 'createLinearGradient' || prop === 'createRadialGradient') return () => ({ addColorStop() {} })
|
||||
if (prop === 'getImageData') return () => ({ data: new Uint8ClampedArray(4) })
|
||||
if (prop in t) return t[prop]
|
||||
return () => {}
|
||||
},
|
||||
set(t, prop, value) {
|
||||
t[prop] = value
|
||||
return true
|
||||
},
|
||||
})
|
||||
|
||||
const canvas = {
|
||||
width: 300,
|
||||
height: 150,
|
||||
style: {},
|
||||
getContext: () => ctx,
|
||||
addEventListener(type, fn, options) {
|
||||
listeners[type] = [...(listeners[type] ?? []), fn]
|
||||
if (options !== undefined) listenerOptions.set(fn, options)
|
||||
},
|
||||
removeEventListener(type, fn) {
|
||||
listeners[type] = (listeners[type] ?? []).filter((f) => f !== fn)
|
||||
},
|
||||
getBoundingClientRect: () => ({ left: 0, top: 0, width, height: width * 0.8 }),
|
||||
}
|
||||
target.canvas = canvas
|
||||
|
||||
// Bildtakt: raf/caf führen Buch, now ist die Uhr der Sandbox
|
||||
let clock = 0
|
||||
let nextId = 0
|
||||
const queue = new Map()
|
||||
const raf = (cb) => {
|
||||
nextId += 1
|
||||
queue.set(nextId, cb)
|
||||
return nextId
|
||||
}
|
||||
const caf = (id) => {
|
||||
queue.delete(id)
|
||||
}
|
||||
const now = () => clock
|
||||
|
||||
function tick(seconds) {
|
||||
const steps = Math.round(seconds * 60)
|
||||
for (let i = 0; i < steps; i++) {
|
||||
clock += STEP_MS
|
||||
for (const id of [...queue.keys()]) {
|
||||
const cb = queue.get(id)
|
||||
if (!cb) continue
|
||||
queue.delete(id)
|
||||
cb(clock)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const sandbox = { console, Math, Date, JSON }
|
||||
vm.createContext(sandbox)
|
||||
vm.runInContext(code, sandbox, { filename: 'scene.bundle.js' })
|
||||
const engine = sandbox.WKEngine
|
||||
|
||||
const fire = (type, event) => {
|
||||
for (const fn of [...(listeners[type] ?? [])]) fn(event)
|
||||
}
|
||||
const makeEvent = (x, y, extra = {}) => ({
|
||||
clientX: x,
|
||||
clientY: y,
|
||||
button: 0,
|
||||
pointerId: 1,
|
||||
ctrlKey: false,
|
||||
deltaY: 0,
|
||||
deltaMode: 0,
|
||||
defaultPrevented: false,
|
||||
preventDefault() {
|
||||
this.defaultPrevented = true
|
||||
},
|
||||
stopPropagation() {},
|
||||
...extra,
|
||||
})
|
||||
|
||||
return {
|
||||
mountTower: engine.mountTower,
|
||||
engine,
|
||||
canvas,
|
||||
counts,
|
||||
seenTexts,
|
||||
rects,
|
||||
texts,
|
||||
listeners,
|
||||
listenerOptions,
|
||||
opts: (extra = {}) => ({ raf, caf, now, dpr, ...extra }),
|
||||
tick,
|
||||
jump(seconds) {
|
||||
clock += seconds * 1000
|
||||
},
|
||||
rafPending: () => queue.size,
|
||||
setCssWidth(w) {
|
||||
width = w
|
||||
},
|
||||
click(cssX, cssY) {
|
||||
fire('click', makeEvent(cssX, cssY))
|
||||
},
|
||||
pointer(type, cssX, cssY, extra = {}) {
|
||||
fire(type, makeEvent(cssX, cssY, extra))
|
||||
},
|
||||
wheel(cssX, cssY, deltaY, { ctrlKey = false } = {}) {
|
||||
const event = makeEvent(cssX, cssY, { deltaY, ctrlKey })
|
||||
fire('wheel', event)
|
||||
return event
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
// Aufzeichnender Canvas-Kontext für die Engine-Tests (Muster: Proxy aus entwuerfe/smoke.js).
|
||||
// fillRect/fillText werden mit dem aktuellen fillStyle/font notiert, alles Übrige ist ein No-op.
|
||||
|
||||
/**
|
||||
* makeCtx({ width, height }) → { ctx, rects, texts, strokes, counts, alphas }
|
||||
* rects: [{ x, y, w, h, fill }] jedes fillRect
|
||||
* texts: [{ text, x, y, font }] jedes fillText (Nicht-String wirft wie im Smoke-Test)
|
||||
* strokes: [{ text, x, y, style, width, rects, texts }] jedes strokeText (style/width = strokeStyle/lineWidth, rects/texts =
|
||||
* Anzahl der bis dahin gezeichneten fillRect/fillText: so ist die Reihenfolge prüfbar)
|
||||
* counts: { save, restore } Aufrufe von ctx.save()/ctx.restore()
|
||||
* alphas: [] jede Zuweisung an ctx.globalAlpha
|
||||
*/
|
||||
export function makeCtx({ width = 960, height = 768 } = {}) {
|
||||
const rects = []
|
||||
const texts = []
|
||||
const strokes = []
|
||||
const counts = { save: 0, restore: 0 }
|
||||
const alphas = []
|
||||
const target = { canvas: { width, height }, fillStyle: '#000000', font: '10px sans-serif', globalAlpha: 1 }
|
||||
const ctx = new Proxy(target, {
|
||||
get(t, prop) {
|
||||
if (typeof prop === 'symbol') return undefined
|
||||
if (prop === 'save') return () => { counts.save++ }
|
||||
if (prop === 'restore') return () => { counts.restore++ }
|
||||
if (prop === 'measureText') return (text) => ({ width: String(text).length * 7 })
|
||||
if (prop === 'fillRect') return (x, y, w, h) => { rects.push({ x, y, w, h, fill: t.fillStyle }) }
|
||||
if (prop === 'fillText') {
|
||||
return (text, x, y) => {
|
||||
if (typeof text !== 'string') throw new Error('fillText mit Nicht-String: ' + text)
|
||||
texts.push({ text, x, y, font: t.font })
|
||||
}
|
||||
}
|
||||
if (prop === 'strokeText') {
|
||||
return (text, x, y) => {
|
||||
if (typeof text !== 'string') throw new Error('strokeText mit Nicht-String: ' + text)
|
||||
strokes.push({ text, x, y, style: t.strokeStyle, width: t.lineWidth, rects: rects.length, texts: texts.length })
|
||||
}
|
||||
}
|
||||
if (prop === 'createLinearGradient' || prop === 'createRadialGradient') return () => ({ addColorStop() {} })
|
||||
if (prop === 'getImageData') return () => ({ data: new Uint8ClampedArray(4) })
|
||||
if (prop in t) return t[prop]
|
||||
return () => {}
|
||||
},
|
||||
set(t, prop, value) {
|
||||
if (prop === 'globalAlpha') alphas.push(value)
|
||||
t[prop] = value
|
||||
return true
|
||||
},
|
||||
})
|
||||
return { ctx, rects, texts, strokes, counts, alphas }
|
||||
}
|
||||
@@ -214,7 +214,8 @@ test('schrift.css bindet Press Start 2P selbst gehostet ein (Datei vorhanden, OF
|
||||
// lateinischer Schnitt aus @fontsource/press-start-2p@5.2.7 (files/press-start-2p-latin-400-normal.woff2): Ziffern, Umlaute, ß, € (kein U+2212)
|
||||
assert.equal(createHash('sha256').update(readFileSync(datei)).digest('hex'), 'afec86997fdaf54af1f59358fa2c1e2a0f1d04146edad18e5cd141d0384a7548')
|
||||
assert.ok(!/googleapis|gstatic/.test(schrift))
|
||||
assert.ok(!/Pixelify/i.test(schrift), 'Pixelify Sans ist schon in workers.css eingebunden, nicht doppelt')
|
||||
// Seit T11 steht auch Pixelify Sans hier (nicht mehr in workers.css): genau eine Einbindung; Datei und Lizenz prüft workersFont.test.mjs
|
||||
assert.equal((schrift.match(/font-family:\s*["']Pixelify Sans["']/g) ?? []).length, 1, 'Pixelify Sans genau einmal')
|
||||
const lizenz = lies(path.join(REPO, 'public/fonts/OFL-PressStart2P.txt'))
|
||||
assert.match(lizenz, /^Copyright 2012 The Press Start 2P Project Authors \(cody@zone38\.net\), with Reserved Font Name "Press Start 2P"\.?$/m)
|
||||
assert.match(lizenz, /SIL OPEN FONT LICENSE Version 1\.1/)
|
||||
|
||||
@@ -621,6 +621,19 @@ test('Komponente: kein innerHTML, kein CSS- oder Bild-Import, Links nur über sa
|
||||
}
|
||||
})
|
||||
|
||||
// Übernommen aus der Prüfung der früheren Worker-Karte (Review Focus 5, PF-08): Ziele nur aus dem safeLink-Ergebnis, extern mit noopener, jeder Knopf mit type.
|
||||
test('Komponente: jedes Link-Ziel (href, to) kommt aus dem safeLink-Ergebnis, externe Links mit rel noopener, jeder Knopf type="button"', () => {
|
||||
const quelle = fs.readFileSync(path.join(ROOT, 'src/components/workers/hochhaus/HustlerDialog.jsx'), 'utf8')
|
||||
const src = quelle.replace(/(^|\s)\/\/.*$/gm, '$1')
|
||||
assert.match(src, /safeLink\(/)
|
||||
assert.match(src, /<a href=\{safe\.href\} target="_blank" rel="noopener noreferrer">/); assert.match(src, /<Link to=\{safe\.href\}>/)
|
||||
assert.doesNotMatch(src, /\b(?:href|to)=\{(?!safe\.href\})/) // kein anderes Ziel, nie aus dem Ereignis selbst
|
||||
assert.equal((src.match(/<a\b/g) || []).length, 1); assert.equal((src.match(/<Link\b/g) || []).length, 1)
|
||||
const knoepfe = (src.match(/<button\b/g) || []).length
|
||||
assert.ok(knoepfe >= 4, 'Hintergrund, Schließen, ◂ und ▸')
|
||||
assert.equal((src.match(/<button type="button"/g) || []).length, knoepfe)
|
||||
})
|
||||
|
||||
test('hustler-detail.css: Kopfkommentar, alle Regeln unter .hk-hochhaus, alle Klassen mit hk-, Handy-Regeln unter 720 px', () => {
|
||||
const css = fs.readFileSync(path.join(ROOT, 'src/styles/hustler/hustler-detail.css'), 'utf8')
|
||||
assert.ok(css.trimStart().startsWith('/*'), 'Kopfkommentar')
|
||||
|
||||
@@ -66,7 +66,8 @@ async function hochhaus(feed, extra = {}) {
|
||||
const props = { api: { name: 'api' }, isAdmin: false, workers: {}, regieRef: { current: null }, onOpen() {}, ...extra, feed }
|
||||
const mounted = __mount(jsx(() => jsx(Hochhaus, props), {}))
|
||||
await settle()
|
||||
return { props, hh: globalThis.__hh, tree: () => mounted.tree(), end: () => mounted.unmount() }
|
||||
// setFeed: der Feed ändert sich zwischendurch (wie der Hook der Seite), das Hochhaus bleibt am selben Einhängepunkt
|
||||
return { props, hh: globalThis.__hh, tree: () => mounted.tree(), setFeed(next) { props.feed = { ...props.feed, ...next }; mounted.rerender() }, end: () => mounted.unmount() }
|
||||
}
|
||||
const counting = (n = { refresh: 0 }) => ({ n, refresh: () => { n.refresh += 1 } })
|
||||
const teil = (tree, name) => nodes(tree, (n) => n.props && n.props['data-teil'] === name)
|
||||
@@ -174,6 +175,28 @@ test('Hochhaus: ohne Zustand gilt auch ein geladen-Flag nicht als bereit (wie bi
|
||||
} finally { view.end() }
|
||||
})
|
||||
|
||||
// Frühere Prüfung des Boards (Hooks vor der frühen Rückgabe): Laden, Leerzustand und Welt wechseln am selben Einhängepunkt, die Auswahl überlebt Verbindungsfehler.
|
||||
test('Hochhaus: Laden -> Dienst nicht erreicht -> bereit -> Verbindungsfehler -> wieder gut, ohne Neueinhängen; die offene Detailansicht überlebt den Fehler', async () => {
|
||||
const c = counting()
|
||||
const view = await hochhaus({ state: null, error: null, loaded: false, refresh: c.refresh })
|
||||
try {
|
||||
assert.equal(byClass(view.tree(), 'hk-laden').length, 1)
|
||||
view.setFeed({ error: 'weg' })
|
||||
assert.equal(byClass(view.tree(), 'hk-leer').length, 1); assert.equal(byClass(view.tree(), 'hk-laden').length, 0)
|
||||
view.setFeed({ state: makeState(), error: null, loaded: true })
|
||||
assert.equal(teil(view.tree(), 'welt').length, 1); assert.equal(byClass(view.tree(), 'hk-leer').length, 0)
|
||||
view.hh.welt.onZeige('lea')
|
||||
await settle()
|
||||
assert.equal(teil(view.tree(), 'dialog').length, 1)
|
||||
view.setFeed({ error: 'Verbindung weg' })
|
||||
assert.equal(teil(view.tree(), 'welt').length, 1); assert.equal(teil(view.tree(), 'dialog').length, 1)
|
||||
assert.equal(view.hh.dialog.workerId, 'lea'); assert.equal(view.hh.welt.gesperrt, true)
|
||||
view.setFeed({ error: null })
|
||||
assert.equal(teil(view.tree(), 'welt').length, 1); assert.equal(view.hh.dialog.workerId, 'lea')
|
||||
assert.equal(c.n.refresh, 0)
|
||||
} finally { view.end() }
|
||||
})
|
||||
|
||||
// ---------- WorkersPage ----------
|
||||
|
||||
// Attrappen: alle Zustände und Aufrufe laufen über globalThis.__hk, das der Test je Lauf anlegt
|
||||
|
||||
@@ -1,119 +0,0 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { loadModule } from './load.mjs'
|
||||
|
||||
// Kamera: Zoom, Verschieben, Umkehr-Transformation, Figuren-Treffer (Ansicht = Welt/Zoom, Bruchteile 0..1)
|
||||
const C = await loadModule('src/components/workers/engine/camera.js')
|
||||
const close = (a, b) => Math.abs(a - b) < 1e-9
|
||||
|
||||
test('fitZoom: ab 560 px 1, darunter wachsend bis 2,2', () => {
|
||||
assert.equal(C.fitZoom(1280), 1)
|
||||
assert.equal(C.fitZoom(600), 1)
|
||||
assert.equal(C.fitZoom(560), 1)
|
||||
assert.ok(close(C.fitZoom(390), 560 / 390))
|
||||
assert.equal(C.fitZoom(200), 2.2)
|
||||
})
|
||||
|
||||
test('fitZoom: ungültige Breite gibt 1, nie NaN', () => {
|
||||
for (const w of [0, -5, NaN, undefined, null, Infinity]) assert.equal(C.fitZoom(w), 1, String(w))
|
||||
})
|
||||
|
||||
test('Zoomgrenzen und Startkamera', () => {
|
||||
assert.deepEqual([C.ZOOM_MIN, C.ZOOM_MAX], [1, 3])
|
||||
assert.deepEqual(C.createCamera(), { zoom: 1, x: 0, y: 0 })
|
||||
assert.notEqual(C.createCamera(), C.createCamera())
|
||||
})
|
||||
|
||||
test('zoomAt hält den Punkt unter dem Zeiger fest, klemmt Zoom und Rand', () => {
|
||||
const p0 = C.viewToWorld(C.createCamera(), 0.25, 0.75)
|
||||
const cam = C.zoomAt(C.createCamera(), 2, 0.25, 0.75)
|
||||
assert.equal(cam.zoom, 2)
|
||||
const p1 = C.viewToWorld(cam, 0.25, 0.75)
|
||||
assert.ok(Math.abs(p1.x - p0.x) < 1e-9 && Math.abs(p1.y - p0.y) < 1e-9)
|
||||
assert.equal(C.zoomAt(C.createCamera(), 10, 0.5, 0.5).zoom, 3)
|
||||
assert.equal(C.zoomAt(C.createCamera(), 0.1, 0.5, 0.5).zoom, 1)
|
||||
const edge = C.panBy({ zoom: 2, x: 0, y: 0 }, 5, 5)
|
||||
assert.deepEqual([edge.x, edge.y], [160, 128])
|
||||
const back = C.panBy({ zoom: 2, x: 10, y: 10 }, -5, -5)
|
||||
assert.deepEqual([back.x, back.y], [0, 0])
|
||||
})
|
||||
|
||||
test('viewToWorld und worldToView sind zueinander invers; centerOn', () => {
|
||||
const cam = { zoom: 2.5, x: 40, y: 60 }
|
||||
const w = C.viewToWorld(cam, 0.3, 0.6)
|
||||
const v = C.worldToView(cam, w.x, w.y)
|
||||
assert.ok(Math.abs(v.vx - 0.3) < 1e-9 && Math.abs(v.vy - 0.6) < 1e-9)
|
||||
const c = C.centerOn({ zoom: 2, x: 0, y: 0 }, 160, 128)
|
||||
assert.deepEqual([c.x, c.y], [80, 64])
|
||||
})
|
||||
|
||||
test('pickAgent: oberste Figur gewinnt, Polster vergrößert die Fläche, Treffer nur im Kasten', () => {
|
||||
const boxes = [{ id: 'a', box: { x: 10, y: 10, w: 12, h: 16 } }, { id: 'b', box: { x: 16, y: 10, w: 12, h: 16 } }]
|
||||
assert.equal(C.pickAgent(boxes, 18, 14), 'b')
|
||||
assert.equal(C.pickAgent(boxes, 11, 14), 'a')
|
||||
assert.equal(C.pickAgent(boxes, 40, 14), null)
|
||||
assert.equal(C.pickAgent(boxes, 30, 14, 0), null)
|
||||
assert.equal(C.pickAgent(boxes, 30, 14, 3), 'b')
|
||||
})
|
||||
|
||||
// ---- Ergänzungen: Verhalten, das die Tests oben nicht berühren ----
|
||||
|
||||
test('Sichtfeld ist Welt/Zoom: bei Zoom 1 gibt es nichts zu verschieben', () => {
|
||||
const cam = C.panBy(C.createCamera(), 0.4, -0.3)
|
||||
assert.deepEqual([cam.zoom, cam.x, cam.y], [1, 0, 0])
|
||||
assert.deepEqual(C.viewToWorld(C.createCamera(), 1, 1), { x: 320, y: 256 })
|
||||
assert.deepEqual(C.viewToWorld({ zoom: 2, x: 160, y: 128 }, 1, 1), { x: 320, y: 256 })
|
||||
})
|
||||
|
||||
test('panBy verschiebt in Bruchteilen der Ansicht und klemmt am Rand', () => {
|
||||
const cam = C.panBy({ zoom: 2, x: 40, y: 30 }, 0.25, 0.5)
|
||||
assert.deepEqual([cam.x, cam.y], [80, 94])
|
||||
})
|
||||
|
||||
test('zoomAt zurück auf 1 setzt die Kamera auf den Ursprung zurück (Rand wird geklemmt)', () => {
|
||||
const cam = C.zoomAt({ zoom: 3, x: 100, y: 80 }, 0.01, 0.5, 0.5)
|
||||
assert.deepEqual([cam.zoom, cam.x, cam.y], [1, 0, 0])
|
||||
})
|
||||
|
||||
test('zoomAt am Rand: der Punkt unter dem Zeiger bleibt, solange der Rand nicht klemmt', () => {
|
||||
const start = { zoom: 2, x: 80, y: 64 }
|
||||
const p0 = C.viewToWorld(start, 0.6, 0.4)
|
||||
const cam = C.zoomAt({ ...start }, 1.5, 0.6, 0.4)
|
||||
assert.equal(cam.zoom, 3)
|
||||
const p1 = C.viewToWorld(cam, 0.6, 0.4)
|
||||
assert.ok(close(p1.x, p0.x) && close(p1.y, p0.y))
|
||||
})
|
||||
|
||||
test('clampCamera klemmt Zoom und Lage, auch mit eigener Welt; Aufrufer bekommt dieselbe Kamera zurück', () => {
|
||||
const cam = { zoom: 5, x: -10, y: 900 }
|
||||
assert.equal(C.clampCamera(cam), cam)
|
||||
assert.deepEqual([cam.zoom, cam.x, cam.y], [3, 0, 256 - 256 / 3])
|
||||
const klein = C.clampCamera({ zoom: 2, x: 99, y: 99 }, { w: 100, h: 50 })
|
||||
assert.deepEqual([klein.x, klein.y], [50, 25])
|
||||
})
|
||||
|
||||
test('centerOn klemmt in den Ecken', () => {
|
||||
const c = C.centerOn({ zoom: 2, x: 80, y: 64 }, 0, 0)
|
||||
assert.deepEqual([c.x, c.y], [0, 0])
|
||||
const d = C.centerOn({ zoom: 2, x: 0, y: 0 }, 320, 256)
|
||||
assert.deepEqual([d.x, d.y], [160, 128])
|
||||
})
|
||||
|
||||
test('ungültige Eingaben (NaN, Infinity) machen die Kamera nie kaputt', () => {
|
||||
const a = C.zoomAt({ zoom: 2, x: 20, y: 10 }, NaN, 0.5, 0.5)
|
||||
assert.deepEqual([a.zoom, a.x, a.y], [2, 20, 10])
|
||||
const b = C.panBy({ zoom: 2, x: 20, y: 10 }, NaN, Infinity)
|
||||
assert.ok(Number.isFinite(b.x) && Number.isFinite(b.y))
|
||||
assert.equal(b.x, 20)
|
||||
const c = C.clampCamera({ zoom: NaN, x: NaN, y: NaN })
|
||||
assert.deepEqual([c.zoom, c.x, c.y], [1, 0, 0])
|
||||
})
|
||||
|
||||
test('pickAgent: leere Liste, kaputte Einträge und Rand des Kastens', () => {
|
||||
assert.equal(C.pickAgent([], 5, 5), null)
|
||||
assert.equal(C.pickAgent(undefined, 5, 5), null)
|
||||
const boxes = [{ id: 'a', box: { x: 10, y: 10, w: 12, h: 16 } }, { id: 'kaputt' }, null]
|
||||
assert.equal(C.pickAgent(boxes, 22, 26), 'a')
|
||||
assert.equal(C.pickAgent(boxes, 23, 26), null)
|
||||
assert.equal(C.pickAgent(boxes, 23, 26, 1), 'a')
|
||||
})
|
||||
@@ -1,29 +1,141 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync, readdirSync } from 'node:fs'
|
||||
import { existsSync, readFileSync, readdirSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
// Die React-Teile der Hustler-Seite sind unter Node nicht ladbar (JSX, Canvas, Router): geprüft wird der Quelltext
|
||||
// (Muster adminRoute.test.mjs). Pfade relativ zu src/components/workers/.
|
||||
const read = (p) => readFileSync(new URL('../../src/components/workers/' + p, import.meta.url), 'utf8')
|
||||
// Regeln für alle React-Teile der Hustler-Seite, geprüft am Quelltext (Muster adminRoute.test.mjs): src/components/workers/** mit hochhaus/, pult/ und keller/.
|
||||
// Das Verhalten der einzelnen Bildschirme prüfen die Komponententests (hustlerKopf, hustlerDialog, hustlerHochhausWelt, hustlerSeite, hustlerPult, hustlerKeller ...).
|
||||
// Seit T11 gibt es die alte helle Oberfläche und die Kanvas-Engine nicht mehr; ihre Zusicherungen sind in diese Tests gewandert (Tabelle im Übergabebericht von T11).
|
||||
const COMPONENTS = fileURLToPath(new URL('../../src/components/workers/', import.meta.url))
|
||||
const SRC = fileURLToPath(new URL('../../src/', import.meta.url))
|
||||
const read = (p) => readFileSync(path.join(COMPONENTS, p), 'utf8')
|
||||
const readSrc = (p) => readFileSync(path.join(SRC, p), 'utf8')
|
||||
const walk = (dir) => readdirSync(dir, { withFileTypes: true }).flatMap((e) => (e.isDirectory() ? walk(path.join(dir, e.name)) : [path.join(dir, e.name)]))
|
||||
const komponenten = () => walk(COMPONENTS).filter((f) => /\.(jsx?|mjs)$/.test(f))
|
||||
const rel = (f) => path.relative(COMPONENTS, f)
|
||||
// Kommentare weg: Block- und Zeilenkommentare (// nur am Zeilenanfang oder nach Leerraum, damit https:// in Zeichenketten bleibt)
|
||||
const ohneKommentare = (code) => code.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|\s)\/\/.*$/gm, '$1')
|
||||
|
||||
test('WorkerCard: Links nur über safeLink, extern mit rel noopener; deutsche Beschriftungen (Review Focus 5)', () => {
|
||||
const src = read('WorkerCard.jsx')
|
||||
assert.match(src, /safeLink\(/); assert.match(src, /rel="noopener noreferrer"/); assert.match(src, /target="_blank"/)
|
||||
for (const t of ['Macht gerade', 'Tokens heute', 'Kosten heute', 'Letzte Aktionen']) assert.ok(src.includes(t), t)
|
||||
assert.match(src, /MODEL_IDS/); assert.match(src, /wk-chip-/)
|
||||
// jeder href und jedes to kommt aus dem safeLink-Ergebnis, nie aus dem Ereignis selbst
|
||||
assert.match(src, /href=\{safe\.href\}/); assert.match(src, /to=\{safe\.href\}/)
|
||||
assert.doesNotMatch(src, /\b(?:href|to)=\{(?!safe\.href\})/)
|
||||
// Öffnende Tags `<name …>` samt Attributen. Klammern {…} und Zeichenketten werden übersprungen, damit das `=>` eines onClick das Tag nicht beendet.
|
||||
function oeffnendeTags(src, name) {
|
||||
const tags = []
|
||||
const re = new RegExp(`<${name}(?=[\\s/>])`, 'g')
|
||||
for (let m; (m = re.exec(src)) !== null;) {
|
||||
let tiefe = 0
|
||||
let quote = ''
|
||||
let i = m.index + m[0].length
|
||||
for (; i < src.length; i++) {
|
||||
const c = src[i]
|
||||
if (quote) {
|
||||
if (c === '\\') i += 1
|
||||
else if (c === quote) quote = ''
|
||||
continue
|
||||
}
|
||||
if (c === '"' || c === "'" || c === '`') quote = c
|
||||
else if (c === '{') tiefe += 1
|
||||
else if (c === '}') tiefe -= 1
|
||||
else if (c === '>' && tiefe === 0) break
|
||||
}
|
||||
tags.push(src.slice(m.index, i + 1))
|
||||
}
|
||||
return tags
|
||||
}
|
||||
|
||||
// Wert eines Attributs im Tag: Zeichenkette (mit Anführungszeichen) oder der Ausdruck in {…} ohne Klammern; null, wenn es das Attribut nicht gibt
|
||||
function attribut(tag, name) {
|
||||
const m = new RegExp(`(?<![\\w-])${name}=`).exec(tag)
|
||||
if (!m) return null
|
||||
const start = m.index + m[0].length
|
||||
if (tag[start] === '"') return tag.slice(start, tag.indexOf('"', start + 1) + 1)
|
||||
if (tag[start] !== '{') return null
|
||||
let tiefe = 0
|
||||
for (let i = start; i < tag.length; i++) {
|
||||
if (tag[i] === '{') tiefe += 1
|
||||
else if (tag[i] === '}') {
|
||||
tiefe -= 1
|
||||
if (tiefe === 0) return tag.slice(start + 1, i).trim()
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
test('kein innerHTML und keine Kanvas-Zeichnung in den Komponenten unter src/components/workers/** (rekursiv, Kommentare ignoriert)', () => {
|
||||
const dateien = komponenten()
|
||||
for (const dir of ['hochhaus', 'pult', 'keller']) assert.ok(dateien.some((f) => rel(f).startsWith(dir + path.sep)), `${dir}/ wird mitgelesen`)
|
||||
assert.ok(dateien.length >= 15, `nur ${dateien.length} Dateien gelesen`)
|
||||
for (const f of dateien) {
|
||||
const code = ohneKommentare(readFileSync(f, 'utf8'))
|
||||
assert.doesNotMatch(code, /dangerouslySetInnerHTML|innerHTML|outerHTML|insertAdjacentHTML|document\.write/, rel(f))
|
||||
assert.doesNotMatch(code, /<canvas\b|getContext\s*\(/, `${rel(f)}: die Pixelwelt ist DOM + CSS, kein Kanvas`)
|
||||
}
|
||||
})
|
||||
test('kein innerHTML in Komponenten und Engine', () => {
|
||||
for (const dir of ['', 'engine/']) for (const f of readdirSync(new URL('../../src/components/workers/' + dir, import.meta.url))) { if (!/\.(jsx|js)$/.test(f)) continue; const s = read(dir + f); assert.doesNotMatch(s, /dangerouslySetInnerHTML|innerHTML/, dir + f) }
|
||||
|
||||
// Frühere Prüfung des Boards: Rechte und Nutzer kommen als Props von der Seite (isAdmin, me), die Bildschirme greifen nie selbst auf Anmeldung oder Appwrite zu.
|
||||
test('Komponenten ohne Anmeldungsbezug: kein useAuth und kein appwrite unter src/components/workers/**', () => {
|
||||
for (const f of komponenten()) assert.doesNotMatch(ohneKommentare(readFileSync(f, 'utf8')), /useAuth|appwrite/i, rel(f))
|
||||
})
|
||||
test('WorkersKpis nutzt Stat und kpiTiles; WorkerList: Knopf je Worker mit data-wk-worker, aria-pressed und Statustext', () => {
|
||||
assert.match(read('WorkersKpis.jsx'), /kpiTiles\(/); assert.match(read('WorkersKpis.jsx'), /<Stat /)
|
||||
const l = read('WorkerList.jsx'); assert.match(l, /data-wk-worker=/); assert.match(l, /aria-pressed=/); assert.match(l, /aria-label=/); assert.match(l, /FLOOR_TITLES/)
|
||||
// die Liste enthält keine Links: ein Klick wählt nur aus
|
||||
assert.doesNotMatch(l, /<a[\s>]|<Link[\s>]|href=/)
|
||||
|
||||
test('die alte Oberfläche und die Kanvas-Engine sind weg, und nichts unter src/ verweist noch darauf (T11)', () => {
|
||||
for (const alt of ['engine', 'WorkersStage.jsx', 'WorkersBoard.jsx', 'WorkerCard.jsx', 'WorkerList.jsx', 'WorkersKpis.jsx']) assert.equal(existsSync(path.join(COMPONENTS, alt)), false, alt)
|
||||
const verweis = /['"][^'"\n]*(?:\/engine\/[\w.-]+|\/(?:WorkersStage|WorkersBoard|WorkerCard|WorkerList|WorkersKpis))(?:\.jsx?)?['"]/
|
||||
let gelesen = 0
|
||||
for (const f of walk(SRC).filter((x) => /\.jsx?$/.test(x))) {
|
||||
gelesen += 1
|
||||
assert.doesNotMatch(ohneKommentare(readFileSync(f, 'utf8')), verweis, path.relative(SRC, f))
|
||||
}
|
||||
assert.ok(gelesen > 100, `nur ${gelesen} Dateien gelesen`)
|
||||
})
|
||||
|
||||
// Frühere Prüfung der Bühne und des Boards (PF-08): der WOMS-Button hat keinen Standard-type, ein <button> ohne type schickt in einem Formular ab.
|
||||
test('jeder Knopf (<button>, <Button>) hat ein ausdrückliches type="button"; „submit“ gibt es nur im Formular der Einstellungen', () => {
|
||||
let anzahl = 0
|
||||
for (const f of komponenten().filter((x) => x.endsWith('.jsx'))) {
|
||||
const code = ohneKommentare(readFileSync(f, 'utf8'))
|
||||
for (const name of ['button', 'Button']) {
|
||||
const tags = oeffnendeTags(code, name)
|
||||
assert.equal(tags.length, (code.match(new RegExp(`<${name}(?=[\\s/>])`, 'g')) || []).length, `${rel(f)}: ${name}-Tags vollständig gelesen`)
|
||||
for (const tag of tags) {
|
||||
anzahl += 1
|
||||
const erlaubt = rel(f) === 'WorkersSettingsDialog.jsx' ? /\btype="(?:button|submit)"/ : /\btype="button"/
|
||||
assert.match(tag, erlaubt, `${rel(f)}: ${tag.replace(/\s+/g, ' ').slice(0, 90)}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
assert.ok(anzahl >= 35, `nur ${anzahl} Knöpfe gelesen`)
|
||||
// „submit“ kommt genau einmal vor: „Speichern“ im Formular
|
||||
assert.equal((ohneKommentare(read('WorkersSettingsDialog.jsx')).match(/type="submit"/g) || []).length, 1)
|
||||
})
|
||||
|
||||
// Frühere Prüfung der Worker-Karte (Review Focus 5): Ziele von Links kommen nur aus geprüften Feldern, externe öffnen mit noopener.
|
||||
test('Links: jedes Ziel (href, to) kommt aus einem geprüften Feld oder ist fest; externe Ziele öffnen im neuen Tab mit rel="noopener noreferrer"', () => {
|
||||
// safeLink (Spur im Dialog) · sourceHref/previewHref (workersPult, botleadsView, workersProspect über safeExternalUrl/safeHref/previewUrl) · mailtoHref · feste Pfade der Etagen
|
||||
const ERLAUBT = new Set(['safe.href', 'row.sourceHref', 'row.previewHref', 'view.sourceHref', 'e.sourceHref', 'mail', '`/hustler?tab=${link.tab}`'])
|
||||
let links = 0
|
||||
for (const f of komponenten().filter((x) => x.endsWith('.jsx'))) {
|
||||
const code = ohneKommentare(readFileSync(f, 'utf8'))
|
||||
for (const [name, ziel] of [['a', 'href'], ['Link', 'to']]) {
|
||||
for (const tag of oeffnendeTags(code, name)) {
|
||||
links += 1
|
||||
const wert = attribut(tag, ziel)
|
||||
assert.ok(wert !== null, `${rel(f)}: ${name} ohne ${ziel}: ${tag.slice(0, 80)}`)
|
||||
assert.ok(wert.startsWith('"') || ERLAUBT.has(wert), `${rel(f)}: ${ziel}={${wert}} ist kein geprüftes Ziel`)
|
||||
if (attribut(tag, 'target') === '"_blank"') assert.equal(attribut(tag, 'rel'), '"noopener noreferrer"', `${rel(f)}: ${tag.slice(0, 80)}`)
|
||||
if (wert.startsWith('"')) assert.doesNotMatch(wert, /javascript:|data:/i)
|
||||
}
|
||||
}
|
||||
}
|
||||
assert.ok(links >= 8, `nur ${links} Links gelesen`)
|
||||
// die Felder selbst entstehen in den Ansichten nur über die Prüfer
|
||||
assert.match(readSrc('lib/workersPult.js'), /sourceHref: freigabe \? null : safeExternalUrl\(item\.sourceUrl\)/)
|
||||
assert.match(readSrc('lib/workersPult.js'), /previewHref: freigabe \? previewUrl\(item\.sourceUrl\) : null/)
|
||||
assert.match(readSrc('lib/workersProspect.js'), /sourceHref: safeExternalUrl\(prospect\.sourceUrl\)/)
|
||||
assert.match(readSrc('lib/botleadsView.js'), /sourceHref: safeHref\(it\.sourceUrl\)/)
|
||||
assert.match(readSrc('lib/workersPult.js'), /return `mailto:\$\{to\}/)
|
||||
// die Etagen-Links des Hochhauses sind feste Pfade
|
||||
assert.match(read('hochhaus/HochhausWelt.jsx'), /tab: 'freigabe'[\s\S]*tab: 'botleads'/)
|
||||
})
|
||||
|
||||
test('Einstellungs-Dialog: Dialog-Baustein, Formularlogik, kein paused, Admin-Beschriftungen, Notbremsen-Block', () => {
|
||||
const s = read('WorkersSettingsDialog.jsx')
|
||||
assert.match(s, /from '\.\.\/ui\/Dialog'/); assert.match(s, /formToPatch\(/); assert.match(s, /validateForm\(/); assert.match(s, /api\.saveSettings\(/)
|
||||
@@ -35,57 +147,8 @@ test('Einstellungs-Dialog: Dialog-Baustein, Formularlogik, kein paused, Admin-Be
|
||||
assert.match(s, /api\.saveSettings\(\{ sendingHalted: false \}\)/); assert.match(s, /sendingHaltedReason/)
|
||||
assert.doesNotMatch(s, /formToPatch\([^)]*sendingHalted/)
|
||||
})
|
||||
test('WorkersStage: Aufräumen, Sichtbarkeit, Bewegungsreduktion', () => {
|
||||
const s = read('WorkersStage.jsx')
|
||||
assert.match(s, /\.destroy\(\)/); assert.match(s, /visibilitychange/); assert.match(s, /removeEventListener\('visibilitychange'/); assert.match(s, /prefers-reduced-motion: reduce/)
|
||||
assert.match(s, /ResizeObserver/); assert.match(s, /disconnect\(\)/); assert.match(s, /fontsReady/); assert.match(s, /forwardRef/)
|
||||
// Controller-Beschluss: Sichtbarkeit steuert die Szene (setPaused), beim Zurückkehren wird die Regie beruhigt (flush)
|
||||
assert.match(s, /setPaused\(/); assert.match(s, /\.flush\(\)/); assert.match(s, /mountTower\(/); assert.match(s, /engineKey/)
|
||||
assert.match(s, /className="wk-stage"/); assert.match(s, /className="wk-canvas"/); assert.match(s, /className="wk-zoom"/)
|
||||
assert.ok(s.includes('Pixel-Hochhaus: Etagen und Räume der Hustler. Auswahl auch über die Liste.'))
|
||||
for (const t of ['+', '−', 'Ganz']) assert.ok(s.includes(`>${t}<`), t)
|
||||
// jeder Knopf der Bühne ist ein type="button" (WOMS-Button hat keinen Standard-type, PF-08)
|
||||
assert.equal((s.match(/<Button\b/g) || []).length, (s.match(/<Button\b[^>]*type="button"/g) || []).length)
|
||||
})
|
||||
test('WorkersBoard: Zustände und Rechte (Review Focus 1)', () => {
|
||||
const s = read('WorkersBoard.jsx')
|
||||
assert.match(s, /Der Hustler-Dienst ist noch nicht erreichbar/); assert.match(s, /Verbindung gestört/); assert.match(s, /reduceEvents\(/); assert.match(s, /pauseLabel\(/)
|
||||
assert.match(s, /isAdmin &&/); assert.match(s, /stageRef\s*(\?\?|\|\|)/); assert.match(s, /useRef\(/); assert.doesNotMatch(s, /useAuth|appwrite/)
|
||||
// H4/A21: Notbremsen-Zeile für alle, Grund als Textknoten
|
||||
assert.match(s, /state\.sendingHalted/); assert.match(s, /state\.sendingHaltedReason/); assert.match(s, /className="msg err"/); assert.match(s, /role="alert"/)
|
||||
assert.ok(s.includes('Versand gestoppt: ')); assert.ok(s.includes('Ein Admin kann ihn in den Einstellungen freigeben.')); assert.ok(s.includes('Grund nicht angegeben'))
|
||||
assert.match(s, /onSaved=\{/); assert.doesNotMatch(s, /dangerouslySetInnerHTML|innerHTML/)
|
||||
// Controller-Beschluss T19: Tabelle nach workerId an Karte und Liste, Karte bekommt Eintrag und Spur, Zustand kommt aus indexWorkers
|
||||
assert.match(s, /indexWorkers\(/); assert.match(s, /useWorkersFeed\(/); assert.match(s, /useWorkerTrace\(/); assert.match(s, /api\.pause\(/)
|
||||
assert.match(s, /<WorkerCard\b[^>]*\bworker=\{/); assert.match(s, /<WorkerCard\b[^>]*\btrace=\{/); assert.match(s, /<WorkerList\b[^>]*\bworkers=\{workers\}/); assert.match(s, /<WorkersKpis\b/)
|
||||
// Links entstehen nur in der Karte über safeLink, das Board hat keine
|
||||
assert.doesNotMatch(s, /href=|<a[\s>]|<Link[\s>]/)
|
||||
assert.match(s, /className="wk-toolbar"/); assert.match(s, /className="wk-main"/); assert.match(s, /className="loading"/)
|
||||
assert.ok(s.includes('Angehalten: Es wird nichts gesendet und nichts gebaut.')); assert.ok(s.includes('Neu laden')); assert.ok(s.includes('Grund nicht angegeben'))
|
||||
// PF-08: jeder Knopf ein type="button" (WOMS-Button hat keinen Standard-type)
|
||||
assert.equal((s.match(/<Button\b/g) || []).length, (s.match(/<Button\b[^>]*type="button"/g) || []).length)
|
||||
})
|
||||
test('Karte und Board: Auswahl setzt den Fokus auf den Namen und holt die Karte ins Bild, Schließen gibt den Fokus zurück (Endprüfung P2, Handy/Tablet)', () => {
|
||||
const card = read('WorkerCard.jsx')
|
||||
// Namen fokussierbar machen (tabIndex -1), Fokus ohne eigenes Springen, danach gezielt an den oberen Rand holen, wenn der Kartenanfang nicht im Fenster liegt
|
||||
assert.match(card, /<h2 className="wk-name" ref=\{headingRef\} tabIndex=\{-1\}>/)
|
||||
assert.match(card, /ref=\{sectionRef\}/)
|
||||
assert.match(card, /heading\.focus\(\{ preventScroll: true \}\)/)
|
||||
assert.match(card, /section\.scrollIntoView\(\{ block: 'start' \}\)/)
|
||||
assert.match(card, /scrollMarginTop/)
|
||||
assert.match(card, /\}, \[workerId\]\)/)
|
||||
// Hooks stehen vor der frühen Rückgabe ohne Auswahl (Regeln der Hooks)
|
||||
assert.ok(card.indexOf('useEffect(() => {\n if (!workerId) return') > 0)
|
||||
assert.ok(card.indexOf('useEffect(() => {\n if (!workerId) return') < card.indexOf(' if (!workerId) {\n return ('))
|
||||
const board = read('WorkersBoard.jsx')
|
||||
assert.match(board, /const sideRef = useRef\(null\)/); assert.match(board, /<div className="wk-side" ref=\{sideRef\}>/)
|
||||
assert.match(board, /onClose=\{closeCard\}/); assert.match(board, /data-wk-worker/); assert.match(board, /button\.focus\(\)/)
|
||||
assert.doesNotMatch(board, /onClose=\{\(\) => setSelectedId\(null\)\}/)
|
||||
})
|
||||
|
||||
// ---- P3-T21: Abmelden setzt das JWT zurück, „Alles anhalten“ fragt vorher nach ----
|
||||
|
||||
const readSrc = (p) => readFileSync(new URL('../../src/' + p, import.meta.url), 'utf8')
|
||||
// ---- P3-T21: Abmelden setzt das JWT zurück ----
|
||||
|
||||
test('Abmelden: AuthContext ruft resetWorkersJwt() auf, workersClient setzt JWT-Cache und Sperre zurück (P3-T21)', () => {
|
||||
const auth = readSrc('context/AuthContext.jsx')
|
||||
@@ -101,50 +164,3 @@ test('Abmelden: AuthContext ruft resetWorkersJwt() auf, workersClient setzt JWT-
|
||||
const fn = client.slice(client.indexOf('export function resetWorkersJwt()'))
|
||||
assert.match(fn, /jwtCache\.invalidate\(\)/); assert.match(fn, /workersApi\.resetAuth\(\)/)
|
||||
})
|
||||
|
||||
test('WorkersBoard: „Alles anhalten“ öffnet erst einen Bestätigungsdialog, „Fortsetzen“ geht sofort (P3-T21)', () => {
|
||||
const s = read('WorkersBoard.jsx')
|
||||
assert.match(s, /import Dialog from '\.\.\/ui\/Dialog'/)
|
||||
assert.match(s, /<Dialog\b[^>]*title="Alles anhalten\?"/)
|
||||
assert.ok(s.includes('Die Worker hören sofort auf, bis jemand fortsetzt.'))
|
||||
// beide Knöpfe des Dialogs: type="button", Beschriftung „Anhalten“ und „Abbrechen“
|
||||
assert.match(s, /<Button type="button" onClick=\{[^}]*\}[^>]*>Abbrechen<\/Button>/)
|
||||
assert.match(s, /<Button variant="primary" type="button" onClick=\{confirmHalt\}[^>]*>Anhalten<\/Button>/)
|
||||
// Der Knopf der Leiste fragt nur beim Anhalten nach; Fortsetzen ruft den Dienst ohne Dialog
|
||||
assert.match(s, /<Button type="button" onClick=\{requestPause\}/)
|
||||
assert.match(s, /const requestPause = \(\) => \{\s*if \(paused\) setPause\(false\)\s*else setConfirmOpen\(true\)\s*\}/)
|
||||
// Bestätigen schließt den Dialog und hält an; der Zielwert steht fest (nicht !paused: sonst setzte ein veralteter Dialog die Arbeit wieder fort)
|
||||
assert.match(s, /const confirmHalt = \(\) => \{\s*setConfirmOpen\(false\)\s*setPause\(true\)\s*\}/)
|
||||
assert.match(s, /api\.pause\(target\)/); assert.doesNotMatch(s, /api\.pause\(!paused\)/)
|
||||
// der Dialog steht nur offen, solange nicht schon angehalten ist (hat jemand anderes angehalten, entfällt die Frage)
|
||||
assert.match(s, /\{confirmOpen && !paused && \(/)
|
||||
// und die Frage taucht nach einem späteren Fortsetzen nicht von selbst wieder auf
|
||||
assert.match(s, /useEffect\(\(\) => \{\s*if \(haltedByState\) setConfirmOpen\(false\)\s*\}, \[haltedByState\]\)/)
|
||||
// Hooks vor der frühen Rückgabe
|
||||
assert.ok(s.indexOf('useState(false)') < s.indexOf('if (!loaded || !state)') && s.indexOf('const [confirmOpen') < s.indexOf('if (!loaded || !state)'))
|
||||
// PF-08 gilt weiter: jeder Knopf ein type="button"
|
||||
assert.equal((s.match(/<Button\b/g) || []).length, (s.match(/<Button\b[^>]*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 <body>.
|
||||
// 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, /<div className="wk-toolbar" ref=\{toolbarRef\}>/)
|
||||
assert.match(s, /<Button type="button" onClick=\{requestPause\} disabled=\{pausing\} data-wk-pause>/)
|
||||
// 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 <body> 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\}/)
|
||||
})
|
||||
@@ -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(/(?<![\w-])wk-[a-z0-9-]*[a-z0-9](?![\w-])/g)) {
|
||||
if (!out.has(m[0])) out.set(m[0], new Set())
|
||||
out.get(m[0]).add(path.relative(srcDir, f))
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
test('workers.css: nur wk-Klassen, keine Hex-Farben, keine Schrift, Bilder oder Importe, ein Handy-Block', () => {
|
||||
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(/(?<![\w-])wk-[a-z0-9-]+/g)) assert.ok(css.includes('.' + m[0]), `${f}: ${m[0]}`) // Lookbehind: das Attribut data-wk-worker ist keine Klasse
|
||||
})
|
||||
test('workers.css: Zoomknöpfe unten rechts (oben läge die Reihe über dem Schild der Kommandobrücke), Meldungen halten Abstand zu den Kennzahlen', () => {
|
||||
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])
|
||||
})
|
||||
@@ -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
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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))
|
||||
})
|
||||
@@ -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))
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
Reference in new issue
Block a user