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