// Basic unit tests for PumpingStation (domain logic, no Node-RED).
// Run with:  node --test test/basic/specificClass.test.js

const test = require('node:test');
const assert = require('node:assert/strict');

const { MeasurementContainer } = require('generalFunctions');
const PumpingStation = require('../../src/specificClass');

// machineGroups is a registry-backed getter (declareChildGetter) — direct
// assignment is no longer possible. Tests inject mock groups through the
// real registration handshake so the registry remains the source of truth.
function registerMockGroup(ps, id, behavior = {}) {
  const calls = { setDemand: [], handleInput: [], turnOff: 0 };
  const mock = {
    config: {
      general: { id, name: id },
      functionality: { softwareType: 'machinegroup', positionVsParent: 'atEquipment' },
      asset: { category: 'controller' },
    },
    measurements: {
      emitter: { on: () => {} },
      setChildId: () => {}, setChildName: () => {}, setParentRef: () => {},
    },
    setDemand: behavior.setDemand
      || (async (value, unit) => { calls.setDemand.push([value, unit]); }),
    handleInput: behavior.handleInput
      || (async (...args) => { calls.handleInput.push(args); }),
    turnOffAllMachines: behavior.turnOffAllMachines
      || (() => { calls.turnOff += 1; }),
    _calls: calls,
  };
  ps.childRegistrationUtils.registerChild(mock, 'atEquipment');
  return mock;
}

// Standard config shape. Override any section by passing { section: {...} }.
function makeConfig(overrides = {}) {
  const base = {
    general: {
      name: 'TestStation',
      id: 'ps-test',
      unit: 'm3/h',
      logging: { enabled: false, logLevel: 'error' },
      flowThreshold: 1e-4,
    },
    functionality: {
      softwareType: 'pumpingStation',
      role: 'stationcontroller',
      positionVsParent: 'atEquipment',
    },
    basin: {
      volume: 50,
      height: 5,
      inflowLevel: 3,
      outflowLevel: 0.2,
      overflowLevel: 4.5,
      inletPipeDiameter: 0.4,
      outletPipeDiameter: 0.3,
    },
    hydraulics: {
      refHeight: 'NAP',
      basinBottomRef: 0,
      minHeightBasedOn: 'outlet',
    },
    control: {
      mode: 'levelbased',
      allowedModes: new Set(['levelbased', 'manual']),
      levelbased: { minLevel: 1, startLevel: 2, maxLevel: 4, curveType: 'linear', logCurveFactor: 9 },
    },
    safety: {
      enableDryRunProtection: false,
      enableOverfillProtection: false,
      dryRunThresholdPercent: 2,
      highVolumeSafetyThresholdPercent: 98,
      overfillThresholdPercent: 98,
      timeleftToFullOrEmptyThresholdSeconds: 0,
    },
  };
  for (const k of Object.keys(overrides)) {
    base[k] = typeof overrides[k] === 'object' && !Array.isArray(overrides[k])
      ? { ...base[k], ...overrides[k] }
      : overrides[k];
  }
  return base;
}

function makeMeasurementChild({ type = 'level', position = 'atequipment', name = 'child-level' } = {}) {
  return {
    config: {
      general: { id: name, name },
      functionality: { positionVsParent: position },
      asset: { type },
    },
    measurements: new MeasurementContainer({
      autoConvert: true,
      preferredUnits: { level: 'm', flow: 'm3/s', pressure: 'Pa' },
    }),
  };
}

test('level child subscription records one sample per event for level-rate fallback', async () => {
  const ps = new PumpingStation(makeConfig());
  const child = makeMeasurementChild();

  ps._subscribeMeasurement(child);
  child.measurements.type('level').variant('measured').position('atequipment')
    .value(1.0, 1000, 'm');
  child.measurements.type('level').variant('measured').position('atequipment')
    .value(1.1, 3000, 'm');

  const series = ps.measurements.type('level').variant('measured').position('atequipment').get();
  assert.deepEqual(series.values, [1.0, 1.1]);

  const net = ps.flowAggregator.selectBestNetFlow();
  assert.equal(net.source, 'level:measured');
  assert.equal(net.direction, 'filling');
  assert.ok(Math.abs(net.value - 0.5) < 1e-9, `net flow was ${net.value}`);
});

