orbit/dev/mineral-hud.test.mjs

245 lines
11 KiB
JavaScript

/**
* Mineral HUD test (dev tool, run with Node — no browser needed):
*
* node --import ./dev/phaser-loader.mjs dev/mineral-hud.test.mjs
*
* Runs the REAL MineralHud component (js/ui/MineralHud.js — the
* upper-right hold readout that replaced the tether stack) against a
* stubbed scene, and asserts:
* - the corner layout (label right-anchored 16 px from the edge, the
* bar right-aligned under it — the old tether stack's spot);
* - set() animates the number counting up AND the bar filling with it
* (ONE tween drives both — number and bar can't drift apart);
* - a landing (value UP) ripples the cyan flash; a drop does not;
* - quick-succession landings: a superseded tween landing LATE can't
* snap the readout back to a stale value (the fragment staggers are
* ~70 ms vs a 420 ms count-up — they overlap in real play);
* - the fill turns amber (theme 'amber') at capacity;
* - clamping (overfull records, negatives), no-op on unchanged value,
* and clean teardown.
* The stub tweens run instantly by default (final values); the
* overlap scenario switches the harness to a manual clock.
*/
import { readdirSync, readFileSync } from 'node:fs';
import assert from 'node:assert/strict';
const { config } = await import('../js/config/Config.js');
const data = {};
for (const f of readdirSync(new URL('../data', import.meta.url))) {
if (f.endsWith('.json')) data[f.slice(0, -5)] = JSON.parse(readFileSync(new URL(`../data/${f}`, import.meta.url), 'utf8'));
}
config.init(data);
const { MineralHud } = await import('../js/ui/MineralHud.js');
// ---------------------------------------------------------------------------
// Stub scene — text with deterministic metrics, graphics that RECORD their
// rects/colors, tweens that log themselves (and run instantly, or on
// demand in defer mode).
// ---------------------------------------------------------------------------
class FakeText {
constructor(x, y, str) {
this.x = x;
this.y = y;
this.text = str;
this.width = str.length * 9; // deterministic fake metrics
this.height = 19;
this.origin = [0.5, 0.5];
this.scrollFactor = 1; // the component must pin UI to 0 explicitly
}
setText(s) { this.text = s; return this; }
setOrigin(o, v) { this.origin = [o, v]; return this; }
setScrollFactor(f) { this.scrollFactor = f; return this; }
setDepth() { return this; }
setPosition(x, y) { this.x = x; this.y = y; return this; }
destroy() { this.destroyed = true; return this; }
}
class FakeGraphics {
constructor() {
this.fills = []; // { color, x, y, w, h }
this.strokes = []; // { width, color, x, y, w, h }
this.alpha = 1;
this.scrollFactor = 1;
}
fillStyle(color, alpha) { this._fillColor = color; return this; }
fillRect(x, y, w, h) { this.fills.push({ color: this._fillColor, x, y, w, h }); return this; }
lineStyle(width, color) { this._stroke = { width, color }; return this; }
strokeRect(x, y, w, h) { this.strokes.push({ ...this._stroke, x, y, w, h }); return this; }
clear() { this.fills = []; return this; }
setAlpha(a) { this.alpha = a; return this; }
setScrollFactor(f) { this.scrollFactor = f; return this; }
setDepth() { return this; }
destroy() { this.destroyed = true; return this; }
}
function makeScene() {
const tweens = [];
const scene = {
scale: { width: 1280, height: 720 },
add: {
existing: (o) => o,
text: (x, y, str) => new FakeText(x, y, str),
graphics: () => new FakeGraphics(),
},
tweens: {
defer: false, // true = manual clock (the overlap scenario)
add(opts) {
const tw = {
opts,
run() {
const targets = Array.isArray(opts.targets) ? opts.targets : [opts.targets];
for (const tg of targets) {
for (const [k, v] of Object.entries(opts)) {
if (['targets', 'duration', 'ease', 'delay', 'onUpdate', 'onComplete'].includes(k)) continue;
tg[k] = v;
}
}
if (typeof opts.onUpdate === 'function') opts.onUpdate();
if (typeof opts.onComplete === 'function') opts.onComplete();
},
};
tweens.push(tw);
if (!scene.tweens.defer) tw.run();
return tw;
},
killTweensOf: () => {},
},
_tweens: tweens,
};
return scene;
}
// ---------------------------------------------------------------------------
// Layout: the old tether stack's corner (upper-right, 16 px pad), and
// screen-fixed (the camera scrolls the world — the HUD must not ride along)
// ---------------------------------------------------------------------------
{
const scene = makeScene();
const hud = new MineralHud(scene);
assert.equal(hud.label.x, 1280 - 16, 'label right-anchored 16 px from the edge');
assert.equal(hud.label.y, 16, 'label top-anchored 16 px from the top');
assert.deepEqual(hud.label.origin, [1, 0], 'label right/top origin');
const { barW } = hud;
assert.equal(hud.barX, 1280 - 16 - barW, 'bar right-aligned to the corner margin');
assert.equal(hud.barY, 16 + hud.label.height + 8, 'bar sits below the label (lineGap 8)');
assert.ok(hud.trackG.fills.length >= 1, 'the track is drawn');
assert.ok(hud.trackG.strokes.length >= 1, 'the track has its border stroke');
assert.equal(hud.trackG.fills[0].w, 220, 'track width = 220 (config)');
assert.equal(hud.trackG.fills[0].h, 8, 'track height = 8 (config)');
hud.set(0, 250); // the scene's first call: seed the capacity
assert.equal(hud.label.text, 'MINERALS 0 / 250', 'starts empty: 0 / capacity');
assert.equal(hud.fillG.fills.length, 0, 'no fill at zero');
assert.equal(hud.isFull, false);
// Screen-fixed: every scene-level layer pins to the screen (scrollFactor
// 0) — without this the HUD renders in WORLD space, thousands of px off.
assert.equal(hud.label.scrollFactor, 0, 'label is screen-fixed');
assert.equal(hud.trackG.scrollFactor, 0, 'track is screen-fixed');
assert.equal(hud.fillG.scrollFactor, 0, 'fill is screen-fixed');
assert.equal(hud.flashG.scrollFactor, 0, 'flash is screen-fixed');
}
// ---------------------------------------------------------------------------
// set(): number counts up, bar fills with it — ONE tween drives both
// ---------------------------------------------------------------------------
{
const scene = makeScene();
const hud = new MineralHud(scene);
const before = scene._tweens.length;
assert.equal(hud.set(37, 250), true, 'a change animates');
assert.equal(scene._tweens.length, before + 2, 'two tweens: the count-up and the flash');
const countUp = scene._tweens[before].opts;
assert.equal(countUp.v, 37, 'the number tween lands on 37');
assert.ok(Math.abs(countUp.f - 37 / 250) < 1e-9, 'the bar tween lands on 37/250 of the fill');
assert.equal(countUp.duration, 420, 'count-up duration = 420 (config)');
assert.equal(hud.label.text, 'MINERALS 37 / 250', 'the number readout follows');
const fill = hud.fillG.fills.at(-1);
assert.ok(Math.abs(fill.w - (hud.innerW * 37) / 250) < 1e-9, 'the bar fills left→right to the exact fraction');
assert.equal(fill.x, hud.barX + 1, 'the fill starts at the left inset (1 px inside the border)');
assert.equal(fill.color, 0x00e5ff, 'the live fill is theme neon');
const flash = scene._tweens[before + 1].opts;
assert.equal(flash.targets, hud.flashG, 'the flash tween rides the flash layer');
assert.equal(flash.alpha, 0, '… and fades the ripple to 0');
assert.equal(hud.isFull, false);
// A repeat of the same value is a NO-OP — no re-flash, no re-tween.
const n = scene._tweens.length;
assert.equal(hud.set(37, 250), false, 'unchanged → no animation');
assert.equal(scene._tweens.length, n, '… and nothing was queued');
}
// ---------------------------------------------------------------------------
// Quick-succession landings: a superseded tween must not write stale values
// (shatter fragments land ~70 ms apart vs the 420 ms count-up — the tweens
// OVERLAP in real play; the manual clock simulates the old one landing late)
// ---------------------------------------------------------------------------
{
const scene = makeScene();
scene.tweens.defer = true;
const hud = new MineralHud(scene);
assert.equal(hud.set(1, 250), true);
assert.equal(hud.set(2, 250), true);
// The log reads [countUp₁, flash₁, countUp₂, flash₂] — one pair per set().
const t1 = scene._tweens[0]; // the superseded count-up
const t2 = scene._tweens[2]; // the live count-up
// The engine finishes the NEW tween…
t2.run();
assert.equal(hud.label.text, 'MINERALS 2 / 250');
assert.ok(Math.abs(hud.fillG.fills.at(-1).w - (hud.innerW * 2) / 250) < 1e-9, '… at the settled value');
// …and the OLD one lands late. It must be a no-op (gen guard).
t1.run();
assert.equal(hud.label.text, 'MINERALS 2 / 250', 'a superseded tween can\'t snap the number back');
assert.ok(Math.abs(hud.fillG.fills.at(-1).w - (hud.innerW * 2) / 250) < 1e-9, '… nor the bar');
}
// ---------------------------------------------------------------------------
// Capacity: amber fill (theme 'amber'); drops don't flash; clamping
// ---------------------------------------------------------------------------
{
const scene = makeScene();
const hud = new MineralHud(scene);
hud.set(250, 250);
assert.equal(hud.isFull, true, 'at capacity the hold is FULL');
assert.equal(hud.fillG.fills.at(-1).color, 0xffc94d, '… and the fill turns theme amber');
assert.equal(hud.label.text, 'MINERALS 250 / 250');
assert.equal(hud.set(99999, 250), false, 'already full → an overfull record is a no-op');
assert.equal(hud.value, 250, '… the value stays clamped at the capacity');
assert.equal(hud.label.text, 'MINERALS 250 / 250', '… and the readout says so');
// A DROP (not a landing) animates but does NOT ripple the flash.
const n = scene._tweens.length;
hud.set(10, 250);
assert.equal(hud.value, 10);
assert.equal(scene._tweens.length, n + 1, 'only the count-up — no flash on a drop');
assert.equal(hud.fillG.fills.at(-1).color, 0x00e5ff, '… and the fill is back to neon (not full)');
assert.equal(hud.set(-5, 250), true);
assert.equal(hud.value, 0, 'a negative record clamps at zero');
assert.equal(hud.fillG.fills.length, 0, '… and the bar is empty (cleared, no fill rect)');
assert.equal(hud.isFull, false);
}
// ---------------------------------------------------------------------------
// Teardown
// ---------------------------------------------------------------------------
{
const scene = makeScene();
const hud = new MineralHud(scene);
hud.set(12, 250);
const { label, trackG, fillG, flashG } = hud;
hud.destroy();
assert.equal(label.destroyed, true);
assert.equal(trackG.destroyed, true);
assert.equal(fillG.destroyed, true);
assert.equal(flashG.destroyed, true);
}
console.log('layout: upper-right corner, screen-fixed (old tether spot): OK');
console.log('set(): count-up + bar fill (one tween, exact fraction): OK');
console.log('overlap: superseded tween is void (no stale snap-back): OK');
console.log('capacity: amber fill + clamping both ends: OK');
console.log('teardown: all layers destroyed: OK');
console.log('\nmineral-hud: all checks passed');