Files
tickte-system/tests/unit/workersFx.test.mjs
T
2026-10-18 02:10:37 +00:00

163 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
})