test('Basin geometry — derived values', async (t) => {
  const ps = new PumpingStation(makeConfig());

  await t.test('surfaceArea = volume / height', () => {
    assert.equal(ps.basin.surfaceArea, 10);  // 50 / 5
  });
  await t.test('maxVol = height × area ≡ volEmptyBasin', () => {
    assert.equal(ps.basin.maxVol, 50);
    assert.equal(ps.basin.maxVol, ps.basin.volEmptyBasin);
  });
  await t.test('maxVolAtOverflow = overflowLevel × area', () => {
    assert.equal(ps.basin.maxVolAtOverflow, 45);  // 4.5 × 10
  });
  await t.test('minVolAtInflow = inflowLevel × area', () => {
    assert.equal(ps.basin.minVolAtInflow, 30);  // 3 × 10
  });
  await t.test('minVolAtOutflow = outflowLevel × area', () => {
    assert.ok(Math.abs(ps.basin.minVolAtOutflow - 2) < 1e-9);  // 0.2 × 10
  });
  await t.test('minVol honours minHeightBasedOn=outlet', () => {
    assert.ok(Math.abs(ps.basin.minVol - 2) < 1e-9);
  });
  await t.test('minVol honours minHeightBasedOn=inlet', () => {
    const ps2 = new PumpingStation(makeConfig({ hydraulics: { minHeightBasedOn: 'inlet' } }));
    assert.equal(ps2.basin.minVol, 30);
  });
  await t.test('pipe diameters are part of basin contract', () => {
    assert.equal(ps.basin.inletPipeDiameter, 0.4);
    assert.equal(ps.basin.outletPipeDiameter, 0.3);
  });
});

test('Level ↔ volume roundtrip', async (t) => {
  const ps = new PumpingStation(makeConfig());

  await t.test('_calcVolumeFromLevel multiplies by area', () => {
    assert.equal(ps._calcVolumeFromLevel(2), 20);
  });
  await t.test('_calcVolumeFromLevel clamps negatives to 0', () => {
    assert.equal(ps._calcVolumeFromLevel(-3), 0);
  });
  await t.test('_calcLevelFromVolume divides by area', () => {
    assert.equal(ps._calcLevelFromVolume(20), 2);
  });
  await t.test('_calcLevelFromVolume clamps negatives to 0', () => {
    assert.equal(ps._calcLevelFromVolume(-10), 0);
  });
  await t.test('roundtrip preserves level', () => {
    const v = ps._calcVolumeFromLevel(2.7);
    assert.ok(Math.abs(ps._calcLevelFromVolume(v) - 2.7) < 1e-10);
  });
});

