orbit/dev/jobboard.test.mjs

531 lines
26 KiB
JavaScript
Raw Permalink 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 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('== FACTION VOICE — each board renders ITS OWN pool, the placeholders already resolved ==');
{
// The REAL model off the REAL data (no fixtures): one world per faction,
// every gate open, so all three live categories post in rival ground.
const { sideQuestPostingsFor } = await import(
pathToFileURL(join(__dirname, '../js/quests/SideQuests.js')).href
);
const FAC = ['apex', 'mindweave', 'zenith'];
const RECS = Object.fromEntries(FAC.map((f, i) => [f, {
id: `sys-${f}`, name: `${f[0].toUpperCase()}${f.slice(1)} Prime`, faction: f, d: 40 + i * 15, zone: 'mid', type: 'void', capital: false,
}]));
const ALL = Object.values(RECS);
const galaxy = { byId: new Map(ALL.map((r) => [r.id, r])), jumpGatesFor: (id) => ALL.filter((r) => r.id !== id) };
const LABELS = { apex: 'Apex', mindweave: 'MindWeave', zenith: 'Zenith' };
for (const f of FAC) {
const posts = [];
for (let s = 0; s < 40 && posts.length < 3; s++) {
for (const p of sideQuestPostingsFor(5100 + s * 11, RECS[f], 'p1', galaxy, 10)) {
if (!posts.some((x) => x.category === p.category)) posts.push(p);
}
}
assert.equal(posts.length, 3, `${f}: the board stands a patrol, a hunt, and an escort (the faction’s three live categories)`);
const win = new JobBoardWindow(makeScene(), {
planetName: RECS[f].name,
onClose: () => {},
postings: () => posts,
isAccepted: () => false, isCompleted: () => false, isActive: () => false, isClaimed: () => false,
routeProgress: () => null, onAccept: () => {}, onActivate: () => {}, onClaim: () => {},
});
// THE ROWS — every listing on the board reads in this faction’s voice:
// its own pool wrote the title, and no placeholder brace survived.
const rowText = win.jobsSection.content.map((t) => String(t.text ?? ''));
assert.ok(!rowText.join(' ').includes('{'), `${f}: no brace pair survives in the rows`);
for (const p of posts) {
const enemy = p.enemyFaction ? LABELS[p.enemyFaction] : '';
const enemies = FAC.filter((x) => x !== f).map((x) => LABELS[x]).join(' and ');
const render = (s) => String(s ?? '')
.replace(/\{system\}/g, RECS[f].name).replace(/\{faction\}/g, LABELS[f])
.replace(/\{issuer\}/g, config.get(`sidequests.factions.${f}`, ''))
.replace(/\{enemies\}/g, enemies).replace(/\{enemy\}/g, enemy);
assert.ok(
config.get(`sidequests.descriptions.faction.${f}.${p.category}`).some((v) => render(v.title) === p.title),
`${f}: “${p.title}” came out of ${f}’s OWN pool (the board is not sharing a line)`,
);
}
const hunt = posts.find((p) => p.category === 'bounty');
assert.ok(posts.every((p) => p.issuer.includes(LABELS[f])), `${f}: every row’s issuer line is the faction’s own board`);
// THE READOUT — select each row: body + (for a hunt) the dossier block,
// all of it resolved to the seeded rival, never a brace.
for (let i = 0; i < win._rows.length; i++) {
win._rows[i].hit.__on.pointerdown();
const detail = win.detailSection.content.map((t) => String(t.text ?? ''));
const sel = posts[i];
assert.ok(!detail.join(' ').includes('{'), `${f}: the ${sel.category} readout ships no brace pair`);
assert.ok(
detail.some((t) => t === sel.title.toUpperCase()),
`${f}: the ${sel.category} title reads in the detail`,
);
assert.ok(
detail.some((t) => sel.description.slice(0, 40).length > 8 && t.includes(sel.description.slice(0, 40))),
`${f}: the ${sel.category} body stands (the faction’s own words)`,
);
const rival = sel.enemyFaction ? LABELS[sel.enemyFaction] : null;
assert.ok(rival && detail.join(' ').includes(rival), `${f}: the ${sel.category} readout names the rival it fights (${rival})`);
if (sel.category === 'bounty') {
assert.ok(detail.some((t) => /WANTED —/.test(t)), `${f}: the hunt’s WANTED line stands`);
assert.ok(detail.some((t) => t.includes(String(sel.dossier.name).toUpperCase())), `${f}: the dossier names its leader`);
assert.ok(detail.some((t) => /CHARGES —/.test(t)), `${f}: the dossier reads its charges`);
// The dossier declares no craft id (the frame rides the mark’s roster),
// so the readout speaks the faction’s own airframe, never a pirate one.
assert.ok(detail.some((t) => /CRAFT —/.test(t)), `${f}: the craft line stands from the seeded frame`);
}
}
if (hunt) assert.ok(hunt.dossier.craft === undefined || hunt.dossier.craft === null, `${f}: a faction dossier declares no craft id`);
win.destroy();
}
}
console.log('\nAll Job Board console checks passed ✔');