import assert from 'node:assert/strict'; import test from 'node:test'; import { CLEANUP_TICKS, ConditionTrigger, DeferredStartQueue, DISPATCH_BUDGET, drainDispatch, OwnershipModel } from '../tools/gc5-lifecycle-model.mjs'; test('resources created through nested events inherit the scenario owner', () => { const runtime = new OwnershipModel(); runtime.startScenario('scenario-1'); runtime.executeActions([{ type: 'event', actions: [{ type: 'event', actions: [{ type: 'resource', kind: 'sound' }] }] }], 'scenario-1'); assert.equal(runtime.ownedResourceCount('scenario-1'), 1); }); test('owner termination bounds duration override release and eventually disposes it', () => { const runtime = new OwnershipModel(); runtime.startScenario('scenario-1'); runtime.executeActions([{ type: 'resource', kind: 'override', releaseTicks: CLEANUP_TICKS * 10 }], 'scenario-1'); runtime.terminate('scenario-1', 'cancel'); assert.equal(runtime.ownedResourceCount('scenario-1'), 0); assert.equal(runtime.resources.size, 1); runtime.tick(CLEANUP_TICKS); assert.equal(runtime.resources.size, 0); assert.ok(runtime.diagnostics.some((entry) => entry.code === 'WARN_CLEANUP_FORCED')); }); test('persistent set survives a later critical ordinary-action failure', () => { const runtime = new OwnershipModel(); runtime.startScenario('scenario-1', { onCancel: [{ type: 'set', target: 'state.cleanup-ran', value: true }] }); runtime.executeActions([ { type: 'set', target: 'state.mode', value: 'changed' }, { type: 'resource', kind: 'visual' }, { type: 'fail', critical: true } ], 'scenario-1'); assert.equal(runtime.state.get('state.mode'), 'changed'); assert.equal(runtime.state.get('state.cleanup-ran'), true); assert.equal(runtime.owners.get('scenario-1').status, 'FAILED'); assert.equal(runtime.resources.size, 0); }); test('startup and termination-hook failures still complete cleanup with the original terminal cause', () => { const runtime = new OwnershipModel(); runtime.startScenario('scenario-1', { onStart: [{ type: 'resource', kind: 'audio' }, { type: 'fail', critical: true }], onCancel: [{ type: 'fail', critical: true }, { type: 'set', target: 'state.after-hook-failure', value: 1 }] }); assert.equal(runtime.owners.get('scenario-1').status, 'FAILED'); assert.equal(runtime.state.get('state.after-hook-failure'), 1); assert.equal(runtime.resources.size, 0); assert.equal(runtime.diagnostics.filter((entry) => entry.code === 'ERR_ACTION_FAILURE').length, 2); }); test('condition trigger requires a false tick before initial and subsequent firing', () => { const trigger = new ConditionTrigger(2); assert.equal(trigger.evaluate(true), false); assert.equal(trigger.evaluate(false), false); assert.equal(trigger.evaluate(true), false); assert.equal(trigger.evaluate(true), true); assert.equal(trigger.evaluate(true), false); assert.equal(trigger.evaluate(false), false); assert.equal(trigger.evaluate(true), false); assert.equal(trigger.evaluate(true), true); }); test('deferred queue keeps one request per definition, expires, rechecks, and orders deterministically', () => { const queue = new DeferredStartQueue(10); assert.equal(queue.defer({ definitionId: 'low', priority: 10, creationTick: 1, documentOrder: 0, sampledInputs: 0.2 }), true); assert.equal(queue.defer({ definitionId: 'low', priority: 99, creationTick: 2, documentOrder: 0, sampledInputs: 0.9 }), false); queue.defer({ definitionId: 'high-late', priority: 80, creationTick: 2, documentOrder: 2 }); queue.defer({ definitionId: 'high-early', priority: 80, creationTick: 1, documentOrder: 1 }); const started = queue.dispatch(3, (request) => request.definitionId !== 'low'); assert.deepEqual(started.map((request) => request.definitionId), ['high-early', 'high-late']); assert.equal(queue.requests.get('low').sampledInputs, 0.2); assert.deepEqual(queue.dispatch(11, () => true), []); assert.equal(queue.requests.size, 0); }); test('feedback is bounded and leaves cleanup capacity outside the ordinary budget', () => { const result = drainDispatch([{ path: 'event.loop', ownerId: 'scenario-1' }], (unit) => [unit]); assert.equal(result.exhausted, true); assert.equal(result.consumed, DISPATCH_BUDGET); assert.equal(result.discarded.length, 1); assert.deepEqual(result.failedOwnerIds, ['scenario-1']); const runtime = new OwnershipModel(); runtime.startScenario('scenario-1'); runtime.executeActions([{ type: 'resource' }], 'scenario-1'); runtime.terminate('scenario-1', 'failure'); assert.equal(runtime.resources.size, 0); }); test('repeated completion, cancellation, and failure cycles return counters to baseline', () => { const runtime = new OwnershipModel(); for (let index = 0; index < 30; index++) { const id = `scenario-${index}`; runtime.startScenario(id); runtime.executeActions([ { type: 'resource', kind: 'sound', releaseTicks: 2 }, { type: 'event', actions: [{ type: 'resource', kind: 'visual', releaseTicks: 3 }] } ], id); runtime.terminate(id, index % 3 === 0 ? 'complete' : index % 3 === 1 ? 'cancel' : 'failure'); runtime.tick(3); } assert.equal(runtime.resources.size, 0); assert.equal(runtime.owners.size, 30); assert.ok([...runtime.owners.values()].every((owner) => ['COMPLETED', 'CANCELLED', 'FAILED'].includes(owner.status))); });