feat(runtime): implement phases 1 and 2
This commit is contained in:
@@ -0,0 +1,83 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { mkdtempSync, readFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join, resolve } from 'node:path';
|
||||
import test from 'node:test';
|
||||
import { ActivationController } from '../src/runtime/activation.js';
|
||||
import { Diagnostics } from '../src/runtime/diagnostics.js';
|
||||
import { LibraryManager } from '../src/runtime/library.js';
|
||||
import { deriveStreamState, SeededRNG } from '../src/runtime/rng.js';
|
||||
import { parseAndValidateExhibit } from '../src/runtime/validator.js';
|
||||
import { buildStandalone } from '../tools/build-xzbt.mjs';
|
||||
|
||||
const fixedSource = readFileSync(resolve('exhibits/minimal-fixed.xzbt'), 'utf8');
|
||||
const randomSource = readFileSync(resolve('exhibits/minimal-random.xzbt'), 'utf8');
|
||||
|
||||
test('the two Phase 1 exhibits parse and validate', () => {
|
||||
assert.equal(parseAndValidateExhibit(fixedSource, 'minimal-fixed.xzbt').valid, true);
|
||||
assert.equal(parseAndValidateExhibit(randomSource, 'minimal-random.xzbt').valid, true);
|
||||
});
|
||||
|
||||
test('malformed JSON, unsupported versions, and invalid metadata fail predictably', () => {
|
||||
assert.equal(parseAndValidateExhibit('{', 'broken.xzbt').errors[0].code, 'ERR_SCHEMA_VALIDATION');
|
||||
const unsupported = JSON.stringify({ xzbt: '9', meta: { id: 'valid-id', name: 'Name' } });
|
||||
assert.ok(parseAndValidateExhibit(unsupported).errors.some(({ code }) => code === 'ERR_UNSUPPORTED_VERSION'));
|
||||
const invalidMeta = JSON.stringify({ xzbt: '0.1', meta: { id: 'Not Valid', name: '' } });
|
||||
const codes = parseAndValidateExhibit(invalidMeta).errors.map(({ code }) => code);
|
||||
assert.ok(codes.includes('ERR_INVALID_ID'));
|
||||
assert.ok(codes.includes('ERR_SCHEMA_VALIDATION'));
|
||||
});
|
||||
|
||||
test('production PRNG preserves the frozen GC4 vectors', () => {
|
||||
assert.deepEqual(deriveStreamState(42, 'scenario', 'storm:1'), [471422921, 3327820418, 120898024, 15893603]);
|
||||
const stream = new SeededRNG(42).stream('scenario', 'storm:1');
|
||||
assert.deepEqual([stream.nextUint32(), stream.nextUint32(), stream.nextUint32(), stream.nextUint32()], [4101533927, 4149236977, 2113172072, 1672809915]);
|
||||
});
|
||||
|
||||
test('library imports, ignores identical bytes, persists, and restores records', async () => {
|
||||
const saved = [];
|
||||
const persistence = { async saveExhibit(record) { saved.push(record); } };
|
||||
const library = new LibraryManager({ persistence, diagnostics: new Diagnostics() });
|
||||
assert.equal((await library.importSource(fixedSource, 'minimal-fixed.xzbt')).status, 'imported');
|
||||
assert.equal((await library.importSource(fixedSource, 'minimal-fixed.xzbt')).status, 'identical');
|
||||
assert.equal(saved.length, 1);
|
||||
const restored = new LibraryManager({ diagnostics: new Diagnostics() });
|
||||
restored.restore(saved);
|
||||
assert.equal(restored.get('minimal-fixed').document.meta.name, 'Minimal Fixed');
|
||||
});
|
||||
|
||||
test('activation prepares before teardown and recovers the previous exhibit on failure', async () => {
|
||||
const events = [];
|
||||
let shouldFail = false;
|
||||
const make = (record) => ({
|
||||
rng: new SeededRNG(42),
|
||||
async activate() { events.push(`activate:${record.id}`); if (shouldFail && record.id === 'next') throw new Error('injected'); },
|
||||
async deactivate() { events.push(`deactivate:${record.id}`); },
|
||||
async dispose() { events.push(`dispose:${record.id}`); }
|
||||
});
|
||||
const controller = new ActivationController({ diagnostics: new Diagnostics(), performanceFactory: make });
|
||||
const record = (id) => ({ id, document: { meta: { id, name: id }, runtime: { seed: 42 } } });
|
||||
assert.equal(await controller.activate(record('previous')), true);
|
||||
shouldFail = true;
|
||||
assert.equal(await controller.activate(record('next')), false);
|
||||
assert.equal(controller.current.record.id, 'previous');
|
||||
assert.deepEqual(events, ['activate:previous', 'deactivate:previous', 'dispose:previous', 'activate:next', 'dispose:next', 'activate:previous']);
|
||||
});
|
||||
|
||||
test('two clean standalone builds are byte-identical and self-contained', () => {
|
||||
const directory = mkdtempSync(join(tmpdir(), 'xzbt-phase1-'));
|
||||
const first = join(directory, 'first.html');
|
||||
const second = join(directory, 'second.html');
|
||||
buildStandalone(first);
|
||||
buildStandalone(second);
|
||||
const firstBytes = readFileSync(first);
|
||||
const secondBytes = readFileSync(second);
|
||||
assert.deepEqual(firstBytes, secondBytes);
|
||||
const html = firstBytes.toString('utf8');
|
||||
assert.doesNotMatch(html, /<(script|link)[^>]+(?:src|href)=/i);
|
||||
assert.doesNotMatch(html, /\bimport\s+[^;(]/);
|
||||
assert.equal(createHash('sha256').update(firstBytes).digest('hex'), createHash('sha256').update(secondBytes).digest('hex'));
|
||||
const script = html.match(/<script>([\s\S]*)<\/script>/)[1];
|
||||
assert.doesNotThrow(() => new Function(script));
|
||||
});
|
||||
@@ -0,0 +1,162 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import test from 'node:test';
|
||||
import { ActionExecutor } from '../src/runtime/actions.js';
|
||||
import { CommonGrammarPerformance } from '../src/runtime/performance.js';
|
||||
import { ResolutionEngine } from '../src/runtime/resolution.js';
|
||||
import { SeededRNG } from '../src/runtime/rng.js';
|
||||
import { RuntimeFault, parseDuration } from '../src/runtime/types.js';
|
||||
import { ConditionEvaluator, ValueResolver } from '../src/runtime/values.js';
|
||||
import { parseAndValidateExhibit } from '../src/runtime/validator.js';
|
||||
|
||||
const closeTo = (actual, expected, epsilon = 1e-12) => assert.ok(Math.abs(actual - expected) <= epsilon, `${actual} != ${expected}`);
|
||||
|
||||
function grammarDocument(overrides = {}) {
|
||||
return {
|
||||
xzbt: '0.1',
|
||||
meta: { id: 'grammar-study', name: 'Grammar Study' },
|
||||
runtime: { seed: 42 },
|
||||
parameters: {
|
||||
activity: { type: 'number', default: 0.2, min: 0, max: 1 },
|
||||
enabled: { type: 'boolean', default: true }
|
||||
},
|
||||
state: {
|
||||
energy: { type: 'number', initial: 0.1, min: 0, max: 1 },
|
||||
mirror: { type: 'number', initial: 0, min: 0, max: 1 },
|
||||
alert: { type: 'boolean', initial: false }
|
||||
},
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
test('production validator matches the GC2 fixture matrix', () => {
|
||||
const matrix = [
|
||||
['valid-minimal.xzbt', null], ['valid-full-feature.xzbt', null],
|
||||
['invalid-unknown-field.xzbt', 'ERR_UNKNOWN_FIELD'],
|
||||
['invalid-unsupported-version.xzbt', 'ERR_UNSUPPORTED_VERSION'],
|
||||
['invalid-id-syntax.xzbt', 'ERR_INVALID_ID'],
|
||||
['invalid-reference-missing.xzbt', 'ERR_INVALID_REFERENCE'],
|
||||
['invalid-type-mismatch.xzbt', 'ERR_TYPE_MISMATCH'],
|
||||
['invalid-cyclic-bindings.xzbt', 'ERR_CYCLIC_DEPENDENCY'],
|
||||
['invalid-out-of-bounds.xzbt', 'ERR_OUT_OF_BOUNDS'],
|
||||
['invalid-operator-arity.xzbt', 'ERR_INVALID_ARITY']
|
||||
];
|
||||
for (const [name, code] of matrix) {
|
||||
const source = readFileSync(new URL(`./fixtures/gc2/${name}`, import.meta.url), 'utf8');
|
||||
const result = parseAndValidateExhibit(source, name);
|
||||
if (code === null) assert.equal(result.valid, true, JSON.stringify(result.errors));
|
||||
else assert.ok(result.errors.some((entry) => entry.code === code), `${name}: ${JSON.stringify(result.errors)}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('ValueSpec implements safe operators, references, random ranges, and weighted choice', () => {
|
||||
const resolver = new ValueResolver((path) => ({ 'state.value': 4 }[path]));
|
||||
const stream = new SeededRNG(42).stream('scenario', 'values:1');
|
||||
assert.equal(resolver.evaluate({ ref: 'state.value' }, stream), 4);
|
||||
assert.equal(resolver.evaluate({ op: 'divide', args: [9, 0] }, stream), 0);
|
||||
assert.equal(resolver.evaluate({ op: 'clamp', args: [12, 2, 8] }, stream), 8);
|
||||
assert.equal(resolver.evaluate({ op: 'lerp', args: [10, 20, 0.25] }, stream), 12.5);
|
||||
const random = resolver.evaluate({ random: { min: 2, max: 8, integer: true } }, stream);
|
||||
assert.ok(Number.isInteger(random) && random >= 2 && random <= 8);
|
||||
const choice = resolver.evaluate({ choose: [{ value: 'a', weight: 1 }, { value: 'b', weight: 3 }] }, stream);
|
||||
assert.ok(['a', 'b'].includes(choice));
|
||||
assert.throws(() => resolver.evaluate({ op: 'add', args: [1] }, stream), (error) => error.code === 'ERR_INVALID_ARITY');
|
||||
});
|
||||
|
||||
test('ConditionSpec composes, short-circuits, and forbids cross-type comparison', () => {
|
||||
const resolver = new ValueResolver((path) => ({ 'state.energy': 0.8, 'parameters.enabled': true }[path]));
|
||||
const conditions = new ConditionEvaluator(resolver);
|
||||
assert.equal(conditions.evaluate({ and: [
|
||||
{ op: 'gt', left: { ref: 'state.energy' }, right: 0.5 },
|
||||
{ not: { op: 'eq', left: { ref: 'parameters.enabled' }, right: false } }
|
||||
] }), true);
|
||||
assert.equal(conditions.evaluate({ or: [{ op: 'lt', left: 1, right: 0 }, { op: 'eq', left: 'x', right: 'x' }] }), true);
|
||||
assert.throws(() => conditions.evaluate({ op: 'eq', left: 1, right: '1' }), (error) => error.code === 'ERR_TYPE_MISMATCH');
|
||||
});
|
||||
|
||||
test('bindings resolve in same-tick dependency order and apply exact smoothing', () => {
|
||||
const document = grammarDocument({ bindings: [
|
||||
{ source: 'parameters.activity', target: 'state.energy', scale: 2, clamp: [0, 1], smoothing: '50ms' },
|
||||
{ source: 'state.energy', target: 'state.mirror' }
|
||||
] });
|
||||
const engine = new ResolutionEngine(document, new SeededRNG(42));
|
||||
assert.equal(engine.get('state.energy'), 0.4);
|
||||
assert.equal(engine.get('state.mirror'), 0.4);
|
||||
engine.setParameter('activity', 0.4);
|
||||
engine.advance(1000 / 60);
|
||||
const expected = 0.4 + (1 - Math.exp(-(1 / 60) / 0.05)) * (0.8 - 0.4);
|
||||
closeTo(engine.get('state.energy'), expected);
|
||||
closeTo(engine.get('state.mirror'), expected);
|
||||
});
|
||||
|
||||
test('a disabled binding exposes base and re-enables without replaying disabled time', () => {
|
||||
const document = grammarDocument({ bindings: [{
|
||||
source: 'parameters.activity', target: 'state.energy', smoothing: '1s',
|
||||
when: { op: 'eq', left: { ref: 'parameters.enabled' }, right: true }
|
||||
}] });
|
||||
const engine = new ResolutionEngine(document, new SeededRNG(42));
|
||||
assert.equal(engine.get('state.energy'), 0.2);
|
||||
engine.setParameter('enabled', false);
|
||||
assert.equal(engine.get('state.energy'), 0.1);
|
||||
engine.setParameter('activity', 0.9);
|
||||
engine.advance(5000);
|
||||
assert.equal(engine.get('state.energy'), 0.1);
|
||||
engine.setParameter('enabled', true);
|
||||
assert.equal(engine.get('state.energy'), 0.9);
|
||||
});
|
||||
|
||||
test('actions execute in document order and set state but never parameters', () => {
|
||||
const engine = new ResolutionEngine(grammarDocument(), new SeededRNG(42));
|
||||
const actions = new ActionExecutor(engine);
|
||||
actions.execute([
|
||||
{ type: 'set', target: 'state.alert', value: true },
|
||||
{ type: 'set', target: 'state.energy', value: 0.75, when: { op: 'eq', left: { ref: 'state.alert' }, right: true } }
|
||||
]);
|
||||
assert.equal(engine.get('state.alert'), true);
|
||||
assert.equal(engine.get('state.energy'), 0.75);
|
||||
assert.throws(() => actions.execute([{ type: 'set', target: 'parameters.activity', value: 1 }]), (error) => error.code === 'ERR_UNSUPPORTED_TARGET');
|
||||
});
|
||||
|
||||
test('numeric set transitions advance deterministically', () => {
|
||||
const engine = new ResolutionEngine(grammarDocument(), new SeededRNG(42));
|
||||
engine.setState('state.energy', 0.9, { duration: '1s', easing: 'linear' });
|
||||
engine.advance(500);
|
||||
closeTo(engine.get('state.energy'), 0.5);
|
||||
engine.advance(500);
|
||||
closeTo(engine.get('state.energy'), 0.9);
|
||||
assert.equal(parseDuration('1.5m'), 90_000);
|
||||
assert.throws(() => parseDuration('1m30s'), RuntimeFault);
|
||||
});
|
||||
|
||||
test('overrides preserve stored edits, use priority and sequence, and release to the live lower value', () => {
|
||||
const engine = new ResolutionEngine(grammarDocument(), new SeededRNG(42));
|
||||
const actions = new ActionExecutor(engine);
|
||||
const [lower] = actions.execute([{ type: 'override', target: 'parameters.activity', value: 0.7, scope: 'scenario', priority: 1, transition: { out: '1s' } }], { owner: 'scenario-1' });
|
||||
const [higher] = actions.execute([{ type: 'override', target: 'parameters.activity', value: 0.9, scope: 'scenario', priority: 2 }], { owner: 'scenario-2' });
|
||||
assert.equal(engine.get('parameters.activity'), 0.9);
|
||||
engine.overrides.beginRelease(higher.instanceId);
|
||||
engine.resolveAll();
|
||||
assert.equal(engine.get('parameters.activity'), 0.7);
|
||||
engine.setParameter('activity', 0.4);
|
||||
assert.equal(engine.parameters.get('activity'), 0.4);
|
||||
assert.equal(engine.get('parameters.activity'), 0.7);
|
||||
engine.overrides.beginRelease(lower.instanceId);
|
||||
engine.advance(500);
|
||||
closeTo(engine.get('parameters.activity'), 0.55);
|
||||
engine.advance(500);
|
||||
closeTo(engine.get('parameters.activity'), 0.4);
|
||||
});
|
||||
|
||||
test('duration overrides expire and a performance restores compatible stored parameters', async () => {
|
||||
const record = { id: 'grammar-study', document: grammarDocument() };
|
||||
const performance = new CommonGrammarPerformance(record, 42, { initialParameters: { activity: 0.6 } });
|
||||
await performance.activate();
|
||||
assert.equal(performance.engine.parameters.get('activity'), 0.6);
|
||||
performance.execute([{ type: 'override', target: 'parameters.activity', value: 1, scope: 'duration', duration: '250ms' }]);
|
||||
assert.equal(performance.engine.get('parameters.activity'), 1);
|
||||
performance.engine.advance(250);
|
||||
assert.equal(performance.engine.get('parameters.activity'), 0.6);
|
||||
await performance.deactivate();
|
||||
await performance.dispose();
|
||||
assert.equal(performance.state, 'disposed');
|
||||
});
|
||||
Reference in New Issue
Block a user