Files
XZBT-NGN/tests/museum-gallery.test.js
T

424 lines
18 KiB
JavaScript

import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import vm from 'node:vm';
const ROOT = new URL('../test-fixtures/reference-exhibits/museum-gallery/', import.meta.url);
const SHARED = new URL('../test-fixtures/reference-exhibits/shared/', import.meta.url);
function source(base, file) {
return readFileSync(new URL(file, base), 'utf8');
}
/** Strips vm-realm object/array identity so assert.deepEqual compares plain
* host-realm structures instead of failing on cross-realm prototypes. */
function plain(value) {
return JSON.parse(JSON.stringify(value));
}
/**
* A fresh vm context standing in for one browser document. `BroadcastChannel`
* is Node's real global implementation (same spec surface as the DOM one),
* shared process-wide by channel name -- so two contexts with the same
* channel name genuinely talk to each other, the same cross-document shape
* Contract 5.3 Section 31.7 and the Step 6.1 architecture document describe,
* minus only the literal separate-window/separate-origin browser plumbing.
*
* Every test that opens channels is responsible for closing them (see
* closeAll below) -- Node's BroadcastChannel is a process-wide bus by name,
* so a leftover open channel from one test would otherwise go on answering
* a later test's attach requests.
*/
function makeContext() {
return vm.createContext({
window: {},
BroadcastChannel,
setTimeout,
clearTimeout,
Math,
Date,
console
});
}
function makeOwner() {
const ctx = makeContext();
vm.runInContext(source(SHARED, 'contract-core.js'), ctx);
for (const file of ['exhibit.js', 'contract-adapter.js', 'surface-bus.js']) {
vm.runInContext(source(ROOT, file), ctx);
}
const gallery = new ctx.window.MuseumGalleryExhibit.Gallery();
const core = ctx.window.MuseumGalleryContract.create(gallery);
const bus = ctx.window.MuseumGallerySurfaceBus.createOwner(core);
return { ctx, gallery, core, bus };
}
/** A non-primary-surface context. Deliberately loads ONLY surface-bus.js --
* not contract-core.js, not exhibit.js, not contract-adapter.js -- so it is
* structurally incapable of constructing a second Core. */
function makeSurfaceContext() {
const ctx = makeContext();
vm.runInContext(source(ROOT, 'surface-bus.js'), ctx);
return ctx;
}
function attachSurface(ctx, timeoutMs) {
const events = [];
return new Promise((resolve, reject) => {
const link = ctx.window.MuseumGallerySurfaceBus.attach({
timeoutMs: timeoutMs || 500,
onSnapshot: (snapshot, registryRevision) => resolve({ link, events, snapshot: plain(snapshot), registryRevision }),
onEvent: (event) => events.push(plain(event)),
onTimeout: () => reject(new Error('attach timed out waiting for an owner'))
});
});
}
function expectTimeout(ctx, timeoutMs) {
return new Promise((resolve, reject) => {
const link = ctx.window.MuseumGallerySurfaceBus.attach({
timeoutMs: timeoutMs || 150,
onSnapshot: () => reject(new Error('unexpectedly attached')),
onTimeout: () => resolve(link)
});
});
}
function waitForEvent(events, predicate, timeoutMs) {
const deadline = Date.now() + (timeoutMs || 500);
return new Promise((resolve, reject) => {
(function poll() {
const found = events.find(predicate);
if (found) return resolve(found);
if (Date.now() > deadline) return reject(new Error('timed out waiting for event'));
setTimeout(poll, 5);
})();
});
}
/** Closes every channel/link handed to it, tolerating already-closed ones. */
function closeAll(...handles) {
for (const h of handles) {
try {
if (!h) continue;
if (typeof h.close === 'function') h.close();
else if (h.link && typeof h.link.detach === 'function') h.link.detach();
else if (h.bus && typeof h.bus.close === 'function') h.bus.close();
} catch {
/* best-effort cleanup */
}
}
}
/* ------------------------------------------------------------------ *
* 1-4: Contract 5.3 describe / surface descriptor conformance
* ------------------------------------------------------------------ */
test('Museum Gallery describe reports Contract 5.3 with a valid, single-primary surfaces array', () => {
const { core, bus } = makeOwner();
try {
const description = plain(core.describe());
assert.equal(description.contract.major, 5);
assert.equal(description.contract.minor, 3);
assert.ok(Array.isArray(description.surfaces), 'surfaces must be an array');
assert.equal(description.surfaces.length, 3);
const ids = description.surfaces.map((s) => s.id).sort();
assert.deepEqual(ids, ['surface.artifact', 'surface.control', 'surface.info-wall']);
const primaries = description.surfaces.filter((s) => s.primary === true);
assert.equal(primaries.length, 1, 'exactly one primary surface');
assert.equal(primaries[0].id, 'surface.control');
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 (Contract 8.1)');
assert.equal(typeof s.label, 'string');
assert.ok(s.label.length > 0);
assert.equal(typeof s.url, 'string');
assert.ok(!/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(s.url), 'url must not carry a scheme');
assert.ok(s.url.indexOf('//') !== 0, 'url must not be protocol-relative');
}
} finally {
closeAll(bus);
}
});
test('Non-surface-aware exhibits are unaffected: describe() omits `surfaces` entirely with no options.surfaces', () => {
const ctx = makeContext();
vm.runInContext(source(SHARED, 'contract-core.js'), ctx);
const Core = ctx.window.XZBTContractCore;
const catalog = new Core.Catalog([
{ id: 'mix.master', kind: 'range', min: 0, max: 1, step: 0.01, readable: true, writable: true, restorable: true, category: 'mix', requires: [] }
]);
const caps = new Core.CapabilityRegistry();
const core = new Core.ContractCore({
identity: { product: 'plain', version: '1', build: 'x' },
catalog, capabilities: caps,
setters: { 'mix.master': () => ({ changed: false }) },
readers: { 'mix.master': () => 0.5 }
});
const description = plain(core.describe());
assert.equal('surfaces' in description, false);
assert.equal(description.contract.major, 5);
assert.equal(description.contract.minor, 2, 'defaults are byte-identical to pre-5.3 behavior');
});
/* ------------------------------------------------------------------ *
* SurfaceCatalog validation order (Contract 5.3 Section 31.3, Part A2)
* ------------------------------------------------------------------ */
test('SurfaceCatalog: an individually-invalid entry is discarded before the primary invariant is evaluated', () => {
const ctx = makeContext();
vm.runInContext(source(SHARED, 'contract-core.js'), ctx);
const SurfaceCatalog = ctx.window.XZBTContractCore.SurfaceCatalog;
const catalog = new SurfaceCatalog([
{ id: 'not_dotted', label: 'Bad', kind: 'surface', primary: true, url: 'bad.html' },
{ id: 'surface.control', label: 'Control', kind: 'surface', primary: true, url: 'control.html' },
{ id: 'surface.artifact', label: 'Artifact', kind: 'surface', primary: false, url: 'artifact.html' }
]);
assert.equal(catalog.isEmpty(), false);
assert.equal(catalog.wasRejectedAsMalformed(), false);
assert.equal(catalog.descriptors().length, 2);
assert.equal(catalog.primaryId(), 'surface.control');
});
test('SurfaceCatalog: zero primary among valid entries rejects the whole catalog and falls back to absent', () => {
const ctx = makeContext();
vm.runInContext(source(SHARED, 'contract-core.js'), ctx);
const SurfaceCatalog = ctx.window.XZBTContractCore.SurfaceCatalog;
const catalog = new SurfaceCatalog([
{ id: 'surface.artifact', label: 'Artifact', kind: 'surface', primary: false, url: 'artifact.html' },
{ id: 'surface.info-wall', label: 'Info', kind: 'surface', primary: false, url: 'info-wall.html' }
]);
assert.equal(catalog.isEmpty(), true);
assert.equal(catalog.wasRejectedAsMalformed(), true);
assert.equal(catalog.descriptors().length, 0);
assert.ok(catalog.diagnostics().some((d) => /exactly one/.test(d)));
});
test('SurfaceCatalog: multiple primary among valid entries rejects the whole catalog', () => {
const ctx = makeContext();
vm.runInContext(source(SHARED, 'contract-core.js'), ctx);
const SurfaceCatalog = ctx.window.XZBTContractCore.SurfaceCatalog;
const catalog = new SurfaceCatalog([
{ id: 'surface.control', label: 'Control', kind: 'surface', primary: true, url: 'control.html' },
{ id: 'surface.artifact', label: 'Artifact', kind: 'surface', primary: true, url: 'artifact.html' }
]);
assert.equal(catalog.isEmpty(), true);
assert.equal(catalog.wasRejectedAsMalformed(), true);
});
test('SurfaceCatalog: discarding every entry (all individually invalid) behaves as absent, not malformed', () => {
const ctx = makeContext();
vm.runInContext(source(SHARED, 'contract-core.js'), ctx);
const SurfaceCatalog = ctx.window.XZBTContractCore.SurfaceCatalog;
const catalog = new SurfaceCatalog([
{ id: 'bad id', label: 'x', kind: 'surface', primary: true, url: 'x.html' },
{ id: 'surface.y', label: '', kind: 'surface', primary: true, url: 'y.html' }
]);
assert.equal(catalog.isEmpty(), true);
assert.equal(catalog.wasRejectedAsMalformed(), false, 'empty valid set is absent-equivalent, not a primary violation');
});
test('SurfaceCatalog: an absolute or scheme-carrying url is rejected as an individually invalid entry', () => {
const ctx = makeContext();
vm.runInContext(source(SHARED, 'contract-core.js'), ctx);
const SurfaceCatalog = ctx.window.XZBTContractCore.SurfaceCatalog;
const catalog = new SurfaceCatalog([
{ id: 'surface.control', label: 'Control', kind: 'surface', primary: true, url: 'https://evil.example/control.html' },
{ id: 'surface.artifact', label: 'Artifact', kind: 'surface', primary: false, url: '//evil.example/artifact.html' },
{ id: 'surface.info-wall', label: 'Info', kind: 'surface', primary: false, url: 'info-wall.html' }
]);
// Only the third entry is individually valid; the valid set then has zero
// primary entries and is rejected as a whole, falling back to absent.
assert.equal(catalog.isEmpty(), true);
});
/* ------------------------------------------------------------------ *
* 5, 12-13: one authoritative Core; primary works fully standalone
* ------------------------------------------------------------------ */
test('The primary surface is fully usable standalone: the Core alone supports full read/set/invoke', () => {
const { core, bus } = makeOwner();
try {
assert.equal(core.stateSnapshot().values['artifact.selected'], 'the-orrery');
const setResult = core.applyMutation('lighting.level', 0.2, 'ui');
assert.equal(setResult.ok, true);
assert.equal(setResult.revisionChanged, true);
assert.equal(core.stateSnapshot().values['lighting.level'], 0.2);
const invokeResult = core.invokeAction('action.spotlight-flash', {}, 'ui');
assert.equal(invokeResult.ok, true);
assert.ok(core.eventLog().some((e) => e.type === 'action.executed' && e.target === 'action.spotlight-flash'));
// None of this touched any surface or bus -- this is a plain Core call,
// exactly what a person opening control.html directly, with no NGN and
// no other surface open, gets.
} finally {
closeAll(bus);
}
});
test('Exactly one Exhibit State Core exists: non-primary surface contexts cannot construct one', () => {
const ctx = makeSurfaceContext();
assert.equal(ctx.window.MuseumGalleryContract, undefined, 'contract-adapter.js was never loaded here');
assert.equal(ctx.window.XZBTContractCore, undefined, 'contract-core.js was never loaded here');
assert.equal(typeof ctx.window.MuseumGallerySurfaceBus.attach, 'function');
});
/* ------------------------------------------------------------------ *
* 6, 7, 8, 9, 11: cross-document synchronization via the real attachment bus
* ------------------------------------------------------------------ */
test('Attach sequence: a surface opened after state already changed receives the CURRENT snapshot', async () => {
const { core, bus } = makeOwner();
let surface;
try {
core.applyMutation('lighting.level', 0.9, 'ui');
assert.equal(core.stateRevision, 1);
const artifactCtx = makeSurfaceContext();
surface = await attachSurface(artifactCtx);
assert.equal(surface.snapshot.stateRevision, 1);
assert.equal(surface.snapshot.values['lighting.level'], 0.9, 'reopen/reattach gets current state, not stale or default state');
assert.equal(surface.registryRevision, core.registryRevision);
} finally {
closeAll(bus, surface);
}
});
test('State changes from the primary propagate to open non-primary surfaces', async () => {
const { core, bus } = makeOwner();
let artifact, info;
try {
artifact = await attachSurface(makeSurfaceContext());
info = await attachSurface(makeSurfaceContext());
core.applyMutation('rotation.speed', 1.2, 'ui'); // simulates the primary UI's own slider
const artifactEvent = await waitForEvent(artifact.events, (e) => e.type === 'state.changed' && e.target === 'rotation.speed');
const infoEvent = await waitForEvent(info.events, (e) => e.type === 'state.changed' && e.target === 'rotation.speed');
assert.equal(artifactEvent.value, 1.2);
assert.equal(infoEvent.value, 1.2);
assert.equal(artifactEvent.stateRevision, core.stateRevision);
assert.equal(infoEvent.stateRevision, core.stateRevision);
} finally {
closeAll(bus, artifact, info);
}
});
test('A native interaction on a non-primary surface updates the primary AND the other non-primary surface, through the canonical mutation path', async () => {
const { core, bus } = makeOwner();
let artifact, info;
try {
artifact = await attachSurface(makeSurfaceContext());
info = await attachSurface(makeSurfaceContext());
const before = core.stateRevision;
const ok = artifact.link.mutate('set', 'artifact.selected', 'star-map');
assert.equal(ok, true);
const infoEvent = await waitForEvent(info.events, (e) => e.type === 'selection.changed' && e.target === 'artifact.selected');
assert.equal(infoEvent.value, 'star-map');
assert.equal(infoEvent.source, 'ui', 'surface interactions use the same source vocabulary as primary UI (Contract 5.3 15)');
// "The primary" here IS the Core itself (control.html owns it) -- its
// authoritative state reflects the change made from the artifact surface.
assert.equal(core.readValue('artifact.selected'), 'star-map');
assert.equal(core.stateRevision, before + 1, 'stateRevision increments exactly once for the one mutation transaction');
} finally {
closeAll(bus, artifact, info);
}
});
test('Event sequence remains one stream regardless of which surface originated the interaction', async () => {
const { core, bus } = makeOwner();
let artifact;
try {
artifact = await attachSurface(makeSurfaceContext());
core.applyMutation('lighting.level', 0.3, 'ui'); // "primary UI" origin
artifact.link.mutate('set', 'rotation.speed', 0.8); // non-primary surface origin
await waitForEvent(artifact.events, (e) => e.type === 'state.changed' && e.target === 'rotation.speed');
const sequences = core.eventLog().map((e) => e.sequence);
const sorted = sequences.slice().sort((a, b) => a - b);
assert.deepEqual(sequences, sorted, 'sequence is monotonic across both origins');
assert.equal(new Set(sequences).size, sequences.length, 'no duplicate sequence numbers -- one stream, not one per surface');
} finally {
closeAll(bus, artifact);
}
});
test('Detach does not mutate state, and a later reattach still observes it correctly', async () => {
const { core, bus } = makeOwner();
let first, second;
try {
first = await attachSurface(makeSurfaceContext());
assert.equal(bus.attachedCount(), 1);
const revisionBeforeDetach = core.stateRevision;
const eventCountBeforeDetach = core.eventLog().length;
first.link.detach();
await new Promise((r) => setTimeout(r, 20));
assert.equal(core.stateRevision, revisionBeforeDetach, 'closing a surface must not change stateRevision');
assert.equal(core.eventLog().length, eventCountBeforeDetach, 'closing a surface must not itself emit a contract event');
core.applyMutation('labels.enabled', false, 'ui');
second = await attachSurface(makeSurfaceContext());
assert.equal(second.snapshot.values['labels.enabled'], false, 'reopened surface reflects current state automatically');
} finally {
closeAll(bus, first, second);
}
});
test('No independent per-surface state exists: two surfaces attached at once never disagree', async () => {
const { core, bus } = makeOwner();
let surfaceA, surfaceB;
try {
surfaceA = await attachSurface(makeSurfaceContext());
surfaceB = await attachSurface(makeSurfaceContext());
for (const value of [0.1, 0.5, 0.95]) {
core.applyMutation('lighting.level', value, 'ui');
}
await waitForEvent(surfaceB.events, (e) => e.target === 'lighting.level' && e.value === 0.95, 500);
await new Promise((r) => setTimeout(r, 20));
const aValues = surfaceA.events.filter((e) => e.target === 'lighting.level').map((e) => e.value);
const bValues = surfaceB.events.filter((e) => e.target === 'lighting.level').map((e) => e.value);
assert.deepEqual(aValues, [0.1, 0.5, 0.95]);
assert.deepEqual(bValues, [0.1, 0.5, 0.95]);
assert.equal(core.readValue('lighting.level'), 0.95, 'the Core remains the single source of truth both surfaces converged on');
} finally {
closeAll(bus, surfaceA, surfaceB);
}
});
/* ------------------------------------------------------------------ *
* Standalone degrade behavior: a non-primary surface opened with no owner
* ------------------------------------------------------------------ */
test('A non-primary surface opened without the Control Room present times out and does not invent a second Core', async () => {
// No makeOwner() call in this test at all -- nothing is listening on the
// channel, matching "artifact.html opened with no control.html open".
const ctx = makeSurfaceContext();
const link = await expectTimeout(ctx, 150);
assert.equal(ctx.window.MuseumGalleryContract, undefined);
closeAll({ link });
});