Decision 2026-05-11: 'highVolumeSafetyLevel' is canonical. The legacy 'overfillLevel' name is gone from computeSafetyPoints + the validator issue tuple. 'overfillVol' parallel alias kept (out of scope for this task; flagged for follow-up). 130/130 tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
125 lines
5.0 KiB
JavaScript
125 lines
5.0 KiB
JavaScript
// Basic unit tests for thresholdValidator.
|
||
// Run with: node --test test/basic/thresholdValidator.basic.test.js
|
||
|
||
const test = require('node:test');
|
||
const assert = require('node:assert/strict');
|
||
|
||
const { validateThresholdOrdering } = require('../../src/basin/thresholdValidator');
|
||
const BasinGeometry = require('../../src/basin/BasinGeometry');
|
||
|
||
// A valid baseline: outlet 0.2 < inflow 3 < overflow 4.5 ≤ height 5,
|
||
// dryRun = 0.2 * 1.10 = 0.22 ≤ minLevel 1 ≤ start 2 < max 4
|
||
// ≤ highVolumeSafetyLevel 4.275.
|
||
function validBasinAndCfg() {
|
||
const basin = new BasinGeometry(
|
||
{ volume: 50, height: 5, inflowLevel: 3, outflowLevel: 0.2, overflowLevel: 4.5 },
|
||
{ minHeightBasedOn: 'outlet' }
|
||
);
|
||
const levelbased = { minLevel: 1, startLevel: 2, maxLevel: 4 };
|
||
const safety = { dryRunThresholdPercent: 10, overfillThresholdPercent: 95 };
|
||
return { basin, levelbased, safety };
|
||
}
|
||
|
||
test('valid ordering returns empty array', () => {
|
||
const { basin, levelbased, safety } = validBasinAndCfg();
|
||
const issues = validateThresholdOrdering(basin, levelbased, safety);
|
||
assert.deepEqual(issues, []);
|
||
});
|
||
|
||
test('outflowLevel >= inflowLevel triggers issue with correct shape', () => {
|
||
const basin = new BasinGeometry(
|
||
// outflow 3.5 > inflow 3 — invariant broken.
|
||
{ volume: 50, height: 5, inflowLevel: 3, outflowLevel: 3.5, overflowLevel: 4.5 },
|
||
{ minHeightBasedOn: 'outlet' }
|
||
);
|
||
const issues = validateThresholdOrdering(basin, { minLevel: 1, startLevel: 2, maxLevel: 4 }, { dryRunThresholdPercent: 0, overfillThresholdPercent: 100 });
|
||
const hit = issues.find((i) => i.aName === 'outflowLevel' && i.bName === 'inflowLevel');
|
||
assert.ok(hit, 'expected an outflowLevel < inflowLevel issue');
|
||
assert.equal(hit.op, '<');
|
||
assert.equal(hit.a, 3.5);
|
||
assert.equal(hit.b, 3);
|
||
assert.match(hit.msg, /outflowLevel.*<.*inflowLevel/);
|
||
});
|
||
|
||
test('maxLevel >= highVolumeSafetyLevel triggers issue', () => {
|
||
const { basin } = validBasinAndCfg();
|
||
// highVolumeSafetyLevel = overflowLevel × highPct/100 = 4.5 × 0.80 = 3.6.
|
||
// maxLevel 4 > 3.6 → expect a `maxLevel <= highVolumeSafetyLevel` issue.
|
||
const issues = validateThresholdOrdering(
|
||
basin,
|
||
{ minLevel: 1, startLevel: 2, maxLevel: 4 },
|
||
{ dryRunThresholdPercent: 10, overfillThresholdPercent: 80 }
|
||
);
|
||
const hit = issues.find((i) => i.aName === 'maxLevel' && i.bName === 'highVolumeSafetyLevel');
|
||
assert.ok(hit, 'expected a maxLevel <= highVolumeSafetyLevel issue');
|
||
assert.equal(hit.op, '<=');
|
||
assert.equal(hit.a, 4);
|
||
assert.ok(Math.abs(hit.b - 3.6) < 1e-9);
|
||
});
|
||
|
||
test('NaN / undefined values are skipped, not flagged as issues', () => {
|
||
const { basin } = validBasinAndCfg();
|
||
const issues = validateThresholdOrdering(
|
||
basin,
|
||
{ minLevel: undefined, startLevel: NaN, maxLevel: 4 },
|
||
{ dryRunThresholdPercent: 10, overfillThresholdPercent: 95 }
|
||
);
|
||
// dryRunLevel <= minLevel skipped (minLevel undefined → NaN)
|
||
// minLevel <= startLevel skipped (both NaN-ish)
|
||
// startLevel < maxLevel skipped (startLevel NaN)
|
||
// maxLevel <= highVolumeSafetyLevel still checked → 4 ≤ 4.275 OK.
|
||
// Geometry checks also OK.
|
||
assert.deepEqual(issues, []);
|
||
});
|
||
|
||
test('multiple violations produce multiple issues in stable order', () => {
|
||
// Build a basin with two geometry violations.
|
||
const basin = new BasinGeometry(
|
||
// outflow 4 > inflow 3 (broken) AND overflow 6 > height 5 (broken)
|
||
{ volume: 50, height: 5, inflowLevel: 3, outflowLevel: 4, overflowLevel: 6 },
|
||
{ minHeightBasedOn: 'outlet' }
|
||
);
|
||
const issues = validateThresholdOrdering(
|
||
basin,
|
||
{ minLevel: 1, startLevel: 2, maxLevel: 4 },
|
||
{ dryRunThresholdPercent: 0, overfillThresholdPercent: 100 }
|
||
);
|
||
// Expect at least the two geometry issues, in declaration order:
|
||
// outflowLevel < inflowLevel comes before overflowLevel <= basinHeight.
|
||
const idxOutflow = issues.findIndex((i) => i.aName === 'outflowLevel');
|
||
const idxOverflow = issues.findIndex((i) => i.aName === 'overflowLevel' && i.bName === 'basinHeight');
|
||
assert.ok(idxOutflow >= 0, 'expected outflowLevel issue');
|
||
assert.ok(idxOverflow >= 0, 'expected overflowLevel <= basinHeight issue');
|
||
assert.ok(idxOutflow < idxOverflow, 'issues should be in check-declaration order');
|
||
});
|
||
|
||
test('accepts a plain basin object (duck-typed via getters)', () => {
|
||
const plainBasin = {
|
||
volEmptyBasin: 50,
|
||
heightBasin: 5,
|
||
inflowLevel: 3,
|
||
outflowLevel: 0.2,
|
||
overflowLevel: 4.5,
|
||
surfaceArea: 10,
|
||
maxVol: 50,
|
||
maxVolAtOverflow: 45,
|
||
minVolAtInflow: 30,
|
||
minVolAtOutflow: 2,
|
||
minVol: 2,
|
||
minHeightBasedOn: 'outlet',
|
||
};
|
||
const issues = validateThresholdOrdering(
|
||
plainBasin,
|
||
{ minLevel: 1, startLevel: 2, maxLevel: 4 },
|
||
{ dryRunThresholdPercent: 10, overfillThresholdPercent: 95 }
|
||
);
|
||
assert.deepEqual(issues, []);
|
||
});
|
||
|
||
test('omitted levelbased / safety objects are tolerated', () => {
|
||
const { basin } = validBasinAndCfg();
|
||
// No control or safety supplied → only geometry checks run; valid basin geometry → []
|
||
const issues = validateThresholdOrdering(basin, undefined, undefined);
|
||
assert.deepEqual(issues, []);
|
||
});
|