245 lines
11 KiB
JavaScript
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');
|