import test from 'node:test'; import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; import vm from 'node:vm'; /* * Step 6.7B -- SciFi Observation Surface Integration. * * SciFi-XZBT's contract-adapter.js is self-contained (no shared * test-fixtures/reference-exhibits/shared/contract-core.js dependency, * unlike Museum Gallery) -- it is one IIFE publishing window.XZBTContractAdapter * directly. surface-mode.js and surface-bus.js are likewise standalone, pure * files with no imports, which is what makes them separately loadable and * testable under vm here even though js/app.js (DOM-bound, ~4900 lines) is * not. See docs/architecture/XZBT-NGN-Step6.7A-SciFi-Observation-Surface.md * SS14 for the full test plan this file implements a representative subset of. */ const ROOT = new URL('../test-fixtures/reference-exhibits/scifi/', import.meta.url); function source(file) { return readFileSync(new URL(file, ROOT), 'utf8'); } /** Strips vm-realm object identity so assert.deepEqual compares plain * host-realm structures (same technique as museum-gallery.test.js). */ function plain(value) { return JSON.parse(JSON.stringify(value)); } function makeContext(extra = {}) { return vm.createContext({ window: {}, BroadcastChannel, URLSearchParams, setTimeout, clearTimeout, Math, Date, console, ...extra }); } /** Loads surface-mode.js + contract-adapter.js only -- structurally * incapable of constructing app.js's audio/AI/visualizer subsystems, * exactly as makeSurfaceContext() does for Museum Gallery. */ function makeAdapterContext() { const ctx = makeContext(); vm.runInContext(source('js/surface-mode.js'), ctx); vm.runInContext(source('js/contract-adapter.js'), ctx); return ctx; } function sciFiCatalog(instanceId = 'xi-test-instance') { const ctx = makeAdapterContext(); const surfaces = ctx.window.XZBTSurfaceMode.SURFACES(instanceId); const adapter = new ctx.window.XZBTContractAdapter({ product: 'SciFi-XZBT', version: '5.3.0', surfaces, instanceId, bindings: {} }); return { ctx, adapter, surfaces: plain(surfaces), description: plain(adapter.describe()) }; } function makeBusContext() { const ctx = makeContext(); vm.runInContext(source('js/surface-bus.js'), ctx); return ctx; } function closeAll(...handles) { for (const h of handles) { try { if (!h) continue; if (typeof h.detach === 'function') h.detach(); else if (h.owner && typeof h.owner.detach === 'function') h.owner.detach(); else if (h.link && typeof h.link.detach === 'function') h.link.detach(); } catch { /* best-effort cleanup */ } } } function waitFor(predicate, timeoutMs = 500) { const deadline = Date.now() + timeoutMs; return new Promise((resolve, reject) => { (function poll() { if (predicate()) return resolve(); if (Date.now() > deadline) return reject(new Error('timed out waiting for condition')); setTimeout(poll, 5); })(); }); } /* ------------------------------------------------------------------ * * 1-7: Catalog and contract conformance * ------------------------------------------------------------------ */ test('describe() includes a surfaces array with exactly one primary and only documented §31.2 fields', () => { const { description } = sciFiCatalog(); assert.ok(Array.isArray(description.surfaces)); assert.equal(description.surfaces.length, 2); const ids = description.surfaces.map((s) => s.id).sort(); assert.deepEqual(ids, ['surface.console', 'surface.observation']); const primaries = description.surfaces.filter((s) => s.primary === true); assert.equal(primaries.length, 1, 'exactly one primary surface'); assert.equal(primaries[0].id, 'surface.console'); assert.equal(primaries[0].url, 'index.html'); const ALLOWED = new Set(['id', 'label', 'kind', 'primary', 'url', 'role', 'category', 'aspectRatio', 'requires', 'description']); for (const s of description.surfaces) { assert.equal(s.kind, 'surface'); assert.match(s.id, /^[a-z][a-z0-9-]*(\.[a-z][a-z0-9-]*)+$/, 'canonical dotted id grammar'); for (const key of Object.keys(s)) { assert.ok(ALLOWED.has(key), `unexpected field '${key}' on ${s.id}`); } } }); test('the observation surface url is a bare query string against the same document, carrying the instance id', () => { const { description } = sciFiCatalog('xi-abc123'); const observation = description.surfaces.find((s) => s.id === 'surface.observation'); assert.equal(observation.primary, false); assert.equal(observation.url, '?surface=observation&xi=xi-abc123'); assert.ok(!/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(observation.url), 'url must not carry a scheme'); assert.ok(observation.url.indexOf('//') !== 0, 'url must not be protocol-relative'); assert.deepEqual(observation.requires, ['observation']); }); test('constructing the adapter with no surfaces option omits `surfaces` entirely (§31.3 form 1 preserved)', () => { const ctx = makeAdapterContext(); const adapter = new ctx.window.XZBTContractAdapter({ product: 'SciFi-XZBT', version: '5.3.0', bindings: {} }); const description = plain(adapter.describe()); assert.equal('surfaces' in description, false); assert.equal(description.contract.major, 5); assert.equal(description.exhibit.version, '5.3.0'); }); test('view.pillars and view.warp-flight are readable/writable boolean state targets and appear in the snapshot key set', () => { const { ctx, adapter } = sciFiCatalog(); const pillars = adapter.targets.get('view.pillars'); const warp = adapter.targets.get('view.warp-flight'); for (const target of [pillars, warp]) { assert.ok(target, 'target must be registered'); assert.equal(target.kind, 'state'); assert.equal(target.valueType, 'boolean'); assert.equal(target.readable, true); assert.equal(target.writable, true); assert.equal(target.restorable, true); assert.equal(target.category, 'observation'); assert.deepEqual(plain(target.requires), ['observation']); } const description = plain(adapter.describe()); const ids = description.targets.map((t) => t.id); assert.ok(ids.includes('view.pillars')); assert.ok(ids.includes('view.warp-flight')); void ctx; }); test('registryRevision stays 1 -- the new targets are a build-time addition, not a runtime registry change', () => { const { adapter } = sciFiCatalog(); assert.equal(adapter.registryRevision, 1); }); /* ------------------------------------------------------------------ * * 8-9: Mode resolution (pure, no DOM) * ------------------------------------------------------------------ */ test('mode resolution: no query -> console; ?surface=observation -> observation; unknown values -> console', () => { const ctx = makeContext(); vm.runInContext(source('js/surface-mode.js'), ctx); const resolve = ctx.window.XZBTSurfaceMode.resolve; assert.equal(resolve({ search: '' }).mode, 'console'); assert.equal(resolve({ search: '?foo=bar' }).mode, 'console'); assert.equal(resolve({ search: '?surface=bogus' }).mode, 'console'); assert.equal(resolve({ search: '?surface=observation&xi=xi-1' }).mode, 'observation'); assert.equal(resolve({ search: '?surface=observation&xi=xi-1' }).instanceId, 'xi-1'); // Order and extra params are tolerated. assert.equal(resolve({ search: '?xi=xi-2&extra=1&surface=observation' }).mode, 'observation'); assert.equal(resolve({ search: '?xi=xi-2&extra=1&surface=observation' }).instanceId, 'xi-2'); }); test('channelName is instance-scoped: two instance ids yield two different channel names', () => { const ctx = makeContext(); vm.runInContext(source('js/surface-mode.js'), ctx); const { channelName, newInstanceId } = ctx.window.XZBTSurfaceMode; const a = newInstanceId(); const b = newInstanceId(); assert.notEqual(a, b); assert.notEqual(channelName(a), channelName(b)); assert.ok(channelName(a).startsWith('xzbt-scifi-surface-v1:')); }); /* ------------------------------------------------------------------ * * 10-17: Bus / synchronization (surface-bus.js against a minimal stub * adapter -- exercises the bus mechanism itself, independent of app.js) * ------------------------------------------------------------------ */ /** A minimal stand-in for XZBTContractAdapter: just enough surface for * surface-bus.js's owner side (getContractState/applyMutation/invokeAction/ * targets/stateRevision/registryRevision), with the same session-independent * onLocalChange/onLocalAction contract the real adapter now provides. */ function makeStubAdapter() { const targets = new Map([ ['view.pillars', { id: 'view.pillars', kind: 'state', readable: true, writable: true }], ['preset.selected', { id: 'preset.selected', kind: 'selection', readable: true, writable: true }] ]); const state = { 'view.pillars': false, 'preset.selected': 'a' }; const adapter = { targets, stateRevision: 0, registryRevision: 1, onLocalChange: null, onLocalAction: null, getContractState: () => ({ ...state }), applyMutation: (id, value) => { if (!targets.has(id)) return { ok: false }; state[id] = value; adapter.stateRevision += 1; if (adapter.onLocalChange) adapter.onLocalChange(id, value, adapter.stateRevision); return { ok: true }; }, invokeAction: (id, args) => { if (adapter.onLocalAction) adapter.onLocalAction(id, args || {}); return { ok: true }; } }; return adapter; } function makeOwner(channelName) { const ctx = makeBusContext(); const adapter = makeStubAdapter(); const owner = ctx.window.XZBTSurfaceBus.createOwner(adapter, { channelName }); adapter.onLocalChange = (id, value, rev) => owner.broadcastState(id, value, rev); adapter.onLocalAction = (id, args) => owner.broadcastAction(id, args); return { ctx, adapter, owner }; } function attachSurface(channelName, timeoutMs) { const ctx = makeBusContext(); const changes = []; const actions = []; const presentations = []; return new Promise((resolve, reject) => { const link = ctx.window.XZBTSurfaceBus.attach({ channelName, timeoutMs: timeoutMs || 500, onSnapshot: (values, stateRevision, registryRevision, presentation) => resolve({ ctx, link, changes, actions, presentations, values: plain(values), stateRevision, registryRevision, presentation: plain(presentation) }), onChange: (target, value, stateRevision) => changes.push({ target, value, stateRevision }), onAction: (target, args) => actions.push({ target, args: plain(args) }), onPresentation: (kind, payload) => presentations.push({ kind, payload: plain(payload) }), onTimeout: () => reject(new Error('attach timed out waiting for an owner')) }); }); } test('late join: attach after a mutation carries the current value and stateRevision in the snapshot', async () => { const channel = 'xzbt-scifi-surface-v1:test-latejoin-' + Math.random(); const { adapter, owner } = makeOwner(channel); let surface; try { adapter.applyMutation('view.pillars', true); surface = await attachSurface(channel); assert.equal(surface.values['view.pillars'], true); assert.equal(surface.stateRevision, adapter.stateRevision); } finally { closeAll(owner, surface); } }); test('propagation: an authority mutation reaches an attached surface as one state message with the post-mutation value', async () => { const channel = 'xzbt-scifi-surface-v1:test-propagate-' + Math.random(); const { adapter, owner } = makeOwner(channel); let surface; try { surface = await attachSurface(channel); adapter.applyMutation('preset.selected', 'b'); await waitFor(() => surface.changes.some((c) => c.target === 'preset.selected' && c.value === 'b')); const matches = surface.changes.filter((c) => c.target === 'preset.selected'); assert.equal(matches.length, 1); assert.equal(matches[0].stateRevision, adapter.stateRevision); } finally { closeAll(owner, surface); } }); test('mutation routing: a surface-side mutate results in exactly one applyMutation call and converges on a second attached surface', async () => { const channel = 'xzbt-scifi-surface-v1:test-routing-' + Math.random(); const { adapter, owner } = makeOwner(channel); let applyCount = 0; const origApply = adapter.applyMutation; adapter.applyMutation = (...args) => { applyCount += 1; return origApply(...args); }; let surfaceA, surfaceB; try { surfaceA = await attachSurface(channel); surfaceB = await attachSurface(channel); surfaceA.link.mutate('set', 'view.pillars', true); await waitFor(() => surfaceB.changes.some((c) => c.target === 'view.pillars' && c.value === true)); assert.equal(applyCount, 1); assert.equal(adapter.getContractState()['view.pillars'], true, 'authority converged'); assert.ok(surfaceA.changes.some((c) => c.target === 'view.pillars' && c.value === true), 'originating surface also converges (no local write)'); } finally { closeAll(owner, surfaceA, surfaceB); } }); test('no second authority: a surface-side mutate with no owner present is a no-op and the surface stays in the waiting state', async () => { const channel = 'xzbt-scifi-surface-v1:test-noowner-' + Math.random(); const ctx = makeBusContext(); let timedOut = false; const link = await new Promise((resolve) => { const l = ctx.window.XZBTSurfaceBus.attach({ channelName: channel, timeoutMs: 60, onSnapshot: () => resolve(l), onTimeout: () => { timedOut = true; resolve(l); } }); }); try { assert.equal(timedOut, true); assert.equal(link.isAttached(), false); assert.equal(link.mutate('set', 'view.pillars', true), false, 'mutate before/without attachment is a no-op'); } finally { closeAll(link); } }); test('reload: re-attaching with a fresh participantId yields the current snapshot and does not ratchet the participant count', async () => { const channel = 'xzbt-scifi-surface-v1:test-reload-' + Math.random(); const { adapter, owner } = makeOwner(channel); let first, second; try { first = await attachSurface(channel); assert.equal(owner.attachedCount(), 1); first.link.detach(); await waitFor(() => owner.attachedCount() === 0); adapter.applyMutation('view.pillars', true); second = await attachSurface(channel); assert.equal(owner.attachedCount(), 1, 'reload does not ratchet the count'); assert.equal(second.values['view.pillars'], true, 'reattach observes current state'); } finally { closeAll(owner, first, second); } }); test('detach is idempotent, never mutates state, and cannot underflow the participant count', async () => { const channel = 'xzbt-scifi-surface-v1:test-detach-' + Math.random(); const { adapter, owner } = makeOwner(channel); let surface; try { surface = await attachSurface(channel); const revBefore = adapter.stateRevision; surface.link.detach(); surface.link.detach(); // duplicate detach must not underflow await waitFor(() => owner.attachedCount() === 0); assert.equal(owner.attachedCount(), 0); assert.equal(adapter.stateRevision, revBefore, 'detaching must not itself mutate state'); } finally { closeAll(owner, surface); } }); test('session independence: onLocalChange fires regardless of any session concept -- the bus has no idea NGN exists', async () => { // The stub adapter here has no sessionActive/eventSequence at all, which is // the point: onLocalChange is a plain method call, not gated on a session, // unlike the real adapter's _emitEvent (contract-adapter.js §5.4). const channel = 'xzbt-scifi-surface-v1:test-sessionindep-' + Math.random(); const { adapter, owner } = makeOwner(channel); let surface; try { surface = await attachSurface(channel); adapter.applyMutation('view.pillars', true); await waitFor(() => surface.changes.length > 0); assert.equal(surface.changes[0].target, 'view.pillars'); } finally { closeAll(owner, surface); } }); test('presentation messages (ticker, obs-activity) are relayed to attached surfaces and are not contract state', async () => { const channel = 'xzbt-scifi-surface-v1:test-presentation-' + Math.random(); const { owner } = makeOwner(channel); let surface; try { surface = await attachSurface(channel); owner.broadcastPresentation('ticker', { tickerText: 'HELLO WORLD' }); owner.broadcastPresentation('obs-activity', { source: 'ambient' }); await waitFor(() => surface.presentations.length >= 2); assert.deepEqual(surface.presentations[0], { kind: 'ticker', payload: { type: 'presentation', kind: 'ticker', tickerText: 'HELLO WORLD' } }); assert.equal(surface.presentations[1].kind, 'obs-activity'); } finally { closeAll(owner, surface); } }); /* ------------------------------------------------------------------ * * 18: Duplicate-subsystem prevention -- construction spy on surface-bus.js * ------------------------------------------------------------------ */ test('surface-bus.js never touches AudioContext, fetch or dynamic import, in either owner or surface role', async () => { let audioContextConstructions = 0; const ctx = vm.createContext({ window: {}, BroadcastChannel, setTimeout, clearTimeout, Math, Date, console, AudioContext: class { constructor() { audioContextConstructions += 1; } }, fetch: () => { throw new Error('surface-bus.js must never fetch'); } }); vm.runInContext(source('js/surface-bus.js'), ctx); const channel = 'xzbt-scifi-surface-v1:test-spy-' + Math.random(); const adapter = makeStubAdapter(); const owner = ctx.window.XZBTSurfaceBus.createOwner(adapter, { channelName: channel }); adapter.onLocalChange = (id, value, rev) => owner.broadcastState(id, value, rev); let link; try { link = await new Promise((resolve, reject) => { const l = ctx.window.XZBTSurfaceBus.attach({ channelName: channel, timeoutMs: 500, onSnapshot: () => resolve(l), onTimeout: () => reject(new Error('attach timed out')) }); }); link.mutate('set', 'view.pillars', true); await waitFor(() => adapter.getContractState()['view.pillars'] === true); assert.equal(audioContextConstructions, 0); } finally { closeAll(owner, link); } }); /* ------------------------------------------------------------------ * * 19-21: app.js is too DOM-bound for vm (per the architecture doc's own * assessment) -- these are asserted as source-structure tests, the same * technique museum-gallery.test.js uses for "exactly one Exhibit State * Core exists". * ------------------------------------------------------------------ */ function appJsSource() { return source('js/app.js'); } test('source structure: enterObservation() only calls prepareExperience() inside an isConsoleMode guard', () => { const src = appJsSource(); const fnStart = src.indexOf('function enterObservation()'); assert.ok(fnStart >= 0, 'enterObservation() must exist'); const fnEnd = src.indexOf('\n function exitObservation()', fnStart); assert.ok(fnEnd > fnStart); const body = src.slice(fnStart, fnEnd); // Search for the real call site, not the word appearing in a comment. const callIdx = body.indexOf('generativeExperience.prepareExperience()'); assert.ok(callIdx >= 0, 'enterObservation() must still prepare the generative experience somewhere'); const guardIdx = body.lastIndexOf('if (isConsoleMode) {', callIdx); assert.ok(guardIdx >= 0 && guardIdx < callIdx, 'prepareExperience() must be reached only through an isConsoleMode guard'); // The guard's closing brace must come after the call (i.e. the call is // actually nested inside the guard, not merely preceded by one elsewhere). const closeIdx = body.indexOf('\n }', callIdx); assert.ok(closeIdx > callIdx, 'the isConsoleMode guard must close after the prepareExperience() call'); }); test('source structure: XZBTGenerativeExperience, XZBTContractAdapter, XZBTControlBus and StarshipVisualizer construction sites are each reached only through an isConsoleMode check', () => { const src = appJsSource(); for (const ctor of ['new XZBTGenerativeExperience(', 'new XZBTControlBus(', 'new XZBTContractAdapter(', 'new StarshipVisualizer(']) { const idx = src.indexOf(ctor); assert.ok(idx >= 0, `${ctor} construction site must exist`); // The isConsoleMode identifier (either as an if-guard or a ternary // condition) must appear on a line at or before the construction site, // within a reasonably tight window -- resilient to exact formatting, // unlike a brittle single-line regex. const window_ = src.slice(Math.max(0, idx - 400), idx); assert.ok(window_.includes('isConsoleMode'), `${ctor} must be structurally gated on isConsoleMode`); } }); test('source structure: the keydown hotkey listener is installed only inside an isConsoleMode guard', () => { const src = appJsSource(); const idx = src.indexOf("window.addEventListener('keydown'"); assert.ok(idx >= 0); const before = src.slice(Math.max(0, idx - 200), idx); assert.ok(before.includes('if (isConsoleMode)'), 'keydown listener must be console-mode only'); }); test('source structure: scheduleObservationAmbientActivity() refuses to run at all outside console mode (no independent surface-side timer)', () => { const src = appJsSource(); const fnStart = src.indexOf('function scheduleObservationAmbientActivity()'); assert.ok(fnStart >= 0); const fnBody = src.slice(fnStart, fnStart + 600); assert.match(fnBody, /if\s*\(!isConsoleMode\)\s*return;/, 'the scheduler must early-return outside console mode'); }); test('source structure: the observation-audience predicate considers attached surfaces, not just the local overlay flag', () => { const src = appJsSource(); assert.match(src, /observationAudienceActive\s*=\s*\(\)\s*=>\s*observationActive\s*\|\|/, 'audience-active must OR in attached-surface state'); assert.match(src, /surfaceOwner\s*&&\s*surfaceOwner\.attachedCount\(\)\s*>\s*0/, 'must consult surfaceOwner.attachedCount()'); }); test('source structure: opening the surface never sets view.observation (surface lifecycle and the console overlay flag stay distinct)', () => { const src = appJsSource(); // The presentation attach path's onSnapshot handler must call // enterObservation() (unconditional local render) and must never call // applyMutation('view.observation', ...) or mutate('set', 'view.observation', ...). const snapshotIdx = src.indexOf('onSnapshot: (values, stateRevision, registryRevision, presentation)'); assert.ok(snapshotIdx >= 0); const handlerEnd = src.indexOf('onChange:', snapshotIdx); const handlerBody = src.slice(snapshotIdx, handlerEnd); assert.ok(handlerBody.includes('enterObservation()')); assert.ok(!handlerBody.includes("'view.observation'"), 'the snapshot handler must never touch view.observation'); }); test('source structure: presentation-side dock routing excludes view.observation from the routed target set', () => { const src = appJsSource(); const idx = src.indexOf('XZBT_PRESENTATION_ROUTED_TARGETS'); assert.ok(idx >= 0); const block = src.slice(idx, idx + 300); assert.ok(!block.includes("'view.observation'"), 'view.observation must never be routed from the surface to the owner'); for (const target of ['view.warp-flight', 'view.viewport-frame', 'view.pillars', 'alert.active', 'preset.selected']) { assert.ok(block.includes(`'${target}'`), `${target} must be routed`); } }); test('source structure: contract-adapter.js exposes onLocalChange/onLocalAction independent of the sessionActive gate on _emitEvent', () => { const src = source('js/contract-adapter.js'); assert.match(src, /if\s*\(!this\.sessionActive\)\s*return;/, '_emitEvent\'s session gate must remain'); assert.match(src, /this\.onLocalChange\s*\(/, 'onLocalChange must be invoked'); assert.match(src, /this\.onLocalAction\s*\(/, 'onLocalAction must be invoked'); // The onLocalChange call site must not itself be behind a sessionActive check. const idx = src.indexOf('if (this.onLocalChange) {'); assert.ok(idx >= 0); const nearby = src.slice(Math.max(0, idx - 150), idx); assert.ok(!nearby.includes('sessionActive'), 'onLocalChange must fire regardless of sessionActive'); });