orbit/dev/actionbar.test.mjs

305 lines
12 KiB
JavaScript

/**
* ActionBar test (dev tool, run with Node — no browser needed):
*
* node dev/actionbar.test.mjs
*
* The command deck's PER-SLOT lock (js/ui/ActionBar.js → setSlotEnabled),
* the "shop is up" state: every slot but SHOP goes standby (dim, inert —
* no hover, no press, clicks swallowed) and only LEAVE SHOP is the live
* button on the deck. Verified:
*
* - CONSTRUCT — the surface deck builds all six live slots.
* - SLOT LOCK — setSlotEnabled(id, false) arms the slot inert (input
* disabled, press blocked, standby paint) and (id, true) re-arms it.
* - DECK LOCK — the SurfaceScene recipe: lock everything but 'shop' →
* only the shop slot fires onAction; unlock brings the rest back.
* - STANDBY PRIORITY — the deck-wide comm hold (setEnabled(false))
* owns input: a per-slot unlock can't re-open a slot underneath it.
* - LABEL + LOCK — relabeling a locked slot keeps its standby paint.
*
* A fake Scene provides the add.* factories + tweens/time as no-ops; the
* phaser stub (dev/phaser-stub.mjs) provides the Container the bar
* extends.
*/
process.env.NODE_ENV = 'dev';
import { pathToFileURL } from 'node:url';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import assert from 'node:assert/strict';
import './phaser-loader.mjs'; // ../vendor/phaser.js -> ./phaser-stub.mjs (Node only)
const __dirname = dirname(fileURLToPath(import.meta.url));
// --- Load the real config (data/*.json) into the config singleton ------
const { config } = await import(pathToFileURL(join(__dirname, '../js/config/Config.js')).href);
const fs = await import('node:fs');
const dataDir = join(__dirname, '../data');
const configData = {};
for (const f of fs.readdirSync(dataDir)) {
if (!f.endsWith('.json') || f === 'manifest.json') continue;
configData[f.replace(/\.json$/i, '')] = JSON.parse(fs.readFileSync(join(dataDir, f), 'utf8'));
}
config.init(configData);
const Phaser = (await import(pathToFileURL(join(__dirname, '../js/vendor/phaser.js')).href)).default;
const { ActionBar } = await import(pathToFileURL(join(__dirname, '../js/ui/ActionBar.js')).href);
const SURFACE = config.get('actionbar.surface.buttons');
assert.ok(Array.isArray(SURFACE) && SURFACE.length >= 6, 'surface deck has six slots');
const IDS = SURFACE.map((b) => b.id).filter(Boolean);
assert.ok(IDS.includes('shop') && IDS.includes('menu'), 'surface deck has the shop + menu slots');
// ---------------------------------------------------------------------------
// A headless Scene: just enough for the bar to CONSTRUCT + drive the slot
// state machine. `setInteractive`/`disableInteractive` on the slot panels
// record the input state, so the test can assert which slots are armed.
// ---------------------------------------------------------------------------
function stubObject(extra = {}) {
const o = {
x: 0, y: 0, width: 60, height: 18, alpha: 1,
setOrigin() { return this; },
setAlpha(a) { this.alpha = a; return this; },
setScale() { return this; },
setTint() { return this; },
setPosition(x, y) { this.x = x; this.y = y; return this; },
setX(v) { this.x = v; return this; },
setY(v) { this.y = v; return this; },
setStrokeStyle() { return this; },
setSize(w, h) { this.width = w; this.height = h; return this; },
setBlendMode() { return this; },
setScrollFactor() { return this; },
setDepth() { return this; },
setInteractive(opts) { this._interactive = opts; return this; },
disableInteractive() { this._interactive = null; return this; },
setDisplaySize() { return this; },
on() { return this; },
once() { return this; },
off() { return this; },
setText(s) { this.text = String(s); return this; },
setColor(c) { this.color = c; return this; },
setFontSize() { return this; },
clear() { return this; },
fillStyle() { return this; },
lineStyle() { return this; },
fillPoints() { return this; },
strokePoints() { return this; },
fillRect() { return this; },
strokeRect() { return this; },
lineBetween() { return this; },
destroy() { this.destroyed = true; },
...extra,
};
return o;
}
function makeScene() {
const scene = {
scale: { width: 1280, height: 720 },
add: {
existing: (o) => o,
text: (x, y, str) => stubObject({ x, y, text: String(str) }),
image: (x, y, key) => stubObject({ x, y, key }),
graphics: () => stubObject({}),
rectangle: (x, y, w, h) => stubObject({ x, y, width: w, height: h }),
tileSprite: (x, y, w, h) => stubObject({ x, y, width: w, height: h }),
circle: (x, y, r) => stubObject({ x, y, radius: r }),
},
tweens: {
add: (t) => {
// Apply the target values immediately (the real tween eases to the
// same place) so hover punches / boot flicker end at their finals.
const apply = (o) => { for (const k of ['x', 'y', 'scale', 'alpha']) if (typeof t?.[k] === 'number' && o) o[k] = t[k]; };
if (Array.isArray(t?.targets)) t.targets.forEach(apply);
else if (t?.targets) apply(t.targets);
return { destroy() {}, stop() {} };
},
killTweensOf() {},
},
time: {
now: 1000,
// Run the callback immediately: press()'s 130 ms press-restore and
// boot()'s end-of-boot burst are the only scheduled calls — both are
// no-op-safe, and running them keeps the state machine honest.
delayedCall: (_ms, fn) => { if (typeof fn === 'function') fn(); return { stop() {} }; },
},
textures: { exists: () => true }, // canvasTexture caches → no DOM canvas
};
return scene;
}
function makeBar() {
const pressed = [];
const scene = makeScene();
const bar = new ActionBar(scene, {
onAction: (id) => pressed.push(id),
buttons: SURFACE,
});
return { scene, bar, pressed };
}
const slot = (bar, id) => bar.slots.find((s) => s.id === id);
const armed = (s) => Boolean(s.panel._interactive);
// ===========================================================================
console.log('== CONSTRUCT — the surface deck builds all six live slots ==');
{
const { bar } = makeBar();
assert.equal(bar.slots.length, 6, 'six slots built');
for (const id of IDS) {
const s = slot(bar, id);
assert.ok(s, `slot '${id}' exists`);
assert.ok(s.live, `slot '${id}' is live`);
assert.ok(armed(s), `slot '${id}' starts armed (interactive)`);
assert.equal(s.disabled, undefined, `slot '${id}' is not locked at construction`);
}
}
// ===========================================================================
console.log('== SLOT LOCK — setSlotEnabled arms/disarms one slot ==');
{
const { bar, pressed } = makeBar();
const s = slot(bar, 'build');
bar.setSlotEnabled('build', false);
assert.equal(s.disabled, true, 'the slot is flagged disabled');
assert.equal(armed(s), false, 'the slot is disarmed (input off)');
assert.equal(s.label.color, '#5a6478', 'the slot paints standby (grayed label)');
bar.press(s); // a stray click over the dead slot
assert.equal(pressed.length, 0, 'pressing a locked slot fires no onAction');
// Idempotent: a second lock is a no-op (no re-paint churn, state kept).
bar.setSlotEnabled('build', false);
assert.equal(s.disabled, true, 're-locking is a no-op');
bar.setSlotEnabled('build', true);
assert.equal(s.disabled, false, 'the slot is un-flagged');
assert.ok(armed(s), 'the slot is re-armed (input on)');
assert.notEqual(s.label.color, '#5a6478', 'the slot paints base again');
bar.press(s);
assert.deepEqual(pressed, ['build'], 'pressing an unlocked slot fires onAction');
// Unknown ids are a safe no-op.
assert.equal(bar.setSlotEnabled('nope', false), null, 'unknown id → null, no crash');
}
// ===========================================================================
console.log('== DECK LOCK — while the shop is up, only the shop slot is live ==');
{
const { bar, pressed } = makeBar();
// The SurfaceScene recipe: lock every live slot except 'shop'.
const lock = (locked) => {
for (const s of bar.slots) {
if (!s.live || s.id === 'shop') continue;
bar.setSlotEnabled(s.id, !locked);
}
};
lock(true);
assert.ok(armed(slot(bar, 'shop')), 'the SHOP slot stays armed (the way out)');
for (const id of IDS.filter((x) => x !== 'shop')) {
const s = slot(bar, id);
assert.equal(armed(s), false, `slot '${id}' is disarmed while the shop is up`);
}
// The deck's presses: only the shop slot can get through.
bar.press(slot(bar, 'build'));
bar.press(slot(bar, 'takeoff'));
bar.press(slot(bar, 'menu'));
assert.equal(pressed.length, 0, 'locked slots swallow the press');
bar.press(slot(bar, 'shop'));
assert.deepEqual(pressed, ['shop'], 'LEAVE SHOP still fires onAction');
lock(false);
for (const id of IDS.filter((x) => x !== 'shop')) {
assert.ok(armed(slot(bar, id)), `slot '${id}' is re-armed after unlock`);
}
pressed.length = 0;
bar.press(slot(bar, 'menu'));
assert.deepEqual(pressed, ['menu'], 'the deck is fully live again');
}
// ===========================================================================
console.log('== STANDBY PRIORITY — the comm hold owns input over a slot unlock ==');
{
const { bar } = makeBar();
const s = slot(bar, 'build');
bar.setEnabled(false); // the 'ack' comm's hold: the whole deck goes inert
for (const id of IDS) assert.equal(armed(slot(bar, id)), false, `slot '${id}' inert under deck standby`);
// A per-slot unlock must NOT re-open a slot underneath the deck hold.
bar.setSlotEnabled('build', true);
assert.equal(armed(s), false, 'the slot stays disarmed (deck standby wins)');
assert.equal(s.disabled, false, '…but its per-slot flag is cleared (the deck, not the slot, owns it)');
bar.setEnabled(true); // the comm releases
assert.ok(armed(s), 'the slot re-arms when the deck hold lifts');
for (const id of IDS) assert.ok(armed(slot(bar, id)), `slot '${id}' re-armed with the deck`);
}
// ===========================================================================
console.log('== LABEL + LOCK — relabeling a locked slot keeps the standby paint ==');
{
const { bar } = makeBar();
const s = slot(bar, 'menu');
bar.setSlotEnabled('menu', false);
bar.setLabel('menu', 'Menu 2');
assert.equal(s.label.text, 'MENU 2', 'the label text updates');
assert.equal(s.label.color, '#5a6478', 'the standby paint survives the relabel');
bar.setSlotEnabled('menu', true);
assert.notEqual(s.label.color, '#5a6478', 'unlocking paints the base label back');
}
// ===========================================================================
console.log('== HIGHLIGHT — the tutorial\u2019s pointing finger (setHighlight) ==');
{
const { bar } = makeBar();
// The rim is drawn by update() — count its stroke passes on the layer.
let strokes = 0;
bar.highlightG.strokePoints = (pts, close) => { strokes += 1; return bar.highlightG; };
assert.equal(bar.highlightId, null, 'no finger at construction');
bar.update(1000, 16);
assert.equal(strokes, 0, 'no rim with no highlight');
// 'scan' is a FLIGHT-deck id — on this surface deck it degrades to a clear.
bar.setHighlight('scan');
assert.equal(bar.highlightId, null, 'an unknown id (on this deck) is a safe no-op');
bar.update(1100, 16);
assert.equal(strokes, 0, '…and draws no rim');
bar.setHighlight('build');
assert.equal(bar.highlightId, 'build', 'setHighlight arms a live slot of the deck');
bar.update(1200, 16);
assert.ok(strokes >= 2, 'the rim drew (soft pass + sharp rim)');
assert.equal(bar.setHighlight('nope'), undefined, 'unknown id is a safe no-op');
assert.equal(bar.highlightId, null, '…and clears the finger');
const cleared = strokes;
bar.update(1300, 16);
assert.equal(strokes, cleared, 'no rim once cleared');
// The deck\u2019s standby (the 'ack' comm\u2019s hold) owns the screen: the rim
// hides — the ACKNOWLEDGE button is the only live control then.
bar.setHighlight('build');
bar.setEnabled(false);
const held = strokes;
bar.update(1400, 16);
assert.equal(strokes, held, 'the rim is hidden under the deck standby');
bar.setEnabled(true);
bar.update(1500, 16);
assert.ok(strokes > held, 'the rim returns when the hold lifts');
// A per-slot lock (deck-locked slot) also hides the rim.
bar.setSlotEnabled('build', false);
const locked = strokes;
bar.update(1600, 16);
assert.equal(strokes, locked, 'the rim is hidden over a locked slot');
bar.setSlotEnabled('build', true);
bar.setHighlight(null);
assert.equal(bar.highlightId, null, 'null clears it');
}
console.log('\nAll ActionBar checks passed ✔');