Files
XZBT/test/phase4-execution.test.mjs
LabyricornandClaude Opus 5 a579b8e41f feat(visual): implement the slice 4f execution, automation, and post-effects
Visual automation in both of its scopes — exhibit-scope tracks measured from
activation and system-scope tracks measured from an instance's own
instantiation, so a track written against spawn-relative time behaves the same
whether the spawn happens at four seconds or at four minutes — together with the
spawned-system lifecycle, the camera, and the seven post-effects of 19.4.

Automation on a missing target object now raises ERR_INVALID_REFERENCE rather
than an unguarded TypeError, per 19.1. Post-effect channel math is clamped
explicitly before assignment and the device blur radius is capped against a
pixel budget, so a large projected radius cannot turn a legal exhibit into a
frame-long stall. Trail fade tapers per segment from the item's own head opacity
to the tail factor, which is what 18.8 describes; the previous flat alpha
ignored the item's ramped opacity entirely. The canvas backend consumes the
per-object buffer grants the engine allocates, rasterizing a buffered node once
and compositing it once under its own blend and alpha, rather than compositing
fill, stroke, glow, and filter separately and double-blending the overlap. Glow
is drawn as a halo around the result rather than painted over it, and text now
gets the glow branch it was silently missing.

Traces 19.7.3 and 19.7.4 are implemented as specified rather than approximated:
the two-spawn four-second offset assertion, and the four negative target cases
that separate ERR_INVALID_REFERENCE from ERR_UNSUPPORTED_TARGET.

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

278 lines
17 KiB
JavaScript

