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
376 lines
24 KiB
JavaScript
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);
|
|
});
|