orbit/dev/jobboard.test.mjs

216 lines
10 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Headless test for the JOB BOARD console (js/ui/JobBoardWindow.js) — the
// build screen's structure (the Explore drawer's Job Board slot), driven
// against a stub scene, like dev/explore.test.mjs's MenuSubBar section:
//
// - CONFIG — data/actionbar.json → surface.explore: the jobBoard item
// (label / labelActive / the isHomeSystem:false clause) and the
// `video` block (assets/videos/jobboard.mp4, 2:3).
// - BUILD — the window constructs headless: the top bar (meta + ✕ +
// rail), the left feed (video from the scene cache, scanlines, sweep
// band, status strip), the right column (the JOB BOARD title + the
// AVAILABLE JOBS / JOB DETAILS placeholder sections).
// - GEOMETRY — the right column sits right of the feed; the body runs
// to the window's bottom padding (nearly full-screen, like the build
// screen — the window is depth 80, above the HUD (60) + deck (50) +
// drawer (60)).
// - OPEN/CLOSE — openState transitions (closed → open → closing →
// closed), the title decodes in (ScrambleDecode settles on 'JOB
// BOARD'), the close door's ✕ fires the onClose seam (the scene's
// full leave), update() ticks (cursor, sweep, glitch, REC pulse)
// without throwing, and destroy() tears the feed down.
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);
// ===========================================================================
console.log('== CONFIG — the drawer item + the feed block ==');
{
const items = config.get('actionbar.surface.explore.items');
const job = items.find((x) => x.id === 'jobBoard');
assert.ok(job, 'the explore drawer has a jobBoard item');
assert.equal(job.label, 'Job Board', 'the slot reads JOB BOARD (static — it does not relabel)');
assert.equal(job.labelActive, undefined, 'no labelActive — the console’s ✕ is the way-out (the slot’s own press is the second door)');
assert.equal(job.where.isHomeSystem, false, 'every system EXCEPT the home (safe space)');
const video = config.get('actionbar.surface.explore.video');
assert.equal(video.file, 'assets/videos/jobboard.mp4', 'the feed file');
assert.deepEqual(video.aspect, [2, 3], 'a 2:3 portrait (the build feed’s shape)');
}
const { JobBoardWindow } = await import(
pathToFileURL(join(__dirname, '../js/ui/JobBoardWindow.js')).href
);
// --- The headless scene (dev/explore.test.mjs's stubObject recipe, plus
// --- the video + the few extra object methods the window reaches for).
function stubObject(extra = {}) {
return {
x: 0,
y: 0,
width: 60,
height: 18,
alpha: 1,
visible: true,
setOrigin() { return this; },
setVisible(v) { this.visible = v; return this; },
setAlpha(a) { this.alpha = a; return this; },
setScale() { return this; },
setTint() { return this; },
setBlendMode() { return this; },
setScrollFactor() { return this; },
setDepth() { return this; },
setPosition(x, y) { this.x = x; this.y = y; return this; },
setX(x) { this.x = x; return this; },
setY(y) { this.y = y; return this; },
setStrokeStyle() { return this; },
setSize(w, h) { this.width = w; this.height = h; return this; },
setDisplaySize(w, h) { this.displayWidth = w; this.displayHeight = h; return this; },
setInteractive() { return this; },
disableInteractive() { return this; },
on(ev, fn) { (this.__on ??= {})[ev] = fn; return this; },
once() { return this; },
off() { return this; },
// setText keeps .width fresh, like Phaser (the cursor tracks it).
setText(s) { this.text = String(s); this.width = Math.max(20, String(s).length * 7); return this; },
setColor(c) { this.color = c; return this; },
setFontSize() { return this; },
setFillStyle() { return this; },
fillStyle() { return this; },
lineStyle() { return this; },
fillPoints() { return this; },
strokePoints() { return this; },
fillRect() { return this; },
strokeRect() { return this; },
lineBetween() { return this; },
clear() { return this; },
destroy() { this.destroyed = true; },
...extra,
};
}
function makeScene() {
const video = stubObject({ key: JobBoardWindow.VIDEO_KEY, volume: 1, loop: false });
video.setVolume = (v) => { video.volume = v; return video; };
video.setLoop = (b) => { video.loop = b; return video; };
video.video = { muted: false, readyState: 0, videoWidth: 544, videoHeight: 800, width: 544, height: 800 };
video.stop = () => {};
return {
scale: { width: 1280, height: 720 },
add: {
existing: (o) => o,
text: (x, y, str) => stubObject({ x, y, text: String(str), width: Math.max(20, String(str).length * 7) }),
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 }),
video: (x, y, key) => { video.x = x; video.y = y; video.key = key; return video; },
},
cache: { video: { has: (key) => key === JobBoardWindow.VIDEO_KEY } },
tweens: {
add: (t) => {
if (typeof t?.onComplete === 'function') t.onComplete();
return { destroy() {}, stop() {} };
},
killTweensOf() {},
},
time: { now: 1000, delayedCall: (_ms, fn) => { if (typeof fn === 'function') fn(); return { stop() {} }; } },
textures: { exists: () => true, addCanvas() {} },
};
}
// ===========================================================================
console.log('== BUILD — the console constructs (top bar, feed, right column) ==');
{
const scene = makeScene();
const closed = [];
const win = new JobBoardWindow(scene, { planetName: 'Klaxoria', onClose: () => closed.push(1) });
assert.equal(win.openState, 'closed', 'starts closed');
assert.equal(win.isOpen, false, '…and not open');
assert.equal(win.depth, 80, 'depth 80 — the build screen’s: above the HUD (60) + deck (50) + drawer (60)');
// top bar: the meta line (the world’s name) + the ✕ plate + the rail.
assert.match(win.topMeta.text, /KLAXORIA/);
assert.match(win.topMeta.text, /SURFACE BOARD/);
assert.equal(win.closeTxt.text, '✕');
// the left feed: the video (muted, looping), the scanlines + sweep band,
// the status strip.
assert.ok(win.video, 'the feed video is created from the scene cache');
assert.equal(win.video.volume, 0, 'muted (the console’s requirement)');
assert.equal(win.video.loop, true, 'looping');
assert.equal(win.video.video.muted, true, '…and the native element is muted');
assert.ok(win.scan, 'scanlines over the feed');
assert.ok(win.sweepBand, 'the sweep band');
assert.match(win.statusTxt.text, /NO CONTRACTS ON FILE/);
// the right column: the title (decoded on open) + the two placeholder
// sections (the postings + the contract).
assert.equal(win.rightTitle.text, '', 'the title decodes in (empty until open)');
assert.equal(win.jobsSection.headTxt.text, 'AVAILABLE JOBS');
assert.match(win.jobsSection.mainTxt.text, /NO CONTRACTS ON FILE/);
assert.equal(win.detailSection.headTxt.text, 'JOB DETAILS');
assert.match(win.detailSection.mainTxt.text, /SELECT A JOB/);
// the section objects are CHILDREN of the window (which carries the
// depth-10 layer) — a scene-level object at depth 0 would hide behind
// the window's own opaque backdrop (caught in the headless render).
for (const k of ['headTxt', 'bg', 'mainTxt', 'subTxt']) {
assert.ok(win.children.includes(win.jobsSection[k]), `jobsSection.${k} is a window child (depth 10, not scene-level depth 0)`);
assert.ok(win.children.includes(win.detailSection[k]), `detailSection.${k} is a window child (depth 10, not scene-level depth 0)`);
}
// GEOMETRY: the right column is right of the feed, and the body stays
// above the deck + drawer band (the way-out lives there).
const g = win.geo;
assert.ok(g.rightX > g.leftX + g.leftW, 'the right column sits right of the feed');
assert.equal(g.bodyBottom, 720 - 10 - 14, 'the body runs to the window’s bottom padding — nearly full-screen, like the build screen');
assert.ok(g.detailPanelY + g.detailH <= g.bodyBottom + 1, 'the details panel fits the body');
assert.ok(g.availH >= 80, 'the postings section has room to grow into');
// OPEN: closed → open; the title decodes in and settles on 'JOB BOARD'.
win.open();
assert.ok(win.isOpen, 'open() counts as open (the guards hold)');
assert.equal(win.video.volume, 0, 'still muted after open');
for (let t = 1000; t <= 4000; t += 100) win.update(t);
assert.equal(win.rightTitle.text, 'JOB BOARD', 'the title has settled on JOB BOARD');
// the ambient life ticks without throwing (cursor, sweep, REC pulse).
for (let t = 4000; t <= 20000; t += 500) win.update(t);
// CLOSE: the ✕ door fires the scene seam (the full leave), then the
// close lands (the stub tween completes inline).
win.closeG.__on.pointerdown();
assert.equal(closed.length, 1, 'the ✕ fired the onClose seam (the scene’s full leave)');
win.close();
assert.equal(win.openState, 'closed', 'the close animation lands on closed');
assert.equal(win.isOpen, false, '…and it no longer counts as open');
// refresh() is the repaint seam — a no-op for now, no throw.
win.refresh();
// destroy() tears the feed down (no crash; the video is gone).
win.destroy();
assert.ok(!win.video, 'the feed video is released');
}
console.log('\nAll Job Board console checks passed ✔');