129 lines
5.3 KiB
JavaScript
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));
|
|
}
|
|
});
|