test('Threshold guardrails — _validateThresholdOrdering', async (t) => {
  await t.test('valid config returns no issues', () => {
    const ps = new PumpingStation(makeConfig());
    assert.equal(ps.thresholdIssues.length, 0);
  });

  await t.test('minLevel > startLevel flagged', () => {
    const ps = new PumpingStation(makeConfig({
      control: {
        mode: 'levelbased',
        allowedModes: new Set(['levelbased']),
        levelbased: { minLevel: 3, startLevel: 2, maxLevel: 4 },
      },
    }));
    assert.ok(ps.thresholdIssues.some((i) => i.aName === 'minLevel'));
  });

  await t.test('startLevel == maxLevel flagged (must be strict <)', () => {
    const ps = new PumpingStation(makeConfig({
      control: {
        mode: 'levelbased',
        allowedModes: new Set(['levelbased']),
        levelbased: { minLevel: 1, startLevel: 4, maxLevel: 4 },
      },
    }));
    assert.ok(ps.thresholdIssues.some((i) => i.aName === 'startLevel'));
  });

  await t.test('startLevel > inflowLevel is allowed (sewer-buffer mode), no issue raised', () => {
    // Inflow gravity point at 3, startLevel pushed to 3.5 → basin is allowed
    // to fill past the inlet before pumps engage. levelBased shifts the ramp
    // foot to startLevel; the validator no longer flags the ordering.
    const ps = new PumpingStation(makeConfig({
      control: {
        mode: 'levelbased',
        allowedModes: new Set(['levelbased']),
        levelbased: { minLevel: 1, startLevel: 3.5, maxLevel: 4, curveType: 'linear' },
      },
    }));
    assert.ok(!ps.thresholdIssues.some((i) => i.aName === 'startLevel' && i.bName === 'inflowLevel'),
      'startLevel vs inflowLevel ordering must not raise an issue');
  });

  await t.test('outflowLevel >= inflowLevel flagged', () => {
    const ps = new PumpingStation(makeConfig({
      basin: { volume: 50, height: 5, inflowLevel: 0.1, outflowLevel: 0.5, overflowLevel: 4.5 },
    }));
    assert.ok(ps.thresholdIssues.some((i) => i.aName === 'outflowLevel'));
  });

  await t.test('overflowLevel > basinHeight flagged', () => {
    const ps = new PumpingStation(makeConfig({
      basin: { volume: 50, height: 5, inflowLevel: 3, outflowLevel: 0.2, overflowLevel: 6 },
    }));
    assert.ok(ps.thresholdIssues.some((i) => i.aName === 'overflowLevel'));
  });

  await t.test('dryRunLevel > minLevel flagged (safety band inverted)', () => {
    // With minHeightBasedOn=inlet, refLowLevel=inflowLevel=3.
    // dryRunLevel = 3 × (1 + 100/100) = 6; minLevel=1 → 6 ≤ 1 fails.
    const ps = new PumpingStation(makeConfig({
      hydraulics: { minHeightBasedOn: 'inlet' },
      safety: { enableDryRunProtection: true, dryRunThresholdPercent: 100 },
    }));
    assert.ok(ps.thresholdIssues.some((i) => i.aName === 'dryRunLevel'));
  });
});

test('Direction derivation — _deriveDirection', async (t) => {
  const ps = new PumpingStation(makeConfig());

  await t.test('positive flow above dead-band → filling', () => {
    assert.equal(ps._deriveDirection(0.01), 'filling');
  });
  await t.test('negative flow below dead-band → draining', () => {
    assert.equal(ps._deriveDirection(-0.01), 'draining');
  });
  await t.test('flow inside dead-band → steady', () => {
    assert.equal(ps._deriveDirection(0), 'steady');
    assert.equal(ps._deriveDirection(1e-5), 'steady');
    assert.equal(ps._deriveDirection(-1e-5), 'steady');
  });
});

test('Mode change — changeMode', async (t) => {
  const ps = new PumpingStation(makeConfig());

  await t.test('valid mode swap updates this.mode', () => {
    ps.changeMode('manual');
    assert.equal(ps.mode, 'manual');
  });
  await t.test('rejected mode leaves this.mode unchanged', () => {
    ps.changeMode('manual');
    ps.changeMode('notamode');
    assert.equal(ps.mode, 'manual');
  });
});

test('Calibration — predicted volume and level', async (t) => {
  const ps = new PumpingStation(makeConfig());

  await t.test('calibratePredictedVolume rewrites volume series', () => {
    ps.calibratePredictedVolume(25);
    const vol = ps.measurements.type('volume').variant('predicted').position('atequipment').getCurrentValue('m3');
    assert.ok(Math.abs(vol - 25) < 1e-9);
  });
  await t.test('calibratePredictedVolume also writes level (= vol / area)', () => {
    ps.calibratePredictedVolume(30);
    const lvl = ps.measurements.type('level').variant('predicted').position('atequipment').getCurrentValue('m');
    assert.ok(Math.abs(lvl - 3) < 1e-9);  // 30 / 10
  });
  await t.test('calibratePredictedLevel writes level + volume = level × area', () => {
    ps.calibratePredictedLevel(2.5);
    const lvl = ps.measurements.type('level').variant('predicted').position('atequipment').getCurrentValue('m');
    const vol = ps.measurements.type('volume').variant('predicted').position('atequipment').getCurrentValue('m3');
    assert.ok(Math.abs(lvl - 2.5) < 1e-9);
    assert.ok(Math.abs(vol - 25) < 1e-9);  // 2.5 × 10
  });
});

