448 lines
22 KiB
JavaScript
448 lines
22 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 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 lines
|
||
// (destination / route) + the copy + the payout + the ACTION door),
|
||
// and the door walks the ledger’s states (ACCEPT → SET PATROL
|
||
// ACTIVE → the active plate → CLAIM REWARD → the claimed plate —
|
||
// each live door firing its seam, each plate standing closed), the
|
||
// row’s mark following (CONTRACT ACCEPTED → PATROL ACTIVE →
|
||
// CONTRACT COMPLETE → REWARD CLAIMED).
|
||
// - 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 stand (the window's postings seam is
|
||
// not wired in this section — the LIVE section drives it).
|
||
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 door walks the ledger ==');
|
||
{
|
||
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 active = new Set();
|
||
const completed = new Set();
|
||
const claimed = new Set();
|
||
const accepts = [];
|
||
const activates = [];
|
||
const claims = [];
|
||
const routeState = { visited: 0, total: 3, xp: 0 };
|
||
const mkPost = (cat, title, n, extra = {}) => ({
|
||
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 },
|
||
...extra,
|
||
});
|
||
// The patrol posts for a NEIGHBOR system (the push-out rule) — the
|
||
// detail’s DESTINATION line has something to say; the delivery/bounty
|
||
// stubs stand local (no target fields — their rules land later).
|
||
const posts = [
|
||
mkPost('patrol', 'Lane Watch', 1, { targetSystemId: 'sys-apex', targetSystemName: 'Apex Threshold' }),
|
||
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),
|
||
isActive: (id) => active.has(id),
|
||
isClaimed: (id) => claimed.has(id),
|
||
routeProgress: (id) => (id === posts[0].id && accepted.has(id) ? { visited: routeState.visited, total: routeState.total, xp: routeState.xp } : null),
|
||
onAccept: (id) => accepts.push(id),
|
||
onActivate: (id) => activates.push(id),
|
||
onClaim: (id) => claims.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
|
||
// DESTINATION line (the patrol’s route stands in ANOTHER system) +
|
||
// the body + the payout + the ACTION 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 && /DESTINATION — APEX THRESHOLD/.test(t.text)),
|
||
'the patrol’s DESTINATION line stands (the route’s system, named)',
|
||
);
|
||
assert.ok(
|
||
!win.detailSection.content.some((t) => t.text && /ROUTE —/.test(t.text)),
|
||
'no ROUTE line while the contract is not taken (no route to count)',
|
||
);
|
||
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 CONTRACT ACCEPTED, the door stands SET PATROL ACTIVE
|
||
// (live — it fires the onActivate seam), the ROUTE line stands (the
|
||
// scene’s routeProgress seam: 0 of 3 logged).
|
||
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)');
|
||
assert.ok(
|
||
win.detailSection.content.some((t) => t.text && /ROUTE — 0\/3 WAYPOINTS LOGGED/.test(t.text)),
|
||
'the ROUTE line stands off the routeProgress seam (0/3)');
|
||
let dTxt = win.detailSection.content[win.detailSection.content.length - 1];
|
||
assert.equal(dTxt.text, 'SET PATROL ACTIVE', 'the door stands SET ACTIVE after the accept');
|
||
let dG = win.detailSection.content[win.detailSection.content.length - 2];
|
||
assert.ok(dG.__on?.pointerdown, 'the door is live while accepted (the activate is a real press)');
|
||
dG.__on.pointerdown();
|
||
assert.deepEqual(activates, [posts[0].id], 'the activate press fired the onActivate seam');
|
||
|
||
// …the ledger says ACTIVE now — the door stands as the active PLATE
|
||
// (PATROL ACTIVE — nothing to press), the row’s mark follows.
|
||
active.add(posts[0].id);
|
||
win.refresh();
|
||
assert.ok(
|
||
win.jobsSection.content.some((t) => t.text && /PATROL ACTIVE/.test(t.text)),
|
||
'the active row shows PATROL ACTIVE (the ledger’s mark)');
|
||
dTxt = win.detailSection.content[win.detailSection.content.length - 1];
|
||
assert.equal(dTxt.text, 'PATROL ACTIVE', 'the door stands as the active plate');
|
||
dG = win.detailSection.content[win.detailSection.content.length - 2];
|
||
assert.equal(dG.__on?.pointerdown, undefined, 'the plate is closed (nothing to press while active)');
|
||
|
||
// …the route’s check-ins land (the scene’s seam advances) — the
|
||
// ROUTE line repaints off it.
|
||
routeState.visited = 1;
|
||
win.refresh();
|
||
assert.ok(
|
||
win.detailSection.content.some((t) => t.text && /ROUTE — 1\/3 WAYPOINTS LOGGED/.test(t.text)),
|
||
'the ROUTE line advances with the check-ins (1/3)');
|
||
|
||
// …and COMPLETE, when the type’s objective stands (the seam’s truth
|
||
// again — the window just reads it back): the door stands CLAIM
|
||
// REWARD (live — it fires the onClaim seam, the payout named).
|
||
active.delete(posts[0].id); // the done contract drops out of the active slot
|
||
completed.add(posts[0].id);
|
||
routeState.xp = 60; // the route’s XP payout (the accept’s fix) stands on the seam
|
||
win.refresh();
|
||
assert.ok(
|
||
win.jobsSection.content.some((t) => t.text && /CONTRACT COMPLETE/.test(t.text)),
|
||
'the completed row shows CONTRACT COMPLETE');
|
||
assert.ok(
|
||
win.detailSection.content.some((t) => t.text && /PAYOUT — 50 CR · \+60 XP/.test(t.text)),
|
||
'the payout line names the XP payout (the route’s fix, beside the credits)');
|
||
dTxt = win.detailSection.content[win.detailSection.content.length - 1];
|
||
assert.equal(dTxt.text, 'CLAIM REWARD — 50 CR · +60 XP', 'the door stands CLAIM (the payout named — credits + the XP)');
|
||
dG = win.detailSection.content[win.detailSection.content.length - 2];
|
||
assert.ok(dG.__on?.pointerdown, 'the door is live while complete (the claim is a real press)');
|
||
dG.__on.pointerdown();
|
||
assert.deepEqual(claims, [posts[0].id], 'the claim press fired the onClaim seam');
|
||
|
||
// …and CLAIMED — the door stands as the claimed PLATE (REWARD
|
||
// CLAIMED — nothing to press; a double-pay is a no-op at the ledger,
|
||
// and the door doesn’t even offer one).
|
||
claimed.add(posts[0].id);
|
||
win.refresh();
|
||
assert.ok(
|
||
win.jobsSection.content.some((t) => t.text && /REWARD CLAIMED/.test(t.text)),
|
||
'the claimed row shows REWARD CLAIMED (the ledger’s mark)');
|
||
dTxt = win.detailSection.content[win.detailSection.content.length - 1];
|
||
assert.equal(dTxt.text, 'REWARD CLAIMED', 'the door stands as the claimed plate');
|
||
dG = win.detailSection.content[win.detailSection.content.length - 2];
|
||
assert.equal(dG.__on?.pointerdown, undefined, 'the plate is closed (nothing to press once claimed)');
|
||
|
||
// the stub categories (delivery/bounty) stand LOCAL — no target
|
||
// fields, no route seam — and their open doors are still plain ACCEPT
|
||
// (the window’s shape is one door; the state reads off the seams).
|
||
win._rows[1].hit.__on.pointerdown();
|
||
assert.ok(
|
||
!win.detailSection.content.some((t) => t.text && /DESTINATION —/.test(t.text)),
|
||
'a local posting carries no DESTINATION line',
|
||
);
|
||
assert.ok(
|
||
!win.detailSection.content.some((t) => t.text && /ROUTE —/.test(t.text)),
|
||
'a stub posting carries no ROUTE line (no route to count)',
|
||
);
|
||
assert.equal(win.detailSection.content[win.detailSection.content.length - 1].text, 'ACCEPT CONTRACT', 'the stub’s open door is plain ACCEPT');
|
||
|
||
// 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 ✔');
|