generated from Labyricorn/labyricorn-project-template
One commit for the work accumulated in the working tree since Step 6.3, which had never been split into per-step commits: - src/local-surfaces.js + src/surface-url.js (new); src/ui.js, src/validation.js, src/connection.js and public/index.html updated for local-surface hosting and generic surface rendering - tests: local-surfaces (20), scifi-surfaces (24) and postmessage-interop (7) new; connection/museum-gallery/surface-validation suites updated - reference exhibits: shared/contract-core.js defaults to Contract 5.3 (major 5, minor 3, xzbt 5.3); museum-gallery advertises its surface catalog; aquarium/haunted-house/planetarium adapters updated - SciFi-XZBT (Step 6.7A/6.7B): surface-mode.js + surface-bus.js, Observation-surface boot branch, local-change hooks, view.pillars / view.warp-flight targets; fixture byte-identical to G:/.vibe/SciFi-XZBT - SciFi-XZBT contract adapter handshake fix: the inbound bridge filter no longer gates on an exact advisory xzbt value (Contract 5.3 §6.5), only on its presence/type, matching the host's own envelope validation; the adapter now advertises contract minor 3 / version 5.3.0, which it already implemented via the 5.3 surfaces field. Root cause of the five failing postmessage-interop tests (host hello was silently dropped). - docs: architecture 6.4 and 6.7A, reference 6.6 and 6.7; evidence logs; test-fixtures/PROVENANCE.md resync record Test results: NGN 154/154 (was 149/154); postmessage-interop 7/7 (was 2/7); SciFi contract harness 21/21, real-adapter suite 32/32. git diff --check clean for changed files; two pre-existing trailing-whitespace lines remain in test-fixtures/reference-exhibits/scifi/index.html, copied verbatim from the authoritative SciFi source. Step 6.7 live verification (browser Observation, packaged standalone) is still pending and is not claimed here.
536 lines
24 KiB
JavaScript
536 lines
24 KiB
JavaScript
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');
|
|
});
|