generated from Labyricorn/labyricorn-project-template
424 lines
18 KiB
JavaScript
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 });
|
|
});
|