test('Levelbased control zones — _controlLevelBased', async (t) => {
  await t.test('level < minLevel → percControl=0 and MGC turnOff called', async () => {
    const ps = new PumpingStation(makeConfig());
    const mock = registerMockGroup(ps, 'mgc1');
    ps.calibratePredictedLevel(0.5);  // below minLevel=1
    await ps._controlLevelBased();
    assert.equal(ps.percControl, 0);
    assert.equal(mock._calls.turnOff, 1);
  });

  await t.test('minLevel ≤ level < active ramp start → soft turnOff (pct=0 no longer dispatched)', async () => {
    const ps = new PumpingStation(makeConfig());
    ps.percControl = 42;  // simulated previous demand
    const mock = registerMockGroup(ps, 'mgc1');
    ps.calibratePredictedLevel(1.5);  // between minLevel=1 and startLevel=2
    await ps._controlLevelBased();
    assert.equal(ps.percControl, 0);
    // pct=0 → turnOff, no setDemand call (avoids MGC interpolating 0 % to dt.flow.min).
    assert.equal(mock._calls.turnOff, 1);
    assert.equal(mock._calls.setDemand.length, 0);
  });

  await t.test('filling: level between startLevel and inflowLevel ramps from startLevel (no implicit hold zone)', async () => {
    const ps = new PumpingStation(makeConfig());
    const mock = registerMockGroup(ps, 'mgc1');
    ps.calibratePredictedLevel(2.5);  // startLevel=2, inflowLevel=3, maxLevel=4
    await ps._controlLevelBased('filling');
    // Ramp foot = startLevel (NOT inflowLevel). lerp(2.5, [2, 4], [0, 100]) = 25.
    assert.ok(Math.abs(ps.percControl - 25) < 1e-9, `expected ~25 %, got ${ps.percControl}`);
    assert.equal(mock._calls.turnOff, 0, 'engaged — pumps must not be turned off in the ramp');
    assert.equal(mock._calls.setDemand.length, 1);
    assert.ok(Math.abs(mock._calls.setDemand[0][0] - 25) < 1e-9);
  });

  await t.test('filling: level ≥ maxLevel → percControl clamped at 100, routed via setDemand', async () => {
    const ps = new PumpingStation(makeConfig());
    const mock = registerMockGroup(ps, 'mgc1');
    ps.calibratePredictedLevel(3.5);  // 3/4 of the [2,4] ramp → 75 %.
    await ps._controlLevelBased('filling');
    assert.ok(Math.abs(ps.percControl - 75) < 1e-9, `expected ~75 %, got ${ps.percControl}`);
    assert.equal(mock._calls.setDemand.length, 1);
    assert.equal(mock._calls.setDemand[0][1], '%');
    assert.ok(Math.abs(mock._calls.setDemand[0][0] - 75) < 1e-9);
  });

  await t.test('filling: holdLevel raises the ramp foot — explicit hold band [startLevel, holdLevel] sits at 0 %', async () => {
    const ps = new PumpingStation(makeConfig({
      control: {
        mode: 'levelbased',
        allowedModes: new Set(['levelbased']),
        levelbased: { minLevel: 1, startLevel: 2, holdLevel: 3, maxLevel: 4, curveType: 'linear', logCurveFactor: 9 },
      },
    }));
    const mock = registerMockGroup(ps, 'mgc1');
    ps.calibratePredictedLevel(2.5);  // inside [startLevel, holdLevel]
    await ps._controlLevelBased('filling');
    assert.equal(ps.percControl, 0);
    assert.equal(mock._calls.turnOff, 0, 'engaged — hold band runs at MGC flow.min, not off');
    assert.deepEqual(mock._calls.setDemand[0], [0, '%']);
  });

  await t.test('shift disabled (default): foot stays at startLevel — falling levels track the ramp down to startLevel', async () => {
    const ps = new PumpingStation(makeConfig());
    registerMockGroup(ps, 'mgc1');
    // Climb above startLevel, then fall to a level inside [start, inflow]. With
    // the new semantics (ramp foot = startLevel, NOT inflowLevel) the falling
    // level still produces a positive demand on the way down.
    ps.calibratePredictedLevel(3.8);
    await ps._controlLevelBased();
    assert.ok(ps.percControl > 0);
    ps.calibratePredictedLevel(2.5);  // startLevel=2, maxLevel=4 → 25 %
    await ps._controlLevelBased();
    assert.ok(Math.abs(ps.percControl - 25) < 1e-9, `expected 25 % on the down ramp, got ${ps.percControl}`);
  });

  await t.test('shift enabled: arming on % threshold + hold-then-ramp on draining (with holdLevel pinning the foot)', async () => {
    // The original shifted-ramp test was authored against the legacy ramp
    // foot = inflowLevel (=3). With the new defaults the foot moves to
    // startLevel (=2), which changes every percentage in the trace. Pin
    // the foot back to 3 by setting holdLevel = 3 — that keeps this test's
    // arithmetic self-consistent: up curve goes 0 %@3 to 100 %@4.
    //   shiftArmPercent=80 ⇒ arms when up curve ≥ 80 % i.e. level ≥ 3.8.
    //   shiftLevel=3.5 ⇒ held output starts ramping down at this level.
    const ps = new PumpingStation(makeConfig({
      control: {
        mode: 'levelbased',
        allowedModes: new Set(['levelbased']),
        levelbased: {
          minLevel: 1, startLevel: 2, holdLevel: 3, maxLevel: 4, curveType: 'linear', logCurveFactor: 9,
          enableShiftedRamp: true, shiftLevel: 3.5, shiftArmPercent: 80,
        },
      },
    }));
    registerMockGroup(ps, 'mgc1');
    // Filling at level=3.5 ⇒ up curve = 50 %, below arm threshold ⇒ not armed.
    ps.calibratePredictedLevel(3.5);
    await ps._controlLevelBased('filling');
    assert.equal(ps._shiftArmed, false);
    assert.ok(Math.abs(ps.percControl - 50) < 1e-9);
    // Filling at level=3.85 ⇒ up curve = 85 % ≥ arm threshold ⇒ ARM.
    ps.calibratePredictedLevel(3.85);
    await ps._controlLevelBased('filling');
    assert.equal(ps._shiftArmed, true);
    assert.ok(Math.abs(ps.percControl - 85) < 1e-9);  // still up curve while filling
    // Direction flips to draining at the same level ⇒ capture hold ≈ 85 %.
    await ps._controlLevelBased('draining');
    assert.ok(Math.abs(ps._shiftHoldValue - 85) < 1e-6);
    // While draining and level ≥ shiftLevel ⇒ output stays at hold (≈85 %).
    ps.calibratePredictedLevel(3.6);
    await ps._controlLevelBased('draining');
    assert.ok(Math.abs(ps.percControl - 85) < 1e-6);
    // Below shiftLevel: ramp [shift, hold] → [start, 0]. At level=2.75
    // (midpoint of [2, 3.5]), x=0.5, output ≈ 85 × 0.5 = 42.5 %.
    ps.calibratePredictedLevel(2.75);
    await ps._controlLevelBased('draining');
    assert.ok(Math.abs(ps.percControl - 42.5) < 1e-6);
    // Below startLevel ⇒ output 0 % AND disarm.
    ps.calibratePredictedLevel(1.9);
    await ps._controlLevelBased('draining');
    assert.equal(ps.percControl, 0);
    assert.equal(ps._shiftArmed, false);
    assert.equal(ps._shiftHoldValue, null);
  });

  await t.test('shift enabled: returning to filling clears hold; new hold captured on next drain', async () => {
    const ps = new PumpingStation(makeConfig({
      control: {
        mode: 'levelbased',
        allowedModes: new Set(['levelbased']),
        levelbased: {
          // Pin the ramp foot at 3 via holdLevel — keeps legacy arithmetic
          // self-consistent with the original test (up curve 0 %@3 → 100 %@4).
          minLevel: 1, startLevel: 2, holdLevel: 3, maxLevel: 4, curveType: 'linear', logCurveFactor: 9,
          enableShiftedRamp: true, shiftLevel: 3.5, shiftArmPercent: 80,
        },
      },
    }));
    registerMockGroup(ps, 'mgc1');
    ps.calibratePredictedLevel(3.85);
    await ps._controlLevelBased('filling');
    await ps._controlLevelBased('draining');
    assert.ok(Math.abs(ps._shiftHoldValue - 85) < 1e-6);
    // Direction back to filling ⇒ up curve, hold cleared, still armed.
    ps.calibratePredictedLevel(3.9);
    await ps._controlLevelBased('filling');
    assert.equal(ps._shiftHoldValue, null);
    assert.equal(ps._shiftArmed, true);
    assert.ok(Math.abs(ps.percControl - 90) < 1e-6);  // up curve at 3.9 = 90 %
    // Flip to draining again at higher level ⇒ new hold ≈ 90 %.
    await ps._controlLevelBased('draining');
    assert.ok(Math.abs(ps._shiftHoldValue - 90) < 1e-6);
  });

  await t.test('log curve has fast early response', async () => {
    const ps = new PumpingStation(makeConfig({
      control: {
        mode: 'levelbased',
        allowedModes: new Set(['levelbased']),
        // holdLevel=3 keeps ramp foot at 3 so x=0.5 means level=3.5, matching
        // the legacy assertion bracket.
        levelbased: { minLevel: 1, startLevel: 2, holdLevel: 3, maxLevel: 4, curveType: 'log', logCurveFactor: 9 },
      },
    }));
    registerMockGroup(ps, 'mgc1');
    ps.calibratePredictedLevel(3.5);  // x=0.5 on filling ramp [3,4]
    await ps._controlLevelBased('filling');
    assert.ok(ps.percControl > 50);
    assert.ok(ps.percControl < 100);
  });

  await t.test('level > maxLevel → percControl ≥ 100 (MGC clamps internally)', async () => {
    const ps = new PumpingStation(makeConfig());
    registerMockGroup(ps, 'mgc1');
    ps.calibratePredictedLevel(4.5);  // above maxLevel=4
    await ps._controlLevelBased();
    assert.ok(ps.percControl >= 100);
  });
});

