Files
XZBT/test/phase2-common-grammar.test.mjs

163 lines
8.3 KiB
JavaScript

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');
});