// 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 ✔');