test('getOutput — flattens basin + state + demand', async (t) => {
  const ps = new PumpingStation(makeConfig());
  ps.percControl = 37;

  await t.test('includes basin geometry fields', () => {
    const out = ps.getOutput();
    assert.equal(out.volEmptyBasin, 50);
    assert.equal(out.maxVolAtOverflow, 45);
    assert.equal(out.minVolAtInflow, 30);
    assert.ok(Math.abs(out.minVolAtOutflow - 2) < 1e-9);
    assert.equal(out.inletPipeDiameter, 0.4);
    assert.equal(out.outletPipeDiameter, 0.3);
    assert.ok(Math.abs(out.highVolumeSafetyLevel - 4.41) < 1e-9);
    assert.ok(Math.abs(out.dryRunLevel - 0.204) < 1e-9);
  });
  await t.test('includes state fields (direction, flowSource, timeleft)', () => {
    const out = ps.getOutput();
    assert.ok('direction' in out);
    assert.ok('flowSource' in out);
    assert.ok('timeleft' in out);
  });
  await t.test('includes percControl', () => {
    assert.equal(ps.getOutput().percControl, 37);
  });
});

test('Manual inflow — setManualInflow stores predicted inflow', async (t) => {
  const ps = new PumpingStation(makeConfig());
  ps.setManualInflow(0.05, Date.now(), 'm3/s');  // 0.05 m³/s
  const v = ps.measurements.type('flow').variant('predicted').position('in').child('manual-qin').getCurrentValue('m3/s');
  assert.ok(Math.abs(v - 0.05) < 1e-9);
});

