Add Format Specification section 21 (Scenario Model 0.1) and the runtime that realizes it: the scenario director, seven trigger classes, timelines with repeats and branches, admission and concurrency control, nested ownership with bounded cleanup, and the production GC5 resource counters. Exhibit E provides a reproducible forty-minute long-scenario reference and scenario-challenge.xzbt covers the PRD 131 cases. A standalone acceptance/soak page is built by tools/build-scenario-acceptance.mjs. 252 automated checks pass. The two-hour real-duration development soak required by the GC6 schedule remains pending. Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01M7dgfQ12mpM4JjSMv3inLA
295 lines
20 KiB
JavaScript
295 lines
20 KiB
JavaScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { readFileSync } from 'node:fs';
|
|
import { CommonGrammarPerformance } from '../src/runtime/performance.js';
|
|
import { VisualEngine } from '../src/runtime/visual-engine.js';
|
|
import { AudioSubsystem } from '../src/runtime/audio-engine.js';
|
|
import { validateExhibit } from '../src/runtime/validator.js';
|
|
import { ExhibitValidator } from '../tools/validate-exhibit.mjs';
|
|
import { buildScenarioAcceptance } from '../tools/build-scenario-acceptance.mjs';
|
|
|
|
const set = (value, target = 'state.count') => ({ type: 'set', target, value });
|
|
const increment = set({ op: 'add', args: [{ ref: 'state.count' }, 1] });
|
|
const scenario = (extra = {}) => ({ timeline: [], duration: '5s', ...extra });
|
|
const documentFor = scenarios => ({ xzbt: '0.1', meta: { id: 'scenario-test', name: 'Scenario test' }, runtime: { seed: 42 },
|
|
parameters: { level: { type: 'number', default: 0.2, min: 0, max: 1 } },
|
|
state: { count: { type: 'integer', initial: 0 }, flag: { type: 'boolean', initial: false } }, scenarios });
|
|
function setup(scenarios, extra = {}) {
|
|
const document = { ...documentFor(scenarios), ...extra };
|
|
const trace = [], diagnostics = [];
|
|
const p = new CommonGrammarPerformance({ id: document.meta.id, document }, 42, {
|
|
onScenarioTrace: e => trace.push(e), diagnostics: { error: (code, message) => diagnostics.push({ code, message }), warn: (code, message) => diagnostics.push({ code, message }) }
|
|
});
|
|
const ticks = count => p.advanceTicks(count);
|
|
const start = id => { p.scenarios.request(id); ticks(1); return [...p.scenarios.active.values()].find(i => i.definitionId === id); };
|
|
return { p, trace, diagnostics, ticks, start, document, count: () => p.engine.get('state.count') };
|
|
}
|
|
const codes = document => validateExhibit(document).errors.map(e => e.code);
|
|
|
|
test('21.6.1 duration boundary beats run before completion, empty finite timelines complete', () => {
|
|
const r = setup({ s: scenario({ timeline: [{ at: '5s', actions: [increment] }, { at: '5001ms', actions: [increment] }], onComplete: [increment] }) });
|
|
r.start('s'); r.ticks(299); assert.equal(r.count(), 0);
|
|
r.ticks(1); assert.equal(r.count(), 2); assert.equal(r.p.scenarios.active.size, 0);
|
|
assert.equal(r.p.scenarios.definitions.get('s').lastState, 'COMPLETED');
|
|
assert.equal(r.p.engine.get('signals.scenario.active'), false);
|
|
const m = setup({ s: scenario({ duration: '3m' }) }); m.start('s'); m.ticks(10800);
|
|
assert.equal(m.p.scenarios.definitions.get('s').lastState, 'COMPLETED');
|
|
});
|
|
|
|
test('21.6.2 absolute/forward-relative ties keep document order and repeats stay lazy', () => {
|
|
const r = setup({ s: { timeline: [
|
|
{ after: 'anchor', delay: '0ms', actions: [set(1)] },
|
|
{ id: 'anchor', at: '100ms', actions: [set(2)] },
|
|
{ after: 'anchor', delay: { random: { min: '100ms', max: '100ms' } }, repeat: { count: 3, every: '100ms' }, actions: [increment] }
|
|
] } });
|
|
r.start('s'); assert.equal(r.p.scenarios.counters().schedulerRecords, 3);
|
|
r.ticks(6); assert.equal(r.count(), 2);
|
|
r.ticks(18); assert.equal(r.count(), 5); assert.equal(r.p.scenarios.counters().schedulerRecords, 0);
|
|
assert.deepEqual(r.trace.filter(e => e.type === 'beat').map(e => e.due), [100, 100, 200, 300, 400]);
|
|
});
|
|
|
|
test('21.6.3 weighted choices exclude false/zero branches and preserve per-instance randomness', () => {
|
|
const defs = { s: { timeline: [{ at: '0ms', repeat: { count: 5, every: { random: { min: '20ms', max: '80ms' } } }, choose: [
|
|
{ weight: 1000, when: { op: 'eq', left: 1, right: 2 }, actions: [set(999)] },
|
|
{ weight: 0, actions: [set(999)] }, { weight: 1, actions: [increment] }, { weight: 3, actions: [increment] }
|
|
] }] } };
|
|
const a = setup(defs), b = setup(defs);
|
|
a.start('s'); b.start('s');
|
|
for (let i = 0; i < 60; i++) { a.p.execute([set(0, 'state.count')], { domain: 'manual-sample' }); a.ticks(1); b.ticks(1); }
|
|
assert.deepEqual(a.trace, b.trace); assert.equal(b.count(), 5);
|
|
});
|
|
|
|
test('21.6.4 once, interval, probability, random interval, event and manual opportunities', () => {
|
|
for (const trigger of [{ type: 'once', at: '50ms' }, { type: 'interval', every: '50ms' }, { type: 'random-interval', min: '50ms', max: '50ms' }, { type: 'probability', every: '50ms', chance: 1 }]) {
|
|
const r = setup({ s: { trigger, timeline: [], onStart: [increment] } }); r.ticks(7);
|
|
assert.equal(r.count(), trigger.type === 'once' ? 1 : 2, trigger.type);
|
|
}
|
|
const never = setup({ s: { trigger: { type: 'probability', every: '20ms', chance: 0 }, timeline: [], onStart: [increment] } }); never.ticks(30); assert.equal(never.count(), 0);
|
|
const r = setup({ s: scenario({ trigger: { type: 'event', event: 'go' }, onStart: [increment] }) }, { events: { go: { actions: [set(true, 'state.flag')] } } });
|
|
r.p.execute([{ type: 'event', event: 'go' }], { domain: 'manual-sample' }); r.ticks(1);
|
|
assert.equal(r.count(), 1); assert.equal(r.p.scenarios.counters().subscriptions, 1);
|
|
});
|
|
|
|
test('GC5 condition startup disarming, interrupted hold and false-tick rearming', () => {
|
|
const r = setup({ s: { timeline: [], trigger: { type: 'condition', when: { op: 'eq', left: { ref: 'state.flag' }, right: true }, for: '50ms' }, onStart: [increment] } });
|
|
r.p.engine.setState('state.flag', true); r.ticks(10); assert.equal(r.count(), 0);
|
|
r.p.engine.setState('state.flag', false); r.ticks(1);
|
|
r.p.engine.setState('state.flag', true); r.ticks(3); assert.equal(r.count(), 0);
|
|
r.p.engine.setState('state.flag', false); r.ticks(1);
|
|
r.p.engine.setState('state.flag', true); r.ticks(4); assert.equal(r.count(), 1);
|
|
r.ticks(50); assert.equal(r.count(), 1);
|
|
r.p.engine.setState('state.flag', false); r.ticks(1);
|
|
r.p.engine.setState('state.flag', true); r.ticks(4); assert.equal(r.count(), 2);
|
|
});
|
|
|
|
test('GC5 deferred deduplication preserves expiry and rechecks eligibility and cooldown', () => {
|
|
const r = setup({ s: { timeline: [], eligibility: { when: { op: 'eq', left: { ref: 'state.flag' }, right: true }, timeout: '100ms' }, cooldown: '100ms', onStart: [increment] } });
|
|
r.p.scenarios.request('s', { inputScope: { level: 1 } }); const original = r.p.scenarios.pending.get('s');
|
|
r.ticks(3); r.p.scenarios.request('s', { inputScope: { level: 2 } });
|
|
assert.equal(r.p.scenarios.pending.get('s'), original); assert.equal(original.triggerInputs.level, 1);
|
|
r.ticks(3); assert.equal(r.p.scenarios.pending.size, 0);
|
|
r.p.engine.setState('state.flag', true); r.start('s'); assert.equal(r.count(), 1);
|
|
r.p.scenarios.request('s'); r.ticks(5); assert.equal(r.count(), 1); r.ticks(1);
|
|
// Expiry is removed before the cooldown recheck at the same timestamp.
|
|
assert.equal(r.p.scenarios.pending.size, 0); assert.equal(r.count(), 1);
|
|
});
|
|
|
|
test('21.6.7 group/global exclusivity is symmetric; replacement is strict and deferred order stable', () => {
|
|
const r = setup({
|
|
a: scenario({ group: 'g', concurrency: { mode: 'parallel' } }),
|
|
b: scenario({ group: 'g', priority: 70, concurrency: { mode: 'exclusive', scope: 'group', policy: 'replace' } }),
|
|
c: scenario({ group: 'g', priority: 70, concurrency: { mode: 'exclusive', scope: 'group', policy: 'replace' } }),
|
|
d: scenario({ group: 'other' })
|
|
});
|
|
r.start('a'); r.start('d'); r.start('b');
|
|
assert.equal(r.p.scenarios.active.size, 2); assert.equal(r.p.scenarios.definitions.get('a').lastState, 'CANCELLED');
|
|
r.start('c'); assert.equal(r.p.scenarios.active.size, 2); assert.equal(r.p.scenarios.pending.size, 0);
|
|
r.p.scenarios.request('a'); r.ticks(1); assert.equal(r.p.scenarios.pending.size, 1);
|
|
r.p.scenarios.cancel('b'); r.ticks(1); assert.ok([...r.p.scenarios.active.values()].some(i => i.definitionId === 'a'));
|
|
const q = setup({ low: scenario({ priority: 10, concurrency: { mode: 'exclusive' } }), high: scenario({ priority: 90, concurrency: { mode: 'exclusive' } }) });
|
|
q.p.scenarios.request('low'); q.p.scenarios.request('high'); q.ticks(1);
|
|
assert.equal([...q.p.scenarios.active.values()][0].definitionId, 'high');
|
|
});
|
|
|
|
test('21.6.7 active capacity defers atomically and admits after cancellation', () => {
|
|
const r = setup(Object.fromEntries(Array.from({ length: 17 }, (_, i) => [`s${i}`, scenario()])));
|
|
for (const id of r.p.scenarios.definitions.keys()) r.p.scenarios.request(id);
|
|
r.ticks(1); assert.equal(r.p.scenarios.active.size, 16); assert.equal(r.p.scenarios.pending.size, 1);
|
|
r.p.scenarios.cancel('s0'); r.ticks(1); assert.equal(r.p.scenarios.active.size, 16); assert.equal(r.p.scenarios.pending.size, 0);
|
|
});
|
|
|
|
function attachResources(r) {
|
|
const visual = new VisualEngine(r.document, { rng: r.p.rng, resolution: r.p.engine });
|
|
r.p.actions.visual = visual; r.p.onTick = step => visual.advance(step);
|
|
// Native API double only. Graph expansion, realization, SoundInstance lifecycle,
|
|
// voice pools and owner cleanup all execute production code.
|
|
const nodes = new Set();
|
|
const param = () => ({ value: 1, setValueAtTime() {}, linearRampToValueAtTime() {}, cancelScheduledValues() {} });
|
|
const node = () => { const n = { gain: param(), frequency: param(), detune: param(), connect(target) { return target; }, disconnect() { nodes.delete(n); }, start() {}, stop() {} }; nodes.add(n); return n; };
|
|
const context = { sampleRate: 48000, currentTime: 0, state: 'running', destination: node(), createGain: node, createOscillator: node, close: async () => {} };
|
|
const audio = new AudioSubsystem({ document: r.document, rng: r.p.rng, resolutionEngine: r.p.engine });
|
|
audio.context = context; audio.ready = true; audio.master = node(); audio.createVoiceGuard = () => null;
|
|
r.p.setAudio(audio);
|
|
return { visual, audio, nodes };
|
|
}
|
|
const resources = {
|
|
sounds: { drone: { name: 'Drone', usage: ['scenario'], recipe: { mode: 'continuous', release: '10s', nodes: { tone: { type: 'oscillator', frequency: 110 } }, routes: [{ from: 'tone', to: 'output' }] } } },
|
|
visuals: { scene: { coordinateSpace: 'virtual', width: 100, height: 100 }, systems: {
|
|
burst: { type: 'graphic', lifecycle: 'spawned', spawn: { release: '8s' }, content: { p: { type: 'point', position: { x: 50, y: 50 } } } }
|
|
} },
|
|
events: {
|
|
outer: { actions: [{ type: 'event', event: 'inner' }] },
|
|
inner: { actions: [{ type: 'sound', sound: 'drone', ownership: 'scenario' }, { type: 'spawn', target: 'visuals.systems.burst' }, { type: 'override', target: 'parameters.level', scope: 'duration', duration: '1h', value: 0.8, transition: { out: '20s' } }] }
|
|
}
|
|
};
|
|
|
|
test('GC5 nested ownership uses real voice pools, visual instances and duration overrides with bounded cleanup', async () => {
|
|
const r = setup({ s: scenario({ onStart: [{ type: 'event', event: 'outer' }] }) }, structuredClone(resources));
|
|
assert.deepEqual(validateExhibit(r.document).errors, []);
|
|
const { audio, visual } = attachResources(r);
|
|
const instance = r.start('s');
|
|
assert.equal(audio.continuousVoices.size, 1); assert.equal(visual.instances.size, 1);
|
|
assert.equal([...audio.voices][0].owner, instance.owner);
|
|
assert.equal([...visual.instances.values()][0].owner, instance.owner);
|
|
r.p.setParameter('level', 0.4);
|
|
r.p.scenarios.cancel('s');
|
|
assert.equal(audio.voices.size, 0); // released but still allocated in its pool
|
|
assert.equal(r.p.scenarios.counters().audioVoices, 1);
|
|
assert.equal(r.p.engine.overrides.instances.size, 1);
|
|
r.ticks(301);
|
|
assert.equal(audio.continuousVoices.size, 0); assert.equal(visual.instances.size, 0);
|
|
assert.equal(r.p.engine.overrides.instances.size, 0); assert.equal(r.p.engine.get('parameters.level'), 0.4);
|
|
assert.equal(r.p.scenarios.cleanups.size, 0); await audio.dispose();
|
|
});
|
|
|
|
test('GC5 critical startup/ordinary failures preserve set, run cancel hook, continue past hook failures', () => {
|
|
for (const phase of ['onStart', 'timeline']) {
|
|
const list = [set(7), { type: 'set', target: 'state.missing', value: 1 }, set(99)];
|
|
const r = setup({ s: scenario({ ...(phase === 'onStart' ? { onStart: list } : { timeline: [{ at: '0ms', actions: list }] }),
|
|
onCancel: [{ type: 'set', target: 'state.missing', value: 0 }, set(true, 'state.flag')] }) });
|
|
r.start('s'); assert.equal(r.count(), 7); assert.equal(r.p.engine.get('state.flag'), true);
|
|
assert.equal(r.p.scenarios.definitions.get('s').lastState, 'FAILED'); assert.equal(r.p.scenarios.active.size, 0);
|
|
}
|
|
});
|
|
|
|
test('GC5 dispatch exhaustion cannot be softened and reserves termination hook budget', () => {
|
|
const r = setup({ s: scenario({ onStart: Array.from({ length: 1030 }, () => ({ ...increment, critical: false })),
|
|
onCancel: [set(true, 'state.flag'), ...Array.from({ length: 260 }, () => increment)] }) });
|
|
r.start('s'); assert.equal(r.count(), 1023 + 255); assert.equal(r.p.engine.get('state.flag'), true);
|
|
assert.equal(r.p.scenarios.definitions.get('s').lastState, 'FAILED');
|
|
assert.ok(r.diagnostics.some(e => e.code === 'ERR_DISPATCH_BUDGET'));
|
|
});
|
|
|
|
test('GC5 repeated completion/cancellation/failure cycles return production counters and native allocations to baseline', async () => {
|
|
const r = setup({ s: scenario({ duration: '50ms', onStart: [{ type: 'event', event: 'outer' }], onCancel: [set(true, 'state.flag')] }) }, structuredClone(resources));
|
|
const { audio, nodes } = attachResources(r); const baseline = nodes.size;
|
|
for (let i = 0; i < 24; i++) {
|
|
r.start('s');
|
|
if (i % 3 === 0) r.p.scenarios.cancel('s');
|
|
else if (i % 3 === 1) r.p.scenarios.fail([...r.p.scenarios.active.values()][0], new Error('Injected failure'));
|
|
r.ticks(310);
|
|
const c = r.p.scenarios.counters();
|
|
for (const key of ['scenarios', 'deferred', 'schedulerRecords', 'overrides', 'audioVoices', 'audioNodes', 'visualInstances', 'cleanupOwners']) assert.equal(c[key], 0, `${i}: ${key}`);
|
|
assert.equal(nodes.size, baseline);
|
|
}
|
|
await audio.dispose();
|
|
});
|
|
|
|
test('21.6.14 pause reasons and direct disposal clear active ownership before resource teardown', async () => {
|
|
const r = setup({ s: scenario({ onStart: [{ type: 'event', event: 'outer' }], onCancel: [set(true, 'state.flag')] }) }, structuredClone(resources));
|
|
const { audio } = attachResources(r); r.start('s');
|
|
const time = r.p.engine.logicalMilliseconds;
|
|
r.p.pause(); r.p.pause('visibility'); r.p.resume('visibility'); r.ticks(100); assert.equal(r.p.engine.logicalMilliseconds, time);
|
|
r.p.resume(); r.ticks(1); assert.ok(r.p.engine.logicalMilliseconds > time);
|
|
await r.p.deactivate(); assert.equal(r.p.engine.get('state.flag'), true);
|
|
assert.equal(r.p.scenarios.counters().audioVoices, 0); assert.equal(r.p.scenarios.counters().visualInstances, 0);
|
|
assert.equal(r.p.scenarios.counters().subscriptions, 0); await audio.dispose();
|
|
});
|
|
|
|
test('continuous pre-unlock intentions disappear with their owner and never replay one-shots', () => {
|
|
const r = setup({ s: scenario({ onStart: [{ type: 'sound', sound: 'drone' }] }) }, structuredClone(resources));
|
|
r.start('s'); assert.equal(r.p.scenarios.continuousRequests.length, 1);
|
|
r.p.scenarios.cancel('s'); assert.equal(r.p.scenarios.continuousRequests.length, 0);
|
|
});
|
|
|
|
test('scenario control stop blocks remaining ordinary actions for the terminated owner', () => {
|
|
const r = setup({ s: scenario({ onStart: [{ type: 'control', target: 'scenarios.s', command: 'stop' }, set(99)], onCancel: [set(1)] }) });
|
|
r.start('s'); assert.equal(r.count(), 1); assert.equal(r.p.scenarios.active.size, 0);
|
|
});
|
|
|
|
test('scenario contract rejects malformed shapes, dangling anchors, feedback and unsupported hooks in both validators', () => {
|
|
const cases = [
|
|
[scenario({ priority: 101 }), 'ERR_OUT_OF_BOUNDS'], [scenario({ onFailure: [] }), 'ERR_UNKNOWN_FIELD'],
|
|
[scenario({ trigger: { type: 'interval', every: '0ms' } }), 'ERR_INVALID_DURATION'],
|
|
[scenario({ trigger: { type: 'random-interval', min: '2s', max: '1s' } }), 'ERR_INVALID_RANGE_ORDER'],
|
|
[scenario({ timeline: [{ after: 'missing', actions: [] }] }), 'ERR_INVALID_REFERENCE'],
|
|
[scenario({ timeline: [{ id: 'a', after: 'b', actions: [] }, { id: 'b', after: 'a', actions: [] }] }), 'ERR_CYCLIC_DEPENDENCY'],
|
|
[scenario({ timeline: [{ at: '0ms', actions: [], choose: [] }] }), 'ERR_SCHEMA_VALIDATION'],
|
|
[scenario({ onCancel: [{ type: 'event', event: 'outer' }] }), 'ERR_UNSUPPORTED_TARGET'],
|
|
[scenario({ onStart: [{ type: 'control', target: 'scenarios.s', command: 'start' }] }), 'ERR_CYCLIC_DEPENDENCY']
|
|
];
|
|
for (const [def, code] of cases) {
|
|
const document = { ...documentFor({ s: def }), ...structuredClone(resources) };
|
|
assert.ok(codes(document).includes(code), code);
|
|
assert.ok(new ExhibitValidator(document).validate().errors.some(e => e.code === code), `CLI: ${code}`);
|
|
}
|
|
const feedback = documentFor({ s: scenario({ trigger: { type: 'event', event: 'go' }, onStart: [{ type: 'event', event: 'indirect' }] }) });
|
|
feedback.events = { go: { actions: [increment] }, indirect: { actions: [{ type: 'event', event: 'go' }] } };
|
|
assert.ok(codes(feedback).includes('ERR_CYCLIC_DEPENDENCY'));
|
|
});
|
|
|
|
test('21.6.13 Exhibit E completes forty logical minutes through production fixed ticks reproducibly', () => {
|
|
const document = JSON.parse(readFileSync(new URL('../exhibits/exhibit-e.xzbt', import.meta.url)));
|
|
assert.deepEqual(validateExhibit(document).errors, []);
|
|
const run = () => {
|
|
const r = setup({}, document);
|
|
const visual = new VisualEngine(document, { rng: r.p.rng, resolution: r.p.engine });
|
|
r.p.actions.visual = visual;
|
|
// Rendering is intentionally absent; the same production simulation runs.
|
|
r.p.onTick = step => visual.advance(step);
|
|
r.start('journey'); r.ticks(40 * 60 * 60 + 301);
|
|
assert.equal(r.p.scenarios.definitions.get('journey').lastState, 'COMPLETED');
|
|
assert.equal(r.p.scenarios.counters().visualInstances, 0);
|
|
assert.equal(r.p.engine.get('state.mode'), 'normal');
|
|
assert.equal(r.p.scenarios.counters().overrides, 0);
|
|
assert.ok(r.p.engine.get('state.beats') > 0);
|
|
visual.dispose(); return r.trace;
|
|
};
|
|
assert.deepEqual(run(), run());
|
|
});
|
|
|
|
test('signals.scenario.active is visible to onStart and termination hooks in the same tick', () => {
|
|
const r = setup({ s: scenario({ onStart: [set({ ref: 'signals.scenario.active' }, 'state.flag')], onCancel: [set({ ref: 'signals.scenario.active' }, 'state.flag')] }) });
|
|
r.start('s'); assert.equal(r.p.engine.get('state.flag'), true);
|
|
r.p.scenarios.cancel('s'); assert.equal(r.p.engine.get('state.flag'), false);
|
|
});
|
|
|
|
test('a persistent sound and a detached persistent visual survive their originating scenario', async () => {
|
|
const extra = structuredClone(resources);
|
|
extra.visuals.systems.burst.spawn.ownership = 'persistent';
|
|
extra.visuals.systems.burst.spawn.cancelWithScenario = false;
|
|
const r = setup({ s: scenario({ onStart: [{ type: 'sound', sound: 'drone', ownership: 'persistent' }, { type: 'spawn', target: 'visuals.systems.burst', ownership: 'persistent' }] }) }, extra);
|
|
const { audio, visual } = attachResources(r); r.start('s'); r.p.scenarios.cancel('s'); r.ticks(301);
|
|
assert.equal(audio.continuousVoices.size, 1); assert.equal(visual.instances.size, 1);
|
|
assert.equal([...audio.continuousVoices][0].owner, 'performance');
|
|
await audio.dispose(); visual.dispose();
|
|
});
|
|
|
|
test('production audio releases obey paused logical time and dispose after the bounded release', async () => {
|
|
const r = setup({ s: scenario({ onStart: [{ type: 'sound', sound: 'drone' }] }) }, structuredClone(resources));
|
|
const { audio } = attachResources(r); r.start('s'); r.p.scenarios.cancel('s');
|
|
const voice = [...audio.continuousVoices][0]; assert.equal(voice.releaseTimer, null);
|
|
r.p.pause(); r.ticks(400); assert.equal(audio.continuousVoices.size, 1);
|
|
r.p.resume(); r.ticks(301); assert.equal(audio.continuousVoices.size, 0); await audio.dispose();
|
|
});
|
|
|
|
test('scenario acceptance build is reproducible and embeds only valid declarative fixtures', () => {
|
|
const a = buildScenarioAcceptance(), b = buildScenarioAcceptance();
|
|
assert.equal(a.html, b.html);
|
|
assert.ok(!/<script[^>]+src=/.test(a.html));
|
|
for (const fixture of a.fixtures) {
|
|
assert.deepEqual(validateExhibit(fixture).errors, []);
|
|
assert.deepEqual(new ExhibitValidator(fixture).validate().errors, []);
|
|
}
|
|
});
|