Files
XZBT/test/phase4-visual-contract.test.mjs
T
LabyricornandClaude Opus 5 c4332363a9 docs: raise the format specification to revision 0.9 and land the reconciliation
Revision 0.9 adds section 20, the cadence and event subsystems contract, and
carries two corrections the implementation forced. Section 6.1 now states that a
duration is the authored literal or a non-negative finite number already in
milliseconds, since a DurationSpec may be the resolved output of a ValueSpec or
a bounded TimeSpec, with the one documented exception of an automation track's
`at`, which 19.1 keeps literal-only so that point ordering stays decidable at
import. Section 20.11 documents the rejection of an undeclared input name in an
event action's `with` map as ERR_UNKNOWN_FIELD — the section's own convention
for that shape of error, replacing an invented code that appeared nowhere in the
registry.

The review record is committed with the code it describes: the two code triages
that found these defects, the reconciliation plan that sequenced the fixes, and
a follow-up debt record listing what was deliberately left open — the unchecked
JSON Schema artifact, degenerate path arcs, post-effect transient allocation,
the window-traffic fixture's per-copy wrap bounds, and the unstated
`ownership: "persistent"` value on a sound action. None of the five blocks phase
6; all five are written down rather than dropped.

Devlog entries are backfilled for the two milestones that had none: phase 3c
slice 2, the audio lifecycle and voice ceilings, and slice 4d, the renderer
core. The implementation status summary now reflects the reconciled state rather
than the in-flight one.

231 tests pass. tools/verify-spec-contract.py reports 46 declared diagnostic
codes with every used code resolving and its two long-standing unresolved
cross-references unchanged.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01ShxxFqFmCUDQnQvFNm4TKy
2026-09-06 21:54:09 +00:00

376 lines
24 KiB
JavaScript

