/** * 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 ✔');