Files
generalFunctions/test/basic/LatestWinsGate.basic.test.js

241 lines
7.2 KiB
JavaScript
Raw Normal View History

'use strict';
const { test } = require('node:test');
const assert = require('node:assert/strict');
const LatestWinsGate = require('../../src/domain/LatestWinsGate');
// Helper: a deferred promise so a test can pause a dispatch and inspect
// gate state before resolving. Avoids real timers entirely.
function deferred() {
let resolve;
let reject;
const promise = new Promise((res, rej) => { resolve = res; reject = rej; });
return { promise, resolve, reject };
}
test('single fire calls dispatch with the value', async () => {
const calls = [];
const gate = new LatestWinsGate(async (v) => { calls.push(v); });
gate.fire('a');
await gate.drain();
assert.deepEqual(calls, ['a']);
});
test('two fires while in-flight: second value runs after first settles', async () => {
const calls = [];
const gates = [deferred(), deferred()];
const started = [deferred(), deferred()];
let n = 0;
const gate = new LatestWinsGate(async (v) => {
const slot = n++;
calls.push(v);
started[slot].resolve();
await gates[slot].promise;
});
gate.fire('first');
gate.fire('second'); // parks while 'first' is in flight
await started[0].promise;
assert.deepEqual(calls, ['first']);
assert.equal(gate.size, 2);
gates[0].resolve();
await started[1].promise;
assert.deepEqual(calls, ['first', 'second']);
gates[1].resolve();
await gate.drain();
});
test('three fires back-to-back: only the last runs after the first settles', async () => {
const calls = [];
const first = deferred();
const firstStarted = deferred();
let count = 0;
const gate = new LatestWinsGate(async (v) => {
calls.push(v);
if (count++ === 0) {
firstStarted.resolve();
await first.promise;
}
});
gate.fire(1);
gate.fire(2); // parked
gate.fire(3); // overwrites 2
await firstStarted.promise;
assert.deepEqual(calls, [1]);
first.resolve();
await gate.drain();
assert.deepEqual(calls, [1, 3]);
});
test('drain() resolves only after all queued work has run', async () => {
const calls = [];
const d = deferred();
let started = 0;
const gate = new LatestWinsGate(async (v) => {
calls.push(v);
if (started++ === 0) await d.promise;
});
gate.fire('x');
gate.fire('y');
let drained = false;
const p = gate.drain().then(() => { drained = true; });
// While first is paused, drain must not have resolved yet.
await Promise.resolve();
await Promise.resolve();
assert.equal(drained, false);
d.resolve();
await p;
assert.deepEqual(calls, ['x', 'y']);
assert.equal(drained, true);
});
test('error in dispatch does not prevent subsequent fire from working', async () => {
const calls = [];
let throwNext = true;
const errors = [];
const logger = { error: (e) => errors.push(e) };
const gate = new LatestWinsGate(async (v) => {
calls.push(v);
if (throwNext) {
throwNext = false;
throw new Error('boom');
}
}, { logger });
gate.fire('a');
await gate.drain();
assert.equal(calls.length, 1);
assert.equal(errors.length, 1);
assert.match(errors[0].message, /boom/);
assert.ok(gate.lastError instanceof Error);
// Gate must still accept further work.
gate.fire('b');
await gate.drain();
assert.deepEqual(calls, ['a', 'b']);
});
test('error is recorded on lastError when no logger is supplied', async () => {
const gate = new LatestWinsGate(async () => { throw new Error('silent'); });
gate.fire('only');
await gate.drain();
assert.ok(gate.lastError instanceof Error);
assert.match(gate.lastError.message, /silent/);
});
test('size reports 0 / 1 / 2 across the lifecycle', async () => {
const d1 = deferred();
const gate = new LatestWinsGate(async () => { await d1.promise; });
assert.equal(gate.size, 0);
gate.fire('one');
// fire is sync, but _dispatch starts on a microtask. Either way the
// gate is marked in-flight synchronously.
assert.equal(gate.size, 1);
gate.fire('two'); // parked
assert.equal(gate.size, 2);
d1.resolve();
await gate.drain();
assert.equal(gate.size, 0);
});
B2.3 + P11.1 + P11.2 + monster schema fix B2.3 LatestWinsGate fireAndWait: Added fireAndWait(value, ctx?) returning per-fire settlement promise. Supersede resolves with frozen sentinel {superseded: true} (no rejection — callers branch on value without try/catch). Dispatch errors also resolve (with undefined); error surfaces via gate.lastError. LatestWinsGate.js 75 → 116 lines. 12/12 tests pass. P11.1 convert.possibilities(measure): New helper returning sorted+deduped unit names for a measure. Cached per measure. Reuses existing convert measures map. Also exposed convert.measures() listing all known measures. convert/index.js +21 lines. New test file: 90 lines, 12/12 tests. P11.2 commandRegistry.units field: Pre-dispatch normalisation pipeline. descriptor.units = {measure, default}; commandRegistry extracts msg.payload + msg.unit (3 shapes), validates against measure, converts to default, falls back + warns with accepted-list on unknown/wrong-measure. Falls back gracefully if convert.possibilities is missing. commandRegistry.js 164 → 237. +7 new tests covering all 4 paths. monster schema fix (P11.2 sibling): generalFunctions/src/configs/monster.json was stripping four legitimate constraint keys (nominalFlowMin, flowMax, maxRainRef, minSampleIntervalSec). Added them with defaults matching the legacy nodeClass coercion. Side effect: this also UNBLOCKED the monster cooldown-guard test (separate ROOT-CAUSE entry below). CONTRACTS.md §4 + §8 updated. 144/144 basic tests + 206/206 full generalFunctions tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 17:29:14 +02:00
test('fireAndWait resolves when the dispatch for that value settles', async () => {
const calls = [];
const gate = new LatestWinsGate(async (v) => { calls.push(v); return `done:${v}`; });
const result = await gate.fireAndWait('a');
assert.deepEqual(calls, ['a']);
assert.equal(result, 'done:a');
});
test('fireAndWait while in-flight: caller awaits OWN settlement, not the first call', async () => {
const calls = [];
const d = deferred();
let count = 0;
const gate = new LatestWinsGate(async (v) => {
calls.push(v);
if (count++ === 0) await d.promise;
return `r:${v}`;
});
const p1 = gate.fireAndWait('first');
// p1 in flight. Park second; second's promise should resolve only
// after second's OWN dispatch runs, not after first's.
const p2 = gate.fireAndWait('second');
let p2Settled = false;
p2.then(() => { p2Settled = true; });
await Promise.resolve(); await Promise.resolve();
assert.equal(p2Settled, false);
d.resolve();
const r1 = await p1;
assert.equal(r1, 'r:first');
const r2 = await p2;
assert.equal(r2, 'r:second');
assert.deepEqual(calls, ['first', 'second']);
});
test('fireAndWait superseded by a later fireAndWait resolves with { superseded: true }', async () => {
const calls = [];
const d = deferred();
let count = 0;
const gate = new LatestWinsGate(async (v) => {
calls.push(v);
if (count++ === 0) await d.promise;
});
const p1 = gate.fireAndWait('first'); // in flight
const pParked = gate.fireAndWait('parked'); // gets superseded
const pLatest = gate.fireAndWait('latest'); // wins
d.resolve();
const supersedeRes = await pParked;
assert.equal(supersedeRes.superseded, true);
await p1;
await pLatest;
assert.deepEqual(calls, ['first', 'latest']); // 'parked' dropped
});
test('fireAndWait + fire intermix: a plain fire supersedes a pending fireAndWait', async () => {
const d = deferred();
let count = 0;
const calls = [];
const gate = new LatestWinsGate(async (v) => {
calls.push(v);
if (count++ === 0) await d.promise;
});
gate.fire('first'); // in flight, no settle
const pParked = gate.fireAndWait('parked');
gate.fire('latest'); // supersedes parked
d.resolve();
const res = await pParked;
assert.equal(res.superseded, true);
await gate.drain();
assert.deepEqual(calls, ['first', 'latest']);
});
test('fireAndWait still resolves (with undefined) when the dispatch throws', async () => {
const errors = [];
const logger = { error: (e) => errors.push(e) };
const gate = new LatestWinsGate(async () => { throw new Error('kaboom'); }, { logger });
const r = await gate.fireAndWait('only');
assert.equal(r, undefined);
assert.equal(errors.length, 1);
assert.ok(gate.lastError instanceof Error);
});