// Phase 4 Stage 0 — shared pre-renderer alignment.
//
// Covers the parts of sections 17-19 (revision 0.8) that the schema, the
// validator, and the shared resolution pipeline must agree on before slice 4d
// writes any renderer code: the `visuals` container surface, the scene and
// layer rules, the `spawn` lifecycle container, procedural fields, the
// post-effect chain, visual automation and its two scopes, and the four
// section 8.1 visual target families.
//
// It draws nothing. Traces of 17.16, 18.10, and 19.7 that require a renderer
// belong to slices 4d, 4e, and 4f and are not claimed here.
import assert from 'node:assert/strict';
import test from 'node:test';
import { ResolutionEngine } from '../src/runtime/resolution.js';
import { SeededRNG } from '../src/runtime/rng.js';
import { RuntimeFault, parseDuration } from '../src/runtime/types.js';
import { validateExhibit } from '../src/runtime/validator.js';
import { POST_EFFECTS, VISUAL_LIMITS, matchVisualTarget, postEffectPassCount } from '../src/runtime/visual-contract.js';
function document(visuals, overrides = {}) {
return {
xzbt: '0.1',
meta: { id: 'visual-study', name: 'Visual Study' },
runtime: { seed: 42 },
state: { energy: { type: 'number', initial: 0.5, min: 0, max: 1 } },
visuals,
...overrides
};
}
const scene = { coordinateSpace: 'virtual', width: 1600, height: 900 };
const graphic = (extra = {}) => ({ type: 'graphic', content: { band: { type: 'rectangle', size: { width: 10, height: 2 } } }, ...extra });
function codes(doc) {
return validateExhibit(doc).errors.map((error) => error.code);
}
function expectClean(doc, label) {
const result = validateExhibit(doc);
assert.deepEqual(result.errors, [], `${label} should validate: ${JSON.stringify(result.errors)}`);
}
// ---------------------------------------------------------------------------
// 17.3-17.5 — the container, the scene, and layers
// ---------------------------------------------------------------------------
test('the visuals container accepts exactly its seven declared fields (17.3, V1)', () => {
expectClean(document({ scene, automation: [] }), 'exhibit-scope automation');
assert.ok(codes(document({ scene, passes: [] })).includes('ERR_UNKNOWN_FIELD'));
assert.ok(codes(document({})).includes('ERR_SCHEMA_VALIDATION'), 'scene is required when visuals is present');
});
test('virtual space requires width and height, and no other space accepts them (17.4)', () => {
assert.ok(codes(document({ scene: { coordinateSpace: 'virtual', width: 1600 } })).includes('ERR_SCHEMA_VALIDATION'));
assert.ok(codes(document({ scene: { coordinateSpace: 'normalized', width: 1 } })).includes('ERR_UNKNOWN_FIELD'));
expectClean(document({ scene: { coordinateSpace: 'normalized' } }), 'normalized scene');
});
test('viewport accepts an absent fit and rejects only an explicit non-stretch one (17.4, V17)', () => {
expectClean(document({ scene: { coordinateSpace: 'viewport' } }), 'absent fit in viewport');
expectClean(document({ scene: { coordinateSpace: 'viewport', fit: 'stretch' } }), 'explicit stretch in viewport');
assert.ok(codes(document({ scene: { coordinateSpace: 'viewport', fit: 'contain' } })).includes('ERR_SCHEMA_VALIDATION'));
});
test('the layer map decides whether a system must name a layer (17.5, 17.7, V17)', () => {
// Absent map: the implicit layer, and naming one is a dangling reference.
expectClean(document({ scene, systems: { horizon: graphic() } }), 'implicit layer');
assert.ok(codes(document({ scene, systems: { horizon: graphic({ layer: 'far' }) } })).includes('ERR_INVALID_REFERENCE'));
// Present map: naming one is required, and no first-layer fallback exists.
expectClean(document({ scene, layers: { far: {} }, systems: { horizon: graphic({ layer: 'far' }) } }), 'declared layer');
assert.ok(codes(document({ scene, layers: { far: {} }, systems: { horizon: graphic() } })).includes('ERR_INVALID_REFERENCE'));
assert.ok(codes(document({ scene, layers: { far: {} }, systems: { horizon: graphic({ layer: 'near' }) } })).includes('ERR_INVALID_REFERENCE'));
// Present but empty is not a layer set.
assert.ok(codes(document({ scene, layers: {} })).includes('ERR_SCHEMA_VALIDATION'));
});
test('more than sixteen layers is a visual limit (17.5, 19.5)', () => {
const layers = Object.fromEntries(Array.from({ length: 17 }, (_, index) => [`l${index}`, {}]));
assert.ok(codes(document({ scene, layers })).includes('ERR_VISUAL_LIMIT_EXCEEDED'));
delete layers.l16;
expectClean(document({ scene, layers }), 'sixteen layers');
});
// ---------------------------------------------------------------------------
// 17.7 / 19.2 — the spawn container (A1)
// ---------------------------------------------------------------------------
test('spawned lifecycle fields live only in the spawn container (17.7, 19.2, A1)', () => {
const spawned = (spawn, extra = {}) => document({ scene, systems: { burst: { ...graphic(), lifecycle: 'spawned', spawn, ...extra } } });
expectClean(spawned({ lifetime: '2s', release: '250ms' }), 'spawn container');
// A persistent system has no spawn container at all.
assert.ok(codes(document({ scene, systems: { horizon: graphic({ spawn: { lifetime: '2s' } }) } })).includes('ERR_UNKNOWN_FIELD'));
// The four spawn-only names never appear at a system's top level.
for (const field of ['release', 'ownership', 'inputs', 'cancelWithScenario']) {
const doc = document({ scene, systems: { horizon: graphic({ [field]: field === 'cancelWithScenario' ? true : 'x' }) } });
assert.ok(codes(doc).includes('ERR_UNKNOWN_FIELD'), `${field} at system top level`);
}
});
test('a top-level lifetime is the per-item duration, and only two types have one (17.7, A1)', () => {
const particles = { type: 'particles', render: { type: 'point' }, count: 10, lifetime: '4s' };
expectClean(document({ scene, systems: { motes: particles } }), 'particle lifetime');
// The same key on a graphic or repeater is an unknown field, not an instance duration.
assert.ok(codes(document({ scene, systems: { horizon: graphic({ lifetime: '4s' }) } })).includes('ERR_UNKNOWN_FIELD'));
// A spawned particles system carries both, independently.
expectClean(document({ scene, systems: { motes: { ...particles, lifecycle: 'spawned', spawn: { lifetime: '30s' } } } }), 'both lifetimes');
});
test('a spawn duration is a literal or a non-negative number of milliseconds (6.1, C5)', () => {
const spawned = (spawn) => document({ scene, systems: { burst: { ...graphic(), lifecycle: 'spawned', spawn } } });
// Both authored forms of the same duration validate.
expectClean(spawned({ lifetime: '2s', release: '250ms' }), 'literal durations');
expectClean(spawned({ lifetime: 2000, release: 250 }), 'millisecond durations');
expectClean(spawned({ lifetime: 0 }), 'zero milliseconds');
// Everything else at that field is ERR_INVALID_DURATION, negatives included.
for (const value of [-1, Number.NaN, Number.POSITIVE_INFINITY, '2 s', '1m30s', true, {}]) {
assert.ok(codes(spawned({ lifetime: value })).includes('ERR_INVALID_DURATION'), `lifetime ${String(value)}`);
}
});
test('parseDuration reads both forms and rejects the rest (6.1, C5)', () => {
assert.equal(parseDuration('2s'), 2000);
assert.equal(parseDuration(2000), 2000);
assert.equal(parseDuration(0), 0);
for (const value of [-1, Number.NaN, Number.POSITIVE_INFINITY, '1m30s', null, true]) {
assert.throws(() => parseDuration(value), (error) => error instanceof RuntimeFault && error.code === 'ERR_INVALID_DURATION', `parseDuration ${String(value)}`);
}
});
test('severing the scenario relationship requires persistent ownership (19.2, V23)', () => {
const doc = (spawn) => document({ scene, systems: { burst: { ...graphic(), lifecycle: 'spawned', spawn } } });
assert.ok(codes(doc({ cancelWithScenario: false })).includes('ERR_UNSUPPORTED_TARGET'));
expectClean(doc({ cancelWithScenario: false, ownership: 'persistent' }), 'persistent-owned survivor');
expectClean(doc({ cancelWithScenario: true }), 'default relationship');
});
// ---------------------------------------------------------------------------
// 18.7 — procedural fields (A2)
// ---------------------------------------------------------------------------
test('a value-mode noise field requires direction and no other mode accepts it (18.7, A2)', () => {
const field = (extra) => document({ scene, fields: { current: { type: 'noise', scale: 200, ...extra } } });
expectClean(field({ mode: 'value', direction: 90 }), 'value mode with direction');
assert.ok(codes(field({ mode: 'value' })).includes('ERR_SCHEMA_VALIDATION'));
assert.ok(codes(field({ mode: 'curl', direction: 90 })).includes('ERR_UNKNOWN_FIELD'));
expectClean(field({}), 'curl by default');
assert.ok(codes(field({ octaves: 5 })).includes('ERR_OUT_OF_BOUNDS'));
assert.ok(codes(document({ scene, fields: { current: { type: 'swirl' } } })).includes('ERR_INVALID_FIELD_TYPE'));
});
// ---------------------------------------------------------------------------
// 19.3 / 19.4 — camera and post-effects
// ---------------------------------------------------------------------------
test('focalLength has a closed range with a real lower bound (19.3, V20)', () => {
expectClean(document({ scene, camera: { focalLength: 1 } }), 'the documented minimum');
assert.ok(codes(document({ scene, camera: { focalLength: 0 } })).includes('ERR_OUT_OF_BOUNDS'));
assert.ok(codes(document({ scene, camera: { focalLength: 0.5 } })).includes('ERR_OUT_OF_BOUNDS'));
assert.ok(codes(document({ scene, camera: { zoom: 200 } })).includes('ERR_OUT_OF_BOUNDS'));
assert.ok(codes(document({ scene, camera: { projection: 'isometric' } })).includes('ERR_SCHEMA_VALIDATION'));
});
test('the post-effect chain is closed at seven types and four entries (19.4)', () => {
expectClean(document({ scene, effects: [{ type: 'bloom', threshold: 0.6 }] }), 'a bloom entry');
assert.ok(codes(document({ scene, effects: [{ type: 'chromatic' }] })).includes('ERR_INVALID_EFFECT_TYPE'));
assert.ok(codes(document({ scene, effects: [{ type: 'blur', threshold: 1 }] })).includes('ERR_UNKNOWN_FIELD'));
assert.ok(codes(document({ scene, effects: [{ type: 'blur', radius: 64 }] })).includes('ERR_OUT_OF_BOUNDS'));
const five = Array.from({ length: 5 }, () => ({ type: 'fade' }));
assert.ok(codes(document({ scene, effects: five })).includes('ERR_VISUAL_LIMIT_EXCEEDED'));
});
test('blur and bloom cost two frame passes each, everything else one (19.4)', () => {
assert.equal(postEffectPassCount([{ type: 'blur' }, { type: 'bloom' }, { type: 'fade' }, { type: 'grain' }]), 6);
// Four entries of two passes is exactly the eight-pass ceiling of 19.5.
assert.equal(postEffectPassCount(Array.from({ length: 4 }, () => ({ type: 'blur' }))), VISUAL_LIMITS.runtime.postEffectPassesPerFrame);
});
// ---------------------------------------------------------------------------
// 19.1 — visual automation
// ---------------------------------------------------------------------------
const track = (target, extra = {}) => ({ target, points: [{ at: '0ms', value: 0 }, { at: '2s', value: 1 }], ...extra });
test('exhibit-scope automation reaches scene, layers, camera, and effects (19.1)', () => {
expectClean(document({ scene, layers: { far: {} }, camera: {}, effects: [{ type: 'fade' }], automation: [
track('camera.zoom'), track('layers.far.opacity'), track('effects[0].amount'),
track('scene.depthFog.density')
], systems: {} }), 'every exhibit-scope family');
assert.ok(codes(document({ scene, automation: [track('camera.projection')] })).includes('ERR_UNSUPPORTED_TARGET'));
assert.ok(codes(document({ scene, layers: { far: {} }, automation: [track('layers.near.opacity')] })).includes('ERR_INVALID_REFERENCE'));
assert.ok(codes(document({ scene, effects: [{ type: 'fade' }], automation: [track('effects[3].amount')] })).includes('ERR_INVALID_REFERENCE'));
assert.ok(codes(document({ scene, effects: [{ type: 'fade' }], automation: [track('effects[0].color')] })).includes('ERR_UNSUPPORTED_TARGET'));
});
test('the two declaration scopes never reach across (19.1)', () => {
const doc = document({ scene, systems: { horizon: { ...graphic(), automation: [track('camera.zoom')] } } });
assert.ok(codes(doc).includes('ERR_INVALID_REFERENCE'), 'system scope cannot address the camera');
const other = document({ scene, systems: { horizon: graphic(), motes: { ...graphic(), automation: [track('systems.horizon.band.z')] } } });
assert.ok(codes(other).includes('ERR_INVALID_REFERENCE'), 'no track addresses another system');
assert.ok(codes(document({ scene, systems: { horizon: graphic() }, automation: [track('systems.horizon.band.z')] })).includes('ERR_INVALID_REFERENCE'));
});
test('a repeater exposes no automatable property, step included (19.1, V11)', () => {
const repeater = (automation) => document({ scene, systems: { wall: { type: 'repeater', repeat: { type: 'point' }, count: 4, automation } } });
assert.ok(codes(repeater([track('step.x')])).includes('ERR_UNSUPPORTED_TARGET'));
assert.ok(codes(repeater([track('count')])).includes('ERR_UNSUPPORTED_TARGET'));
// A particle system does expose its live channels.
const particles = document({ scene, systems: { motes: { type: 'particles', render: { type: 'point' }, count: 4, automation: [track('rate'), track('drag')] } } });
expectClean(particles, 'particle live channels');
});
test('a graphic system automates numeric object properties by container key (19.1)', () => {
const system = { type: 'graphic', content: { hull: { type: 'group', children: { band: { type: 'rectangle', size: { width: 4, height: 2 } } } } } };
expectClean(document({ scene, systems: { rig: { ...system, automation: [track('hull.band.transform.rotation')] } } }), 'nested numeric property');
assert.ok(codes(document({ scene, systems: { rig: { ...system, automation: [track('hull.band.style.blend')] } } })).includes('ERR_UNSUPPORTED_TARGET'));
assert.ok(codes(document({ scene, systems: { rig: { ...system, automation: [track('hull.missing.z')] } } })).includes('ERR_INVALID_REFERENCE'));
});
test('an infinite loop is legal in every scope (19.1, V12)', () => {
const loop = { mode: 'ping-pong', count: 'infinite' };
expectClean(document({ scene, camera: {}, automation: [track('camera.zoom', { loop })] }), 'exhibit scope');
expectClean(document({ scene, systems: { motes: { type: 'particles', render: { type: 'point' }, count: 1, automation: [track('rate', { loop })] } } }), 'persistent system');
// Both spawned cases: with a declared instance lifetime and without one.
const spawned = (spawn) => document({ scene, systems: { motes: { type: 'particles', render: { type: 'point' }, count: 1, lifecycle: 'spawned', spawn, automation: [track('rate', { loop })] } } });
expectClean(spawned({ lifetime: '8s' }), 'finite-lifetime spawned');
expectClean(spawned({}), 'indefinite spawned');
});
test('one track per target, strictly increasing point times (19.1)', () => {
const doc = document({ scene, camera: {}, automation: [track('camera.zoom'), track('camera.zoom')] });
assert.ok(codes(doc).includes('ERR_AUTOMATION_CONFLICT'));
const backwards = { target: 'camera.zoom', points: [{ at: '2s', value: 0 }, { at: '1s', value: 1 }] };
assert.ok(codes(document({ scene, camera: {}, automation: [backwards] })).includes('ERR_INVALID_RANGE_ORDER'));
const procedural = { target: 'camera.zoom', points: [{ at: { random: { min: 0, max: 1 } }, value: 0 }, { at: '1s', value: 1 }] };
assert.ok(codes(document({ scene, camera: {}, automation: [procedural] })).includes('ERR_INVALID_DURATION'));
});
test('declared automation records are an authoring bound counted once each (19.1, V7)', () => {
const many = Array.from({ length: 129 }, (_, index) => track(`effects[${index % 4}].amount`));
const doc = document({ scene, effects: Array.from({ length: 4 }, () => ({ type: 'fade' })), automation: many });
assert.ok(codes(doc).includes('ERR_VISUAL_LIMIT_EXCEEDED'));
});
// ---------------------------------------------------------------------------
// 8.1 / 19.1 — the four visual target families
// ---------------------------------------------------------------------------
test('matchVisualTarget separates unsupported targets from dangling references (19.1, V2)', () => {
const doc = document({ scene, layers: { far: {} }, systems: { horizon: graphic({ layer: 'far' }) }, effects: [{ type: 'blur' }] });
for (const path of ['visuals.camera.zoom', 'visuals.layers.far.opacity', 'visuals.systems.horizon.visible', 'visuals.effects[0].radius']) {
assert.equal(matchVisualTarget(doc, path).reason, undefined, path);
}
assert.equal(matchVisualTarget(doc, 'visuals.layers.near.opacity').reason, 'reference');
assert.equal(matchVisualTarget(doc, 'visuals.effects[2].radius').reason, 'reference');
assert.equal(matchVisualTarget(doc, 'visuals.layers.far.visible').reason, 'unsupported', 'layer visibility is deliberately absent');
assert.equal(matchVisualTarget(doc, 'visuals.camera.projection').reason, 'unsupported');
assert.equal(matchVisualTarget(doc, 'visuals.systems.horizon.content.band.style.opacity').reason, 'unsupported');
assert.equal(matchVisualTarget(doc, 'parameters.activity'), null);
});
test('the stage table matches the four section 8.1 rows (19.1)', () => {
const doc = document({ scene, layers: { far: {} }, systems: { horizon: graphic({ layer: 'far' }) }, effects: [{ type: 'blur' }] });
assert.deepEqual(matchVisualTarget(doc, 'visuals.camera.zoom').stages, { binding: true, automation: true, override: true, modulation: true });
assert.deepEqual(matchVisualTarget(doc, 'visuals.layers.far.opacity').stages, { binding: true, automation: true, override: true, modulation: false });
// A boolean target takes no automation and no modulation: absent, not identity hooks.
assert.deepEqual(matchVisualTarget(doc, 'visuals.systems.horizon.visible').stages, { binding: true, automation: false, override: true, modulation: false });
assert.deepEqual(matchVisualTarget(doc, 'visuals.effects[0].radius').stages, { binding: true, automation: true, override: true, modulation: false });
});
test('bindings reach the four families and nothing else (8.1, 19.1, V2)', () => {
const base = { scene, layers: { far: {} }, systems: { horizon: graphic({ layer: 'far' }) }, effects: [{ type: 'blur' }] };
const bound = (target) => document(base, { bindings: [{ source: 'state.energy', target }] });
for (const target of ['visuals.camera.zoom', 'visuals.layers.far.opacity', 'visuals.effects[0].radius']) {
expectClean(bound(target), target);
}
assert.ok(codes(bound('visuals.systems.horizon.content.band.style.opacity')).includes('ERR_UNSUPPORTED_TARGET'));
assert.ok(codes(bound('visuals.fields.current.strength')).includes('ERR_UNSUPPORTED_TARGET'));
assert.ok(codes(bound('visuals.layers.near.opacity')).includes('ERR_INVALID_REFERENCE'));
// A boolean target and a numeric source do not match.
assert.ok(codes(bound('visuals.systems.horizon.visible')).includes('ERR_TYPE_MISMATCH'));
});
// ---------------------------------------------------------------------------
// Resolution — bases, clamps, and stage exposure
// ---------------------------------------------------------------------------
function engine(visuals, overrides = {}) {
const doc = document(visuals, overrides);
return new ResolutionEngine(doc, new SeededRNG(42));
}
test('visual target bases resolve from the document, or from their documented defaults (19.3)', () => {
const resolver = engine({ scene, layers: { far: { opacity: 0.4 } }, systems: { horizon: graphic({ layer: 'far' }) }, camera: { zoom: 2 }, effects: [{ type: 'blur', radius: 6 }] });
assert.equal(resolver.get('visuals.camera.zoom'), 2);
assert.equal(resolver.get('visuals.camera.focalLength'), 1000, 'documented default');
assert.equal(resolver.get('visuals.camera.x'), 800, 'the scene center, so a declared default looks identical');
assert.equal(resolver.get('visuals.layers.far.opacity'), 0.4);
assert.equal(resolver.get('visuals.systems.horizon.visible'), true);
assert.equal(resolver.get('visuals.effects[0].radius'), 6);
// A parameter the effect's own table does not declare exposes no capability.
assert.throws(() => resolver.get('visuals.effects[0].threshold'), (error) => error.code === 'ERR_UNSUPPORTED_TARGET');
});
test('a resolved camera value clamps into the closed range rather than failing (19.3, V20)', () => {
const resolver = engine({ scene, camera: { focalLength: { ref: 'state.energy' } } });
// state.energy is 0.5, below the documented minimum of 1.
assert.equal(resolver.get('visuals.camera.focalLength'), 1);
});
test('a layer opacity binding runs through the shared pipeline and clamps to [0, 1] (8.1)', () => {
const resolver = engine(
{ scene, layers: { far: { opacity: 0.2 } } },
{ bindings: [{ source: 'state.energy', target: 'visuals.layers.far.opacity', scale: 4 }] }
);
assert.equal(resolver.get('visuals.layers.far.opacity'), 1, '0.5 * 4 clamps to the row range');
});
test('the automation stage is refused where the 8.1 row does not expose it (8.1, 19.1)', () => {
const resolver = engine({ scene, systems: { horizon: graphic() }, camera: {} });
assert.doesNotThrow(() => resolver.requireStage('visuals.camera.zoom', 'automation'));
assert.throws(() => resolver.requireStage('visuals.systems.horizon.visible', 'automation'), (error) => error instanceof RuntimeFault && error.code === 'ERR_UNSUPPORTED_TARGET');
assert.throws(() => resolver.requireStage('visuals.layers.far.opacity', 'modulation'), (error) => error.code === 'ERR_UNSUPPORTED_TARGET');
assert.throws(() => resolver.requireStage('parameters.nope', 'automation'), (error) => error.code === 'ERR_UNSUPPORTED_TARGET');
});
test('effect parameter ranges in the contract match the 19.4 tables', () => {
assert.equal(POST_EFFECTS.bloom.numeric.intensity.max, 2);
assert.equal(POST_EFFECTS.grain.numeric.speed.max, 60);
assert.equal(POST_EFFECTS.scanlines.numeric.spacing.min, 1);
assert.equal(VISUAL_LIMITS.authoring.declaredSystems, 64);
assert.equal(VISUAL_LIMITS.authoring.expandedStaticObjects, 16384);
});
// ---------------------------------------------------------------------------
// Fixture and schema integrity
// ---------------------------------------------------------------------------
test('the minimal visual exhibit validates end to end', async () => {
const { readFileSync } = await import('node:fs');
const { parseAndValidateExhibit } = await import('../src/runtime/validator.js');
const source = readFileSync(new URL('../exhibits/minimal-visual.xzbt', import.meta.url), 'utf8');
const result = parseAndValidateExhibit(source, 'minimal-visual.xzbt');
assert.deepEqual(result.errors, [], JSON.stringify(result.errors));
// Its three bindings reach three of the four families through the pipeline.
const resolver = new ResolutionEngine(result.document, new SeededRNG(42));
assert.equal(resolver.get('visuals.camera.zoom'), 1, 'bound to parameters.depth');
assert.equal(resolver.get('visuals.layers.near.opacity'), 0.35);
assert.ok(Math.abs(resolver.get('visuals.effects[0].amount') - 0.21) < 1e-12);
assert.equal(resolver.get('visuals.systems.flare.visible'), true);
});
test('every $ref in the JSON Schema resolves to a declared definition', async () => {
const { readFileSync } = await import('node:fs');
const schema = JSON.parse(readFileSync(new URL('../schema/xzbt-0.1.schema.json', import.meta.url), 'utf8'));
const declared = new Set(Object.keys(schema.definitions));
const missing = [];
const walk = (node) => {
if (Array.isArray(node)) return node.forEach(walk);
if (typeof node !== 'object' || node === null) return;
for (const [key, value] of Object.entries(node)) {
if (key === '$ref' && typeof value === 'string') {
const name = value.replace('#/definitions/', '');
if (!declared.has(name)) missing.push(value);
} else walk(value);
}
};
walk(schema);
assert.deepEqual(missing, []);
// 17.3: the visuals container declares exactly its seven fields.
assert.deepEqual(Object.keys(schema.properties.visuals.properties).sort(),
['automation', 'camera', 'effects', 'fields', 'layers', 'scene', 'systems']);
// 17.8: a visual object carries no `id`; its container key is its identity.
assert.equal(schema.definitions.VisualObject.properties.id, false);
});