// _updatePredictedVolume now clamps [dryRunSafetyVol, maxVolAtOverflow] and
// tracks any excess as cumulative `overflowVolume` plus a synthetic
// `flow.predicted.out.overflow` rate so net-flow balance stays at ~0 while
// pinned. We drive ticks manually with monotonic timestamps to keep tests
// deterministic (Date.now() in the integrator can step by 0 ms in fast loops).
test('Predicted volume — overflow clamp and spill tracking', async (t) => {
  const ps = new PumpingStation(makeConfig({
    safety: { enableDryRunProtection: false, enableHighVolumeSafety: false, dryRunThresholdPercent: 0 },
  }));
  // Seed predicted volume just below the spill point.
  // maxVolAtOverflow = overflowLevel × area = 4.5 × 10 = 45 m³.
  const t0 = 1_700_000_000_000;
  ps.calibratePredictedVolume(44, t0);
  // Heavy inflow, no real outflow (no pumps wired).
  ps.setManualInflow(2, t0, 'm3/s');  // 2 m³/s, dt=1s → 2 m³/tick

  await t.test('first overflow tick clamps volume and records spill increment', () => {
    ps._predictedFlowState = { inflow: 2, outflow: 0, lastTimestamp: t0 };
    Date.now = () => t0 + 1000;
    ps._updatePredictedVolume();
    const vol = ps.measurements.type('volume').variant('predicted').position('atequipment').getCurrentValue('m3');
    assert.equal(vol, 45);  // pinned at overflow
    const cumulative = ps.measurements.type('overflowVolume').variant('predicted').position('atequipment').getCurrentValue('m3');
    assert.equal(cumulative, 1);  // proposed=44+2=46, excess=1 m³ this tick
    const spill = ps.measurements.type('flow').variant('predicted').position('overflow').getCurrentValue('m3/s');
    assert.equal(spill, 2);  // instantaneous balance: inflow − outflowReal
  });

  await t.test('subsequent ticks accumulate full inflow as spill (stable)', () => {
    Date.now = () => t0 + 2000;
    ps._updatePredictedVolume();
    const vol = ps.measurements.type('volume').variant('predicted').position('atequipment').getCurrentValue('m3');
    assert.equal(vol, 45);
    const cumulative = ps.measurements.type('overflowVolume').variant('predicted').position('atequipment').getCurrentValue('m3');
    assert.equal(cumulative, 3);  // 1 + 2
    const spill = ps.measurements.type('flow').variant('predicted').position('overflow').getCurrentValue('m3/s');
    assert.equal(spill, 2);
  });

  await t.test('predicted net flow reads ~0 while pinned at overflow', () => {
    const net = ps._selectBestNetFlow();
    // inflow=2, outflow_total=2 (synthetic spill), net = 0
    assert.ok(Math.abs(net.value) < 1e-9);
    assert.equal(net.source, 'predicted');
  });

  await t.test('once inflow stops, spill flow clears and clamp releases', () => {
    ps.setManualInflow(0, t0 + 2000, 'm3/s');
    ps._predictedFlowState = { inflow: 0, outflow: 0, lastTimestamp: t0 + 2000 };
    Date.now = () => t0 + 3000;
    ps._updatePredictedVolume();
    const spill = ps.measurements.type('flow').variant('predicted').position('overflow').getCurrentValue('m3/s');
    assert.equal(spill, 0);
    // Volume stays at 45 (no draining force) but is no longer "pinned".
    const vol = ps.measurements.type('volume').variant('predicted').position('atequipment').getCurrentValue('m3');
    assert.equal(vol, 45);
  });
});

