Files
XZBT/test/phase4-procedural.test.mjs
T
LabyricornandClaude Opus 5 8b00f5c4a0 feat(visual): implement the slice 4e procedural systems and behaviors
The normative coherent noise of 18.7 (ordered gradients, a 255-sample
Fisher-Yates permutation, octave normalization, and curl as the explicit
perpendicular of the potential's gradient, with published tolerances), the nine
distributions with their normative draw-order table, all seventeen behaviors
with their field contracts and the accumulating-versus-fresh split, component
expansion independent of group nesting depth, particles, emitters, repeaters,
trails, and links. Traces 1 through 19 of 18.10 are automated.

The renderer core this builds on is corrected here rather than separately,
because 4e is what exercises it: the production render path now passes a real
compositing surface factory, so a masked group or a non-opaque layer composites
instead of throwing and permanently deactivating the visual subsystem;
system-level `behaviors` arrays run `validateBehaviors` at import with field
context, so the six classes of invalid document that used to fail at activation
now fail where an author can see them; velocity-accumulating behaviors integrate
on object and repeater hosts; `morph` guards both sides of the z delta rather
than poisoning perspective projection with NaN; `face-motion` initializes its
follow state lazily and holds the previous rotation at zero velocity; a `ring`
with equal start and end angles draws nothing instead of a radial spoke; a
resolved non-integer creation count raises ERR_TYPE_MISMATCH at the boundary
instead of being rounded; boolean leaves are type-checked; per-type required
fields and the static half of the unbounded-emission rule are enforced at
import; and a repeater rejects the emitter-only fields 18.5 says it has none of.

A duration is now the authored literal or a non-negative finite number already
in milliseconds, resolving the one design question the review triage left open.
Runtime, validator, and specification agree on it.

A guard test asserts that no two bundled modules declare the same top-level
identifier: the bundler concatenates into one scope, so a private helper name
collision is a SyntaxError in the artifact while every unit test still passes.

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

526 lines
32 KiB
JavaScript

// Slice 4e — components, procedural systems, behaviors, fields, trails, links.
//
// The automated traces of 18.10. Where a trace names a closed-form result — the
// integrator, the emission accumulator, a life ramp, a distribution's draw
// count, a noise sample — the test asserts that number rather than a rendered
// pixel, which is what makes these executable without a display.
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 } from '../src/runtime/types.js';
import { validateExhibit } from '../src/runtime/validator.js';
import { VisualEngine } from '../src/runtime/visual-engine.js';
import { ProceduralSystem } from '../src/runtime/visual-systems.js';
import { FieldSet } from '../src/runtime/visual-fields.js';
import { placeItem, sampleCount } from '../src/runtime/visual-distributions.js';
import { PERMUTATION_DRAWS, noiseVector, octaveNoise, permutationTable } from '../src/runtime/visual-noise.js';
import { behaviorChannels, curveValue, waveform } from '../src/runtime/visual-behaviors.js';
const close = (actual, expected, epsilon = 1e-9) => assert.ok(Math.abs(actual - expected) <= epsilon, `${actual} != ${expected}`);
const TICK = 1 / 60;
function exhibit(visuals, extra = {}) {
return { xzbt: '0.1', meta: { id: 'procedural-study', name: 'Procedural Study' }, runtime: { seed: 42 }, visuals, ...extra };
}
const codes = (document) => validateExhibit(document).errors.map((error) => error.code);
const scene = { coordinateSpace: 'viewport' };
function engineFor(document, seed = 42) {
return new VisualEngine(document, { resolution: new ResolutionEngine(document, new SeededRNG(seed)), rng: new SeededRNG(seed) });
}
/** A bare system, for the motion traces that want a closed-form oracle. */
function system(spec, { seed = 42, fields = null, scene: box = { x: 0, y: 0, width: 100, height: 100 } } = {}) {
const document = exhibit({ scene, systems: {} });
const resolution = new ResolutionEngine(document, new SeededRNG(seed));
return new ProceduralSystem('s', spec, {
systemPath: 'visuals.systems.s',
resolver: resolution.valueResolver,
rng: new SeededRNG(seed),
noiseTable: permutationTable(new SeededRNG(seed).stream('visual', 'behavior-noise')),
scene: box,
fields,
instantiateItemNode: () => null
});
}
// ---------------------------------------------------------------------------
// Traces 1 to 3 — components (18.1)
// ---------------------------------------------------------------------------
const panel = {
parameters: { tint: { type: 'color', default: '#3a6ea5' }, lit: { type: 'boolean', default: false }, scale: { type: 'number', default: 1, min: 0, max: 4 } },
content: { frame: { type: 'rectangle', size: { width: 24, height: 16 }, style: { fill: { ref: 'inputs.tint' } } } }
};
test('18.10.1 a component instantiates with defaults, with inputs, and nested', () => {
const document = exhibit({
scene,
systems: { s: { type: 'graphic', content: {
a: { type: 'component', component: 'panel' },
b: { type: 'component', component: 'panel', inputs: { tint: '#ff0000' } }
} } }
}, { components: { visual: { panel } } });
assert.deepEqual(validateExhibit(document).errors, []);
const plan = engineFor(document).planFrame({ width: 200, height: 200 });
const [a, b] = plan.layers[0].nodes;
assert.equal(a.children[0].fill.color, '#3a6ea5', 'the declared default');
assert.equal(b.children[0].fill.color, '#ff0000', 'the supplied input');
// An undeclared input key, and inputs.* used outside a component.
const undeclared = exhibit({ scene, systems: { s: { type: 'graphic', content: { a: { type: 'component', component: 'panel', inputs: { hue: 1 } } } } } }, { components: { visual: { panel } } });
assert.throws(() => engineFor(undeclared).planFrame({ width: 10, height: 10 }), (error) => error.code === 'ERR_INVALID_REFERENCE');
const outside = exhibit({ scene, systems: { s: { type: 'graphic', content: { a: { type: 'rectangle', size: { width: { ref: 'inputs.tint' }, height: 1 } } } } } });
assert.throws(() => engineFor(outside), (error) => error.code === 'ERR_INVALID_REFERENCE');
// A supplied value of the wrong type, and one outside a declared range.
const wrongType = exhibit({ scene, systems: { s: { type: 'graphic', content: { a: { type: 'component', component: 'panel', inputs: { scale: 'big' } } } } } }, { components: { visual: { panel } } });
assert.throws(() => engineFor(wrongType), (error) => error.code === 'ERR_TYPE_MISMATCH');
const outOfRange = exhibit({ scene, systems: { s: { type: 'graphic', content: { a: { type: 'component', component: 'panel', inputs: { scale: 9 } } } } } }, { components: { visual: { panel } } });
assert.throws(() => engineFor(outOfRange), (error) => error.code === 'ERR_OUT_OF_BOUNDS');
});
test('18.10.2 component recursion and nesting are bounded at eight levels', () => {
const selfReferential = { visual: { loop: { content: { inner: { type: 'component', component: 'loop' } } } } };
const document = exhibit({ scene, systems: { s: { type: 'graphic', content: { a: { type: 'component', component: 'loop' } } } } }, { components: selfReferential });
assert.throws(() => engineFor(document), (error) => error.code === 'ERR_COMPONENT_RECURSION');
const chain = (depth) => {
const components = {};
for (let level = 0; level < depth; level += 1) {
components[`c${level}`] = level === depth - 1
? { content: { leaf: { type: 'point' } } }
: { content: { next: { type: 'component', component: `c${level + 1}` } } };
}
return components;
};
const deep = exhibit({ scene, systems: { s: { type: 'graphic', content: { a: { type: 'component', component: 'c0' } } } } }, { components: { visual: chain(10) } });
assert.throws(() => engineFor(deep), (error) => error.code === 'ERR_COMPONENT_RECURSION');
const legal = exhibit({ scene, systems: { s: { type: 'graphic', content: { a: { type: 'component', component: 'c0' } } } } }, { components: { visual: chain(6) } });
assert.doesNotThrow(() => engineFor(legal).planFrame({ width: 50, height: 50 }));
});
test('18.10.3 two instances sample independently from their expansion paths', () => {
const jittered = { parameters: {}, content: { dot: { type: 'point', position: { x: { random: { min: 0, max: 100 } }, y: 0 } } } };
const build = (keys) => {
const content = {};
for (const key of keys) content[key] = { type: 'component', component: 'jitter' };
return engineFor(exhibit({ scene, systems: { s: { type: 'graphic', content } } }, { components: { visual: { jitter: jittered } } }))
.planFrame({ width: 200, height: 200 }).layers[0].nodes;
};
const [first, second] = build(['a', 'b']);
assert.notEqual(first.children[0].center[0], second.children[0].center[0], 'two instances sample independently');
// Adding an unrelated sibling does not perturb either instance.
const withExtra = build(['a', 'b', 'zzz']);
close(withExtra[0].children[0].center[0], first.children[0].center[0]);
close(withExtra[1].children[0].center[0], second.children[0].center[0]);
});
// ---------------------------------------------------------------------------
// Traces 4 to 7 — particles (18.2)
// ---------------------------------------------------------------------------
test('18.10.4 particle fields resolve once per particle and hold for its life', () => {
const document = exhibit({ scene, systems: { motes: {
type: 'particles', capacity: 8, count: 4, lifetime: '10s',
velocity: { x: { random: { min: -5, max: 5 } }, y: 0 },
render: { type: 'point' }
} } });
const engine = engineFor(document);
const velocities = engine.systems[0].procedural.items.map((item) => item.vx);
assert.equal(new Set(velocities).size, 4, 'each particle samples its own velocity');
for (let tick = 0; tick < 120; tick += 1) engine.advance(1000 / 60);
assert.deepEqual(engine.systems[0].procedural.items.map((item) => item.vx), velocities, 'and holds it for its whole life');
// Two runs of one seed create identical particles in identical order.
assert.deepEqual(engineFor(document).systems[0].procedural.items.map((item) => item.vx), velocities);
assert.notDeepEqual(engineFor(document, 7).systems[0].procedural.items.map((item) => item.vx), velocities);
});
test('18.10.5 the integrator matches its closed form and is order-sensitive', () => {
// v <- v + a*dt, then p <- p + v*dt, so after n ticks p = a*dt^2*n(n+1)/2.
const acceleration = 10;
const ticks = 60;
const particles = system({ type: 'particles', capacity: 4, count: 1, render: { type: 'point' }, acceleration: { x: acceleration, y: 0 } });
for (let tick = 0; tick < ticks; tick += 1) particles.advance(TICK);
const item = particles.items[0];
close(item.vx, acceleration * ticks * TICK, 1e-9);
close(item.base.x, acceleration * TICK * TICK * ((ticks * (ticks + 1)) / 2), 1e-9);
// Integrating position before velocity would leave it one step behind.
assert.notEqual(item.base.x, acceleration * TICK * TICK * (((ticks - 1) * ticks) / 2));
});
test('18.10.6 drag is dt-correct: one tick of 1s equals ten of 0.1s', () => {
const make = () => system({ type: 'particles', capacity: 4, count: 1, render: { type: 'point' }, velocity: { x: 100, y: 0 }, drag: 0.5 });
const coarse = make();
coarse.advance(1);
const fine = make();
for (let tick = 0; tick < 10; tick += 1) fine.advance(0.1);
close(coarse.items[0].vx, fine.items[0].vx, 1e-9);
close(coarse.items[0].vx, 100 * 0.5, 1e-9);
});
test('18.10.7 a life ramp interpolates under each curve, and needs a lifetime', () => {
const ramps = system({
type: 'particles', capacity: 4, count: 1, lifetime: '10s', render: { type: 'point' },
size: { from: 0, to: 1, curve: 'smooth' }
});
const item = ramps.items[0];
close(ramps.rampValue(item.ramps.size, 0), 0);
close(ramps.rampValue(item.ramps.size, 0.5), 0.5);
close(ramps.rampValue(item.ramps.size, 1), 1);
for (const [curve, midpoint] of [['step', 0], ['linear', 0.5], ['exponential', 0.25], ['smooth', 0.5]]) {
close(curveValue(curve, 0.5), midpoint, 1e-12);
close(curveValue(curve, 0), 0);
close(curveValue(curve, 1), 1);
}
assert.throws(() => system({ type: 'particles', capacity: 4, count: 1, render: { type: 'point' }, size: { from: 0, to: 1 } }),
(error) => error instanceof RuntimeFault && error.code === 'ERR_SCHEMA_VALIDATION');
});
// ---------------------------------------------------------------------------
// Traces 8 and 9 — emission (18.2, 18.4)
// ---------------------------------------------------------------------------
test('18.10.8 rate emission is exactly floor(rate * t) under three tick lengths', () => {
// Steps that are exact in binary, so the oracle is floor(rate * t) and not a
// statement about floating-point summation.
for (const [step, ticks] of [[1 / 64, 64], [1 / 32, 32], [1 / 16, 16]]) {
const emitter = system({ type: 'emitter', capacity: 512, rate: 6, lifetime: '60s', emit: { type: 'point' } });
for (let tick = 0; tick < ticks; tick += 1) emitter.advance(step);
assert.equal(emitter.created, 6, `at a step of ${step}`);
// Half a second in, the count is exactly floor(rate * t) too.
const half = system({ type: 'emitter', capacity: 512, rate: 6, lifetime: '60s', emit: { type: 'point' } });
for (let tick = 0; tick < ticks / 2; tick += 1) half.advance(step);
assert.equal(half.created, 3);
}
// A burst emits its whole count on the first tick at or after its `at`.
const bursting = system({ type: 'emitter', capacity: 512, burst: [{ at: '500ms', count: 24 }], lifetime: '60s', emit: { type: 'point' } });
bursting.advance(0.4);
assert.equal(bursting.created, 0, 'not before its offset');
bursting.advance(0.2);
assert.equal(bursting.created, 24, 'the whole count on the first tick at or after it');
bursting.advance(1);
assert.equal(bursting.created, 24, 'and never again');
});
test('18.10.9 unbounded emission is rejected, count alone passes, capacity evicts oldest', () => {
assert.throws(() => system({ type: 'emitter', capacity: 8, rate: 4, emit: { type: 'point' } }),
(error) => error.code === 'ERR_UNBOUNDED_EMISSION');
assert.doesNotThrow(() => system({ type: 'particles', capacity: 8, count: 8, render: { type: 'point' } }));
const full = system({ type: 'particles', capacity: 4, rate: 60, lifetime: '60s', render: { type: 'point' } });
for (let tick = 0; tick < 60; tick += 1) full.advance(TICK);
assert.equal(full.items.length, 4, 'the live count stays at capacity');
assert.equal(full.items[0].ordinal, full.created - 4, 'and the oldest were evicted');
});
// ---------------------------------------------------------------------------
// Trace 10 — distributions (18.3)
// ---------------------------------------------------------------------------
test('18.10.10 each distribution places at its documented position and draw count', () => {
const grid = { type: 'grid', origin: { x: 0, y: 0 }, columns: 12, rows: 8, spacing: { x: 10, y: 10 } };
assert.deepEqual([0, 1, 12, 13].map((index) => placeItem(grid, { index, count: 96 }).x), [0, 10, 0, 10]);
assert.deepEqual([0, 1, 12, 13].map((index) => placeItem(grid, { index, count: 96 }).y), [0, 0, 10, 10]);
// A count larger than rows * columns wraps rather than failing.
assert.equal(placeItem(grid, { index: 96, count: 200 }).x, 0);
// n == 1 places at fraction 0, matching repeat.fraction.
assert.equal(placeItem({ type: 'line', from: { x: 0, y: 0 }, to: { x: 10, y: 0 }, mode: 'even' }, { index: 0, count: 1 }).x, 0);
// The three depth curves are monotonic and bias toward near.
const depth = { type: 'depth', near: 0, far: 100 };
for (const curve of ['uniform', 'linear', 'exponential']) {
const stream = new SeededRNG(42).stream('visual', `d-${curve}`);
const value = placeItem({ ...depth, curve }, { stream }).z;
assert.ok(value >= 0 && value <= 100, curve);
}
// The draw-count table of 18.3, asserted by counting the draws themselves.
const drawn = (distribution) => {
const source = new SeededRNG(42).stream('visual', 'draws');
let draws = 0;
const counting = { nextFloat: () => { draws += 1; return source.nextFloat(); }, nextInteger: (a, b) => source.nextInteger(a, b) };
placeItem(distribution, { index: 0, count: 4, stream: counting });
return draws;
};
assert.equal(sampleCount({ type: 'point' }), 0);
assert.equal(sampleCount({ type: 'ellipse' }), 2);
assert.equal(sampleCount({ type: 'ellipse', fill: 'perimeter' }), 1, 'one distance sample, not two');
assert.equal(sampleCount({ type: 'rectangle', fill: 'perimeter' }), 1);
assert.equal(sampleCount({ type: 'grid' }), 0);
assert.equal(sampleCount({ type: 'grid', jitter: { x: 1, y: 1 } }), 2);
assert.equal(sampleCount({ type: 'ring', mode: 'even' }), 0);
assert.equal(sampleCount({ type: 'ring', mode: 'even', depth: { near: 0, far: 1 } }), 1, 'the depth sub-block draws last');
for (const distribution of [
{ type: 'point' },
{ type: 'ellipse', center: { x: 0, y: 0 }, radius: 4 },
{ type: 'ellipse', center: { x: 0, y: 0 }, radius: 4, fill: 'perimeter' },
{ type: 'rectangle', center: { x: 0, y: 0 }, size: { width: 4, height: 2 }, fill: 'perimeter' },
{ type: 'rectangle', center: { x: 0, y: 0 }, size: { width: 4, height: 2 } },
{ type: 'uniform', min: { x: 0, y: 0 }, max: { x: 1, y: 1 } },
{ type: 'uniform', min: { x: 0, y: 0, z: 0 }, max: { x: 1, y: 1, z: 1 } },
{ type: 'ring', radius: 4, innerRadius: 1 },
{ type: 'ring', radius: 4, innerRadius: 1, mode: 'even' },
{ type: 'grid', columns: 2, rows: 2, spacing: { x: 1, y: 1 } },
{ type: 'grid', columns: 2, rows: 2, spacing: { x: 1, y: 1 }, jitter: { x: 1, y: 1 } },
{ type: 'depth', near: 0, far: 10 },
{ type: 'ellipse', center: { x: 0, y: 0 }, radius: 4, depth: { near: 0, far: 10 } }
]) {
assert.equal(drawn(distribution), sampleCount(distribution), JSON.stringify(distribution));
}
// An index-driven placement on a continuous rate emission has no count.
assert.throws(() => system({ type: 'particles', capacity: 8, rate: 4, lifetime: '2s', render: { type: 'point' }, distribution: { type: 'grid', columns: 2, rows: 2 } }),
(error) => error.code === 'ERR_INVALID_DISTRIBUTION');
assert.throws(() => placeItem({ type: 'spiral' }, {}), (error) => error.code === 'ERR_INVALID_DISTRIBUTION_TYPE');
});
// ---------------------------------------------------------------------------
// Trace 11 — repeaters (18.5)
// ---------------------------------------------------------------------------
test('18.10.11 a repeater resolves repeat.index, repeat.count, and repeat.fraction', () => {
const document = exhibit({ scene, systems: { wall: {
type: 'repeater', count: 4,
repeat: { type: 'rectangle', size: { width: { ref: 'repeat.index' }, height: { ref: 'repeat.fraction' } }, position: { x: 0, y: 0 } },
distribution: { type: 'line', from: { x: 0, y: 0 }, to: { x: 30, y: 0 }, mode: 'even' }
} } });
const engine = engineFor(document);
const items = engine.systems[0].procedural.items;
assert.equal(items.length, 4);
assert.deepEqual(items.map((item) => item.node.raw.size.width), [0, 1, 2, 3]);
assert.deepEqual(items.map((item) => item.node.raw.size.height), [0, 1 / 3, 2 / 3, 1]);
assert.deepEqual(items.map((item) => Math.round(item.base.x)), [0, 10, 20, 30], 'even placement consumes no samples');
// repeat.* outside a repeater does not resolve.
const outside = exhibit({ scene, systems: { s: { type: 'graphic', content: { a: { type: 'rectangle', size: { width: { ref: 'repeat.index' }, height: 1 } } } } } });
assert.throws(() => engineFor(outside), (error) => error.code === 'ERR_INVALID_REFERENCE');
});
// ---------------------------------------------------------------------------
// Traces 12 to 14 — behaviors (18.6)
// ---------------------------------------------------------------------------
test('18.10.12 every behavior advances a known object, and the rejections differ', () => {
const target = { type: 'polyline', points: [{ x: 0, y: 0 }, { x: 1, y: 1 }] };
const behaviors = [
{ type: 'drift', velocity: { x: 10, y: 0 } },
{ type: 'rotate', speed: 90 },
{ type: 'oscillate', property: 'position.y', amplitude: 5, frequency: 1 },
{ type: 'orbit', center: { x: 0, y: 0 }, radius: 10, speed: 90 },
{ type: 'wander', strength: 5, rate: 1 },
{ type: 'follow-path', path: [{ op: 'move', to: { x: 0, y: 0 } }, { op: 'line', to: { x: 10, y: 0 } }], speed: 5 },
{ type: 'point-wander', amplitude: { x: 1, y: 1 }, rate: 1 },
{ type: 'pulse', property: 'style.opacity', amplitude: 0.5, frequency: 2 },
{ type: 'twinkle', min: 0.2, max: 1, rate: 3 },
{ type: 'noise-displace', amplitude: { x: 2, y: 2 }, scale: 50, speed: 0.1 },
{ type: 'face-motion' },
{ type: 'wrap' },
{ type: 'bounce', restitution: 0.5 },
{ type: 'attract', target: { x: 50, y: 50 }, strength: 20 },
{ type: 'repel', target: { x: 50, y: 50 }, strength: 20 },
{ type: 'field-follow', field: 'wind', strength: 1 },
{ type: 'morph', to: 'other', duration: '1s' }
];
const fields = new FieldSet({ wind: { type: 'directional', direction: 0, strength: 5 } }, { rng: new SeededRNG(42) });
for (const behavior of behaviors) {
const owner = system({ type: 'particles', capacity: 4, count: 1, lifetime: '10s', render: target, behaviors: [behavior] }, { fields });
const item = owner.items[0];
item.points = [{ x: 0, y: 0, z: 0 }, { x: 1, y: 1, z: 0 }];
owner.advance(TICK, {
resolveTarget: () => ({ x: 50, y: 50 }),
resolveMorphTarget: () => [{ x: 5, y: 5, z: 0 }, { x: 6, y: 6, z: 0 }]
});
const place = owner.itemPosition(item);
assert.ok(Number.isFinite(place.x) && Number.isFinite(place.y) && Number.isFinite(place.rotation), behavior.type);
}
// Documented single-behavior oracles.
const drifting = system({ type: 'particles', capacity: 4, count: 1, render: { type: 'point' }, behaviors: [{ type: 'drift', velocity: { x: 60, y: 0 } }] });
for (let tick = 0; tick < 60; tick += 1) drifting.advance(TICK);
close(drifting.itemPosition(drifting.items[0]).x, 60, 1e-9);
const spinning = system({ type: 'particles', capacity: 4, count: 1, render: { type: 'point' }, behaviors: [{ type: 'rotate', speed: 90 }] });
for (let tick = 0; tick < 60; tick += 1) spinning.advance(TICK);
close(spinning.itemPosition(spinning.items[0]).rotation, 90, 1e-9);
// An orbit is recomputed from t, so it traces its circle rather than spiralling.
const orbiting = system({ type: 'particles', capacity: 4, count: 1, render: { type: 'point' }, behaviors: [{ type: 'orbit', center: { x: 0, y: 0 }, radius: 10, speed: 360 }] });
for (let tick = 0; tick < 60; tick += 1) orbiting.advance(TICK);
const orbited = orbiting.itemPosition(orbiting.items[0]);
close(Math.hypot(orbited.x, orbited.y), 10, 1e-9);
// Rejections.
const owner = (object, extra = {}) => exhibit({ scene, systems: { s: { type: 'graphic', content: { a: { ...object, ...extra } } } } });
assert.ok(codes(owner({ type: 'point' }, { behaviors: [{ type: 'levitate' }] })).includes('ERR_INVALID_BEHAVIOR_TYPE'));
assert.ok(codes(owner({ type: 'point' }, { behaviors: [{ type: 'oscillate', property: 'style.hue', amplitude: 1 }] })).includes('ERR_INVALID_BEHAVIOR_TARGET'));
assert.ok(codes(owner({ type: 'ellipse', radius: 2 }, { behaviors: [{ type: 'oscillate', property: 'size.width', amplitude: 1 }] })).includes('ERR_INVALID_BEHAVIOR_TARGET'));
assert.ok(codes(owner({ type: 'rectangle', size: { width: 1, height: 1 } }, { behaviors: [{ type: 'point-wander', amplitude: { x: 1, y: 1 } }] })).includes('ERR_INVALID_BEHAVIOR_TARGET'));
const nine = Array.from({ length: 9 }, () => ({ type: 'rotate', speed: 1 }));
assert.ok(codes(owner({ type: 'point' }, { behaviors: nine })).includes('ERR_VISUAL_LIMIT_EXCEEDED'));
assert.ok(codes(owner({ type: 'point' }, { behaviors: [{ type: 'field-follow', field: 'nope' }] })).includes('ERR_INVALID_REFERENCE'));
assert.ok(codes(owner({ type: 'point' }, { behaviors: [{ type: 'follow-path', path: [], speed: 1, duration: '1s' }] })).includes('ERR_SCHEMA_VALIDATION'));
});
test('18.10.13 behavior composition follows array order and the documented rule', () => {
const two = system({
type: 'particles', capacity: 4, count: 1, render: { type: 'point' },
behaviors: [{ type: 'drift', velocity: { x: 60, y: 0 } }, { type: 'drift', velocity: { x: 30, y: 0 } }]
});
for (let tick = 0; tick < 60; tick += 1) two.advance(TICK);
// Position accumulates: 60 + 30 over one logical second.
close(two.itemPosition(two.items[0]).x, 90, 1e-9);
// Scale multiplies rather than accumulating.
const scaled = system({
type: 'particles', capacity: 4, count: 1, render: { type: 'point' },
behaviors: [
{ type: 'oscillate', property: 'transform.scale.x', amplitude: 0, center: 2, frequency: 0 },
{ type: 'oscillate', property: 'transform.scale.x', amplitude: 0, center: 3, frequency: 0 }
]
});
scaled.advance(TICK);
close(scaled.items[0].scale.x, 6, 1e-12);
assert.deepEqual(behaviorChannels({ type: 'attract' }), ['velocity.x', 'velocity.y', 'velocity.z'],
'a force behavior writes velocity, which no automation track can address');
});
test('18.10.14 morph is restricted to point-list geometry and rejects mismatches', () => {
const pair = (a, b) => exhibit({ scene, systems: { s: { type: 'graphic', content: {
source: { ...a, behaviors: [{ type: 'morph', to: 'target', duration: '1s' }] },
target: b
} } } });
const line3 = { type: 'polyline', points: [{ x: 0, y: 0 }, { x: 1, y: 0 }, { x: 2, y: 0 }] };
assert.deepEqual(codes(pair(line3, { type: 'polyline', points: [{ x: 0, y: 1 }, { x: 1, y: 1 }, { x: 2, y: 1 }] })), []);
assert.ok(codes(pair(line3, { type: 'polyline', points: [{ x: 0, y: 1 }, { x: 1, y: 1 }] })).includes('ERR_MORPH_INCOMPATIBLE'));
assert.ok(codes(pair(line3, { type: 'rectangle', size: { width: 1, height: 1 } })).includes('ERR_MORPH_INCOMPATIBLE'));
assert.ok(codes(pair({ type: 'spline', mode: 'linear', points: [{ x: 0, y: 0 }, { x: 1, y: 0 }] }, { type: 'spline', mode: 'catmull-rom', points: [{ x: 0, y: 1 }, { x: 1, y: 1 }] })).includes('ERR_MORPH_INCOMPATIBLE'));
const missing = exhibit({ scene, systems: { s: { type: 'graphic', content: { source: { ...line3, behaviors: [{ type: 'morph', to: 'nowhere', duration: '1s' }] } } } } });
assert.ok(codes(missing).includes('ERR_INVALID_REFERENCE'));
});
// ---------------------------------------------------------------------------
// Traces 15 and 16 — coherent noise (18.7)
// ---------------------------------------------------------------------------
test('18.10.15 the noise function reproduces its oracles and consumes 255 samples', () => {
const before = new SeededRNG(42).stream('visual', 'current');
const counting = new SeededRNG(42).stream('visual', 'current');
const table = permutationTable(counting);
// The shuffle draws one sample per swap, not one per entry.
for (let draw = 0; draw < PERMUTATION_DRAWS; draw += 1) before.nextFloat();
close(before.nextFloat(), counting.nextFloat(), 0);
assert.equal(PERMUTATION_DRAWS, 255);
// A gradient noise value is exactly zero on the lattice.
close(octaveNoise(table, 0, 0, 0, 1, 0.5), 0, 0);
close(octaveNoise(table, 3, -2, 5, 1, 0.5), 0, 0);
// Two runs of one seed agree exactly, at lattice and non-lattice positions.
const again = permutationTable(new SeededRNG(42).stream('visual', 'current'));
for (const [x, y, z] of [[0.5, 0.25, 0], [13.37, -7.5, 2.25], [0, 0, 0]]) {
close(octaveNoise(table, x, y, z, 3, 0.5), octaveNoise(again, x, y, z, 3, 0.5), 0);
}
// A different seed gives a different table.
const other = permutationTable(new SeededRNG(7).stream('visual', 'current'));
assert.notEqual(octaveNoise(table, 0.5, 0.25, 0, 1, 0.5), octaveNoise(other, 0.5, 0.25, 0, 1, 0.5));
// persistence: 0 stays defined, contributing one octave.
close(octaveNoise(table, 0.5, 0.25, 0, 4, 0), octaveNoise(table, 0.5, 0.25, 0, 1, 0), 0);
const field = (extra) => exhibit({ scene, fields: { n: { type: 'noise', scale: 100, ...extra } } });
assert.ok(codes(field({ octaves: 5 })).includes('ERR_OUT_OF_BOUNDS'));
assert.ok(codes(field({ persistence: 1.5 })).includes('ERR_OUT_OF_BOUNDS'));
assert.ok(codes(exhibit({ scene, fields: { n: { type: 'noise', scale: 0 } } })).includes('ERR_OUT_OF_BOUNDS'));
assert.ok(codes(exhibit({ scene, fields: { n: { type: 'swirl' } } })).includes('ERR_INVALID_FIELD_TYPE'));
const nine = Object.fromEntries(Array.from({ length: 9 }, (_, index) => [`f${index}`, { type: 'directional', direction: 0, strength: 1 }]));
assert.ok(codes(exhibit({ scene, fields: nine })).includes('ERR_VISUAL_LIMIT_EXCEEDED'));
});
test('18.10.16 curl is divergence-free within tolerance at non-lattice positions', () => {
const table = permutationTable(new SeededRNG(42).stream('visual', 'current'));
const amplitude = 1;
const step = 1e-3;
let worst = 0;
for (let i = 0; i < 12; i += 1) {
for (let j = 0; j < 12; j += 1) {
const x = i * 0.37 + 0.13;
const y = j * 0.41 + 0.07;
const dx = noiseVector(table, x + step, y, 0, { mode: 'curl', amplitude })[0] - noiseVector(table, x - step, y, 0, { mode: 'curl', amplitude })[0];
const dy = noiseVector(table, x, y + step, 0, { mode: 'curl', amplitude })[1] - noiseVector(table, x, y - step, 0, { mode: 'curl', amplitude })[1];
worst = Math.max(worst, Math.abs((dx + dy) / (2 * step)));
}
}
assert.ok(worst < 1e-4, `divergence ${worst} exceeds the documented tolerance`);
// `value` mode points along its declared direction.
const [vx, vy] = noiseVector(table, 0.3, 0.7, 0, { mode: 'value', direction: 90, amplitude: 1 });
assert.ok(Math.abs(vx) < 1e-12 && Math.abs(vy) > 0, 'the scalar rides the declared direction');
});
// ---------------------------------------------------------------------------
// Traces 17 to 19 — trails, links, and the target surface (18.8, 8.1)
// ---------------------------------------------------------------------------
test('18.10.17 trail history is sampled on the logical clock and is bounded', () => {
const build = () => system({
type: 'particles', capacity: 4, count: 1, lifetime: '60s', render: { type: 'point' },
velocity: { x: 60, y: 0 }, trail: { length: 8 }
});
const fast = build();
for (let tick = 0; tick < 30; tick += 1) fast.advance(TICK);
const geometry = fast.items[0].trail.map((sample) => Math.round(sample.x * 1e6) / 1e6);
assert.equal(geometry.length, 8, 'the history is capped at its declared length');
// The same logical span gives the same geometry however many frames drew it.
const slow = build();
for (let tick = 0; tick < 30; tick += 1) slow.advance(TICK);
assert.deepEqual(slow.items[0].trail.map((sample) => Math.round(sample.x * 1e6) / 1e6), geometry);
assert.throws(() => {
const over = system({ type: 'particles', capacity: 4, count: 1, lifetime: '60s', render: { type: 'point' }, trail: { length: 129 } });
over.advance(TICK);
}, (error) => error.code === 'ERR_VISUAL_LIMIT_EXCEEDED');
// A removed item's history goes with it.
const expiring = system({ type: 'particles', capacity: 4, count: 1, lifetime: '100ms', render: { type: 'point' }, trail: { length: 8 } });
for (let tick = 0; tick < 12; tick += 1) expiring.advance(TICK);
assert.equal(expiring.items.length, 0);
});
test('18.10.18 link enumeration is deterministic, de-duplicated, and bounded', () => {
const linked = system({
type: 'repeater', count: 4,
repeat: { type: 'point' },
distribution: { type: 'line', from: { x: 0, y: 0 }, to: { x: 30, y: 0 }, mode: 'even' },
links: { rule: 'distance', maxDistance: 15 }
});
const pairs = linked.linkPairs();
// Items sit at 0, 10, 20, 30: adjacent pairs only, each once.
assert.equal(pairs.length, 3);
assert.deepEqual(pairs.map((pair) => Math.round(pair.distance)), [10, 10, 10]);
assert.deepEqual(linked.linkPairs().map((pair) => Math.round(pair.from.x)), pairs.map((pair) => Math.round(pair.from.x)));
const indexed = system({ type: 'repeater', count: 4, repeat: { type: 'point' }, links: { rule: 'index', stride: 1, closed: true } });
assert.equal(indexed.linkPairs().length, 4, 'three strides plus the closing link');
const capped = system({ type: 'repeater', count: 4, repeat: { type: 'point' }, distribution: { type: 'line', from: { x: 0, y: 0 }, to: { x: 3, y: 0 }, mode: 'even' }, links: { rule: 'distance', maxDistance: 100, maxLinks: 2 } });
assert.equal(capped.linkPairs().length, 2, 'the tail of the ascending order is dropped');
assert.ok(codes(exhibit({ scene, systems: { e: { type: 'emitter', capacity: 8, rate: 1, lifetime: '1s', emit: { type: 'point' }, links: { rule: 'index' } } } })).includes('ERR_UNKNOWN_FIELD'));
assert.throws(() => system({ type: 'particles', capacity: 400, count: 1, render: { type: 'point' }, links: { rule: 'distance', maxDistance: 10 } }),
(error) => error.code === 'ERR_VISUAL_LIMIT_EXCEEDED');
assert.throws(() => system({ type: 'particles', capacity: 8, count: 1, render: { type: 'point' }, links: { rule: 'distance', fadeWithDistance: true } }),
(error) => error.code === 'ERR_SCHEMA_VALIDATION');
});
test('18.10.19 nothing introduced by this section becomes an external target', () => {
const document = exhibit({
scene,
fields: { wind: { type: 'directional', direction: 0, strength: 1 } },
systems: { motes: { type: 'particles', capacity: 8, count: 1, render: { type: 'point' }, behaviors: [{ type: 'rotate', speed: 1 }] } }
}, { state: { energy: { type: 'number', initial: 0.5 } } });
for (const target of ['visuals.fields.wind.strength', 'visuals.systems.motes.rate', 'visuals.systems.motes.behaviors[0].speed', 'visuals.systems.motes.capacity']) {
const bound = { ...document, bindings: [{ source: 'state.energy', target }] };
assert.ok(codes(bound).includes('ERR_UNSUPPORTED_TARGET'), target);
}
// The one system-level property that is a target stays one.
const visible = { ...document, bindings: [{ source: 'state.energy', target: 'visuals.systems.motes.visible' }] };
assert.ok(!codes(visible).includes('ERR_UNSUPPORTED_TARGET'));
});