216 lines
10 KiB
JavaScript
216 lines
10 KiB
JavaScript
// 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 ✔');
|