test('Predicted volume — dry-run lower clamp', async (t) => {
  const ps = new PumpingStation(makeConfig({
    // dryRunSafetyVol = minVolAtOutflow × (1 + 5/100) = 2 × 1.05 = 2.1 m³
    safety: { enableDryRunProtection: true, dryRunThresholdPercent: 5 },
  }));
  const t0 = 1_700_000_000_000;

  await t.test('initial seed below dryRunSafetyVol is left alone (no upward bump)', () => {
    // Seed defaults to minVol=2 (below dryRunSafetyVol=2.1).
    ps._predictedFlowState = { inflow: 0, outflow: 0, lastTimestamp: t0 };
    Date.now = () => t0 + 1000;
    ps._updatePredictedVolume();
    const vol = ps.measurements.type('volume').variant('predicted').position('atequipment').getCurrentValue('m3');
    assert.equal(vol, 2);  // unchanged — clamp doesn't fire because we started below it
  });

  await t.test('drain across dryRunSafetyVol clamps at the threshold', () => {
    // Calibrate well above, then push outflow that would cross the threshold.
    ps.calibratePredictedVolume(3, t0 + 1000);
    // outflow=2 m³/s for 1s → would drop to 1; clamp catches at 2.1.
    ps.setManualOutflow(2, t0 + 1000, 'm3/s');
    ps._predictedFlowState = { inflow: 0, outflow: 2, lastTimestamp: t0 + 1000 };
    Date.now = () => t0 + 2000;
    ps._updatePredictedVolume();
    const vol = ps.measurements.type('volume').variant('predicted').position('atequipment').getCurrentValue('m3');
    assert.ok(Math.abs(vol - 2.1) < 1e-9);
  });
});

