305 lines
12 KiB
JavaScript
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 ✔');
|