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>
241 lines
7.2 KiB
JavaScript
241 lines
7.2 KiB
JavaScript
'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);
|
|
});
|
|
|
|
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);
|
|
});
|