Files
XZBT/test/gc3-resolution.test.mjs

129 lines
5.3 KiB
JavaScript

import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import test from 'node:test';
import { ExhibitValidator } from '../tools/validate-exhibit.mjs';
import {
attackValue,
bindingValue,
releaseValue,
resolveNumericTarget,
selectWinningOverride,
smoothBinding
} from '../tools/gc3-resolution-model.mjs';
const fixture = JSON.parse(readFileSync(new URL('./fixtures/gc3/resolution-traces.json', import.meta.url)));
const closeTo = (actual, expected, epsilon = 1e-12) => assert.ok(Math.abs(actual - expected) <= epsilon, `${actual} != ${expected}`);
test('direct bus override masks a live binding and releases toward its current value', () => {
for (const row of fixture.traces.directBusOverride) {
const overrides = row.override === undefined
? []
: [{ id: 'direct', priority: 0, activationSequence: 1, value: row.override }];
let resolved = resolveNumericTarget({ base: 0, binding: row.binding, overrides, safetyClamp: [0, 1] }).resolved;
if (row.releaseProgress !== undefined) resolved = releaseValue(0.8, row.binding, row.releaseProgress);
closeTo(resolved, row.resolved);
}
});
test('a parameter override feeds a downstream binding in the same tick and preserves user edits', () => {
for (const row of fixture.traces.parameterOverrideFeedsBinding) {
let activity = row.activityOverride ?? row.storedActivity;
if (row.releaseProgress !== undefined) activity = releaseValue(0.9, row.storedActivity, row.releaseProgress);
const gain = bindingValue(activity, { scale: 0.5, offset: 0.5, clamp: [0, 1] });
closeTo(activity, row.resolvedActivity);
closeTo(gain, row.busGain);
}
});
test('priority and activation sequence decide competition independently of scope', () => {
const overrides = [
{ id: 'scenario', scope: 'scenario', priority: 4, activationSequence: 2, value: 0.4 },
{ id: 'duration-low', scope: 'duration', priority: 3, activationSequence: 99, value: 0.9 }
];
assert.equal(selectWinningOverride(overrides).id, 'scenario');
overrides.push({ id: 'duration-tie', scope: 'duration', priority: 4, activationSequence: 3, value: 0.7 });
assert.equal(selectWinningOverride(overrides).id, 'duration-tie');
});
test('automation continues while masked and becomes the live release destination', () => {
for (const row of fixture.traces.automationMaskedThenReleased) {
const overrides = row.override === undefined
? []
: [{ id: 'mask', priority: 1, activationSequence: 1, value: row.override }];
let resolved = resolveNumericTarget({ base: 0, automation: row.automation, overrides }).resolved;
if (row.releaseProgress !== undefined) resolved = releaseValue(0.75, row.automation, row.releaseProgress);
closeTo(resolved, row.resolved);
}
});
test('legal modulation follows override and safety clamp runs last', () => {
const result = resolveNumericTarget({
base: 0.2,
overrides: [{ id: 'boost', priority: 0, activationSequence: 1, value: 0.9 }],
modulation: 0.3,
safetyClamp: [0, 1]
});
assert.equal(result.afterOverride, 0.9);
closeTo(result.beforeClamp, 1.2);
assert.equal(result.resolved, 1);
});
test('binding smoothing is exact, and re-enable initializes without replaying disabled time', () => {
const first = smoothBinding(undefined, 0.8, fixture.tickSeconds, 0.05);
assert.equal(first, 0.8);
const second = smoothBinding(first, 0.2, fixture.tickSeconds, 0.05);
closeTo(second, 0.8 + (1 - Math.exp(-fixture.tickSeconds / 0.05)) * (0.2 - 0.8));
assert.equal(smoothBinding(undefined, 0.35, fixture.tickSeconds, 0.05), 0.35);
});
test('interrupted transitions begin from the visible value without cancelling lower overrides', () => {
closeTo(attackValue(0.2, 0.8, 0.5, 'linear'), 0.5);
const lower = { id: 'lower', priority: 1, activationSequence: 1, value: 0.5 };
const higher = { id: 'higher', priority: 2, activationSequence: 2, value: attackValue(0.5, 1, 0, 'linear') };
assert.equal(selectWinningOverride([lower, higher]).value, 0.5);
higher.live = false;
assert.equal(selectWinningOverride([lower, higher]).id, 'lower');
});
test('validator rejects conflicting writers, cycles, unsupported targets, and draft aliases', () => {
const base = {
xzbt: '0.1',
meta: { id: 'gc3-validation', name: 'GC3 validation' },
parameters: { activity: { type: 'number', default: 0.5 } },
state: { energy: { type: 'number', initial: 0.2 } }
};
const cases = [
{
code: 'ERR_CONFLICTING_BINDING',
bindings: [
{ source: 'parameters.activity', target: 'state.energy' },
{ source: 'parameters.activity', target: 'state.energy', when: { op: 'gt', left: 1, right: 0 } }
]
},
{
code: 'ERR_CYCLIC_DEPENDENCY',
bindings: [
{ source: 'parameters.activity', target: 'state.energy' },
{ source: 'state.energy', target: 'parameters.activity' }
]
},
{
code: 'ERR_UNSUPPORTED_TARGET',
bindings: [{ source: 'parameters.activity', target: 'signals.time.elapsed' }]
},
{
code: 'ERR_UNKNOWN_FIELD',
bindings: [{ from: 'parameters.activity', to: 'state.energy' }]
}
];
for (const entry of cases) {
const result = new ExhibitValidator({ ...base, bindings: entry.bindings }).validate();
assert.equal(result.valid, false);
assert.ok(result.errors.some((error) => error.code === entry.code), JSON.stringify(result.errors));
}
});