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) } })