orbit/dev/jobboard.test.mjs

360 lines
17 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 sections — the standby plate while no
// postings stand).
// - LIVE — the postings seam renders the AVAILABLE JOBS rows (chip +
// title + issuer line + the payout), a row’s press selects it (the
// JOB DETAILS readout stands: title + issuer + the contract’s copy
// + the payout + the ACCEPT door), the ACCEPT press fires the
// onAccept seam, and the ledger’s seams repaint the states (the row
// shows CONTRACT ACCEPTED / CONTRACT COMPLETE; the door’s label
// follows, and its door closes once the contract is taken).
// - 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 live
// sections (the postings list + the contract readout) — the standby
// plate stands while no postings seam is wired (all categories
// OFFLINE — data/sidequests.json’s gate).
assert.equal(win.rightTitle.text, '', 'the title decodes in (empty until open)');
assert.equal(win.jobsSection.headTxt.text, 'AVAILABLE JOBS');
assert.ok(
win.jobsSection.content.some((t) => t.text && /NO CONTRACTS ON FILE/.test(t.text)),
'the standby plate stands (NO CONTRACTS ON FILE)',
);
assert.equal(win.detailSection.headTxt.text, 'JOB DETAILS');
assert.ok(
win.detailSection.content.some((t) => t.text && /NO CONTRACTS ON FILE/.test(t.text)),
'the detail stands on the same standby plate (no postings → nothing to select)',
);
// 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 sec of [win.jobsSection, win.detailSection]) {
for (const k of ['headTxt', 'bg']) {
assert.ok(win.children.includes(sec[k]), `${k} is a window child (depth 10, not scene-level depth 0)`);
}
for (const c of sec.content) {
assert.ok(win.children.includes(c), 'the section’s content is a window child too (depth 10)');
}
}
// 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 (the live board repaints off it after
// the scene’s accept) — a no-throw pass with the standby board.
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('== LIVE — the postings stand, the selection reads, the ACCEPT fires ==');
{
const scene = makeScene();
// The board’s seams — the GameScene/SurfaceScene shape (the ledger’s
// truth lives in the seams; the window just reads it back on repaint).
const accepted = new Set();
const completed = new Set();
const accepts = [];
const mkPost = (cat, title, n) => ({
id: `sq-test-${cat}-${n}`,
systemId: 'sys-free',
systemName: 'Free Reach',
faction: null,
category: cat,
label: cat.toUpperCase(),
accent: cat === 'patrol' ? '#00e5ff' : cat === 'delivery' ? '#ffc94d' : '#ff2d6f',
title,
issuer: 'The Tally',
description: `The ${cat} contract for the test board — the body stands in the readout.`,
reward: { credits: 40 + n * 10 },
objective: { type: cat },
});
const posts = [mkPost('patrol', 'Lane Watch', 1), mkPost('delivery', 'Dead Freight', 2), mkPost('bounty', 'Salt the Raiders', 3)];
const win = new JobBoardWindow(scene, {
planetName: 'Free Reach',
onClose: () => {},
postings: () => posts,
isAccepted: (id) => accepted.has(id),
isCompleted: (id) => completed.has(id),
onAccept: (id) => accepts.push(id),
});
// the list: three rows stand (three postings off the seam), and the
// feed’s status strip counts them (the data’s {n} live status).
assert.equal(win._rows.length, 3, 'three postings → three rows');
assert.ok(
win.jobsSection.content.some((t) => t.text && /LANE WATCH/.test(t.text)),
'the first row’s title stands',
);
assert.ok(
win.jobsSection.content.some((t) => t.text && /PATROL — THE TALLY/.test(t.text)),
'the first row’s issuer line stands (label — issuer)',
);
assert.ok(
win.jobsSection.content.some((t) => t.text && /^50 CR$/.test(t.text)),
'the first row’s payout stands (50 CR)',
);
assert.match(win.statusTxt.text, /3 CONTRACTS ON FILE/, 'the status strip counts the live postings');
// no selection yet — the detail stands on its SELECT A JOB plate.
assert.ok(
win.detailSection.content.some((t) => t.text && /SELECT A JOB/.test(t.text)),
'the detail waits for a selection',
);
// a row’s press selects it (the seam’s pointerdown fires headless):
// the detail’s readout stands — title + the ISSUER line + the body +
// the payout + the ACCEPT door (open state).
win._rows[0].hit.__on.pointerdown();
assert.equal(win.selectedId, posts[0].id, 'the row’s press lands the selection');
assert.ok(
win.detailSection.content.some((t) => t.text && t.text === 'LANE WATCH'),
'the detail’s title stands',
);
assert.ok(
win.detailSection.content.some((t) => t.text && /ISSUER — THE TALLY/.test(t.text)),
'the detail’s issuer line stands',
);
assert.ok(
win.detailSection.content.some((t) => t.text && /the patrol contract/i.test(t.text)),
'the contract’s body stands in the readout',
);
assert.ok(
win.detailSection.content.some((t) => t.text && /PAYOUT — 50 CR/.test(t.text)),
'the payout stands',
);
const doorTxt = win.detailSection.content[win.detailSection.content.length - 1];
assert.equal(doorTxt.text, 'ACCEPT CONTRACT', 'the door stands OPEN (the contract is not taken)');
assert.ok(win.detailSection.content[win.detailSection.content.length - 2].__on?.pointerdown, 'the door is interactive while open');
// the ACCEPT press fires the scene’s seam (the ledger lands there —
// the surface’s note is theirs, not the window’s).
win.detailSection.content[win.detailSection.content.length - 2].__on.pointerdown();
assert.deepEqual(accepts, [posts[0].id], 'the ACCEPT fired the onAccept seam with the job id');
// the ledger says ACCEPTED now (the seam’s truth) — the repaint
// (refresh() — the scene’s call after its accept) lands the states:
// the row shows the ledger’s mark, the door stands ACCEPTED and
// closed (no more pointerdown — a double-take is a no-op).
accepted.add(posts[0].id);
win.refresh();
assert.ok(
win.jobsSection.content.some((t) => t.text && /CONTRACT ACCEPTED/.test(t.text)),
'the taken row shows CONTRACT ACCEPTED (the ledger’s mark)');
const dTxt = win.detailSection.content[win.detailSection.content.length - 1];
assert.equal(dTxt.text, 'CONTRACT ACCEPTED', 'the door stands ACCEPTED after the repaint');
const dG = win.detailSection.content[win.detailSection.content.length - 2];
assert.equal(dG.__on?.pointerdown, undefined, 'the door is CLOSED once taken (no double-take)');
const again = [...accepts];
// a fresh open board for the same job (the ledger forgot — the seam
// says it’s open again) would re-fire; the point is the door’s state
// tracks the seams.
assert.deepEqual(accepts, again, 'nothing fired on its own');
// …and COMPLETE, when the type’s objective stands (the seam’s truth
// again — the window just reads it back).
completed.add(posts[0].id);
win.refresh();
assert.ok(
win.jobsSection.content.some((t) => t.text && /CONTRACT COMPLETE/.test(t.text)),
'the completed row shows CONTRACT COMPLETE');
assert.equal(win.detailSection.content[win.detailSection.content.length - 1].text, 'CONTRACT COMPLETE', 'the door stands COMPLETE');
// SEAM FAULT — a postings seam that throws stands the standby board
// (the console never crashes on a scene fault).
const broken = new JobBoardWindow(scene, { postings: () => { throw new Error('scene fault'); }, planetName: 'X' });
assert.ok(
broken.jobsSection.content.some((t) => t.text && /NO CONTRACTS ON FILE/.test(t.text)),
'a throwing postings seam stands the standby plate (degrade, not crash)');
assert.match(broken.statusTxt.text, /BOARD STANDBY/);
broken.destroy();
}
console.log('\nAll Job Board console checks passed ✔');