test('getOutput — exposes predictedOverflowVolume / predictedOverflowRate', () => {
  const ps = new PumpingStation(makeConfig());
  // Seed an overflow scenario.
  const t0 = 1_700_000_000_000;
  ps.calibratePredictedVolume(44, t0);
  ps.setManualInflow(2, t0, 'm3/s');
  ps._predictedFlowState = { inflow: 2, outflow: 0, lastTimestamp: t0 };
  Date.now = () => t0 + 1000;
  ps._updatePredictedVolume();
  const out = ps.getOutput();
  assert.equal(out.predictedOverflowVolume, 1);
  assert.equal(out.predictedOverflowRate, 2);
});

// Hard physical floor at 0. The dryRunSafetyVol clamp only fires on transition
// from above, so a basin seeded below + continued outflow used to integrate
// the volume arbitrarily negative. The level helper masked this by flooring
// at 0 in _calcLevelFromVolume — fix is to floor the integrator itself.
test('Predicted volume — physical floor at 0 (underflow track)', async (t) => {
  const ps = new PumpingStation(makeConfig({
    safety: { enableDryRunProtection: true, dryRunThresholdPercent: 5 },
  }));
  const t0 = 1_700_000_000_000;

  await t.test('seeded below dryRun + continued outflow does NOT go negative', () => {
    ps.calibratePredictedVolume(0.5, t0);  // below dryRunSafetyVol (2.1)
    ps.setManualOutflow(2, t0, 'm3/s');     // 2 m³/s for 1s → would drop to -1.5
    ps._predictedFlowState = { inflow: 0, outflow: 2, lastTimestamp: t0 };
    Date.now = () => t0 + 1000;
    ps._updatePredictedVolume();
    const vol = ps.measurements.type('volume').variant('predicted').position('atequipment').getCurrentValue('m3');
    assert.equal(vol, 0);  // floored at 0, not -1.5
    const underflow = ps.measurements
      .type('underflowVolume').variant('predicted').position('atequipment').getCurrentValue('m3');
    assert.equal(underflow, 1.5);  // tracked as diagnostic
  });

  await t.test('subsequent ticks accumulate underflow while outflow continues', () => {
    Date.now = () => t0 + 2000;
    ps._predictedFlowState = { inflow: 0, outflow: 2, lastTimestamp: t0 + 1000 };
    ps._updatePredictedVolume();
    const vol = ps.measurements.type('volume').variant('predicted').position('atequipment').getCurrentValue('m3');
    assert.equal(vol, 0);
    const underflow = ps.measurements
      .type('underflowVolume').variant('predicted').position('atequipment').getCurrentValue('m3');
    assert.equal(underflow, 3.5);  // 1.5 + 2.0
  });

  await t.test('getOutput exposes predictedUnderflowVolume', () => {
    const out = ps.getOutput();
    assert.equal(out.predictedUnderflowVolume, 3.5);
  });

  await t.test('inflow returns and basin refills from 0 (no jump to dryRunSafetyVol)', () => {
    ps.setManualInflow(1, t0 + 2000, 'm3/s');
    ps.setManualOutflow(0, t0 + 2000, 'm3/s');
    ps._predictedFlowState = { inflow: 1, outflow: 0, lastTimestamp: t0 + 2000 };
    Date.now = () => t0 + 3000;
    ps._updatePredictedVolume();
    const vol = ps.measurements.type('volume').variant('predicted').position('atequipment').getCurrentValue('m3');
    assert.ok(Math.abs(vol - 1) < 1e-9);  // 0 + 1 = 1, NOT pinned to 2.1
  });
});