import test from 'node:test';
import assert from 'node:assert/strict';
import { VisualEngine } from '../src/runtime/visual-engine.js';
import { ResolutionEngine } from '../src/runtime/resolution.js';
import { SeededRNG } from '../src/runtime/rng.js';
import { ActionExecutor } from '../src/runtime/actions.js';
import { VisualDiagnosticCadence } from '../src/runtime/visual-diagnostics.js';
import { VisualInstance } from '../src/runtime/visual-lifecycle.js';
import { applyVisualEffect, renderVisualEffects } from '../src/runtime/visual-effects.js';
import { validateExhibit } from '../src/runtime/validator.js';
const point = { type: 'point', position: { x: 10, y: 20 }, style: { fill: '#ffffff', pointSize: 3 } };
const track = (target, a = 0, b = 1, extra = {}) => ({ target, points: [{ at: '0ms', value: a }, { at: '1s', value: b }], ...extra });
function setup(visuals, extra = {}) {
const document = { xzbt: '0.1', meta: { id: 'execution-test', name: 'Execution test' }, runtime: { seed: 42 },
visuals: { scene: { coordinateSpace: 'virtual', width: 100, height: 100 }, ...visuals }, ...extra };
const rng = new SeededRNG(42), resolution = new ResolutionEngine(document, rng);
const engine = new VisualEngine(document, { rng, resolution });
const tick = ms => { resolution.advance(ms); engine.advance(ms); };
return { document, engine, resolution, rng, tick, frame: () => engine.planFrame({ width: 100, height: 100 }) };
}
const close = (actual, expected) => assert.ok(Math.abs(actual - expected) < 1e-8, `${actual} != ${expected}`);
test('19.7.1 shared curves and modes drive actual graphic geometry', () => {
for (const interpolation of ['step', 'linear', 'smooth', 'exponential']) for (const mode of ['absolute', 'offset', 'scale']) {
const run = setup({ systems: { s: { type: 'graphic', content: { p: point }, automation: [track('p.position.x', 2, 8, { interpolation, mode })] } } });
run.tick(500);
const value = interpolation === 'step' ? 2 : interpolation === 'exponential' ? 4 : 5;
close(run.frame().layers[0].nodes[0].center[0], mode === 'offset' ? 10 + value : mode === 'scale' ? 10 * value : value);
}
});
test('19.7.2 repeat and finite ping-pong loops hold the correct endpoint', () => {
for (const mode of ['repeat', 'ping-pong']) {
const run = setup({ camera: { zoom: 1 }, automation: [track('camera.zoom', 1, 3, { loop: { mode, count: 2 } })], systems: {} });
run.tick(1500); close(run.frame().camera.zoom, 2);
run.tick(3500); close(run.frame().camera.zoom, mode === 'repeat' ? 3 : 1);
}
});
test('4e rendered graphic motion, nested repeater motion, and morph reach geometry', () => {
const run = setup({ systems: {
s: { type: 'graphic', content: {
p: { ...point, behaviors: [{ type: 'drift', velocity: { x: 10, y: 0 } }] },
a: { type: 'polyline', points: [{ x: 0, y: 0 }, { x: 10, y: 0 }], behaviors: [{ type: 'morph', to: 'b', duration: '1s' }] },
b: { type: 'polyline', visible: false, points: [{ x: 0, y: 10 }, { x: 20, y: 10 }] }
} },
r: { type: 'repeater', count: 1, repeat: { ...point, behaviors: [{ type: 'drift', velocity: { x: 20, y: 0 } }] } }
} });
run.tick(500);
const nodes = run.frame().layers[0].nodes;
close(nodes[0].center[0], 15);
close(nodes[1].subpaths[0].start[1], 5);
close(nodes[2].children[0].center[0], 20);
});
test('19.7.4 behavior and automation collision is rejected at import', () => {
const run = setup({ systems: {} });
run.document.visuals.systems.s = { type: 'graphic', content: { p: { ...point, behaviors: [{ type: 'drift', velocity: { x: 1 } }] } }, automation: [track('p.position.x')] };
assert.ok(validateExhibit(run.document).errors.some(error => error.code === 'ERR_AUTOMATION_CONFLICT'));
});
test('19.7.4 cross-scope, undeclared, and out-of-registry targets raise documented errors', () => {
const doc = visuals => ({ xzbt: '0.1', meta: { id: 't', name: 'T' }, runtime: { seed: 42 }, visuals: { scene: { coordinateSpace: 'virtual', width: 100, height: 100 }, ...visuals } });
// Cross-scope target from exhibit to system: ERR_INVALID_REFERENCE
const r1 = doc({ systems: { s: { type: 'graphic', content: { p: point } } }, automation: [track('systems.s.p.position.x')] });
assert.ok(validateExhibit(r1).errors.some(e => e.code === 'ERR_INVALID_REFERENCE'));
// Cross-scope target from system to camera: ERR_INVALID_REFERENCE
const r2 = doc({ systems: { s: { type: 'graphic', content: { p: point }, automation: [track('camera.zoom')] } } });
assert.ok(validateExhibit(r2).errors.some(e => e.code === 'ERR_INVALID_REFERENCE'));
// Undeclared layer in exhibit scope: ERR_INVALID_REFERENCE
const r3 = doc({ layers: { main: {} }, systems: { s: { type: 'graphic', layer: 'main', content: { p: point } } }, automation: [track('layers.missing.opacity')] });
assert.ok(validateExhibit(r3).errors.some(e => e.code === 'ERR_INVALID_REFERENCE'));
// Out-of-range effect index: ERR_INVALID_REFERENCE
const r4 = doc({ effects: [{ type: 'blur', radius: 4 }], systems: { s: { type: 'graphic', content: { p: point } } }, automation: [track('effects[5].radius')] });
assert.ok(validateExhibit(r4).errors.some(e => e.code === 'ERR_INVALID_REFERENCE'));
// Out-of-registry target (scene.background is a color, not automatable): ERR_UNSUPPORTED_TARGET
const r5 = doc({ systems: { s: { type: 'graphic', content: { p: point } } }, automation: [track('scene.background')] });
assert.ok(validateExhibit(r5).errors.some(e => e.code === 'ERR_UNSUPPORTED_TARGET'));
});
test('19.7.3 system-scope track at is measured from instantiation (two spawns offset by 4s)', () => {
const visuals = {
systems: {
s: {
type: 'graphic',
lifecycle: 'spawned',
content: { p: { ...point, position: { x: 0, y: 0 } } },
automation: [track('p.position.x', 0, 100)]
}
}
};
const run = setup(visuals);
const a = run.engine.spawn('s');
run.tick(4000);
const b = run.engine.spawn('s');
// At t=4000ms, b has age 0. Advance 500ms further (halfway through the 1s track).
run.tick(500);
// b's age is 500ms, so its position.x should be 50.
close(b.system.objects[0].position.x, 50);
});
test('19.7.7 override masks moving visual automation and releases to its current value', () => {
const run = setup({ automation: [track('camera.zoom', 1, 3)], systems: {} });
const actions = new ActionExecutor(run.resolution);
const [result] = actions.execute([{ type: 'override', target: 'visuals.camera.zoom', value: 5, scope: 'duration', duration: '10s', release: '0ms' }]);
run.tick(500); close(run.frame().camera.zoom, 5);
run.resolution.overrides.beginRelease(result.instanceId); run.tick(100);
close(run.frame().camera.zoom, 2.2);
});
test('19.7.8-10 spawned templates activate next tick, fade, dispose and reproduce', () => {
const visuals = { systems: { s: { type: 'graphic', lifecycle: 'spawned', spawn: { release: '1s' }, content: { p: { ...point, position: { x: { random: { min: 0, max: 100 } }, y: 20 }, style: { opacity: 0.4 } } } } } };
const first = setup(visuals), second = setup(visuals);
const a = first.engine.spawn('s'), b = second.engine.spawn('s');
assert.equal(first.frame().layers[0].nodes.length, 0);
first.tick(10); second.tick(10);
assert.deepEqual(first.frame().layers, second.frame().layers);
first.engine.remove(a.id); first.tick(500);
close(first.frame().layers[0].nodes[0].alpha, 0.2);
first.tick(500); assert.equal(a.state, 'DISPOSED');
first.engine.remove(a.id); assert.equal(first.engine.instances.size, 0);
assert.equal(b.state, 'ACTIVE');
});
test('19.7.9 every lifecycle edge is enforced, zero release lasts a tick', () => {
const edges = { CREATED: ['ACTIVE', 'FINISHED', 'FAILED'], ACTIVE: ['RELEASING', 'FINISHED', 'FAILED'], RELEASING: ['FINISHED', 'FAILED'], FINISHED: ['DISPOSED'], DISPOSED: [], FAILED: [] };
for (const [from, allowed] of Object.entries(edges)) for (const to of Object.keys(edges)) {
const instance = new VisualInstance({ system: { objects: [] } }); instance.state = from;
if (allowed.includes(to)) assert.doesNotThrow(() => instance.transition(to));
else assert.throws(() => instance.transition(to));
}
const run = setup({ systems: { s: { type: 'graphic', lifecycle: 'spawned', content: { p: point } } } });
const instance = run.engine.spawn('s'); run.tick(10); run.engine.remove(instance.id);
assert.equal(instance.state, 'RELEASING'); assert.equal(instance.factor, 0);
run.tick(10); assert.equal(instance.state, 'DISPOSED');
});
test('19.7.11 ownership, originating scenario and forced cleanup are separate', () => {
const run = setup({ systems: { s: { type: 'graphic', lifecycle: 'spawned', spawn: { ownership: 'persistent', cancelWithScenario: false, release: '10s' }, content: { p: point } } } });
const inherited = run.engine.spawn('s', { owner: 'scenario' });
const persistent = run.engine.spawn('s', { owner: 'scenario', ownership: 'persistent' });
run.tick(10); run.engine.cleanup('scenario');
assert.equal(inherited.state, 'RELEASING'); assert.equal(persistent.state, 'ACTIVE');
run.tick(5100); assert.equal(inherited.state, 'DISPOSED');
assert.ok(run.engine.cadence.raised.some(w => w.code === 'WARN_CLEANUP_FORCED'));
});
test('19.7.6/12 live spawn and automation budgets refuse atomically without consuming ordinal', () => {
for (const manyTracks of [false, true]) {
const content = Object.fromEntries(Array.from({ length: 3 }, (_, i) => [`p${i}`, point]));
const run = setup({ systems: { s: { type: 'graphic', lifecycle: 'spawned', content, automation: manyTracks ? [0, 1, 2].map(i => track(`p${i}.position.x`)) : [] } } });
const count = manyTracks ? 42 : 64;
const instances = Array.from({ length: count }, () => run.engine.spawn('s'));
assert.ok(instances.every(Boolean));
assert.equal(run.engine.spawn('s'), null); assert.equal(run.engine.spawn('s'), null);
assert.equal(run.engine.spawnOrdinals.get('s'), count);
assert.equal(run.engine.cadence.raised.length, 1);
run.engine.remove(instances[0].id); run.tick(10);
assert.equal(run.engine.spawn('s').id, `instances.s#${count}`);
}
});
test('spawn/remove actions use PRD target/with shape and instance addresses', () => {
const run = setup({ systems: { s: { type: 'graphic', lifecycle: 'spawned', spawn: { inputs: { x: { type: 'number', default: 1 } } }, content: { p: { ...point, position: { x: { ref: 'inputs.x' }, y: 20 } } } } } });
const actions = new ActionExecutor(run.resolution); actions.visual = run.engine;
const [result] = actions.execute([{ type: 'spawn', target: 'visuals.systems.s', with: { x: 77 } }]);
run.tick(10); close(run.frame().layers[0].nodes[0].center[0], 77);
actions.execute([{ type: 'remove', target: result.instanceId }]); run.tick(10);
assert.equal(run.frame().layers[0].nodes.length, 0);
});
test('19.7.15 each effect changes known pixels, preserves alpha and respects order', () => {
const source = new Uint8ClampedArray([255, 255, 255, 255, 60, 40, 20, 255, 0, 0, 0, 255]);
for (const effect of [
{ type: 'fade', amount: 0.5 }, { type: 'vignette', radius: 0, amount: 1, softness: 0.1 },
{ type: 'scanlines', amount: 1 }, { type: 'grain', amount: 1 },
{ type: 'color-adjust', brightness: 0.5 }, { type: 'blur', radius: 1 }, { type: 'bloom', radius: 1, intensity: 1 }
]) {
const actual = applyVisualEffect(source, 3, 1, effect);
assert.notDeepEqual(actual, source, effect.type);
assert.equal(actual[3], 255);
}
const bloom = data => applyVisualEffect(data, 3, 1, { type: 'bloom', radius: 1 });
const adjust = data => applyVisualEffect(data, 3, 1, { type: 'color-adjust', brightness: 0.4 });
assert.notDeepEqual(bloom(adjust(source)), adjust(bloom(source)));
assert.deepEqual([...applyVisualEffect(new Uint8ClampedArray([0, 0, 0, 255]), 1, 1, { type: 'fade', color: '#804020', amount: 1 })], [128, 64, 32, 255]);
});
test('19.7.16-17 effects do not consume procedural randomness and unavailable passes warn', () => {
const run = setup({ effects: [{ type: 'grain', enabled: false }, { type: 'blur', radius: 2 }], systems: {} });
const before = run.rng.stream('visual', 'later').nextFloat();
for (let i = 0; i < 10; i++) run.frame();
const reference = new SeededRNG(42); close(before, reference.stream('visual', 'later').nextFloat());
close(run.rng.stream('visual', 'later').nextFloat(), reference.stream('visual', 'later').nextFloat());
assert.equal(run.frame().effects.length, 1);
renderVisualEffects({}, run.frame(), (effect, message) => run.engine.cadence.once('WARN_VISUAL_APPROXIMATION', `effect:${effect.index}`, message));
renderVisualEffects({}, run.frame(), (effect, message) => run.engine.cadence.once('WARN_VISUAL_APPROXIMATION', `effect:${effect.index}`, message));
assert.equal(run.engine.cadence.raised.length, 1);
});
test('19.7.18 diagnostic cadence counts ticks, not refusals, and recovers promptly', () => {
const cadence = new VisualDiagnosticCadence();
for (let tick = 0; tick <= 120; tick++) {
for (let i = 0; i < 4; i++) cadence.shed('WARN_VISUAL_CEILING', 'test', 'test', tick * 1000 / 60);
cadence.endTick();
}
assert.equal(cadence.keys.get('WARN_VISUAL_CEILING test').consecutive, 121);
assert.ok(cadence.raised.at(-1).sustained);
cadence.endTick(); cadence.shed('WARN_VISUAL_CEILING', 'test', 'test', 2010);
assert.equal(cadence.raised.at(-1).at, 2010);
});
test('19.7.18 particle overload evicts oldest from largest populations; declared systems survive', () => {
const run = setup({ systems: Object.fromEntries([0, 1, 2].map(i => [`s${i}`, { type: 'particles', capacity: 4096, count: 4096, render: { type: 'point' } }])) });
assert.equal(run.engine.systems.length, 3);
assert.equal(run.engine.systems.reduce((sum, system) => sum + system.procedural.items.length, 0), 8192);
assert.ok(run.engine.systems.every(system => system.procedural.items[0].ordinal > 0));
});
test('19.7.18 emitted items, trail histories, and link tails obey aggregate ceilings', () => {
const run = setup({ systems: Object.fromEntries([0, 1, 2, 3, 4].map(i => [`s${i}`, { type: 'emitter', capacity: 512, count: 512, emit: { type: 'point' } }])) });
const systems = run.engine.systems.map(system => system.procedural);
assert.equal(systems.reduce((n, system) => n + system.items.length, 0), 2048);
for (const system of systems) {
for (const item of system.items) item.trail = Array.from({ length: 17 }, (_, i) => ({ t: i, x: 0, y: 0 }));
system.linkPairs = () => Array.from({ length: 1024 }, (_, ordinal) => ({ ordinal }));
}
run.engine.enforcePopulations();
assert.equal(systems.reduce((n, system) => n + system.items.reduce((m, item) => m + item.trail.length, 0), 0), 32768);
assert.equal(run.engine.systems.reduce((n, system) => n + system.links.length, 0), 4096);
assert.equal(run.engine.systems.at(-1).links.length, 0);
});
test('19.7.18 fields evaluate nearest items first and remaining forces become zero', () => {
const run = setup({
fields: Object.fromEntries([0, 1, 2, 3].map(i => [`f${i}`, { type: 'directional', strength: 1, direction: 0 }])),
systems: { far: { type: 'particles', count: 4096, capacity: 4096, z: 100, fields: ['f0', 'f1', 'f2', 'f3'], render: { type: 'point' }, behaviors: [{ type: 'field-follow', field: 'f0' }] },
near: { type: 'particles', count: 4096, capacity: 4096, z: 0, fields: ['f0', 'f1', 'f2', 'f3'], render: { type: 'point' }, behaviors: [{ type: 'field-follow', field: 'f0' }] } }
});
run.tick(1000 / 60);
assert.equal(run.engine.fieldSet.evaluations, 32768);
assert.equal(run.engine.fieldSet.refused, true);
assert.ok(run.engine.systems[1].procedural.items.every(item => item.vx > 0));
assert.equal(run.engine.systems[0].procedural.items.at(-1).vx, 0);
});
test('19.7.19 resolved counts cannot enlarge pools or repeater limits', () => {
assert.throws(() => setup({ systems: { s: { type: 'repeater', count: { random: { min: 1025, max: 1026 } }, repeat: point } } }), error => error.code === 'ERR_VISUAL_LIMIT_EXCEEDED');
});
test('runtime failure reclaims a spawned instance without stopping another system', () => {
const run = setup({ systems: { s: { type: 'particles', lifecycle: 'spawned', count: 1, render: point }, healthy: { type: 'graphic', content: { p: point } } } });
const instance = run.engine.spawn('s');
instance.system.procedural.advance = () => { throw new Error('injected failure'); };
assert.doesNotThrow(() => run.tick(10));
assert.equal(instance.state, 'FAILED'); assert.equal(instance.system.procedural.items.length, 0);
assert.equal(run.engine.instances.size, 0); assert.equal(run.frame().layers[0].nodes.length, 1);
});
test('viewport default camera center still participates in automation and bindings', () => {
const run = setup({ scene: { coordinateSpace: 'viewport' }, automation: [track('camera.x', 0, 100)], systems: {} });
run.tick(250); close(run.frame().camera.x, 25);
});
test('19.7.3 exhibit automation samples point values in one scope stream', () => {
const value = { random: { min: 1, max: 2 } };
const run = setup({ automation: [track('camera.zoom', value, value), track('camera.rotation', value, value)], systems: {} });
const stream = new SeededRNG(42).stream('visual', 'automation');
const expected = Array.from({ length: 4 }, () => 1 + stream.nextFloat());
assert.deepEqual([...run.resolution.automation.values()].flatMap(track => track.points.map(point => point.value)), expected);
});