Files
SPipe/tests/regression.test.ts
bjkim1 14f07181db 예전 프로그램 이름 표기 정리: 실행 파일 이름을 start-spipe.bat, stop-spipe.bat로 변경
- README, 예제 설명, 코드 주석, CSS 머리말, 시험 임시 폴더 접두어의 예전 이름 정리
- 브라우저 저장 키·세션 DB 이름은 저장된 설정 보존을 위해 유지
2026-10-30 11:07:14 +09:00

3934 lines
154 KiB
TypeScript

import assert from 'node:assert/strict';
import test from 'node:test';
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import { fileURLToPath } from 'node:url';
import { execSync as execSyncRaw, type ExecSyncOptions } from 'node:child_process';
// Resolve everything from the repo root, not the caller's cwd: the suite used
// relative paths ('.venv/...', 'public/...') and 9 DLL tests failed when run
// from any other directory (TEST-1).
const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
process.chdir(REPO_ROOT);
// node:test's per-test timeout cannot interrupt a synchronous child process,
// so a stuck venv launcher ("Unable to create process using ...") hung the
// whole run indefinitely (TEST-2). Every shell-out gets a hard timeout.
const EXEC_TIMEOUT_MS = 120_000;
function execSync(command: string, options: ExecSyncOptions = {}): any {
return execSyncRaw(command, { timeout: EXEC_TIMEOUT_MS, windowsHide: true, ...options });
}
import {
parseInp,
generateInp,
updatePattern,
updateControl,
deleteNode,
splitPipe,
validateTopology,
findPathToNearestSource,
renameNode,
renamePipe,
updateNodeAttributes,
updatePipeAttributes,
updatePumpAttributes,
updateValveAttributes,
convertPipeToValve,
reversePipeDirection,
updateOptions,
} from '../src/inpModel.ts';
import { UNIT_FIELD_KINDS, validateSingleField } from '../src/attributeTableValidation.ts';
import { computeComparison, DEFAULT_TOLERANCES } from '../src/comparisonEngine.ts';
import { inspectConvergence, extractEngineIssues } from '../src/convergence.ts';
import {
computeIsStale,
computeScenarioMetrics,
deriveBaseScenarioResult,
evaluateFilterCondition,
formatTimeLabel,
resolveDualEngineOutcome,
rowMatchesAllConditions,
saveActiveScenarioState,
seriesValue,
type FilterCondition,
} from '../src/resultSync.ts';
import {
calculateGeoAwareDistance,
haversineMeters,
modelToLngLat,
lngLatToModel,
SUPPORTED_CRS_DEFINITIONS,
type CRS,
} from '../src/crs.ts';
import {
buildNetworkFromGeoJSON,
autoDetectFields,
inferRoughnessFromMaterial,
} from '../src/gisImport.ts';
import {
parseGeoJsonString,
computeGeoJsonExtents,
normalizeGeoJsonToWgs84,
unzipArchive,
parseDbfBinary,
parseShpBinary,
} from '../src/gisLayers.ts';
import { exportNetworkShapefileZip } from '../src/shapefileExport.ts';
import {
createNetworkClipboardPayload,
validateAndPreparePaste,
applyPasteToModel,
} from '../src/networkClipboard.ts';
import type { SimulationResult } from '../src/types.ts';
import type { Scenario } from '../src/types.ts';
import {
computeUnitHeadloss,
diameterToMillimeters,
flowToM3Day,
getDisplayUnits,
lengthToMeters,
m3DayToFlow,
metersToLength,
metersToPressure,
millimetersToDiameter,
pressureToMeters,
resolveNativeThresholds,
resolvePressureUnit,
} from '../src/unitSystem.ts';
import { normalizeStoredSnapshot } from '../src/sessionStore.ts';
import { decodeInpBytes } from '../src/inpEncoding.ts';
import { selectNetworkFeaturesInBox } from '../src/mapSelection.ts';
import { formatMapServiceError, MapServiceError } from '../src/mapServices.ts';
test('1. PATTERNS: should preserve high-precision multipliers and avoid appending duplicate original rows', () => {
const sampleInp = `[TITLE]
Pattern Test
[JUNCTIONS]
J1 10 10 1 ;
[RESERVOIRS]
R1 50 ;
[PIPES]
P1 R1 J1 100 200 100 0 Open ;
[PATTERNS]
;ID Multipliers
1 1.234567 0.987654 1.555555
1 0.444444 1.111111 0.777777
[COORDINATES]
J1 10 10
R1 0 0
[END]
`;
const model = parseInp(sampleInp);
assert.ok(model.patterns['1'], 'Pattern 1 should be parsed');
// Check precision preservation
assert.equal(
model.patterns['1'].multipliers[0],
1.234567,
'Multiplier precision should be preserved, not truncated by toFixed(2)'
);
// Update pattern
const updated = updatePattern(model, '1', [2.123456, 1.987654]);
const regen = generateInp(updated);
// Check that original lines were not duplicated or appended
const linesWithPattern1 = regen
.split(/\r?\n/)
.filter((l) => l.trim().startsWith('1') && !l.includes('['));
// Should have only lines representing the updated multipliers, not the old ones
const joinedText = linesWithPattern1.join(' ');
assert.ok(
joinedText.includes('2.123456'),
'Should contain updated high-precision multiplier'
);
assert.ok(
!joinedText.includes('1.234567'),
'Should NOT contain old multiplier after update'
);
assert.ok(
!joinedText.includes('0.444444'),
'Should NOT contain old second row multiplier after update'
);
});
test('2. OPTIONS: should preserve Demand Model (PDA) and Demand Multiplier without key collision', () => {
const sampleInp = `[TITLE]
Options Collision Test
[OPTIONS]
Units LPS
Headloss H-W
Demand Model PDA
Demand Multiplier 1.25
Minimum Pressure 2.5
Required Pressure 15.0
Pressure Exponent 0.5
Accuracy 0.0005
Trials 50
[JUNCTIONS]
J1 10 10 ;
[RESERVOIRS]
R1 50 ;
[PIPES]
P1 R1 J1 100 200 100 0 Open ;
[COORDINATES]
J1 10 10
R1 0 0
[END]
`;
const model = parseInp(sampleInp);
const regen = generateInp(model);
// Demand Model PDA and Demand Multiplier 1.25 must both be present
assert.ok(
/Demand\s+Model\s+PDA/i.test(regen),
'Demand Model PDA must be preserved'
);
assert.ok(
/Demand\s+Multiplier\s+1\.25/i.test(regen),
'Demand Multiplier 1.25 must be preserved'
);
assert.ok(
/Minimum\s+Pressure\s+2\.5/i.test(regen),
'Minimum Pressure must be preserved'
);
assert.ok(
/Required\s+Pressure\s+15/i.test(regen),
'Required Pressure must be preserved'
);
});
test('3. TANKS / CURVES: should preserve MinVol, VolCurve, Overflow and Curves references', () => {
const sampleInp = `[TITLE]
Tank & Curve Test
[JUNCTIONS]
J1 10 10 ;
[RESERVOIRS]
R1 50 ;
[TANKS]
;ID Elevation InitLevel MinLevel MaxLevel Diameter MinVol VolCurve Overflow
T1 45.0 5.0 1.0 10.0 20.0 150.0 CURVE_VOL_1 Yes ;
[PIPES]
P1 R1 J1 100 200 100 0 Open ;
P2 J1 T1 100 150 100 0 Open ;
[CURVES]
;ID X-Value Y-Value
CURVE_VOL_1 1.0 15.0
CURVE_VOL_1 5.0 100.0
CURVE_VOL_1 10.0 250.0
[COORDINATES]
J1 10 10
R1 0 0
T1 20 20
[END]
`;
const model = parseInp(sampleInp);
const tank = model.nodes['T1'];
assert.ok(tank, 'Tank T1 must exist');
assert.equal(tank.type, 'TANK');
const regen = generateInp(model);
// Check that MinVol, VolCurve, Overflow are retained
assert.ok(
regen.includes('CURVE_VOL_1'),
'VolCurve CURVE_VOL_1 must be preserved in regenerated INP'
);
assert.ok(
/T1\s+45(\.0+)?\s+5(\.0+)?\s+1(\.0+)?\s+10(\.0+)?\s+20(\.0+)?\s+150(\.0+)?\s+CURVE_VOL_1/i.test(
regen
),
'Tank parameters including MinVol and VolCurve must be preserved in TANKS section'
);
assert.ok(
regen.includes('15.0') && regen.includes('250.0'),
'CURVES section data must be preserved'
);
});
test('4. CONTROLS / RULES: should not convert numeric setting to CLOSED and should update rawText on edit', () => {
const sampleInp = `[TITLE]
Controls Setting Test
[JUNCTIONS]
J1 10 10 ;
J2 10 10 ;
[RESERVOIRS]
R1 50 ;
[PIPES]
P1 R1 J1 100 200 100 0 Open ;
P2 J1 J2 100 150 100 0 Open ;
[CONTROLS]
LINK P2 1.5 AT TIME 4
LINK P1 OPEN IF NODE J2 BELOW 15
[RULES]
RULE 1
IF NODE J2 PRESSURE < 10
THEN PIPE P1 STATUS = OPEN
[COORDINATES]
J1 10 10
J2 20 10
R1 0 0
[END]
`;
const model = parseInp(sampleInp);
assert.ok(model.controls.length >= 2, 'Controls should be parsed');
// Check first control: should preserve numeric setting 1.5, NOT convert to CLOSED
assert.ok(
!model.controls[0].rawText.includes('CLOSED'),
'Numeric control setting should not be turned into CLOSED'
);
// Update control threshold
const updated = updateControl(model, model.controls[1].id, { value: 25 });
const regen = generateInp(updated);
// Updated control must output 25, not 15
assert.ok(
regen.includes('BELOW 25'),
'Updated control value 25 must appear in regenerated INP'
);
assert.ok(
!regen.includes('BELOW 15'),
'Old control value 15 must NOT appear in regenerated INP'
);
// Rules section must be preserved
assert.ok(regen.includes('RULE 1'), 'RULES section must be preserved');
});
test('5. VALVES, STATUS and REFERENCE CLEANUP: should parse valves, preserve status, and handle deletion references', () => {
const sampleInp = `[TITLE]
Valves and Status Test
[JUNCTIONS]
J1 10 10 ;
J2 10 10 ;
J3 10 10 ;
[RESERVOIRS]
R1 50 ;
[PIPES]
P1 R1 J1 100 200 100 0 Open ;
P2 J2 J3 100 150 100 0 Open ;
[VALVES]
;ID Node1 Node2 Diameter Type Setting MinorLoss
V1 J1 J2 150 PRV 25.0 0 ;
[STATUS]
P2 CLOSED
V1 OPEN
[DEMANDS]
;Junction Demand Pattern Category
J2 5 1 Domestic
J2 15 1 Commercial
[COORDINATES]
J1 10 10
J2 20 10
J3 30 10
R1 0 0
[END]
`;
const model = parseInp(sampleInp);
// Topology validation should NOT report J2 as isolated because V1 connects J1 to J2
const issues = validateTopology(model);
const j2Isolated = issues.some(
(i) => i.target?.id === 'J2' && i.message.includes('고립')
);
assert.equal(
j2Isolated,
false,
'J2 is connected via Valve V1, so it must not be reported as isolated'
);
// Check STATUS preservation in regeneration
const regen = generateInp(model);
assert.ok(
regen.includes('[VALVES]'),
'[VALVES] section must exist in regenerated INP'
);
assert.ok(
/V1\s+J1\s+J2\s+150\s+PRV/i.test(regen),
'Valve V1 must be properly preserved'
);
assert.ok(
/P2\s+CLOSED/i.test(regen) || /P2.*Closed/i.test(regen),
'STATUS CLOSED for P2 must be preserved'
);
});
test('5b. PIPE TO VALVE: conversion moves the link and preserves editable valve output', () => {
const model = parseInp(`[JUNCTIONS]
J1 10 0 ;
J2 10 0 ;
[PIPES]
P1 J1 J2 100 150 120 0.25 Open ;
[COORDINATES]
J1 0 0
J2 100 0
[END]
`);
const modelWithTank = {
...model,
nodes: {
...model.nodes,
T1: {
id: 'T1',
type: 'TANK' as const,
x: 50,
y: 25,
elevation: 15,
initLevel: 5,
minLevel: 0,
maxLevel: 10,
diameter: 8,
minVol: 0,
},
},
};
const converted = convertPipeToValve(modelWithTank, 'P1', 'PRV', 25);
const edited = updateValveAttributes(converted, 'P1', { valveType: 'FCV', setting: 12.5 });
assert.equal(edited.pipes.P1, undefined);
assert.deepEqual(edited.valves.P1, {
id: 'P1',
node1: 'J1',
node2: 'J2',
diameter: 150,
valveType: 'FCV',
setting: 12.5,
minorLoss: 0.25,
});
const regenerated = generateInp(edited);
assert.match(regenerated, /P1\s+J1\s+J2\s+150\s+FCV\s+12\.5\s+0\.25/);
assert.match(regenerated, /\[VALVES\]/);
assert.match(regenerated, /\[TANKS\]/);
const reparsed = parseInp(regenerated);
assert.equal(reparsed.pipes.P1, undefined);
assert.equal(reparsed.valves.P1.valveType, 'FCV');
assert.equal(reparsed.nodes.T1.type, 'TANK');
assert.throws(
() => updateValveAttributes(edited, 'P1', { valveType: 'FCV', setting: 'not-a-number' }),
/숫자/
);
});
test('6. COORDINATES: should preserve geographic lat/lon precision without forced 2-decimal rounding', () => {
const sampleInp = `[TITLE]
Geographic Coordinates Test
[JUNCTIONS]
J1 10 10 ;
[RESERVOIRS]
R1 50 ;
[PIPES]
P1 R1 J1 100 200 100 0 Open ;
[COORDINATES]
J1 126.9784321 37.5665432
R1 126.9741234 37.5621234
[END]
`;
const model = parseInp(sampleInp);
const j1 = model.nodes['J1'];
assert.equal(
j1.x,
126.9784321,
'High precision longitude must be preserved in model'
);
assert.equal(
j1.y,
37.5665432,
'High precision latitude must be preserved in model'
);
const regen = generateInp(model);
assert.ok(
regen.includes('126.9784321'),
'Regenerated INP must contain 126.9784321, not truncated to 126.98'
);
assert.ok(
regen.includes('37.5665432'),
'Regenerated INP must contain 37.5665432, not truncated to 37.57'
);
});
test('7. SHORT PIPE SPLIT: should strictly conserve L1 + L2 = L_old without arbitrary >=1 truncation and split MinorLoss K correctly', () => {
const sampleInp = `[TITLE]
Short Pipe Split Test
[JUNCTIONS]
J1 10 10 ;
J2 10 10 ;
[RESERVOIRS]
R1 50 ;
[PIPES]
P1 R1 J1 100 200 100 0 Open ;
P_SHORT J1 J2 0.8 150 100 2.4 Open ;
[COORDINATES]
J1 10 10
J2 10.8 10
R1 0 0
[END]
`;
const model = parseInp(sampleInp);
const originalPipe = model.pipes['P_SHORT'];
assert.equal(originalPipe.length, 0.8);
assert.equal(originalPipe.minorLoss, 2.4);
// Split P_SHORT at 50%
const splitRes = splitPipe(model, 'P_SHORT', 0.5);
const pA = splitRes.model.pipes[splitRes.retainedPipeId];
const pB = splitRes.model.pipes[splitRes.newPipeId];
// Length must strictly sum to 0.8 and both segments must be strictly positive
assert.ok(pA.length > 0, 'L1 must be strictly greater than 0');
assert.ok(pB.length > 0, 'L2 must be strictly greater than 0');
const sumLen = Math.round((pA.length + pB.length) * 1000) / 1000;
assert.equal(
sumLen,
0.8,
'Split lengths must sum exactly to original length 0.8, NOT forced to 1.0 + 1.0 = 2.0'
);
// Minor loss K should NOT be doubled to 2.4 + 2.4 = 4.8!
const sumK = Math.round((pA.minorLoss + pB.minorLoss) * 100) / 100;
assert.equal(
sumK,
2.4,
'Total minor loss K must be conserved (e.g. 1.2 + 1.2 or assigned to one segment), NOT duplicated to double total loss'
);
// Ultra-short pipe (0.05 m) with K = 0.3 split case
const ultraShortModel = {
...model,
pipes: {
...model.pipes,
P_ULTRA: { ...originalPipe, id: 'P_ULTRA', length: 0.05, minorLoss: 0.3 },
},
};
const ultraRes = splitPipe(ultraShortModel, 'P_ULTRA', 0.5);
const uA = ultraRes.model.pipes[ultraRes.retainedPipeId];
const uB = ultraRes.model.pipes[ultraRes.newPipeId];
assert.ok(uA.length > 0, 'Ultra-short L1 must be > 0');
assert.ok(uB.length > 0, 'Ultra-short L2 must be > 0');
assert.equal(Math.round((uA.length + uB.length) * 10000) / 10000, 0.05, 'L1 + L2 must strictly equal 0.05m');
assert.equal(Math.round((uA.minorLoss + uB.minorLoss) * 10000) / 10000, 0.3, 'K1 + K2 must strictly equal 0.3');
});
import {
getUnitSystem,
convertNetworkUnits,
checkUnitConversion,
} from '../src/unitSystem.ts';
test('8. UNIT CONVERSION: SI <-> US and flow conversion within SI preserving dimensionless values and coordinates', () => {
const sampleInp = `[TITLE]
Unit Conversion Test
[OPTIONS]
Units GPM
Headloss H-W
[JUNCTIONS]
J1 100.0 500.0 1 ;
[RESERVOIRS]
R1 250.0 ;
[TANKS]
T1 200.0 10.0 2.0 15.0 50.0 5000.0 ;
[PIPES]
P1 R1 J1 5280.0 12.0 120 1.5 Open ;
P2 J1 T1 2640.0 10.0 130 0.8 Open ;
[CONTROLS]
LINK P1 OPEN IF NODE T1 BELOW 5
[PATTERNS]
1 1.2 0.8 1.5
[COORDINATES]
J1 126.9754321 37.5654321
R1 126.9700000 37.5600000
T1 126.9800000 37.5700000
[END]
`;
const model = parseInp(sampleInp);
assert.equal(getUnitSystem('GPM'), 'US');
assert.equal(getUnitSystem('LPS'), 'SI');
// Convert US (GPM, ft, in) to SI (LPS, m, mm)
const check = checkUnitConversion(model, 'LPS');
assert.equal(check.canConvert, true);
assert.equal(check.sourceSystem, 'US');
assert.equal(check.targetSystem, 'SI');
const converted = convertNetworkUnits(model, 'LPS');
const siModel = converted.model;
// Length: 5280 ft = 1609.34 m
assert.equal(siModel.pipes['P1'].length, 1609.34);
// Diameter: 12 in = 304.8 mm
assert.equal(siModel.pipes['P1'].diameter, 304.8);
// Roughness: C=120 conserved!
assert.equal(siModel.pipes['P1'].roughness, 120);
// Minor loss: K=1.5 conserved!
assert.equal(siModel.pipes['P1'].minorLoss, 1.5);
// Demand: 500 GPM = 500 * 0.0630902 = 31.545 LPS
assert.equal(siModel.nodes['J1'].demand, 31.545);
// Elevation: 100 ft = 30.48 m
assert.equal(siModel.nodes['J1'].elevation, 30.48);
// Tank diameter: 50 ft = 15.24 m
assert.equal(siModel.nodes['T1'].diameter, 15.24);
// GIS coordinates must NOT be modified
assert.equal(siModel.nodes['J1'].x, 126.9754321);
assert.equal(siModel.nodes['J1'].y, 37.5654321);
// Control threshold: T1 below 5 ft -> 5 * 0.3048 = 1.52 m
assert.equal(siModel.controls[0].value, 1.52);
assert.ok(siModel.controls[0].rawText.includes('1.52'));
// Intra-SI conversion: LPS -> CMH (1 LPS = 3.6 CMH)
const cmhConverted = convertNetworkUnits(siModel, 'CMH');
const cmhModel = cmhConverted.model;
// Length/diameter untouched in intra-SI
assert.equal(cmhModel.pipes['P1'].length, 1609.34);
// Demand: 31.545 LPS * 3.6 = 113.562 CMH
assert.equal(cmhModel.nodes['J1'].demand, 113.562);
});
test('9. ROUNDTRIP CONVERSION: US -> SI -> US should recover original dimensions within tolerance', () => {
const sampleInp = `[TITLE]
Roundtrip Test
[OPTIONS]
Units GPM
Headloss H-W
[JUNCTIONS]
J1 120.0 250.0 ;
[RESERVOIRS]
R1 300.0 ;
[PIPES]
P1 R1 J1 3500.0 14.0 120 0.5 Open ;
[COORDINATES]
J1 100 100
R1 0 0
[END]
`;
const model = parseInp(sampleInp);
const toSI = convertNetworkUnits(model, 'LPS').model;
const backToUS = convertNetworkUnits(toSI, 'GPM').model;
// Tolerances: allow 0.1% or 0.1 unit rounding drift
const lenDiff = Math.abs(backToUS.pipes['P1'].length - 3500.0);
assert.ok(lenDiff < 1.0, `Length recovered within 1 ft: actual ${backToUS.pipes['P1'].length}`);
const diaDiff = Math.abs(backToUS.pipes['P1'].diameter - 14.0);
assert.ok(diaDiff < 0.1, `Diameter recovered within 0.1 in: actual ${backToUS.pipes['P1'].diameter}`);
const demandDiff = Math.abs((backToUS.nodes['J1'].demand ?? 0) - 250.0);
assert.ok(demandDiff < 0.5, `Demand recovered within 0.5 GPM: actual ${backToUS.nodes['J1'].demand}`);
});
test('10. STALE RESULTS PRESERVATION: computeIsStale and saveActiveScenarioState must detect and clear outdated results', () => {
const sampleInp = `[TITLE]
Stale Check
[OPTIONS]
Units LPS
[JUNCTIONS]
J1 10 10 ;
[RESERVOIRS]
R1 50 ;
[PIPES]
P1 R1 J1 100 200 100 0 Open ;
[COORDINATES]
J1 10 10
R1 0 0
[END]
`;
const model = parseInp(sampleInp);
const inpA = generateInp(model);
// computeIsStale: identical model text -> not stale
assert.equal(computeIsStale(inpA, inpA), false, 'Identical model text must not be flagged stale');
// No analysis has ever run for this model text -> stale (must force analysis before export)
assert.equal(computeIsStale(inpA, null), true, 'A model with no prior analysis snapshot must be stale');
// Simulate editing (elevation change) - real generateInp output must differ
const editedModel = {
...model,
nodes: { ...model.nodes, J1: { ...model.nodes['J1'], elevation: 25 } },
};
const inpB = generateInp(editedModel);
assert.notEqual(inpA, inpB, 'Edited model must regenerate different INP text');
assert.equal(computeIsStale(inpB, inpA), true, 'Model edited after last analysis snapshot must be stale');
// saveActiveScenarioState: auto-save on scenario switch must never carry forward
// a result that no longer matches the saved (edited) model text.
const fakeResult = { engine: { runtime: 'wasm', version: '2.3.5' }, timeSeconds: 0, units: { flow: 'LPS', pressure: 'm' }, nodes: {}, links: {} } as unknown as SimulationResult;
const scenarios: Scenario[] = [
{ id: 'base', name: 'base', inpStr: inpA, result: fakeResult, createdAt: 'now' },
];
const savedStale = saveActiveScenarioState(scenarios, 'base', inpB, fakeResult, true);
assert.equal(savedStale[0].inpStr, inpB, 'Edited model text must be persisted even when the result is stale');
assert.equal(savedStale[0].result, null, 'A stale result must be cleared, never mislabeled as matching the saved model');
const savedFresh = saveActiveScenarioState(scenarios, 'base', inpA, fakeResult, false);
assert.equal(savedFresh[0].result, fakeResult, 'A non-stale result must be preserved when saving scenario state');
});
test('11. EPS TIMESTAMPS: computeComparison must match Native/WASM series by real timestamp, not array index', () => {
const buildNode = (demandByTime: number[]) => ({
id: 'J1',
type: 0,
coordinates: null,
demand: demandByTime[0],
head: 0,
pressure: 0,
timeSeries: { demand: demandByTime, head: demandByTime.map(() => 0), pressure: demandByTime.map(() => 0) },
});
// Server reports regular hourly steps; WASM reports an extra irregular step at 1800s.
// A naive index-based comparison would wrongly line up server's 3600s reading against
// WASM's 1800s reading (index 1 in both arrays).
const server = {
engine: { runtime: 'native', version: '2.3.5' },
timeSeconds: 7200,
timeSteps: [0, 3600, 7200],
units: { flow: 'LPS', pressure: 'm' },
nodes: { J1: buildNode([10, 20, 30]) },
links: {},
} as unknown as SimulationResult;
const wasm = {
engine: { runtime: 'wasm', version: '2.3.5' },
timeSeconds: 7200,
timeSteps: [0, 1800, 3600, 7200],
units: { flow: 'LPS', pressure: 'm' },
// Bogus value 999 sits at 1800s, a timestamp the server never reports.
nodes: { J1: buildNode([10, 999, 20, 30]) },
links: {},
} as unknown as SimulationResult;
const result = computeComparison(server, wasm);
assert.equal(
result.maxDiffs.demand,
0,
`Real-timestamp matching must find 0 diff (10/20/30 line up at 0/3600/7200s); index-based matching would report ~979`
);
});
test('12. CONVERGENCE WARNING CHECK: inspectConvergence must classify actual engine report text, never hardcode success', () => {
const reportWithWarning = `
Page 1
EPANET 2.3.5
WARNING: Negative pressures occurred at 04:00:00.
System unbalanced at 05:00:00.
`;
const warned = inspectConvergence(reportWithWarning);
assert.equal(warned.hasWarning, true, 'Report with warnings must be flagged as hasWarning');
assert.match(warned.status, /경고|불균형/, 'Status text must reflect the warning');
const cleanReport =
'Page 1\nEPANET 2.3.5\nHydraulic Status\nAnalysis begun...\nAnalysis completed successfully.\n' +
'x'.repeat(60);
const clean = inspectConvergence(cleanReport);
assert.equal(clean.hasWarning, false, 'A clean report with no warning markers must not be flagged');
assert.match(clean.status, /정상 수렴/, 'Clean report must report normal convergence');
const missing = inspectConvergence(undefined);
assert.equal(missing.hasWarning, false, 'Missing report must not be flagged as a warning');
assert.match(missing.status, /미실행/, 'Missing report must state analysis has not run, never claim success');
});
test('13. RE-OPEN IN ENGINE + FULL EPS: every engine return code must be 0 across all hydraulic periods', () => {
const sampleInp = `[TITLE]
Full Synthesis Engine Test
[OPTIONS]
Units LPS
Headloss H-W
[JUNCTIONS]
J1 10 15 ;
J2 12 20 ;
[RESERVOIRS]
R1 60 ;
[PIPES]
P1 R1 J1 300 200 120 0 Open ;
P2 J1 J2 250 150 120 0 Open ;
[PATTERNS]
1 1.0 1.2 0.8
[TIMES]
Duration 7200
Hydraulic Timestep 3600
Pattern Timestep 3600
[COORDINATES]
J1 10 10
J2 20 10
R1 0 0
[END]
`;
const model = parseInp(sampleInp);
const regen = generateInp(model);
assert.ok(regen.trim().endsWith('[END]'), 'Must terminate with [END]');
assert.ok(regen.includes('[PIPES]'), 'Must include [PIPES]');
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'spipe_test_'));
const inpPath = path.join(tmpDir, 'test_regen.inp');
const rptPath = path.join(tmpDir, 'test_regen.rpt');
const outPath = path.join(tmpDir, 'test_regen.out');
const jsonPath = path.join(tmpDir, 'result.json');
fs.writeFileSync(inpPath, regen);
const pythonExe = path.resolve('.venv/Scripts/python.exe');
const pyFile = path.join(tmpDir, 'runner.py');
fs.writeFileSync(
pyFile,
`import ctypes, os, sys, json
lib = ctypes.CDLL(os.path.abspath('64bit/epanet2.dll'))
ph = ctypes.c_void_p()
lib.EN_getnodevalue.argtypes = [ctypes.c_void_p, ctypes.c_int, ctypes.c_int, ctypes.POINTER(ctypes.c_double)]
lib.EN_getnodevalue.restype = ctypes.c_int
def check(rc, step):
if rc != 0:
print(json.dumps({'failedStep': step, 'rc': rc}))
sys.exit(1)
check(lib.EN_createproject(ctypes.byref(ph)), 'createproject')
check(lib.EN_open(ph, sys.argv[1].encode('utf-8'), sys.argv[2].encode('utf-8'), sys.argv[3].encode('utf-8')), 'open')
check(lib.EN_openH(ph), 'openH')
check(lib.EN_initH(ph, 0), 'initH')
steps = []
t = ctypes.c_long()
tstep = ctypes.c_long(1)
while tstep.value != 0 or not steps:
check(lib.EN_runH(ph, ctypes.byref(t)), f'runH@{len(steps)}')
pressure = ctypes.c_double()
check(lib.EN_getnodevalue(ph, 1, 11, ctypes.byref(pressure)), f'getnodevalue@{len(steps)}')
steps.append({'time': t.value, 'pressure': pressure.value})
check(lib.EN_nextH(ph, ctypes.byref(tstep)), f'nextH@{len(steps)}')
if tstep.value == 0:
break
check(lib.EN_closeH(ph), 'closeH')
check(lib.EN_close(ph), 'close')
check(lib.EN_deleteproject(ph), 'deleteproject')
with open(sys.argv[4], 'w') as f:
json.dump({'steps': steps}, f)
print('EPANET_OPEN_SOLVE_OK')
`
);
const pythonCmd = `"${pythonExe}" "${pyFile}" "${inpPath}" "${rptPath}" "${outPath}" "${jsonPath}"`;
try {
const stdout = execSync(pythonCmd, { encoding: 'utf-8' });
assert.ok(stdout.includes('EPANET_OPEN_SOLVE_OK'), `Every engine call must return rc=0 across the full EPS run; runner output: ${stdout}`);
const parsed = JSON.parse(fs.readFileSync(jsonPath, 'utf-8')) as { steps: { time: number; pressure: number }[] };
assert.ok(
parsed.steps.length >= 2,
`A 7200s duration with 3600s hydraulic timestep must produce >= 2 EPS periods; got ${parsed.steps.length}`
);
for (const s of parsed.steps) {
assert.ok(
Number.isFinite(s.pressure) && Math.abs(s.pressure) < 1000,
`Node pressure at t=${s.time}s must be a real, in-range value (LPS/m units), got ${s.pressure}`
);
}
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});
test('14. CFS FLOW UNIT: checkUnitConversion and convertNetworkUnits must support CFS (previously missing from the flow-unit table)', () => {
const sampleInp = `[TITLE]
CFS Test
[OPTIONS]
Units GPM
[JUNCTIONS]
J1 100.0 500.0 ;
[RESERVOIRS]
R1 250.0 ;
[PIPES]
P1 R1 J1 100 12 120 0 Open ;
[COORDINATES]
J1 10 10
R1 0 0
[END]
`;
const model = parseInp(sampleInp);
const check = checkUnitConversion(model, 'CFS');
assert.equal(check.canConvert, true, 'CFS must be recognized as a valid, convertible flow unit');
assert.equal(check.targetSystem, 'US');
const converted = convertNetworkUnits(model, 'CFS').model;
// 500 GPM = 500 * 0.0630901964 LPS = 31.5450982 LPS; / 28.316846592 LPS-per-CFS = 1.11406... CFS
const expectedCfs = Math.round(((500 * 0.0630901964) / 28.316846592) * 1000) / 1000;
assert.equal(converted.nodes['J1'].demand, expectedCfs);
});
test('15. SPECIFIC GRAVITY: pressure-quantity conversions must scale with the model Specific Gravity, not assume water (SG=1)', () => {
const sampleInp = `[TITLE]
SG Test
[OPTIONS]
Units GPM
Specific Gravity 1.2
Minimum Pressure 20
[JUNCTIONS]
J1 100.0 500.0 ;
[RESERVOIRS]
R1 250.0 ;
[PIPES]
P1 R1 J1 100 12 120 0 Open ;
[COORDINATES]
J1 10 10
R1 0 0
[END]
`;
const modelSg = parseInp(sampleInp);
assert.equal(modelSg.options.specificGravity, 1.2, 'Specific Gravity 1.2 must be parsed from OPTIONS');
const convertedSg = convertNetworkUnits(modelSg, 'LPS').model;
// 20 psi -> m of head for SG=1.2: (20 * 0.70307) / 1.2 = 11.7178.. -> 11.72
assert.equal(convertedSg.options.minimumPressure, 11.72);
const sampleInpDefaultSg = sampleInp.replace('Specific Gravity 1.2', 'Specific Gravity 1.0');
const modelDefault = parseInp(sampleInpDefaultSg);
const convertedDefault = convertNetworkUnits(modelDefault, 'LPS').model;
// Same 20 psi with SG=1.0 using EPANET's PSIperFT=0.4333: 20 * (0.3048/0.4333) = 14.0688 -> 14.07
assert.equal(convertedDefault.options.minimumPressure, 14.07);
assert.notEqual(
convertedSg.options.minimumPressure,
convertedDefault.options.minimumPressure,
'Specific Gravity must actually change the converted pressure value'
);
});
test('16. UNSAFE CONSTRUCT BLOCKING: RULES and numeric STATUS entries abort the conversion; structured DEMANDS convert with the network', () => {
const demandsInp = `[TITLE]
Demands Block Test
[OPTIONS]
Units GPM
[JUNCTIONS]
J1 100.0 ;
J2 100.0 ;
[RESERVOIRS]
R1 250.0 ;
[PIPES]
P1 R1 J1 100 12 120 0 Open ;
P2 J1 J2 100 12 120 0 Open ;
[DEMANDS]
J2 5 1 Domestic
J2 15 1 Commercial
[COORDINATES]
J1 10 10
J2 20 10
R1 0 0
[END]
`;
const demandsModel = parseInp(demandsInp);
// [DEMANDS] is now parsed into model.extras, so its flow values are
// converted together with every other flow quantity (never left stale).
const demandsCheck = checkUnitConversion(demandsModel, 'LPS');
assert.equal(demandsCheck.canConvert, true, 'structured [DEMANDS] no longer blocks conversion');
const convertedDemands = convertNetworkUnits(demandsModel, 'LPS').model;
const j2 = convertedDemands.extras!.demands['J2'];
assert.equal(j2.length, 2);
assert.ok(Math.abs(j2[0].demand - 5 * 0.0630902) < 1e-4, `5 GPM -> LPS, got ${j2[0].demand}`);
assert.ok(Math.abs(j2[1].demand - 15 * 0.0630902) < 1e-4);
assert.match(generateInp(convertedDemands), /\[DEMANDS\][\s\S]*J2\s+0\.31/);
// A model without the structured extras (built outside parseInp) still blocks.
const legacy = { ...demandsModel, extras: undefined };
const legacyCheck = checkUnitConversion(legacy, 'LPS');
assert.equal(legacyCheck.canConvert, false, 'raw-only [DEMANDS] must still block');
assert.match(legacyCheck.reason ?? '', /DEMANDS/);
const rulesInp = demandsInp.replace(
'[DEMANDS]\n J2 5 1 Domestic\n J2 15 1 Commercial\n',
'[RULES]\n RULE 1\n IF NODE J2 PRESSURE < 10\n THEN PIPE P1 STATUS = OPEN\n'
);
const rulesModel = parseInp(rulesInp);
const rulesCheck = checkUnitConversion(rulesModel, 'LPS');
assert.equal(rulesCheck.canConvert, false, '[RULES] section must block automatic conversion');
assert.match(rulesCheck.reason ?? '', /RULES/);
const statusInp = demandsInp.replace(
'[DEMANDS]\n J2 5 1 Domestic\n J2 15 1 Commercial\n',
'[STATUS]\n P2 2500\n'
);
const statusModel = parseInp(statusInp);
const statusCheck = checkUnitConversion(statusModel, 'LPS');
assert.equal(statusCheck.canConvert, false, '[STATUS] numeric initial setting must block automatic conversion');
assert.match(statusCheck.reason ?? '', /STATUS/);
});
test('17. COORDINATE EDIT PROPAGATION: a node move must survive regeneration despite a stale rawX/rawY from parsing', () => {
const sampleInp = `[TITLE]
Node Move Test
[JUNCTIONS]
J1 10 10 ;
[RESERVOIRS]
R1 50 ;
[PIPES]
P1 R1 J1 100 200 100 0 Open ;
[COORDINATES]
J1 100.5 200.5
R1 0 0
[END]
`;
const model = parseInp(sampleInp);
assert.equal(model.nodes['J1'].rawX, '100.5', 'Parsed rawX must retain the original text');
// Simulate exactly what handleNodeDragEnd/handleUpdateNode do: update x/y only,
// without touching rawX/rawY (this is the real production code path).
const moved = {
...model,
nodes: {
...model.nodes,
J1: { ...model.nodes['J1'], x: 555.123, y: 777.456 },
},
};
const regen = generateInp(moved);
assert.ok(regen.includes('555.123'), 'Regenerated INP must reflect the moved X coordinate');
assert.ok(regen.includes('777.456'), 'Regenerated INP must reflect the moved Y coordinate');
assert.ok(!regen.includes('100.5 '), 'Stale rawX must never override an actual coordinate edit');
});
test('18. CRS-AWARE LENGTH: distance calculation must respect the coordinate reference system and length unit system', () => {
// Two points ~0.01 degrees apart in longitude near the equator: ~1112 m (haversine).
const geoDist = calculateGeoAwareDistance({ x: 0, y: 0 }, { x: 0.01, y: 0 }, 'EPSG:4326', 'SI');
assert.ok(geoDist.isPhysical, 'EPSG:4326 must be treated as physically meaningful');
assert.ok(
Math.abs(geoDist.distance - 1113.2) < 5,
`Expected ~1113m for 0.01 deg longitude at the equator, got ${geoDist.distance}`
);
// Same physical distance, but requested in US units (feet) must scale by 3.28084.
const geoDistUS = calculateGeoAwareDistance({ x: 0, y: 0 }, { x: 0.01, y: 0 }, 'EPSG:4326', 'US');
assert.ok(
Math.abs(geoDistUS.distance / geoDist.distance - 3.280839895013123) < 1e-6,
'US unit system must convert the same physical distance to feet'
);
// Projected meters (EPSG:5179): Euclidean distance is already in meters, no haversine needed.
const projDist = calculateGeoAwareDistance({ x: 1000000, y: 2000000 }, { x: 1000300, y: 2000400 }, 'EPSG:5179', 'SI');
assert.equal(projDist.distance, 500, 'Projected meters must use plain Euclidean distance (3-4-5 triangle)');
// LOCAL (arbitrary planar) coordinates carry no real-world scale.
const localDist = calculateGeoAwareDistance({ x: 0, y: 0 }, { x: 3, y: 4 }, 'LOCAL', 'SI');
assert.equal(localDist.isPhysical, false, 'LOCAL coordinate systems must be flagged as not physically meaningful');
assert.equal(localDist.distance, 5, 'LOCAL distance must be plain Euclidean (3-4-5 triangle), not unit-scaled');
// A US-unit network on a LOCAL crs must NOT apply the meters->feet factor to
// an already-scaleless coordinate space (e.g. Net1.inp: GPM units, LOCAL crs,
// 10 units apart must recalc to 10, not 32.8).
const localDistUS = calculateGeoAwareDistance({ x: 0, y: 0 }, { x: 10, y: 0 }, 'LOCAL', 'US');
assert.equal(localDistUS.distance, 10, 'LOCAL + US unit system must not fabricate a feet scale for arbitrary local coordinates');
// EPSG:3857: Raw Euclidean in 3857 would be inflated by ~26% in Korea (~1261m for 1000m).
// calculateGeoAwareDistance projects via EPSG:5179, yielding the un-distorted ~1000m metric distance.
const seoul3857P1 = { x: 14135169.54, y: 4518770.83 }; // Seoul City Hall
const seoul3857P2 = { x: 14136431.14, y: 4518770.83 }; // ~1km East in 3857 (raw dx = 1261.6m)
const dist3857 = calculateGeoAwareDistance(seoul3857P1, seoul3857P2, 'EPSG:3857', 'SI');
assert.ok(dist3857.isPhysical, 'EPSG:3857 distance must be physically meaningful');
assert.ok(
Math.abs(dist3857.distance - 1000.8) < 2,
`EPSG:3857 distance must be corrected to ~1000.8m (un-distorted), got ${dist3857.distance}`
);
// Sanity-check the underlying haversine function directly against a known reference.
const oneDegreeLon = haversineMeters(0, 0, 0, 1);
assert.ok(Math.abs(oneDegreeLon - 111320) < 500, `1 degree of longitude at the equator should be ~111.32km, got ${oneDegreeLon}`);
});
test('19. AT TIME CONTROL PRESERVATION: editing a time-based control must not silently drop its AT TIME clause', () => {
const sampleInp = `[TITLE]
Time Control Test
[JUNCTIONS]
J1 10 10 ;
J2 10 10 ;
[RESERVOIRS]
R1 50 ;
[PIPES]
P1 R1 J1 100 200 100 0 Open ;
P2 J1 J2 100 150 100 0 Open ;
[CONTROLS]
LINK P2 CLOSED AT TIME 4
[COORDINATES]
J1 10 10
J2 20 10
R1 0 0
[END]
`;
const model = parseInp(sampleInp);
const ctrl = model.controls.find((c) => c.linkId === 'P2');
assert.ok(ctrl, 'Time-based control for P2 must be parsed');
assert.equal(ctrl!.atClause, 'AT TIME 4', 'AT TIME clause must be captured verbatim');
assert.equal(ctrl!.timeSec, 4 * 3600, 'AT TIME 4 must be interpreted as 4 hours (14400 seconds)');
// Edit only the status (simulating a UI status-field edit), not the time clause.
const updated = updateControl(model, ctrl!.id, { status: 'OPEN' });
const regen = generateInp(updated);
assert.ok(regen.includes('AT TIME 4'), 'AT TIME 4 must be preserved after editing the control status');
assert.ok(/LINK\s+P2\s+OPEN\s+AT TIME 4/.test(regen), 'Updated status OPEN must combine with the preserved AT TIME clause');
});
test('20. MODEL SNAPSHOT NORMALIZATION: a freshly loaded and analyzed model must not be immediately flagged stale', () => {
// Realistic desktop-authored spacing, deliberately different from generateInp's
// fixed-width output - this is what App.tsx actually receives from file upload.
const rawUploadedInp = `[TITLE]
Snapshot Normalization Test
[JUNCTIONS]
J1 10 15 ;
[RESERVOIRS]
R1 60 ;
[PIPES]
P1 R1 J1 300 200 120 0 Open ;
[COORDINATES]
J1 10 10
R1 0 0
[END]
`;
const model = parseInp(rawUploadedInp);
// Bug (pre-fix): App.tsx stored the raw submitted text verbatim as the result
// snapshot. Comparing it directly against generateInp(model) output falsely
// reports staleness even though zero edits were made.
const buggySnapshot = rawUploadedInp;
assert.equal(
computeIsStale(generateInp(model), buggySnapshot),
true,
'Demonstrates the bug: comparing regenerated text against raw source text falsely reports staleness'
);
// Fix: normalize through parse+regenerate (what App.tsx's normalizeInp does)
// before storing the snapshot, so the comparison is apples-to-apples.
const normalizedSnapshot = generateInp(parseInp(rawUploadedInp));
assert.equal(
computeIsStale(generateInp(model), normalizedSnapshot),
false,
'A freshly loaded and analyzed model must not be flagged stale immediately after load'
);
// Idempotency: normalizing an already-regenerated string must be a no-op,
// so the same snapshot logic works uniformly whether the source is a raw
// upload or output from a prior generateInp call (e.g. scenario switching).
const once = generateInp(model);
const twice = generateInp(parseInp(once));
assert.equal(once, twice, 'generateInp(parseInp(x)) must be idempotent on already-regenerated INP text');
});
test('21. OFFICIAL EXAMPLE NETWORKS (Net1/Net2/Net3): parse -> generateInp -> re-open and solve in the real EPANET 2.3.5 C DLL', () => {
// Source: github.com/OpenWaterAnalytics/EPANET (dev branch, example-networks/), MIT-licensed
// public-domain-origin USEPA example networks. See public/EXAMPLE_NETWORKS.md.
const networks = [
{ name: 'Net1', path: 'public/Net1.inp', expectedNodes: 11, expectedLinks: 13 },
{ name: 'Net2', path: 'public/Net2.inp', expectedNodes: 36, expectedLinks: 40 },
{ name: 'Net3', path: 'public/Net3.inp', expectedNodes: 97, expectedLinks: 119 },
];
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'spipe_examples_'));
try {
for (const net of networks) {
const raw = fs.readFileSync(net.path, 'utf-8');
const model = parseInp(raw);
const nodeCount = Object.keys(model.nodes).length;
const linkCount =
Object.keys(model.pipes).length + Object.keys(model.pumps).length + Object.keys(model.valves).length;
assert.equal(nodeCount, net.expectedNodes, `${net.name}: parsed node count must match the official network`);
assert.equal(linkCount, net.expectedLinks, `${net.name}: parsed link count must match the official network`);
const regen = generateInp(model);
assert.ok(regen.trim().endsWith('[END]'), `${net.name}: regenerated INP must terminate with [END]`);
// Round-trip: re-parsing the regenerated text must preserve identical topology counts.
const reparsed = parseInp(regen);
const reparsedNodeCount = Object.keys(reparsed.nodes).length;
const reparsedLinkCount =
Object.keys(reparsed.pipes).length + Object.keys(reparsed.pumps).length + Object.keys(reparsed.valves).length;
assert.equal(reparsedNodeCount, nodeCount, `${net.name}: round-trip node count must be conserved`);
assert.equal(reparsedLinkCount, linkCount, `${net.name}: round-trip link count must be conserved`);
// Actually invoke the bundled EPANET 2.3.5 DLL to prove the regenerated INP re-opens
// and solves without syntax/physics errors (rc=0 on every call).
const inpPath = path.join(tmpDir, `${net.name}_regen.inp`);
const rptPath = path.join(tmpDir, `${net.name}_regen.rpt`);
const outPath = path.join(tmpDir, `${net.name}_regen.out`);
fs.writeFileSync(inpPath, regen);
const pythonExe = path.resolve('.venv/Scripts/python.exe');
const pyFile = path.join(tmpDir, `${net.name}_runner.py`);
fs.writeFileSync(
pyFile,
`import ctypes, os, sys
lib = ctypes.CDLL(os.path.abspath('64bit/epanet2.dll'))
ph = ctypes.c_void_p()
def check(rc, step):
if rc != 0:
sys.stderr.write(f'FAILED at {step}: rc={rc}\\n')
sys.exit(1)
check(lib.EN_createproject(ctypes.byref(ph)), 'createproject')
check(lib.EN_open(ph, sys.argv[1].encode('utf-8'), sys.argv[2].encode('utf-8'), sys.argv[3].encode('utf-8')), 'open')
check(lib.EN_openH(ph), 'openH')
check(lib.EN_initH(ph, 0), 'initH')
t = ctypes.c_long()
check(lib.EN_runH(ph, ctypes.byref(t)), 'runH')
check(lib.EN_closeH(ph), 'closeH')
check(lib.EN_close(ph), 'close')
check(lib.EN_deleteproject(ph), 'deleteproject')
print('EPANET_OPEN_SOLVE_OK')
`
);
const pythonCmd = `"${pythonExe}" "${pyFile}" "${inpPath}" "${rptPath}" "${outPath}"`;
const stdout = execSync(pythonCmd, { encoding: 'utf-8' });
assert.ok(
stdout.includes('EPANET_OPEN_SOLVE_OK'),
`${net.name}: regenerated INP must open and solve in the real EPANET 2.3.5 C DLL (rc=0 on every call)`
);
}
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});
test('22. TOLERANCE-BASED COMPARISON: absTol + relTol criterion and near-zero relative diff separation', () => {
// 1. Verify default tolerances as specified by requirements
assert.deepEqual(DEFAULT_TOLERANCES.head, { absTol: 1e-4, relTol: 1e-8 });
assert.deepEqual(DEFAULT_TOLERANCES.pressure, { absTol: 1e-4, relTol: 1e-8 });
assert.deepEqual(DEFAULT_TOLERANCES.demand, { absTol: 1e-3, relTol: 1e-8 });
assert.deepEqual(DEFAULT_TOLERANCES.flow, { absTol: 1e-3, relTol: 1e-8 });
assert.deepEqual(DEFAULT_TOLERANCES.velocity, { absTol: 1e-5, relTol: 1e-8 });
assert.deepEqual(DEFAULT_TOLERANCES.headloss, { absTol: 1e-5, relTol: 1e-8 });
// 2. Near-zero test: Pipe flow is 1e-6 vs 2e-6 (relative diff is 50%, yet absolute diff is 1e-6 << absTol 1e-3)
const serverNearZero = {
engine: { runtime: 'native', version: '2.3.5' },
timeSeconds: 0,
units: { flow: 'GPM', pressure: 'psi' },
nodes: {},
links: {
P1: { id: 'P1', type: 0, fromNode: 'N1', toNode: 'N2', vertices: [], flow: 1e-6, velocity: 1e-6, headloss: 1e-6 },
},
} as unknown as SimulationResult;
const wasmNearZero = {
engine: { runtime: 'wasm', version: '2.3.5' },
timeSeconds: 0,
units: { flow: 'GPM', pressure: 'psi' },
nodes: {},
links: {
P1: { id: 'P1', type: 0, fromNode: 'N1', toNode: 'N2', vertices: [], flow: 2e-6, velocity: 2e-6, headloss: 2e-6 },
},
} as unknown as SimulationResult;
const resNearZero = computeComparison(serverNearZero, wasmNearZero);
// Even though relative diff is 0.5 (50%), it must PASS because diff (1e-6) <= absTol + relTol * max
assert.equal(resNearZero.passed, true, 'Near-zero diff must pass tolerance check');
assert.equal(resNearZero.toleranceViolations.length, 0, 'No violations should occur within tolerance');
assert.equal(resNearZero.nearZeroCounts.flow, 1, 'Near-zero denominator must be tracked in nearZeroCounts');
assert.equal(resNearZero.maxRelativeDiffs.flow, 0.5, 'Raw maxRelativeDiff is still tracked');
assert.equal(resNearZero.maxSignificantRelativeDiffs.flow, 0, 'Significant relative diff excludes near-zero values');
// 3. Significant value test: Pipe flow is 100.0 vs 100.00005 (diff is 5e-5 <= absTol 1e-3, scale is 100 >> absTol)
const serverSig = {
engine: { runtime: 'native', version: '2.3.5' },
timeSeconds: 0,
units: { flow: 'GPM', pressure: 'psi' },
nodes: {},
links: {
P2: { id: 'P2', type: 0, fromNode: 'N1', toNode: 'N2', vertices: [], flow: 100.0, velocity: 1.0, headloss: 0.1 },
},
} as unknown as SimulationResult;
const wasmSig = {
engine: { runtime: 'wasm', version: '2.3.5' },
timeSeconds: 0,
units: { flow: 'GPM', pressure: 'psi' },
nodes: {},
links: {
P2: { id: 'P2', type: 0, fromNode: 'N1', toNode: 'N2', vertices: [], flow: 100.00005, velocity: 1.0, headloss: 0.1 },
},
} as unknown as SimulationResult;
const resSig = computeComparison(serverSig, wasmSig);
assert.equal(resSig.passed, true, 'Significant value within tolerance must pass');
assert.equal(resSig.nearZeroCounts.flow, 0, 'Denominator 100 is not near-zero');
assert.ok(
Math.abs(resSig.maxSignificantRelativeDiffs.flow - 5e-7) < 1e-9,
'Significant relative diff must capture true non-near-zero relative difference'
);
// 4. Violation test: Difference exceeds tolerance (e.g. flow 100.0 vs 105.0)
const wasmBad = {
...wasmSig,
links: {
P2: { ...wasmSig.links.P2, flow: 105.0 },
},
} as unknown as SimulationResult;
const resBad = computeComparison(serverSig, wasmBad);
assert.equal(resBad.passed, false, 'Violation beyond tolerance must fail');
assert.equal(resBad.toleranceViolations.length, 1, 'Should record exactly one violation');
assert.equal(resBad.toleranceViolations[0].variable, 'flow');
assert.equal(resBad.toleranceViolations[0].id, 'P2');
assert.equal(resBad.toleranceViolations[0].diff, 5.0);
// 5. NaN/Infinity detection test
const serverNaN = structuredClone(serverSig);
(serverNaN.links.P2 as any).flow = Number.NaN;
const resNaN = computeComparison(serverNaN, wasmSig);
assert.equal(resNaN.passed, false, 'Comparison must fail on NaN value');
assert.ok(resNaN.missingIds.some((id) => id.includes('NonFiniteValue:flow:P2')), 'Must record NonFiniteValue for NaN');
// 6. Missing timestamp or truncated time-series detection test
const serverShort = structuredClone(serverSig);
serverShort.timeSteps = [0, 3600];
(serverShort.links.P2 as any).timeSeries = {
flow: [100.0, 100.0],
velocity: [1.0, 1.0],
headloss: [0.1, 0.1],
};
const wasmTruncated = structuredClone(serverShort);
(wasmTruncated.links.P2 as any).timeSeries = {
flow: [100.0], // missing index 1 at 3600s
velocity: [1.0, 1.0],
headloss: [0.1, 0.1],
};
const resTrunc = computeComparison(serverShort, wasmTruncated);
assert.equal(resTrunc.passed, false, 'Truncated series missing index must fail');
assert.ok(resTrunc.missingIds.some((id) => id.includes('MissingValue:flow:P2@3600s')), 'Must record MissingValue for missing time-series element');
// 7. Unit metadata mismatch test
const wasmDiffUnits = structuredClone(serverSig);
wasmDiffUnits.units.flow = 'LPS';
const resUnitMismatch = computeComparison(serverSig, wasmDiffUnits);
assert.equal(resUnitMismatch.passed, false, 'Unit metadata mismatch must fail');
assert.ok(resUnitMismatch.missingIds.some((id) => id.includes('UnitMismatch:flow')), 'Must record UnitMismatch:flow');
});
test('23. GIS IMPORT & MULTILINESTRING: GeoJSON conversion with material roughness inference and real C DLL solution', () => {
// 1. Material roughness inference check
assert.equal(inferRoughnessFromMaterial('DIP (덕타일주철관)'), 110);
assert.equal(inferRoughnessFromMaterial('PE (폴리에틸렌관)'), 130);
assert.equal(inferRoughnessFromMaterial('PVC (염화비닐관)'), 140);
assert.equal(inferRoughnessFromMaterial('ST (강관)'), 100);
assert.equal(inferRoughnessFromMaterial('UNKNOWN', 120), 120);
// 2. Auto-detect fields check with standard Korean municipal GIS properties
const sampleProps = {
FTR_IDN: 1001,
PIP_DIA: 200,
PIP_LEN: 350.5,
PIP_MAT: 'DIP',
LAYER: '배수관',
};
const detected = autoDetectFields(sampleProps);
assert.equal(detected.pipeIdField, 'FTR_IDN');
assert.equal(detected.diameterField, 'PIP_DIA');
assert.equal(detected.lengthField, 'PIP_LEN');
assert.equal(detected.materialField, 'PIP_MAT');
// 3. MultiLineString and LineString conversion to EPANET model
const geojson = {
type: 'FeatureCollection',
features: [
{
type: 'Feature',
properties: { FTR_IDN: 'PIPE_MAIN', PIP_DIA: 300, PIP_MAT: 'DIP' },
geometry: {
type: 'LineString',
coordinates: [
[126.975, 37.565],
[126.978, 37.565],
],
},
},
{
// MultiLineString with 2 segments connected to the first line's endpoint
type: 'Feature',
properties: { FTR_IDN: 'PIPE_BRANCH', PIP_DIA: 150, PIP_MAT: 'PE' },
geometry: {
type: 'MultiLineString',
coordinates: [
[
[126.978, 37.565],
[126.98, 37.567],
],
[
[126.98, 37.567],
[126.982, 37.565],
],
],
},
},
],
};
const { model, summary } = buildNetworkFromGeoJSON(geojson as any, {
pipeIdField: 'FTR_IDN',
diameterField: 'PIP_DIA',
materialField: 'PIP_MAT',
defaultDiameter: 200,
defaultRoughness: 120,
sourceHead: 60,
});
// Both pipes had a real GIS diameter field, so neither should be flagged
// as needing review for an estimated diameter -- but roughness for both
// came from material inference (a guess, not a measured coefficient), so
// both must be flagged for review.
assert.equal(summary.defaultDiameterPipeCount, 0);
assert.equal(summary.defaultRoughnessPipeCount, summary.totalPipeCount);
assert.equal(summary.placeholderElevationCount, summary.totalNodeCount - 1);
// MultiLineString must be decomposed into PIPE_BRANCH_1 and PIPE_BRANCH_2
assert.ok(model.pipes['PIPE_MAIN'], 'Single LineString pipe must exist');
assert.ok(model.pipes['PIPE_BRANCH_1'], 'MultiLineString segment 1 must exist');
assert.ok(model.pipes['PIPE_BRANCH_2'], 'MultiLineString segment 2 must exist');
// Material-to-roughness inference check
assert.equal(model.pipes['PIPE_MAIN'].roughness, 110, 'DIP material must infer C=110');
assert.equal(model.pipes['PIPE_BRANCH_1'].roughness, 130, 'PE material must infer C=130');
// Reservoir check: must have created 1 Reservoir to act as water source
const reservoirs = Object.values(model.nodes).filter((n) => n.type === 'RESERVOIR');
assert.ok(reservoirs.length >= 1, 'At least one water source (Reservoir) must be created');
// Topology validation: network must be connected without errors
const issues = validateTopology(model);
const errors = issues.filter((i) => i.type === 'error');
assert.equal(errors.length, 0, `Imported network must have 0 topology errors; got: ${errors.map((e) => e.message).join(', ')}`);
// 4. Generate INP and execute inside the bundled EPANET 2.3.5 C DLL
const regen = generateInp(model);
assert.ok(regen.includes('[PIPES]'), 'Regenerated INP must contain [PIPES]');
assert.ok(regen.trim().endsWith('[END]'), 'Regenerated INP must terminate with [END]');
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'spipe_geojson_'));
try {
const inpPath = path.join(tmpDir, 'gis_imported.inp');
const rptPath = path.join(tmpDir, 'gis_imported.rpt');
const outPath = path.join(tmpDir, 'gis_imported.out');
fs.writeFileSync(inpPath, regen);
const pythonExe = path.resolve('.venv/Scripts/python.exe');
const pyFile = path.join(tmpDir, 'runner.py');
fs.writeFileSync(
pyFile,
`import ctypes, os, sys
lib = ctypes.CDLL(os.path.abspath('64bit/epanet2.dll'))
ph = ctypes.c_void_p()
def check(rc, step):
if rc != 0:
sys.stderr.write(f'FAILED at {step}: rc={rc}\\n')
sys.exit(1)
check(lib.EN_createproject(ctypes.byref(ph)), 'createproject')
check(lib.EN_open(ph, sys.argv[1].encode('utf-8'), sys.argv[2].encode('utf-8'), sys.argv[3].encode('utf-8')), 'open')
check(lib.EN_openH(ph), 'openH')
check(lib.EN_initH(ph, 0), 'initH')
t = ctypes.c_long()
check(lib.EN_runH(ph, ctypes.byref(t)), 'runH')
check(lib.EN_closeH(ph), 'closeH')
check(lib.EN_close(ph), 'close')
check(lib.EN_deleteproject(ph), 'deleteproject')
print('EPANET_OPEN_SOLVE_OK')
`
);
const pythonCmd = `"${pythonExe}" "${pyFile}" "${inpPath}" "${rptPath}" "${outPath}"`;
const stdout = execSync(pythonCmd, { encoding: 'utf-8' });
assert.ok(
stdout.includes('EPANET_OPEN_SOLVE_OK'),
'Imported GeoJSON network must solve in EPANET 2.3.5 C DLL with rc=0'
);
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});
test('24. WATER QUALITY SIMULATION: execute Chlorine, Fluoride and Source Trace in EPANET 2.3.5 C DLL', () => {
// Verifies that the full water quality pipeline runs with rc=0 across all three official networks:
// Net1 (Chlorine decay, 24h), Net2 (Fluoride calibration, 55h), Net3 (Lake Source Tracing, 24h)
const networks = [
{ name: 'Net1', path: 'public/Net1.inp', expectedType: 1, expectedChem: 'Chlorine', expectedUnits: 'mg/L' },
{ name: 'Net2', path: 'public/Net2.inp', expectedType: 1, expectedChem: 'Fluoride', expectedUnits: 'mg/L' },
{ name: 'Net3', path: 'public/Net3.inp', expectedType: 3, expectedChem: 'TRACE', expectedUnits: '% from' },
];
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'spipe_qual_'));
try {
for (const net of networks) {
const pythonExe = path.resolve('.venv/Scripts/python.exe');
const pyFile = path.join(tmpDir, `${net.name}_qual.py`);
const rptPath = path.join(tmpDir, `${net.name}.rpt`);
const outPath = path.join(tmpDir, `${net.name}.out`);
const jsonPath = path.join(tmpDir, `${net.name}_res.json`);
fs.writeFileSync(
pyFile,
`import ctypes, os, sys, json
lib = ctypes.CDLL(os.path.abspath('64bit/epanet2.dll'))
ph = ctypes.c_void_p()
def check(rc, step):
if rc != 0:
sys.stderr.write(f'FAILED at {step}: rc={rc}\\n')
sys.exit(1)
check(lib.EN_createproject(ctypes.byref(ph)), 'createproject')
check(lib.EN_open(ph, sys.argv[1].encode('utf-8'), sys.argv[2].encode('utf-8'), sys.argv[3].encode('utf-8')), 'open')
qual_type = ctypes.c_int()
trace_node = ctypes.c_int()
chem_name = ctypes.create_string_buffer(256)
chem_units = ctypes.create_string_buffer(256)
check(lib.EN_getqualinfo(ph, ctypes.byref(qual_type), chem_name, chem_units, ctypes.byref(trace_node)), 'getqualinfo')
# 1. Run hydraulics and save for quality
check(lib.EN_openH(ph), 'openH')
check(lib.EN_initH(ph, 1), 'initH')
t = ctypes.c_long()
tstep = ctypes.c_long(1)
while tstep.value > 0:
check(lib.EN_runH(ph, ctypes.byref(t)), 'runH')
check(lib.EN_nextH(ph, ctypes.byref(tstep)), 'nextH')
check(lib.EN_closeH(ph), 'closeH')
# 2. Run water quality simulation
check(lib.EN_openQ(ph), 'openQ')
check(lib.EN_initQ(ph, 0), 'initQ')
tQ = ctypes.c_long()
tstepQ = ctypes.c_long(1)
node_count = ctypes.c_int()
check(lib.EN_getcount(ph, 0, ctypes.byref(node_count)), 'getcount')
quality_steps = []
while tstepQ.value > 0:
check(lib.EN_runQ(ph, ctypes.byref(tQ)), 'runQ')
qv = ctypes.c_double()
check(lib.EN_getnodevalue(ph, 1, 12, ctypes.byref(qv)), 'getnodevalue_qual')
quality_steps.append({'time': tQ.value, 'quality': qv.value})
check(lib.EN_nextQ(ph, ctypes.byref(tstepQ)), 'nextQ')
check(lib.EN_closeQ(ph), 'closeQ')
check(lib.EN_close(ph), 'close')
check(lib.EN_deleteproject(ph), 'deleteproject')
with open(sys.argv[4], 'w') as f:
json.dump({
'qualType': qual_type.value,
'chemName': chem_name.value.decode('utf-8', errors='ignore').strip(),
'chemUnits': chem_units.value.decode('utf-8', errors='ignore').strip(),
'steps': len(quality_steps),
'firstQual': quality_steps[0]['quality'] if quality_steps else 0,
}, f)
print('EPANET_QUALITY_OK')
`
);
const pythonCmd = `"${pythonExe}" "${pyFile}" "${path.resolve(net.path)}" "${rptPath}" "${outPath}" "${jsonPath}"`;
const stdout = execSync(pythonCmd, { encoding: 'utf-8' });
assert.ok(stdout.includes('EPANET_QUALITY_OK'), `${net.name}: Water quality run must succeed with rc=0`);
const parsed = JSON.parse(fs.readFileSync(jsonPath, 'utf-8'));
assert.equal(parsed.qualType, net.expectedType, `${net.name}: Quality type must match expected`);
assert.equal(parsed.chemName, net.expectedChem, `${net.name}: Chemical name must match expected`);
assert.equal(parsed.chemUnits, net.expectedUnits, `${net.name}: Chemical units must match expected`);
assert.ok(parsed.steps >= 2, `${net.name}: Quality simulation must produce multiple time steps`);
assert.ok(Number.isFinite(parsed.firstQual), `${net.name}: Initial quality must be finite`);
}
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});
test('25. PUMP ENERGY & POWER ANALYSIS: compute kWh, peak kW, and efficiency in EPANET 2.3.5 C DLL', () => {
// Verifies real-time power (kW), total energy (kWh), and efficiency (%) calculations
// on pumps in Net1 (single pump) using actual variable time-step intervals.
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'spipe_energy_'));
try {
const pythonExe = path.resolve('.venv/Scripts/python.exe');
const pyFile = path.join(tmpDir, 'energy_runner.py');
const jsonPath = path.join(tmpDir, 'energy_res.json');
const rptPath = path.join(tmpDir, 'net1_e.rpt');
const outPath = path.join(tmpDir, 'net1_e.out');
const inpPath = path.resolve('public/Net1.inp');
fs.writeFileSync(
pyFile,
`import ctypes, os, sys, json
lib = ctypes.CDLL(os.path.abspath('64bit/epanet2.dll'))
ph = ctypes.c_void_p()
def check(rc, step):
if rc != 0:
sys.stderr.write(f'FAILED at {step}: rc={rc}\\n')
sys.exit(1)
check(lib.EN_createproject(ctypes.byref(ph)), 'createproject')
check(lib.EN_open(ph, sys.argv[1].encode('utf-8'), sys.argv[2].encode('utf-8'), sys.argv[3].encode('utf-8')), 'open_net1')
link_count = ctypes.c_int()
check(lib.EN_getcount(ph, 2, ctypes.byref(link_count)), 'getcount')
pump_indices = []
for i in range(1, link_count.value + 1):
ltype = ctypes.c_int()
check(lib.EN_getlinktype(ph, i, ctypes.byref(ltype)), 'getlinktype')
if ltype.value == 2: # EN_PUMP
lid = ctypes.create_string_buffer(256)
check(lib.EN_getlinkid(ph, i, lid), 'getlinkid')
pump_indices.append((i, lid.value.decode()))
check(lib.EN_openH(ph), 'openH')
check(lib.EN_initH(ph, 0), 'initH')
t = ctypes.c_long()
tstep = ctypes.c_long(1)
readings = []
val_energy = ctypes.c_double()
val_effic = ctypes.c_double()
while tstep.value > 0:
check(lib.EN_runH(ph, ctypes.byref(t)), 'runH')
p_idx = pump_indices[0][0]
check(lib.EN_getlinkvalue(ph, p_idx, 13, ctypes.byref(val_energy)), 'get_energy')
check(lib.EN_getlinkvalue(ph, p_idx, 17, ctypes.byref(val_effic)), 'get_effic')
readings.append({'time': t.value, 'kw': val_energy.value, 'effic': val_effic.value})
check(lib.EN_nextH(ph, ctypes.byref(tstep)), 'nextH')
check(lib.EN_closeH(ph), 'closeH')
check(lib.EN_close(ph), 'close')
check(lib.EN_deleteproject(ph), 'deleteproject')
total_kwh = sum(
readings[i]['kw'] * (readings[i + 1]['time'] - readings[i]['time']) / 3600.0
for i in range(len(readings) - 1)
)
peak_kw = max(r['kw'] for r in readings)
active_effics = [r['effic'] for r in readings if r['effic'] > 0]
avg_effic = sum(active_effics) / len(active_effics) if active_effics else 0
active_sec = sum(
(readings[i + 1]['time'] - readings[i]['time'])
for i in range(len(readings) - 1)
if readings[i]['kw'] > 0
)
total_sec = readings[-1]['time'] - readings[0]['time']
utilization = (active_sec / total_sec) * 100.0 if total_sec > 0 else 0.0
with open(sys.argv[4], 'w') as f:
json.dump({
'pumpId': pump_indices[0][1],
'steps': len(readings),
'totalKwh': round(total_kwh, 2),
'peakKw': round(peak_kw, 2),
'avgEfficiency': round(avg_effic * 100, 1),
'utilization': round(utilization, 1),
}, f)
print('EPANET_ENERGY_OK')
`
);
const pythonCmd = `"${pythonExe}" "${pyFile}" "${inpPath}" "${rptPath}" "${outPath}" "${jsonPath}"`;
const stdout = execSync(pythonCmd, { encoding: 'utf-8' });
assert.ok(stdout.includes('EPANET_ENERGY_OK'), 'Pump energy calculation in C DLL must succeed with rc=0');
const res = JSON.parse(fs.readFileSync(jsonPath, 'utf-8'));
assert.equal(res.pumpId, '9', 'Net1 pump id must be 9');
assert.equal(res.steps, 27, 'Net1 must produce 27 EPS energy steps');
// True interval-integrated kWh of Net1 pump 9 is ~1333 kWh, not ~1538 kWh
assert.ok(res.totalKwh > 1300 && res.totalKwh < 1360, `Net1 pump interval-integrated energy should be ~1333 kWh, got ${res.totalKwh}`);
assert.ok(res.peakKw > 90 && res.peakKw < 110, `Net1 pump peak power should be ~96.7 kW, got ${res.peakKw}`);
assert.equal(res.avgEfficiency, 75.0, 'Net1 pump operating efficiency must be 75.0%');
assert.ok(res.utilization > 50 && res.utilization < 65, `Net1 pump utilization should be ~57.7%, got ${res.utilization}%`);
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});
test('26. BACKEND RUN_EPANET INTEGRATION: Net1 & Net3 pump energy integration, simultaneous peak, and returned series match', () => {
const pythonExe = path.resolve('.venv/Scripts/python.exe');
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'spipe_be_energy_'));
try {
const pyFile = path.join(tmpDir, 'test_be_energy.py');
fs.writeFileSync(
pyFile,
`import json, os, pathlib, sys
root = pathlib.Path(r'${path.resolve('.').replace(/\\/g, '/')}')
sys.path.insert(0, str(root))
from backend.main import run_epanet
tmp = pathlib.Path(r'${tmpDir.replace(/\\/g, '/')}')
tmp = pathlib.Path(r'${tmpDir.replace(/\\/g, '/')}')
# Net1
r1 = run_epanet(str(root / 'public/Net1.inp'), str(tmp / 'net1.rpt'), str(tmp / 'net1.out'))
ts1 = r1['timeSteps']
p9 = r1['energySummary']['pumps']['9']
s9 = r1['links']['9']['timeSeries']['energy']
reintegrated1 = round(sum(s9[i] * (ts1[i + 1] - ts1[i]) / 3600.0 for i in range(len(ts1) - 1)), 2)
# Net3
r3 = run_epanet(str(root / 'public/Net3.inp'), str(tmp / 'net3.rpt'), str(tmp / 'net3.out'))
ts3 = r3['timeSteps']
pumps3 = r3['energySummary']['pumps']
simul_peak3 = max(
sum(r3['links'][pid]['timeSeries']['energy'][i] for pid in pumps3)
for i in range(len(ts3))
)
print(json.dumps({
'net1_totalKwh': p9['totalKwh'],
'net1_reintegrated': reintegrated1,
'net3_peakKw': r3['energySummary']['peakKw'],
'net3_simultaneousPeak': round(simul_peak3, 2),
}))
`
);
const out = execSync(`"${pythonExe}" "${pyFile}"`, { encoding: 'utf-8' });
const parsed = JSON.parse(out.trim());
assert.equal(parsed.net1_totalKwh, parsed.net1_reintegrated, 'Net1 totalKwh must strictly match time-interval reintegrated series');
assert.equal(parsed.net3_peakKw, parsed.net3_simultaneousPeak, 'Net3 peakKw must strictly match simultaneous multi-pump peak sum');
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});
test('27. WATER QUALITY ERROR HANDLING: failed EN_runQ must flag status="failed" and not mask failure with zeros', () => {
const pythonExe = path.resolve('.venv/Scripts/python.exe');
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'spipe_be_qual_'));
try {
const pyFile = path.join(tmpDir, 'test_be_qual.py');
fs.writeFileSync(
pyFile,
`import json, os, pathlib, sys
root = pathlib.Path(r'${path.resolve('.').replace(/\\/g, '/')}')
sys.path.insert(0, str(root))
from backend.main import run_epanet, api
tmp = pathlib.Path(r'${tmpDir.replace(/\\/g, '/')}')
tmp = pathlib.Path(r'${tmpDir.replace(/\\/g, '/')}')
original_runQ = api.lib.EN_runQ
def failing_runQ(*args):
raise RuntimeError('injected quality solver error')
api.lib.EN_runQ = failing_runQ
try:
res = run_epanet(str(root / 'public/Net1.inp'), str(tmp / 'net1_q.rpt'), str(tmp / 'net1_q.out'))
qinfo = res['qualityInfo']
# Nodes must not have fake zero arrays
all_quality_empty = all(len(n['timeSeries']['quality']) == 0 for n in res['nodes'].values())
all_quality_val_none = all(n['quality'] is None for n in res['nodes'].values())
print(json.dumps({
'status': qinfo.get('status'),
'hasErrorMessage': bool(qinfo.get('errorMessage')),
'allQualityEmpty': all_quality_empty,
'allQualityValNone': all_quality_val_none,
}))
finally:
api.lib.EN_runQ = original_runQ
`
);
const out = execSync(`"${pythonExe}" "${pyFile}"`, { encoding: 'utf-8' });
const parsed = JSON.parse(out.trim());
assert.equal(parsed.status, 'failed', 'qualityInfo.status must be "failed" when EN_runQ throws');
assert.equal(parsed.hasErrorMessage, true, 'qualityInfo must record errorMessage');
assert.equal(parsed.allQualityEmpty, true, 'Node quality series must remain empty, never zero-filled on failure');
assert.equal(parsed.allQualityValNone, true, 'Node quality scalar must be null on failure');
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});
test('28. UNIT CONVERSION SAFETY: FCV valve with numeric control on flow scale change must be blocked', () => {
const sampleInp = `[TITLE]
FCV Control Test
[JUNCTIONS]
J1 10 1 ;
J2 10 1 ;
[RESERVOIRS]
R1 50 ;
[PIPES]
P1 R1 J1 100 200 100 0 Open ;
[VALVES]
V1 J1 J2 100 FCV 10 0 ;
[CONTROLS]
LINK V1 10 AT TIME 2
[OPTIONS]
UNITS LPS
[END]
`;
const model = parseInp(sampleInp);
// Converting LPS -> LPM (same SI system, but flow multiplier is 60)
const check = checkUnitConversion(model, 'LPM');
assert.equal(
check.canConvert,
false,
'Conversion must be blocked when unit-dependent numeric control setting exists on flow-scale change'
);
assert.ok(
(check.reason || '').includes('V1'),
`Blocking reason must mention the affected link: ${check.reason}`
);
});
test('29. UNIT CONVERSION SAFETY: PRV valve with numeric control across system switch must be blocked', () => {
const sampleInp = `[TITLE]
PRV Control Test
[JUNCTIONS]
J1 10 1 ;
J2 10 1 ;
[RESERVOIRS]
R1 50 ;
[PIPES]
P1 R1 J1 100 200 100 0 Open ;
[VALVES]
V1 J1 J2 100 PRV 50 0 ;
[CONTROLS]
LINK V1 40 AT TIME 2
[OPTIONS]
UNITS GPM
[END]
`;
const model = parseInp(sampleInp);
const check = checkUnitConversion(model, 'LPS');
assert.equal(check.canConvert, false, 'Numeric control on PRV must block conversion across system switch');
});
test('30. ATTRIBUTE TABLE: validateSingleField rejects invalid numbers/status and accepts valid edits', () => {
const sampleInp = `[TITLE]
Attr Table Validation Test
[JUNCTIONS]
J1 10.5 25.0 ;
[RESERVOIRS]
R1 50.0 ;
[PIPES]
P1 R1 J1 150.0 200.0 120 0.5 Open ;
[OPTIONS]
UNITS LPS
[END]
`;
const model = parseInp(sampleInp);
// 1. Junction elevation validation
const validElev = validateSingleField('junction', 'elevation', '15.75', 'J1', model);
assert.equal(validElev.valid, true);
assert.equal(validElev.parsedValue, 15.75);
const invalidElev = validateSingleField('junction', 'elevation', 'not-a-number', 'J1', model);
assert.equal(invalidElev.valid, false);
assert.ok(invalidElev.error?.includes('숫자'));
// 2. Pipe length/diameter validation
const validLen = validateSingleField('pipe', 'length', '250.5', 'P1', model);
assert.equal(validLen.valid, true);
assert.equal(validLen.parsedValue, 250.5);
const zeroLen = validateSingleField('pipe', 'length', '0', 'P1', model);
assert.equal(zeroLen.valid, false);
assert.ok(zeroLen.error?.includes('0보다 큰 숫자'));
const negDia = validateSingleField('pipe', 'diameter', '-50', 'P1', model);
assert.equal(negDia.valid, false);
// 3. Pipe status validation
const validStatus = validateSingleField('pipe', 'status', 'closed', 'P1', model);
assert.equal(validStatus.valid, true);
assert.equal(validStatus.parsedValue, 'Closed');
const invalidStatus = validateSingleField('pipe', 'status', 'BROKEN', 'P1', model);
assert.equal(invalidStatus.valid, false);
// 4. Node self-loop validation
const selfLoop = validateSingleField('pipe', 'node2', 'R1', 'P1', model);
assert.equal(selfLoop.valid, false);
assert.ok(selfLoop.error?.includes('자기순환 불가'));
});
test('31. ATTRIBUTE TABLE TOPOLOGY: renameNode & renamePipe update connected links, controls, and reject duplicates', () => {
const sampleInp = `[TITLE]
Rename Test
[JUNCTIONS]
J1 10.0 5.0 ;
J2 12.0 8.0 ;
[RESERVOIRS]
R1 50.0 ;
[PIPES]
P1 R1 J1 100 200 100 0 Open ;
P2 J1 J2 150 150 100 0 Open ;
[CONTROLS]
LINK P1 OPEN IF NODE J1 BELOW 15
[OPTIONS]
UNITS LPS
[END]
`;
const model = parseInp(sampleInp);
// 1. Rename J1 -> J_MAIN
const renamedModel = renameNode(model, 'J1', 'J_MAIN');
assert.equal(renamedModel.nodes['J1'], undefined);
assert.ok(renamedModel.nodes['J_MAIN']);
assert.equal(renamedModel.nodes['J_MAIN'].elevation, 10.0);
// Check that connected pipes were updated
assert.equal(renamedModel.pipes['P1'].node2, 'J_MAIN');
assert.equal(renamedModel.pipes['P2'].node1, 'J_MAIN');
assert.equal(renamedModel.pipes['P2'].node2, 'J2');
// Check that control reference was updated
assert.equal(renamedModel.controls[0].nodeId, 'J_MAIN');
assert.ok(renamedModel.controls[0].rawText.includes('NODE J_MAIN'));
// 2. Reject duplicate node rename
assert.throws(() => renameNode(model, 'J1', 'J2'), /이미 존재하는/);
// 3. Rename P2 -> P_FEED
const renamedPipeModel = renamePipe(renamedModel, 'P2', 'P_FEED');
assert.equal(renamedPipeModel.pipes['P2'], undefined);
assert.ok(renamedPipeModel.pipes['P_FEED']);
assert.equal(renamedPipeModel.pipes['P_FEED'].node1, 'J_MAIN');
assert.equal(renamedPipeModel.pipes['P_FEED'].node2, 'J2');
// 4. Reject duplicate pipe rename
assert.throws(() => renamePipe(model, 'P1', 'P2'), /이미 존재하는/);
});
test('32. ATTRIBUTE TABLE INP REGENERATION & SOLVE: table edits propagate to generateInp text and solve in EPANET DLL', () => {
const sampleInp = `[TITLE]
Table Edit Regeneration Test
[JUNCTIONS]
J1 10.0 5.0 ;
[RESERVOIRS]
R1 50.0 ;
[PIPES]
P1 R1 J1 100.0 200.0 100 0 Open ;
[COORDINATES]
J1 127.123456 37.654321
R1 127.0 37.5
[OPTIONS]
UNITS LPS
[END]
`;
const model = parseInp(sampleInp);
// Apply table edits: Elevation 10 -> 22.5, Demand 5 -> 17.8, Coordinates, Pipe Length 100 -> 345.6
let edited = updateNodeAttributes(model, 'J1', {
elevation: 22.5,
demand: 17.8,
x: 127.987654,
y: 37.112233,
});
edited = updatePipeAttributes(edited, 'P1', {
length: 345.6,
diameter: 250.0,
roughness: 130,
});
const regen = generateInp(edited);
// Verify that regenerated text reflects all edits
assert.ok(regen.includes('22.5'), 'Regenerated INP must contain edited elevation 22.5');
assert.ok(regen.includes('17.8'), 'Regenerated INP must contain edited demand 17.8');
assert.ok(regen.includes('345.6'), 'Regenerated INP must contain edited pipe length 345.6');
assert.ok(regen.includes('250'), 'Regenerated INP must contain edited pipe diameter 250');
assert.ok(regen.includes('127.987654'), 'Regenerated INP must contain edited X coordinate');
assert.ok(regen.includes('37.112233'), 'Regenerated INP must contain edited Y coordinate');
// Test solving in C DLL
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'spipe_table_edit_'));
try {
const inpPath = path.join(tmpDir, 'table_edited.inp');
const rptPath = path.join(tmpDir, 'table_edited.rpt');
const outPath = path.join(tmpDir, 'table_edited.out');
fs.writeFileSync(inpPath, regen);
const pythonExe = path.resolve('.venv/Scripts/python.exe');
const pyFile = path.join(tmpDir, 'runner.py');
fs.writeFileSync(
pyFile,
`import ctypes, os, sys
lib = ctypes.CDLL(os.path.abspath('64bit/epanet2.dll'))
ph = ctypes.c_void_p()
def check(rc, step):
if rc != 0:
sys.stderr.write(f'FAILED at {step}: rc={rc}\\n')
sys.exit(1)
check(lib.EN_createproject(ctypes.byref(ph)), 'createproject')
check(lib.EN_open(ph, sys.argv[1].encode('utf-8'), sys.argv[2].encode('utf-8'), sys.argv[3].encode('utf-8')), 'open')
check(lib.EN_openH(ph), 'openH')
check(lib.EN_initH(ph, 0), 'initH')
t = ctypes.c_long()
check(lib.EN_runH(ph, ctypes.byref(t)), 'runH')
check(lib.EN_closeH(ph), 'closeH')
check(lib.EN_close(ph), 'close')
check(lib.EN_deleteproject(ph), 'deleteproject')
print('EPANET_TABLE_EDIT_OK')
`
);
const stdout = execSync(`"${pythonExe}" "${pyFile}" "${inpPath}" "${rptPath}" "${outPath}"`, {
encoding: 'utf-8',
});
assert.ok(stdout.includes('EPANET_TABLE_EDIT_OK'), 'Table-edited model must solve cleanly in EPANET DLL');
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});
test('33. GIS LAYERS: parseGeoJsonString validates features, detects geometry types, and estimates CRS', () => {
// 1. WGS84 GeoJSON
const wgs84Json = JSON.stringify({
type: 'FeatureCollection',
features: [
{
type: 'Feature',
properties: { name: 'Node A', pipe_dia: 200 },
geometry: { type: 'Point', coordinates: [127.0286, 37.4981] },
},
{
type: 'Feature',
properties: { name: 'Pipe 1', length: 150 },
geometry: {
type: 'LineString',
coordinates: [
[127.0286, 37.4981],
[127.0312, 37.5005],
],
},
},
],
});
const layerWgs = parseGeoJsonString(wgs84Json, 'water_network.geojson');
assert.equal(layerWgs.name, 'water_network');
assert.equal(layerWgs.featureCount, 2);
assert.ok(layerWgs.geomTypes.includes('Point'));
assert.ok(layerWgs.geomTypes.includes('LineString'));
assert.equal(layerWgs.estimatedCrs, 'WGS84 (EPSG:4326)');
assert.equal(layerWgs.visible, true);
assert.ok(layerWgs.opacity > 0);
// 2. Korean UTM-K (EPSG:5179) GeoJSON
const utmkJson = JSON.stringify({
type: 'FeatureCollection',
features: [
{
type: 'Feature',
properties: { FID: 1, PIP_ID: 'P_01' },
geometry: {
type: 'LineString',
coordinates: [
[960500, 1952000],
[961000, 1952500],
],
},
},
],
});
const layerUtmk = parseGeoJsonString(utmkJson, 'suwon_pipes.geojson');
assert.equal(layerUtmk.estimatedCrs, '한국 UTM-K (EPSG:5179)');
// Normalized coordinates must be in WGS84 range (roughly 126~128 lon, 37~38 lat)
const firstCoord = (layerUtmk.featureCollection.features[0].geometry as any).coordinates[0];
assert.ok(firstCoord[0] > 126 && firstCoord[0] < 129, `Longitude ${firstCoord[0]} must be in WGS84 range`);
assert.ok(firstCoord[1] > 36 && firstCoord[1] < 39, `Latitude ${firstCoord[1]} must be in WGS84 range`);
// 3. Error handling: invalid JSON
assert.throws(() => {
parseGeoJsonString('{ invalid json', 'bad.json');
}, /JSON 구문 분석 오류/);
// 4. Error handling: empty features
assert.throws(() => {
parseGeoJsonString(JSON.stringify({ type: 'FeatureCollection', features: [] }), 'empty.json');
}, /공간 객체\(features\)가 없습니다/);
});
test('34. GIS EXTENTS: computeGeoJsonExtents accurately calculates bounding box across point, line, and polygon', () => {
const multiGeomJson = {
type: 'FeatureCollection' as const,
features: [
{
type: 'Feature' as const,
properties: {},
geometry: { type: 'Point' as const, coordinates: [10, 20] },
},
{
type: 'Feature' as const,
properties: {},
geometry: {
type: 'LineString' as const,
coordinates: [
[5, 15],
[25, 35],
],
},
},
{
type: 'Feature' as const,
properties: {},
geometry: {
type: 'Polygon' as const,
coordinates: [
[
[0, 5],
[30, 5],
[30, 40],
[0, 40],
[0, 5],
],
],
},
},
],
};
const extents = computeGeoJsonExtents(multiGeomJson);
assert.ok(extents !== null);
assert.equal(extents.minX, 0);
assert.equal(extents.maxX, 30);
assert.equal(extents.minY, 5);
assert.equal(extents.maxY, 40);
});
test('35. NETWORK CLIPBOARD: dual TSV/JSON payload, topological connectivity check, collision avoidance, and offset paste', () => {
const sampleInp = `[TITLE]
Clipboard Test
[JUNCTIONS]
J1 50 0 ;
J2 45 10 ;
[PIPES]
P1 J1 J2 100 200 100 0 Open ;
[COORDINATES]
J1 10 20
J2 30 40
[END]
`;
const model = parseInp(sampleInp);
// 1. Create payload with J1 and P1 selected
const payloadResult = createNetworkClipboardPayload(model, { type: 'link', id: 'P1' }, {
nodes: new Set(['J1', 'J2']),
links: new Set(['P1']),
gisFeatures: [],
});
assert.ok(payloadResult !== null);
assert.equal(payloadResult.json.source, 'GISPipe');
assert.equal(payloadResult.json.version, 1);
assert.equal(payloadResult.json.nodes.length, 2);
assert.equal(payloadResult.json.pipes.length, 1);
assert.ok(payloadResult.tsv.includes('J1\t10\t20\t50'));
assert.ok(payloadResult.tsv.includes('P1\tJ1\tJ2\t200\t100'));
// 2. Validate and prepare paste into the same model (triggers ID collision rename + offset)
const pastePrep = validateAndPreparePaste(payloadResult.json, model, { dx: 15, dy: 25 });
assert.equal(pastePrep.valid, true);
assert.equal(pastePrep.nodesToInsert.length, 2);
assert.equal(pastePrep.pipesToInsert.length, 1);
// IDs must have collided and been renamed with _copy
assert.ok(pastePrep.nodesToInsert.some((n) => n.id === 'J1_copy' && n.x === 25 && n.y === 45));
assert.ok(pastePrep.nodesToInsert.some((n) => n.id === 'J2_copy' && n.x === 45 && n.y === 65));
const pastedPipe = pastePrep.pipesToInsert[0];
assert.equal(pastedPipe.id, 'P1_copy');
assert.equal(pastedPipe.node1, 'J1_copy');
assert.equal(pastedPipe.node2, 'J2_copy');
// 3. Merging into model
const updatedModel = applyPasteToModel(model, pastePrep);
assert.ok(updatedModel.nodes['J1_copy']);
assert.ok(updatedModel.nodes['J2_copy']);
assert.ok(updatedModel.pipes['P1_copy']);
assert.equal(Object.keys(updatedModel.nodes).length, 4);
assert.equal(Object.keys(updatedModel.pipes).length, 2);
// 4. Connectivity safety check: copying a pipe ALONE without connected nodes must be BLOCKED
const orphanPipePayload = {
source: 'GISPipe',
version: 1,
kind: 'network-selection',
units: 'GPM',
nodes: [],
pipes: [{ id: 'P_ORPHAN', node1: 'GHOST_NODE_1', node2: 'GHOST_NODE_2', length: 100, diameter: 200, roughness: 100, status: 'Open' }],
pumps: [],
valves: [],
tanks: [],
reservoirs: [],
};
const orphanCheck = validateAndPreparePaste(orphanPipePayload, model);
assert.equal(orphanCheck.valid, false);
assert.ok(orphanCheck.error?.includes('연결 절점'));
// 5. Foreign / invalid JSON must be rejected
const badJsonCheck = validateAndPreparePaste({ foo: 'bar' }, model);
assert.equal(badJsonCheck.valid, false);
});
test('36. SHAPEFILE PARSER: parseShpBinary extracts Point and Polyline records without third-party packages', () => {
// Synthesize minimal valid Point Shapefile binary buffer (100-byte header + 1 Point record)
const buffer = new ArrayBuffer(100 + 28);
const view = new DataView(buffer);
// Header
view.setInt32(0, 9994, false); // File code
view.setInt32(24, (100 + 28) / 2, false); // File length in 16-bit words
view.setInt32(28, 1000, true); // Version
view.setInt32(32, 1, true); // ShapeType: Point (1)
// Record 1 (8-byte record header + 20-byte point shape)
view.setInt32(100, 1, false); // Record number
view.setInt32(104, 10, false); // Content length in 16-bit words (20 bytes / 2 = 10)
view.setInt32(108, 1, true); // ShapeType: Point
view.setFloat64(112, 127.05, true); // X (lon)
view.setFloat64(120, 37.55, true); // Y (lat)
const parsed = parseShpBinary(new Uint8Array(buffer), [{ NAME: 'Point A' }]);
assert.equal(parsed.features.length, 1);
assert.equal(parsed.features[0].geometry.type, 'Point');
const coords = (parsed.features[0].geometry as any).coordinates;
assert.equal(coords[0], 127.05);
assert.equal(coords[1], 37.55);
assert.equal(parsed.features[0].properties?.NAME, 'Point A');
});
test('37. PIPE METADATA: reactions, tag, description, and reversed orientation survive INP round-trip', () => {
const source = `[JUNCTIONS]
J1 10 0
J2 20 0
[PIPES]
P1 J1 J2 100 200 110 0 Open ; 기존 설명
[TAGS]
LINK P1 기존태그
[REACTIONS]
ORDER BULK 1
GLOBAL BULK -0.5
BULK P1 -0.7
WALL P1 -1.2
[CONTROLS]
LINK P1 CLOSED AT TIME 4
[VERTICES]
P1 10 10
[OPTIONS]
UNITS LPS
HEADLOSS H-W
[TIMES]
DURATION 24:00
[END]`;
const parsed = parseInp(source);
assert.equal(parsed.pipes.P1.bulkCoeff, -0.7);
assert.equal(parsed.pipes.P1.wallCoeff, -1.2);
assert.equal(parsed.pipes.P1.tag, '기존태그');
assert.equal(parsed.pipes.P1.description, '기존 설명');
const edited = updatePipeAttributes(reversePipeDirection(parsed, 'P1'), 'P1', {
bulkCoeff: -0.8,
wallCoeff: -1.4,
tag: '갱신태그',
description: '갱신 설명',
});
const generated = generateInp(edited);
const reparsed = parseInp(generated);
assert.equal(reparsed.pipes.P1.node1, 'J2');
assert.equal(reparsed.pipes.P1.node2, 'J1');
assert.equal(reparsed.pipes.P1.bulkCoeff, -0.8);
assert.equal(reparsed.pipes.P1.wallCoeff, -1.4);
assert.equal(reparsed.pipes.P1.tag, '갱신태그');
assert.equal(reparsed.pipes.P1.description, '갱신 설명');
assert.match(generated, /LINK P1 CLOSED AT TIME 4/);
assert.match(generated, /P1 10 10/);
assert.match(generated, /GLOBAL BULK\s+-0\.5/);
});
test('38. KOREAN DISPLAY UNITS: model values convert without mutating source units', () => {
const daily = flowToM3Day(1, 'LPS');
assert.equal(daily, 86.4);
assert.ok(Math.abs(m3DayToFlow(daily, 'LPS') - 1) < 1e-12);
assert.ok(Math.abs(flowToM3Day(1, 'GPM') - 5.450992) < 0.00001);
// pressureToMeters takes the engine's actual reported pressure unit
// (psi/kPa/m/bar/ft), never a flow unit -- see resolvePressureUnit for the
// flow-unit-based fallback used only before any result exists.
assert.ok(Math.abs(pressureToMeters(1, 'psi') - 0.7034387) < 0.00001);
assert.equal(diameterToMillimeters(1, 'GPM'), 25.4);
});
test('39. INP ENCODING: invalid UTF-8 Korean bytes fall back to CP949', () => {
const prefix = Array.from(Buffer.from('[TITLE]\\r\\n', 'ascii'));
const suffix = Array.from(Buffer.from('\\r\\n[END]\\r\\n', 'ascii'));
const bytes = Uint8Array.from([...prefix, 0xc7, 0xd1, 0xb1, 0xdb, ...suffix]);
const decoded = decodeInpBytes(bytes.buffer);
assert.equal(decoded.encoding, 'cp949');
assert.match(decoded.text, /한글/);
assert.match(decoded.warning || '', /CP949/);
});
test('40. MODEL SETTINGS: quality and time options persist through update and regeneration', () => {
const source = `[JUNCTIONS]
J1 10 0
[OPTIONS]
UNITS LPS
QUALITY NONE
[TIMES]
DURATION 24:00
HYDRAULIC TIMESTEP 1:00
PATTERN START 0:00
REPORT START 0:00
STATISTIC NONE
[END]`;
const parsed = parseInp(source);
const qualityEntries = parsed.options.rawEntries.map((entry) =>
entry.key.toUpperCase() === 'QUALITY' ? { ...entry, value: 'AGE' } : entry
);
const timeEntries = parsed.options.timeEntries.map((entry) => {
const key = entry.key.toUpperCase();
if (key === 'DURATION') return { ...entry, value: '48:00' };
if (key === 'PATTERN START') return { ...entry, value: '2:00' };
if (key === 'REPORT START') return { ...entry, value: '3:00' };
if (key === 'STATISTIC') return { ...entry, value: 'MAXIMUM' };
return entry;
});
const updated = updateOptions(parsed, { rawEntries: qualityEntries, timeEntries });
const reparsed = parseInp(generateInp(updated));
assert.equal(reparsed.options.rawEntries.find((entry) => entry.key.toUpperCase() === 'QUALITY')?.value, 'AGE');
assert.equal(reparsed.options.timeEntries.find((entry) => entry.key.toUpperCase() === 'DURATION')?.value, '48:00');
assert.equal(reparsed.options.timeEntries.find((entry) => entry.key.toUpperCase() === 'PATTERN START')?.value, '2:00');
assert.equal(reparsed.options.timeEntries.find((entry) => entry.key.toUpperCase() === 'REPORT START')?.value, '3:00');
assert.equal(reparsed.options.timeEntries.find((entry) => entry.key.toUpperCase() === 'STATISTIC')?.value, 'MAXIMUM');
});
test('41. DISPLAY UNIT MODE: source labels and inverse pressure thresholds match EPANET units', () => {
assert.deepEqual(getDisplayUnits('LPS', 'korean'), {
pressure: 'm',
head: 'm',
flow: 'm³/day',
demand: 'm³/day',
velocity: 'm/s',
length: 'm',
diameter: 'mm',
});
// With no actual result yet, getDisplayUnits falls back to guessing psi
// for a US flow unit (see resolvePressureUnit's fallback branch).
assert.deepEqual(getDisplayUnits('GPM', 'original'), {
pressure: 'psi',
head: 'ft',
flow: 'GPM',
demand: 'GPM',
velocity: 'ft/s',
length: 'ft',
diameter: 'in',
});
// Once a real result exists, its own reported pressure unit -- which may
// differ from the flow-unit guess (EPANET 2.2+ EN_PRESSUNITS) -- wins.
assert.equal(getDisplayUnits('GPM', 'original', 'kPa').pressure, 'kPa');
assert.equal(getDisplayUnits('LPS', 'original', 'ft').pressure, 'ft');
const pressureMeters = pressureToMeters(50, 'psi', 1.2);
assert.ok(Math.abs(metersToPressure(pressureMeters, 'psi', 1.2) - 50) < 1e-10);
});
test('42. CAD BOX SELECTION: window requires containment while crossing accepts intersection', () => {
const nodes = [
{ id: 'inside', point: { x: 5, y: 5 } },
{ id: 'outside', point: { x: 15, y: 5 } },
];
const links = [
{ id: 'contained', points: [{ x: 2, y: 2 }, { x: 8, y: 8 }] },
{ id: 'crossing', points: [{ x: -5, y: 5 }, { x: 15, y: 5 }] },
{ id: 'outside', points: [{ x: 12, y: 12 }, { x: 18, y: 18 }] },
];
const windowSelection = selectNetworkFeaturesInBox(nodes, links, { x: 0, y: 0 }, { x: 10, y: 10 });
assert.equal(windowSelection.crossing, false);
assert.deepEqual([...windowSelection.nodes], ['inside']);
assert.deepEqual([...windowSelection.links], ['contained']);
const crossingSelection = selectNetworkFeaturesInBox(nodes, links, { x: 10, y: 10 }, { x: 0, y: 0 });
assert.equal(crossingSelection.crossing, true);
assert.deepEqual([...crossingSelection.nodes], ['inside']);
assert.deepEqual([...crossingSelection.links], ['contained', 'crossing']);
});
test('43. MAP SERVICE ERRORS: authorization, domain, quota, and network failures stay distinct', () => {
assert.match(formatMapServiceError(new MapServiceError('', 'authorization')), /API 키 인증/);
assert.match(formatMapServiceError(new MapServiceError('', 'domain')), /등록 도메인/);
assert.match(formatMapServiceError(new MapServiceError('', 'quota')), /호출 한도/);
assert.match(formatMapServiceError(new MapServiceError('', 'network')), /네트워크/);
});
test('44. EXTENDED KOREAN CRS: transforms and distance calculations across 5186, 5181, 5187, 5174, 102080, 102082, 3857, 5176', () => {
assert.ok(SUPPORTED_CRS_DEFINITIONS.length >= 19, 'Must define at least 19 supported CRS profiles');
const targetCodes: CRS[] = [
'EPSG:5186',
'EPSG:5181',
'EPSG:5187',
'EPSG:5174',
'EPSG:5176',
'EPSG:102080',
'EPSG:102082',
'EPSG:3857',
];
const seoulLon = 127.0;
const seoulLat = 37.5;
const dummyExtents = { minX: 0, minY: 0, maxX: 0, maxY: 0 };
for (const code of targetCodes) {
// 1. Forward model coords
const [modelX, modelY] = lngLatToModel(seoulLon, seoulLat, code, dummyExtents);
assert.ok(Number.isFinite(modelX) && Number.isFinite(modelY), `${code} forward transform must be finite`);
assert.notEqual(modelX, 0, `${code} modelX should not be zero for Seoul`);
// 2. Inverse to WGS84
const [recoveredLon, recoveredLat] = modelToLngLat(modelX, modelY, code, dummyExtents);
assert.ok(
Math.abs(recoveredLon - seoulLon) < 1e-4,
`${code} longitude round-trip diff too large: ${Math.abs(recoveredLon - seoulLon)}`
);
assert.ok(
Math.abs(recoveredLat - seoulLat) < 1e-4,
`${code} latitude round-trip diff too large: ${Math.abs(recoveredLat - seoulLat)}`
);
// 3. Metric pipe distance calculation
const distResult = calculateGeoAwareDistance(
{ x: modelX, y: modelY },
{ x: modelX + 300, y: modelY + 400 },
code,
'SI'
);
assert.ok(distResult.isPhysical, `${code} must be flagged as physically meaningful`);
if (code === 'EPSG:3857') {
// EPSG:3857 is projected via EPSG:5179 to correct the ~26% Web Mercator distortion in Seoul
assert.ok(Math.abs(distResult.distance - 395.94) < 1, `EPSG:3857 distance expected ~395.94m, got ${distResult.distance}`);
} else {
assert.equal(distResult.distance, 500, `${code} 3-4-5 delta must equal exactly 500 meters`);
}
}
});
test('45. SHAPEFILE EXPORT ROUNDTRIP: exportNetworkShapefileZip creates valid ESRI Shapefile Point and PolyLine archives', async () => {
const testInp = `[TITLE]
Test
[JUNCTIONS]
J1 10 5
J2 12 0
[PIPES]
P1 J1 J2 100 200 100 0 Open
[COORDINATES]
J1 10 10
J2 20 20
[END]`;
const sampleNet = parseInp(testInp);
const zipBytes = exportNetworkShapefileZip(sampleNet, null, 'EPSG:5179');
assert.ok(zipBytes.length > 500, 'Exported ZIP byte array must be non-empty');
// Roundtrip unzip
const unzipped = await unzipArchive(zipBytes.buffer);
assert.ok(unzipped['pipes.shp'], 'ZIP must contain pipes.shp');
assert.ok(unzipped['pipes.shx'], 'ZIP must contain pipes.shx');
assert.ok(unzipped['pipes.dbf'], 'ZIP must contain pipes.dbf');
assert.ok(unzipped['pipes.prj'], 'ZIP must contain pipes.prj');
assert.ok(unzipped['nodes.shp'], 'ZIP must contain nodes.shp');
assert.ok(unzipped['nodes.shx'], 'ZIP must contain nodes.shx');
assert.ok(unzipped['nodes.dbf'], 'ZIP must contain nodes.dbf');
assert.ok(unzipped['nodes.prj'], 'ZIP must contain nodes.prj');
// Parse back with internal shapefile parser
const pipeDbfRecords = parseDbfBinary(unzipped['pipes.dbf']);
const { features: pipeFeatures } = parseShpBinary(unzipped['pipes.shp'], pipeDbfRecords);
assert.equal(pipeFeatures.length, Object.keys(sampleNet.pipes).length, 'All pipes must be recovered');
assert.equal(pipeFeatures[0].geometry.type, 'LineString');
const nodeDbfRecords = parseDbfBinary(unzipped['nodes.dbf']);
const { features: nodeFeatures } = parseShpBinary(unzipped['nodes.shp'], nodeDbfRecords);
assert.equal(nodeFeatures.length, Object.keys(sampleNet.nodes).length, 'All nodes must be recovered');
assert.equal(nodeFeatures[0].geometry.type, 'Point');
});
test('46. NO AUTO RE-ANALYSIS ON EDIT: editing properties leaves analysis uncalled and sets stale flag', () => {
const baseInp = `[TITLE]
Test Model
[JUNCTIONS]
J1 10 5
[COORDINATES]
J1 10 10
[END]`;
const model = parseInp(baseInp);
const initialSnapshot = generateInp(model);
// Initially, before edit, stale is false
assert.equal(computeIsStale(generateInp(model), initialSnapshot), false);
// Edit node elevation without running analysis
let analysisRunCount = 0;
const mockRunAnalysis = () => { analysisRunCount++; };
const editedModel = {
...model,
nodes: {
...model.nodes,
J1: { ...model.nodes.J1, elevation: 25 },
},
};
// Property edited, but analysis was NOT called
assert.equal(analysisRunCount, 0, 'Analysis must NOT be auto-triggered on property edit');
// isStale flag must now be TRUE
const editedInp = generateInp(editedModel);
assert.equal(computeIsStale(editedInp, initialSnapshot), true, 'Model edit must mark result as stale');
});
test('47. PYPROJ REFERENCE COORDINATES VALIDATION (<0.01m difference)', () => {
// Test coordinates at Seoul (lon: 127.0, lat: 37.5) against official pyproj standards
const seoulLon = 127.0;
const seoulLat = 37.5;
const dummyExtents = { minX: 0, minY: 0, maxX: 0, maxY: 0 };
const pyprojReferences: Array<{ code: CRS; expectedX: number; expectedY: number }> = [
{ code: 'EPSG:5179', expectedX: 955804.816341, expectedY: 1944643.715789 },
{ code: 'EPSG:5186', expectedX: 200000.000000, expectedY: 544504.123486 },
{ code: 'EPSG:5185', expectedX: 376860.274923, expectedY: 546383.679359 },
{ code: 'EPSG:5187', expectedX: 23139.725077, expectedY: 546383.679359 },
{ code: 'EPSG:5188', expectedX: -153776.758307, expectedY: 552028.763947 },
{ code: 'EPSG:5180', expectedX: 376860.274923, expectedY: 446383.679359 },
{ code: 'EPSG:5181', expectedX: 200000.000000, expectedY: 444504.123486 },
{ code: 'EPSG:3857', expectedX: 14137575.330746, expectedY: 4509031.393077 },
];
for (const ref of pyprojReferences) {
const [modelX, modelY] = lngLatToModel(seoulLon, seoulLat, ref.code, dummyExtents);
const diff = Math.hypot(modelX - ref.expectedX, modelY - ref.expectedY);
assert.ok(
diff < 0.01,
`${ref.code} difference from pyproj reference exceeds 0.01m: got [${modelX}, ${modelY}], expected [${ref.expectedX}, ${ref.expectedY}], diff: ${diff.toFixed(6)}m`
);
}
});
test('48. UNDO / REDO 30-STEP RING BUFFER: batch edit -> undo -> restore original and oldest drop on >30 steps', () => {
const baseInp = `[TITLE]
Undo Test
[JUNCTIONS]
J1 10 5
J2 20 0
[PIPES]
P1 J1 J2 100 150 100 0 Open
P2 J1 J2 200 150 100 0 Open
[COORDINATES]
J1 10 10
J2 20 20
[END]`;
let currentModel = parseInp(baseInp);
const undoStack: typeof currentModel[] = [];
let redoStack: typeof currentModel[] = [];
const pushUndo = (modelToSave: typeof currentModel) => {
undoStack.push(JSON.parse(JSON.stringify(modelToSave)));
if (undoStack.length > 30) undoStack.shift();
redoStack = [];
};
const undo = () => {
if (undoStack.length === 0) return;
const prev = undoStack.pop()!;
redoStack.push(JSON.parse(JSON.stringify(currentModel)));
if (redoStack.length > 30) redoStack.shift();
currentModel = prev;
};
const redo = () => {
if (redoStack.length === 0) return;
const next = redoStack.pop()!;
undoStack.push(JSON.parse(JSON.stringify(currentModel)));
if (undoStack.length > 30) undoStack.shift();
currentModel = next;
};
// 1. Initial diameters are 150
assert.equal(currentModel.pipes.P1.diameter, 150);
assert.equal(currentModel.pipes.P2.diameter, 150);
// 2. Perform 1 batch edit: change both pipes to 250
pushUndo(currentModel);
currentModel = {
...currentModel,
pipes: {
...currentModel.pipes,
P1: { ...currentModel.pipes.P1, diameter: 250 },
P2: { ...currentModel.pipes.P2, diameter: 250 },
},
};
assert.equal(currentModel.pipes.P1.diameter, 250);
assert.equal(currentModel.pipes.P2.diameter, 250);
assert.equal(undoStack.length, 1);
// 3. Undo: restores original 150
undo();
assert.equal(currentModel.pipes.P1.diameter, 150, 'Undo must restore original diameter 150');
assert.equal(currentModel.pipes.P2.diameter, 150, 'Undo must restore original diameter 150');
assert.equal(redoStack.length, 1, 'Redo stack must have 1 entry');
// 4. Redo: re-applies 250
redo();
assert.equal(currentModel.pipes.P1.diameter, 250, 'Redo must re-apply diameter 250');
assert.equal(currentModel.pipes.P2.diameter, 250, 'Redo must re-apply diameter 250');
// 5. 35 edits test ring buffer cap at 30
for (let i = 1; i <= 35; i++) {
pushUndo(currentModel);
currentModel = {
...currentModel,
pipes: {
...currentModel.pipes,
P1: { ...currentModel.pipes.P1, diameter: 300 + i },
},
};
}
assert.equal(undoStack.length, 30, 'Undo stack must be capped at exactly 30 entries');
// The oldest entries (step 1..5: diameter 301..305) must have been discarded
assert.equal(undoStack[0].pipes.P1.diameter, 305, 'Oldest retained entry must be step 5 (diameter 305)');
assert.equal(undoStack[29].pipes.P1.diameter, 334, 'Youngest entry must be step 34 (diameter 334)');
});
test('49. BESSEL KOREAN 1985 DATUM SHIFT VALIDATION (<1.0m difference against pyproj.Transformer)', () => {
const dummyExtents = { minX: 0, minY: 0, maxX: 0, maxY: 0 };
const seoul = { lon: 126.978388, lat: 37.566610 };
const jeju = { lon: 126.5, lat: 33.4 };
// Hardcoded ground-truth reference values directly produced by pyproj.Transformer
const besselGroundTruth: Array<{
code: CRS;
seoulX: number;
seoulY: number;
jejuX: number;
jejuY: number;
}> = [
{ code: 'EPSG:5174', seoulX: 198021.0274, seoulY: 451592.0513, jejuX: 153408.5708, jejuY: -10582.5524 },
{ code: 'EPSG:5176', seoulX: 21332.2897, seoulY: 453514.6427, jejuX: -32663.8341, jejuY: -7898.6441 },
{ code: 'EPSG:5173', seoulX: 374709.1403, seoulY: 453430.3674, jejuX: 339458.4288, jejuY: -9690.2678 },
{ code: 'EPSG:5177', seoulX: -155412.7557, seoulY: 459204.6902, jejuX: -218848.7042, jejuY: -1628.0302 },
{ code: 'EPSG:5175', seoulX: 198021.0274, seoulY: 501592.0513, jejuX: 153408.5708, jejuY: 39417.4476 },
];
for (const ref of besselGroundTruth) {
// 1. Seoul test point
const [sX, sY] = lngLatToModel(seoul.lon, seoul.lat, ref.code, dummyExtents);
const sDiff = Math.hypot(sX - ref.seoulX, sY - ref.seoulY);
assert.ok(
sDiff < 1.0,
`${ref.code} Seoul diff ${sDiff.toFixed(4)}m exceeds 1.0m tolerance (got [${sX}, ${sY}], expected [${ref.seoulX}, ${ref.seoulY}])`
);
// 2. Jeju test point
const [jX, jY] = lngLatToModel(jeju.lon, jeju.lat, ref.code, dummyExtents);
const jDiff = Math.hypot(jX - ref.jejuX, jY - ref.jejuY);
assert.ok(
jDiff < 1.0,
`${ref.code} Jeju diff ${jDiff.toFixed(4)}m exceeds 1.0m tolerance (got [${jX}, ${jY}], expected [${ref.jejuX}, ${ref.jejuY}])`
);
}
});
test('50. R2 3-TIER NETWORK ROUND-TRIP: Anonymized real + Synthetic large (>2000 pipes) + Korean CP949', () => {
const networks = [
{ name: 'Anonymized Real', path: 'public/sample_pressure_network.inp' },
{ name: 'Synthetic Large 5179', path: 'public/synthetic_large_network_5179.inp' },
{ name: 'Korean CP949', path: 'public/korean_cp949_network.inp' },
];
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'r2_net_'));
try {
for (const net of networks) {
const buf = fs.readFileSync(net.path);
const decoded = decodeInpBytes(buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength));
const model = parseInp(decoded.text);
const nodeCount = Object.keys(model.nodes).length;
const linkCount = Object.keys(model.pipes).length + Object.keys(model.pumps).length + Object.keys(model.valves).length;
// Assert size criteria
if (net.name.includes('Large')) {
assert.ok(linkCount >= 2000, `Large network must have >= 2000 links, got ${linkCount}`);
}
if (net.name.includes('CP949')) {
assert.ok(Object.keys(model.nodes).some(id => /[가-힣]/.test(id)), 'CP949 network must contain Korean node IDs');
}
// Roundtrip regeneration & reparsing
const regenerated = generateInp(model);
const reparsed = parseInp(regenerated);
const reparsedNodeCount = Object.keys(reparsed.nodes).length;
const reparsedLinkCount = Object.keys(reparsed.pipes).length + Object.keys(reparsed.pumps).length + Object.keys(reparsed.valves).length;
assert.equal(reparsedNodeCount, nodeCount, `${net.name} node count must be conserved`);
assert.equal(reparsedLinkCount, linkCount, `${net.name} link count must be conserved`);
// C DLL execution verification
const inpPath = path.join(tmpDir, `${path.basename(net.path, '.inp')}_regen.inp`);
const rptPath = path.join(tmpDir, `${path.basename(net.path, '.inp')}_regen.rpt`);
const outPath = path.join(tmpDir, `${path.basename(net.path, '.inp')}_regen.out`);
fs.writeFileSync(inpPath, Buffer.from(new TextEncoder().encode(regenerated)));
const pyRunner = `import sys, os; sys.path.insert(0, os.path.abspath('.')); from backend.main import run_epanet; res = run_epanet(r'''${inpPath}''', r'''${rptPath}''', r'''${outPath}'''); assert len(res.get('nodes', {})) > 0; assert len(res.get('timeSteps', [])) >= 25; assert res['timeSteps'][-1] == 86400`;
const pythonExe = path.resolve('.venv/Scripts/python.exe');
execSync(`"${pythonExe}" -c "${pyRunner}"`);
}
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});
test('51. BATCH EDIT UNIT CONVERSION (P0-1): flow-unit system classification and display<->model round-trips', () => {
// The bug: BatchPropertyEditor used to guess US-vs-SI from the flow unit's
// first letter (G/C/M -> "US"), which misclassified CMH/CMD/CMS/MLD as US
// and never converted IMGD/AFD (which ARE US units). getUnitSystem must be
// the single source of truth, driven by the full EPANET flow-unit table.
const officialTable: Record<string, 'SI' | 'US'> = {
LPS: 'SI', LPM: 'SI', MLD: 'SI', CMH: 'SI', CMD: 'SI', CMS: 'SI',
CFS: 'US', GPM: 'US', MGD: 'US', IMGD: 'US', AFD: 'US',
};
for (const [unit, system] of Object.entries(officialTable)) {
assert.equal(getUnitSystem(unit), system, `${unit} must classify as ${system}`);
}
// Exact regression scenario: 200mm entered in the batch diameter field on an
// LPS (SI) model must be stored as 200 (mm), never 200/25.4 = 7.874in.
assert.equal(millimetersToDiameter(200, 'LPS'), 200);
// CMH/CMD/MLD share the SI system with LPS -- previously misclassified as US
// because their unit strings start with 'C' or 'M'.
for (const siUnit of ['CMH', 'CMD', 'MLD', 'CMS', 'LPM']) {
assert.equal(millimetersToDiameter(200, siUnit), 200, `${siUnit} diameter must stay 200mm`);
}
// IMGD/AFD are US units and previously received NO conversion at all.
for (const usUnit of ['IMGD', 'AFD', 'GPM', 'CFS', 'MGD']) {
const stored = millimetersToDiameter(200, usUnit);
assert.ok(Math.abs(stored - 200 / 25.4) < 1e-9, `${usUnit} diameter must convert mm->in, got ${stored}`);
assert.ok(Math.abs(diameterToMillimeters(stored, usUnit) - 200) < 1e-9, `${usUnit} diameter round-trip must recover 200mm`);
}
// Exact regression scenario: setting demand to 864 m3/day on an LPS model
// means 10 L/s -- it must not be stored as 864 in LPS.
const storedDemandLps = m3DayToFlow(864, 'LPS');
assert.ok(Math.abs(storedDemandLps - 10) < 1e-9, `864 m3/day on LPS must store as 10 L/s, got ${storedDemandLps}`);
assert.ok(Math.abs(flowToM3Day(storedDemandLps, 'LPS') - 864) < 1e-6);
// Demand round-trip across every EPANET flow unit (SI and US).
for (const unit of Object.keys(officialTable)) {
const stored = m3DayToFlow(864, unit);
const roundTripped = flowToM3Day(stored, unit);
assert.ok(Math.abs(roundTripped - 864) < 1e-6, `${unit} demand round-trip failed: got ${roundTripped}`);
}
// Elevation: a +5m offset entered on a US-units (GPM/ft) model must be
// converted to feet before being added to the stored (feet) elevation.
const elevOffsetFt = metersToLength(5, 'GPM');
assert.ok(Math.abs(elevOffsetFt - 5 * 3.280839895013123) < 1e-9);
assert.ok(Math.abs(lengthToMeters(elevOffsetFt, 'GPM') - 5) < 1e-9);
// On an SI model, meters pass through unchanged.
assert.equal(metersToLength(5, 'LPS'), 5);
});
test('52. FULL ATTRIBUTE TABLE ID EDIT (P0-2): rename survives INP export and invalid numbers are rejected, not zeroed', () => {
const sampleInp = `[TITLE]
Full Table ID Rename Test
[JUNCTIONS]
J1 10.0 5.0 ;
J2 12.0 8.0 ;
[RESERVOIRS]
R1 50.0 ;
[PIPES]
P1 R1 J1 100 200 100 0 Open ;
P2 J1 J2 150 150 100 0 Open ;
[CONTROLS]
LINK P1 OPEN IF NODE J1 BELOW 15
[OPTIONS]
UNITS LPS
[END]
`;
const model = parseInp(sampleInp);
// FullAttributeTableModal.commitEdit must route ID edits through the same
// updateNodeAttributes/updatePipeAttributes common mutator as the bottom
// attribute table -- not a raw spread that leaves the dictionary key and
// connected references pointing at the old ID.
let renamed = updateNodeAttributes(model, 'J1', { id: 'J_MAIN' });
assert.equal(renamed.nodes['J1'], undefined, 'old dictionary key must be gone');
assert.ok(renamed.nodes['J_MAIN'], 'new dictionary key must exist');
assert.equal(renamed.nodes['J_MAIN'].id, 'J_MAIN');
assert.equal(renamed.pipes['P1'].node2, 'J_MAIN', 'connected pipe endpoint must follow the rename');
assert.equal(renamed.pipes['P2'].node1, 'J_MAIN');
assert.equal(renamed.controls[0].nodeId, 'J_MAIN', 'control reference must follow the rename');
renamed = updatePipeAttributes(renamed, 'P1', { id: 'P_SOURCE' });
assert.equal(renamed.pipes['P1'], undefined);
assert.ok(renamed.pipes['P_SOURCE']);
assert.equal(renamed.controls[0].linkId, 'P_SOURCE', 'control link reference must follow the pipe rename');
// Round-trip through INP export/re-parse: connectivity must still hold.
const regenerated = generateInp(renamed);
const reparsed = parseInp(regenerated);
assert.ok(reparsed.nodes['J_MAIN']);
assert.equal(reparsed.nodes['J1'], undefined);
assert.ok(reparsed.pipes['P_SOURCE']);
assert.equal(reparsed.pipes['P_SOURCE'].node2, 'J_MAIN');
assert.equal(reparsed.pipes['P2'].node1, 'J_MAIN');
assert.equal(reparsed.controls.find((c) => c.linkId === 'P_SOURCE')?.nodeId, 'J_MAIN');
// Duplicate ID must be rejected with a clear error, exactly like the bottom table.
assert.throws(() => updateNodeAttributes(renamed, 'J2', { id: 'J_MAIN' }), /이미 존재하는/);
assert.throws(() => updatePipeAttributes(renamed, 'P2', { id: 'P_SOURCE' }), /이미 존재하는/);
// Invalid numeric input must be rejected by the shared validator, never
// silently coerced to 0 (the FullAttributeTableModal bug this closes).
const badElevation = validateSingleField('junction', 'elevation', 'not-a-number', 'J2', renamed);
assert.equal(badElevation.valid, false);
assert.notEqual(badElevation.parsedValue, 0);
const badInvertElev = validateSingleField('junction', 'invertElev', 'abc', 'J2', renamed);
assert.equal(badInvertElev.valid, false);
const badDiameter = validateSingleField('pipe', 'diameter', '-5', 'P2', renamed);
assert.equal(badDiameter.valid, false);
const badDemand = validateSingleField('junction', 'demand', '', 'J2', renamed);
assert.equal(badDemand.valid, false);
});
test('53. GIS IMPORT [TIMES] SECTION (P0-3): a GIS-converted model must emit its 24h time settings in generateInp', () => {
const geojson = {
type: 'FeatureCollection',
features: [
{
type: 'Feature',
properties: { FTR_IDN: 'PIPE_A', PIP_DIA: 200, PIP_MAT: 'DIP' },
geometry: {
type: 'LineString',
coordinates: [
[126.975, 37.565],
[126.978, 37.565],
],
},
},
],
};
const { model } = buildNetworkFromGeoJSON(geojson as any, {
pipeIdField: 'FTR_IDN',
diameterField: 'PIP_DIA',
materialField: 'PIP_MAT',
defaultDiameter: 200,
defaultRoughness: 120,
sourceHead: 60,
});
// Bug (pre-fix): buildNetworkFromGeoJSON populates options.timeEntries with a
// 24h duration, but its synthetic rawLines/sectionIndices never included a
// [TIMES] header, and generateInp only ever rewrote a [TIMES] section that
// already existed in sectionIndices -- so the time settings were silently
// dropped from every GIS-imported model's exported INP.
assert.equal(model.sectionIndices['TIMES'], undefined, 'fixture must reproduce the missing-header condition');
assert.ok(model.options.timeEntries.length > 0, 'GIS import must carry default 24h time settings');
const generated = generateInp(model);
assert.match(generated, /\[TIMES\]/, 'generateInp must emit a [TIMES] section even when the source never had one');
assert.match(generated, /Duration\s+24:00/);
assert.match(generated, /Hydraulic Timestep\s+1:00/);
const reparsed = parseInp(generated);
assert.equal(
reparsed.options.timeEntries.find((e) => e.key.toUpperCase() === 'DURATION')?.value,
'24:00'
);
});
test('54. INP SECTION ROUND-TRIP (P0-3): PATTERNS/CONTROLS/TIMES/OPTIONS added purely in the UI survive export even without a source header', () => {
// Minimal source INP with no [OPTIONS], [PATTERNS], [CONTROLS] or [TIMES]
// sections at all -- simulating a bare-bones or hand-trimmed INP onto which
// the user adds a pattern, a control, custom time settings, and an option
// purely through the UI (never present in the original file).
const bareInp = `[TITLE]
Bare INP
[JUNCTIONS]
J1 10 5 ;
[RESERVOIRS]
R1 60 ;
[PIPES]
P1 R1 J1 100 200 120 0 Open ;
[END]
`;
const base = parseInp(bareInp);
assert.equal(base.sectionIndices['PATTERNS'], undefined);
assert.equal(base.sectionIndices['CONTROLS'], undefined);
assert.equal(base.sectionIndices['TIMES'], undefined);
assert.equal(base.sectionIndices['OPTIONS'], undefined);
const withData: typeof base = {
...base,
patterns: {
PAT1: { id: 'PAT1', description: 'Daily demand pattern', multipliers: [0.8, 1.0, 1.2, 0.9] },
},
controls: [
{
linkId: 'P1',
status: 'CLOSED',
nodeId: 'J1',
condition: 'BELOW',
value: 5,
rawText: 'LINK P1 CLOSED IF NODE J1 BELOW 5',
},
],
options: {
...base.options,
demandModel: 'PDA',
timeEntries: [
{ key: 'Duration', value: '48:00' },
{ key: 'Hydraulic Timestep', value: '0:30' },
],
},
};
const generated = generateInp(withData);
assert.match(generated, /\[PATTERNS\]/);
assert.match(generated, /PAT1/);
assert.match(generated, /\[CONTROLS\]/);
assert.match(generated, /LINK P1 CLOSED IF NODE J1 BELOW 5/);
assert.match(generated, /\[TIMES\]/);
assert.match(generated, /Duration\s+48:00/);
assert.match(generated, /\[OPTIONS\]/);
assert.match(generated, /Demand Model\s+PDA/);
// Full round-trip: re-parse the generated text and confirm nothing was lost.
const reparsed = parseInp(generated);
assert.equal(reparsed.patterns['PAT1']?.multipliers.length, 4);
assert.equal(reparsed.patterns['PAT1']?.multipliers[2], 1.2);
assert.equal(reparsed.controls[0]?.linkId, 'P1');
assert.equal(reparsed.controls[0]?.status, 'CLOSED');
assert.equal(
reparsed.options.timeEntries.find((e) => e.key.toUpperCase() === 'DURATION')?.value,
'48:00'
);
assert.equal(reparsed.options.demandModel, 'PDA');
// Idempotency: regenerating from the re-parsed model (which now DOES have
// real section headers) must produce byte-identical output on a second pass.
const twice = generateInp(reparsed);
assert.equal(generateInp(parseInp(twice)), twice);
});
test('55. RAW-PRESERVED SECTION RENAME (P0-4): renameNode/renamePipe rewrite DEMANDS/QUALITY/SOURCES/EMITTERS/STATUS/RULES references', () => {
const sampleInp = `[TITLE]
P0-4 Rename Propagation Test
[JUNCTIONS]
J1 10.0 5.0 ;
J2 12.0 8.0 ;
[RESERVOIRS]
R1 50.0 ;
[PIPES]
P1 R1 J1 100 200 100 0 Open ;
P2 J1 J2 150 150 100 0 Open ;
[STATUS]
P2 CLOSED
[DEMANDS]
;Junction Demand Pattern Category
J1 5 1 Domestic
[QUALITY]
J1 0.5
[SOURCES]
J1 CONCEN 1.0
[EMITTERS]
J1 0.5
[RULES]
RULE RULE1
IF NODE J1 ABOVE 5
THEN LINK P2 CLOSED
[CONTROLS]
LINK P1 OPEN IF NODE J1 BELOW 15
[OPTIONS]
UNITS LPS
[END]
`;
const model = parseInp(sampleInp);
// Bug (pre-fix): renameNode/renamePipe only rewrote structured references
// (pipes/pumps/valves endpoints, Controls) and left the preserved raw text
// of DEMANDS/QUALITY/SOURCES/EMITTERS/STATUS/RULES pointing at the old ID.
const renamedNode = renameNode(model, 'J1', 'J_MAIN');
const demandsLine = renamedNode.rawLines[renamedNode.sectionIndices['DEMANDS'].start + 2];
assert.match(demandsLine, /^\s*J_MAIN\b/, 'DEMANDS row must follow the node rename');
const qualityLine = renamedNode.rawLines[renamedNode.sectionIndices['QUALITY'].start + 1];
assert.match(qualityLine, /^\s*J_MAIN\b/, 'QUALITY row must follow the node rename');
const sourcesLine = renamedNode.rawLines[renamedNode.sectionIndices['SOURCES'].start + 1];
assert.match(sourcesLine, /^\s*J_MAIN\b/, 'SOURCES row must follow the node rename');
const emittersLine = renamedNode.rawLines[renamedNode.sectionIndices['EMITTERS'].start + 1];
assert.match(emittersLine, /^\s*J_MAIN\b/, 'EMITTERS row must follow the node rename');
assert.ok(
renamedNode.rules.some((r) => r.includes('NODE J_MAIN') && !r.includes('NODE J1')),
'RULES text must follow the node rename'
);
const renamedBoth = renamePipe(renamedNode, 'P2', 'P_BRANCH');
const statusLine = renamedBoth.rawLines[renamedBoth.sectionIndices['STATUS'].start + 1];
assert.match(statusLine, /^\s*P_BRANCH\b/, 'STATUS row must follow the pipe rename');
assert.ok(
renamedBoth.rules.some((r) => r.includes('LINK P_BRANCH') && !r.includes('LINK P2 ')),
'RULES text must follow the pipe rename'
);
// Round-trip through export/re-parse: the renamed references must still resolve.
const reparsed = parseInp(generateInp(renamedBoth));
assert.equal(reparsed.sectionIndices['DEMANDS'] !== undefined, true);
const reDemands = reparsed.rawLines
.slice(reparsed.sectionIndices['DEMANDS'].start + 1, reparsed.sectionIndices['DEMANDS'].end + 1)
.join('\n');
assert.match(reDemands, /J_MAIN/);
assert.doesNotMatch(reDemands, /\bJ1\b/);
// [DEMANDS] rows REPLACE the junction's base demand in EPANET (verified
// against the 2.3.5 DLL), so the first category is the primary demand and a
// base-demand edit lands there instead of being rejected or double-counted.
const demandEdited = updateNodeAttributes(renamedBoth, 'J_MAIN', { demand: 99 });
assert.equal(demandEdited.extras!.demands['J_MAIN'][0].demand, 99);
assert.match(generateInp(demandEdited), /\[DEMANDS\][\s\S]*J_MAIN\s+99/);
// A node without any DEMANDS override edits normally.
const j2Edited = updateNodeAttributes(renamedBoth, 'J2', { demand: 42 });
assert.equal(j2Edited.nodes['J2'].demand, 42);
// Guard: editing the status of a link with a preserved [STATUS] override
// must be rejected -- STATUS wins at solve time regardless of what the
// PIPES/VALVES status column says, so silently "changing" it here would do
// nothing at solve time while the UI shows the new value.
assert.equal(renamedBoth.pipes['P_BRANCH'].status, 'Open', 'PIPES line status (pre-edit baseline)');
assert.equal(renamedBoth.extras!.statusOverrides['P_BRANCH'], 'CLOSED', 'STATUS override follows the rename');
// [STATUS] wins at solve time, so a status edit updates the override too.
const statusEdited = updatePipeAttributes(
updatePipeAttributes(renamedBoth, 'P_BRANCH', { status: 'Closed' }),
'P_BRANCH',
{ status: 'Open' }
);
assert.equal(statusEdited.extras!.statusOverrides['P_BRANCH'], 'OPEN');
assert.match(generateInp(statusEdited), /\[STATUS\][\s\S]*P_BRANCH\s+OPEN/);
// A pipe without any STATUS override edits normally.
const p1Edited = updatePipeAttributes(renamedBoth, 'P1', { status: 'Closed' });
assert.equal(p1Edited.pipes['P1'].status, 'Closed');
});
test('56. PRESSURE UNIT HANDLING (P0-5): kPa/bar/ft conversions and correct low-pressure judgement from the engine-reported unit', () => {
// resolvePressureUnit must use the engine's own reported unit verbatim...
assert.equal(resolvePressureUnit('kPa', 'LPS'), 'kPa');
assert.equal(resolvePressureUnit('bar', 'GPM'), 'bar');
assert.equal(resolvePressureUnit('ft', 'LPS'), 'ft');
// ...and only fall back to a flow-unit-based guess when no result exists yet.
assert.equal(resolvePressureUnit(undefined, 'GPM'), 'psi');
assert.equal(resolvePressureUnit(undefined, 'LPS'), 'm');
assert.equal(resolvePressureUnit('Unknown', 'CMH'), 'm');
// Round-trip every EPANET pressure unit through pressureToMeters/metersToPressure.
for (const unit of ['psi', 'kPa', 'm', 'bar', 'ft']) {
for (const sg of [1.0, 1.2]) {
const meters = pressureToMeters(50, unit, sg);
const back = metersToPressure(meters, unit, sg);
assert.ok(Math.abs(back - 50) < 1e-9, `${unit} @ SG=${sg} round-trip failed: got ${back}`);
}
}
// kPa/bar are true pressure quantities and must scale with specific gravity,
// exactly like psi already does -- higher SG means less head for the same
// reported pressure.
assert.ok(Math.abs(pressureToMeters(98.0665, 'kPa', 1.0) - 10) < 1e-9);
assert.ok(Math.abs(pressureToMeters(98.0665, 'kPa', 2.0) - 5) < 1e-9);
assert.ok(Math.abs(pressureToMeters(1, 'bar', 1.0) - pressureToMeters(100, 'kPa', 1.0)) < 1e-9);
// ft, like m, is already a head-of-fluid length: pure unit conversion, no SG.
assert.ok(Math.abs(pressureToMeters(1, 'ft', 1.0) - pressureToMeters(1, 'ft', 3.0)) < 1e-12);
// Bug scenario: a model whose pressure is reported in kPa (independent of
// its LPS flow unit, per EPANET's EN_PRESSUNITS) reports 98.0665 kPa at a
// junction -- a real 10m head, which is a low-pressure violation against a
// 15m threshold. The pre-fix code guessed the pressure unit from the flow
// unit's US/SI system (LPS -> not "US" -> treated as already-meters), so it
// would read this as "98.0665 m" and wrongly conclude the node is fine.
const threshold = 15;
const rawPressureKpa = 98.0665;
const correctMeters = pressureToMeters(rawPressureKpa, resolvePressureUnit('kPa', 'LPS'), 1.0);
assert.ok(Math.abs(correctMeters - 10) < 1e-9);
assert.ok(correctMeters < threshold, 'must be correctly flagged as a low-pressure violation');
});
test('57. UNIT HEADLOSS vs TOTAL HEADLOSS (P0-6): comparing pipes of different lengths must use the length-normalized rate', () => {
// EN_HEADLOSS is the TOTAL head difference across the whole pipe -- it
// scales with length, so a longer pipe can show a bigger number than a
// shorter, much-worse pipe purely because it's longer.
const shortSeverePipe = { length: 50, totalHeadloss: 4 }; // 80 ‰ -- genuinely bad
const longMildPipe = { length: 2000, totalHeadloss: 6 }; // 3 ‰ -- perfectly fine
// Comparing raw totals would wrongly rank the long, mild pipe as "worse".
assert.ok(
longMildPipe.totalHeadloss > shortSeverePipe.totalHeadloss,
'raw totals alone would misrank these two pipes'
);
const shortUnitHl = computeUnitHeadloss(shortSeverePipe.totalHeadloss, shortSeverePipe.length);
const longUnitHl = computeUnitHeadloss(longMildPipe.totalHeadloss, longMildPipe.length);
assert.ok(Math.abs(shortUnitHl - 80) < 1e-9);
assert.ok(Math.abs(longUnitHl - 3) < 1e-9);
// The length-normalized rate correctly ranks the short pipe as far worse.
assert.ok(shortUnitHl > longUnitHl, 'unit headloss must correctly identify the short pipe as the severe one');
// Zero-length (or missing) pipes must not divide by zero.
assert.equal(computeUnitHeadloss(5, 0), 0);
});
test('58. SCENARIO INP/RESULT PAIRING (P0-7): base scenario never inherits a result computed from a different model version', () => {
const baseInp = `[TITLE]
Scenario Base
[JUNCTIONS]
J1 10 10 ;
[RESERVOIRS]
R1 50 ;
[PIPES]
P1 R1 J1 100 200 100 0 Open ;
[COORDINATES]
J1 10 10
R1 0 0
[OPTIONS]
UNITS LPS
[END]
`;
const originalModel = parseInp(baseInp);
const normalizedOriginal = generateInp(originalModel);
const fakeResult = (tag: string) =>
({
engine: { runtime: 'wasm', version: '2.3.5' },
timeSeconds: 0,
units: { flow: 'LPS', pressure: 'm' },
nodes: {},
links: {},
__tag: tag,
}) as unknown as SimulationResult;
// --- Bug scenario: the user edits the model and re-analyzes BEFORE ever
// creating a scenario, so the live result reflects the EDITED model, not
// the original file, by the time handleCreateScenario builds 'base'.
const editedModel = {
...originalModel,
nodes: { ...originalModel.nodes, J1: { ...originalModel.nodes['J1'], elevation: 99 } },
};
const editedInp = generateInp(editedModel);
assert.notEqual(editedInp, normalizedOriginal, 'edit must actually change the regenerated INP');
const editedResult = fakeResult('edited');
const resultInpSnapshotAfterEdit = editedInp; // runAnalysis(editedInp) just completed
const staleAfterEdit = computeIsStale(editedInp, resultInpSnapshotAfterEdit); // false: matches what was just analyzed
// Pre-fix behavior was `initialResult || wasmResult`, which would have
// attached editedResult to 'base' here. The fix must refuse to.
const baseResult = deriveBaseScenarioResult(editedResult, staleAfterEdit, resultInpSnapshotAfterEdit, normalizedOriginal);
assert.equal(baseResult, null, "base must not inherit a result computed from the edited model");
// Build the scenario list the way handleCreateScenario now does.
let scenarios: Scenario[] = saveActiveScenarioState([], 'base', editedInp, editedResult, staleAfterEdit);
scenarios.push({ id: 'base', name: 'base', inpStr: normalizedOriginal, result: baseResult, createdAt: 'now' });
scenarios.push({
id: 'scen_1',
name: 'alt1',
inpStr: editedInp,
result: staleAfterEdit ? null : editedResult,
createdAt: 'now',
});
assert.equal(scenarios.find((s) => s.id === 'base')?.result, null);
assert.equal(scenarios.find((s) => s.id === 'scen_1')?.result, editedResult, 'scen_1 correctly keeps its own matching result');
assert.equal(scenarios.find((s) => s.id === 'scen_1')?.inpStr, editedInp);
// --- Switch back to base (handleSwitchScenario): base has no result yet,
// forcing a fresh analysis of the TRUE original text.
const baseEntry = scenarios.find((s) => s.id === 'base')!;
assert.equal(baseEntry.result, null, 'switching to base must not surface a stale/foreign result');
const baseAnalysisResult = fakeResult('base-analyzed');
const baseResultInpSnapshot = baseEntry.inpStr;
const baseStale = computeIsStale(baseEntry.inpStr, baseResultInpSnapshot);
scenarios = saveActiveScenarioState(scenarios, 'base', baseEntry.inpStr, baseAnalysisResult, baseStale);
assert.equal(scenarios.find((s) => s.id === 'base')?.result, baseAnalysisResult);
assert.equal(scenarios.find((s) => s.id === 'base')?.inpStr, normalizedOriginal, "base's INP must remain the true original");
// --- Create a second alternative from base's freshly-analyzed state and
// confirm every scenario's INP/result pair is still mutually consistent
// (each carries either null or a result tagged for exactly its own INP).
scenarios = saveActiveScenarioState(scenarios, 'base', baseEntry.inpStr, baseAnalysisResult, baseStale);
scenarios.push({
id: 'scen_2',
name: 'alt2',
inpStr: baseEntry.inpStr,
result: baseStale ? null : baseAnalysisResult,
createdAt: 'now',
});
const tagOf = (r: SimulationResult | null) => (r as unknown as { __tag: string } | null)?.__tag;
assert.equal(scenarios.find((s) => s.id === 'base')?.result, baseAnalysisResult, "base result must survive scen_2's creation untouched");
assert.equal(tagOf(scenarios.find((s) => s.id === 'scen_2')?.result ?? null), 'base-analyzed');
assert.equal(tagOf(scenarios.find((s) => s.id === 'scen_1')?.result ?? null), 'edited');
// No scenario ever ends up with a result tag that doesn't belong to its own inpStr.
for (const s of scenarios) {
if (s.result) {
assert.equal(computeIsStale(s.inpStr, s.inpStr), false, `scenario ${s.id} must be internally consistent`);
}
}
});
test('59. AUTOSAVE FULL PROJECT STATE (P0-8): scenarios/results/GIS layers survive the v1->v2 snapshot migration', () => {
const model = parseInp(`[TITLE]
Autosave Test
[JUNCTIONS]
J1 10 5 ;
[RESERVOIRS]
R1 50 ;
[PIPES]
P1 R1 J1 100 200 100 0 Open ;
[END]
`);
const fakeResult = { engine: { runtime: 'wasm', version: '2.3.5' }, timeSeconds: 0, units: { flow: 'LPS', pressure: 'm' }, nodes: {}, links: {} } as unknown as SimulationResult;
// A v2 snapshot (this fix) must round-trip every field untouched.
const v2Stored = {
id: 'tab_abc',
version: 2 as const,
savedAt: '2026-01-01T00:00:00.000Z',
fileName: 'net.inp',
networkModel: model,
crs: 'EPSG:4326' as unknown as CRS,
basemap: 'none' as const,
displayUnitMode: 'korean' as const,
styleSettings: {} as unknown as Parameters<typeof normalizeStoredSnapshot>[0]['styleSettings'],
isModified: true,
rawInp: 'ORIGINAL RAW TEXT',
scenarios: [
{ id: 'base', name: '원안', inpStr: 'ORIGINAL RAW TEXT', result: fakeResult, createdAt: 'now' },
{ id: 'scen_1', name: '대안 1', inpStr: 'EDITED TEXT', result: null, createdAt: 'now' },
],
activeScenarioId: 'scen_1',
wasmResult: fakeResult,
serverResult: fakeResult,
resultInpSnapshot: 'EDITED TEXT',
userLayers: [{ id: 'layer1', name: 'ref layer' } as unknown as (typeof v2Stored)['userLayers'][number]],
};
const normalized = normalizeStoredSnapshot(v2Stored as never);
assert.ok(normalized);
assert.equal(normalized!.scenarios.length, 2);
assert.equal(normalized!.scenarios[0].result, fakeResult);
assert.equal(normalized!.activeScenarioId, 'scen_1');
assert.equal(normalized!.wasmResult, fakeResult);
assert.equal(normalized!.userLayers.length, 1);
assert.equal(normalized!.rawInp, 'ORIGINAL RAW TEXT');
// An old (pre-P0-8) v1 snapshot must still load -- degraded (no scenarios/
// results/layers) rather than discarded outright or crashing the restore.
const v1Stored = {
id: 'latest',
version: 1 as const,
savedAt: '2025-01-01T00:00:00.000Z',
fileName: 'old.inp',
networkModel: model,
crs: 'EPSG:4326' as unknown as CRS,
basemap: 'none' as const,
displayUnitMode: 'korean' as const,
styleSettings: {} as unknown as Parameters<typeof normalizeStoredSnapshot>[0]['styleSettings'],
isModified: false,
};
const migrated = normalizeStoredSnapshot(v1Stored as never);
assert.ok(migrated);
assert.equal(migrated!.scenarios.length, 0);
assert.equal(migrated!.wasmResult, null);
assert.equal(migrated!.userLayers.length, 0);
assert.equal(migrated!.activeScenarioId, 'base');
assert.equal(migrated!.networkModel, model);
// A record with no networkModel at all (corrupt/empty) must be rejected, not crash.
assert.equal(normalizeStoredSnapshot({ id: 'x', version: 2 } as never), null);
});
test('60. EXCEL EXPORT SELECTED TIME (P0-9): the workbook must use the on-screen selected time step, not always the first', () => {
// Bug (pre-fix): exportHydraulicCalculationXlsx read only the result's flat
// (first-step) fields (res.pressure / res.flow / ...) and never consulted
// res.timeSeries at all, so selecting a later time on screen before
// exporting had no effect on the workbook.
const pressureSeries = [50, 42, 30, 18];
const flatFirstStepValue = pressureSeries[0];
// Selecting time step 2 (index 2) must yield THAT step's value, not step 0's.
assert.equal(seriesValue(pressureSeries, 2, flatFirstStepValue), 30);
assert.equal(seriesValue(pressureSeries, 0, flatFirstStepValue), 50);
assert.equal(seriesValue(pressureSeries, 3, flatFirstStepValue), 18);
// No time series (steady-state result) -- falls back to the flat value.
assert.equal(seriesValue(undefined, 2, flatFirstStepValue), flatFirstStepValue);
// No time step selected at all -- also falls back (never index into the series blindly).
assert.equal(seriesValue(pressureSeries, undefined, flatFirstStepValue), flatFirstStepValue);
// Out-of-range index -- falls back rather than returning undefined.
assert.equal(seriesValue(pressureSeries, 99, flatFirstStepValue), flatFirstStepValue);
// The exported workbook must also record WHICH instant it represents.
const timeSteps = [0, 3600, 7200, 10800];
assert.equal(formatTimeLabel(timeSteps, 2), '02:00 (7200초)');
assert.equal(formatTimeLabel(timeSteps, 0), '00:00 (0초)');
assert.equal(formatTimeLabel(undefined, 2), '정상상태(Steady-State)');
assert.equal(formatTimeLabel(timeSteps, undefined), '정상상태(Steady-State)');
});
test('61. RESERVOIR/TANK/PUMP EDITS PERSIST (P1-1): full attribute table columns that look editable actually save', () => {
const sampleInp = `[TITLE]
Reservoir Tank Pump Edit Test
[JUNCTIONS]
J1 10 5 ;
J2 12 8 ;
[RESERVOIRS]
R1 50 ;
[TANKS]
T1 20 5 0 10 15 0 ;
[PUMPS]
PU1 R1 J1 HEAD C1 ;
[PIPES]
P1 T1 J1 100 200 100 0 Open ;
P2 J1 J2 150 150 100 0 Open ;
[CURVES]
C1 100 50
[CONTROLS]
LINK PU1 OPEN IF NODE J1 BELOW 15
[OPTIONS]
UNITS LPS
[END]
`;
const model = parseInp(sampleInp);
// Reservoir 'head' must actually update the model (and stay in sync with elevation).
const withReservoirEdit = updateNodeAttributes(model, 'R1', { head: 55, elevation: 55 });
assert.equal(withReservoirEdit.nodes['R1'].head, 55);
assert.equal(withReservoirEdit.nodes['R1'].elevation, 55);
// Reservoir/Tank id rename must go through renameNode (updates PIPES/PUMPS endpoints).
const renamedReservoir = updateNodeAttributes(withReservoirEdit, 'R1', { id: 'SOURCE' });
assert.equal(renamedReservoir.nodes['R1'], undefined);
assert.ok(renamedReservoir.nodes['SOURCE']);
assert.equal(renamedReservoir.pumps['PU1'].node1, 'SOURCE', 'pump endpoint must follow the reservoir rename');
// Tank numeric fields (elevation/initLevel/minLevel/maxLevel/diameter) must persist.
const withTankEdit = updateNodeAttributes(renamedReservoir, 'T1', {
elevation: 25,
initLevel: 6,
minLevel: 1,
maxLevel: 12,
diameter: 18,
});
assert.equal(withTankEdit.nodes['T1'].elevation, 25);
assert.equal(withTankEdit.nodes['T1'].initLevel, 6);
assert.equal(withTankEdit.nodes['T1'].diameter, 18);
// Tank rename must update the connected pipe's endpoint.
const renamedTank = updateNodeAttributes(withTankEdit, 'T1', { id: 'TANK_MAIN' });
assert.equal(renamedTank.pipes['P1'].node1, 'TANK_MAIN');
// Node tag (junction, reservoir, or tank) must round-trip through INP export/re-parse --
// [TAGS] previously only ever stored LINK entries, so a NODE tag was silently dropped.
const withTags = {
...renamedTank,
nodes: {
...renamedTank.nodes,
J2: { ...renamedTank.nodes['J2'], tag: '중요절점' },
SOURCE: { ...renamedTank.nodes['SOURCE'], tag: '취수원' },
},
};
const regenerated = generateInp(withTags);
assert.match(regenerated, /NODE\s+J2\s+중요절점/);
assert.match(regenerated, /NODE\s+SOURCE\s+취수원/);
const reparsed = parseInp(regenerated);
assert.equal(reparsed.nodes['J2'].tag, '중요절점');
assert.equal(reparsed.nodes['SOURCE'].tag, '취수원');
// Pump id/node1/node2/parameters edits must actually save (previously
// commitEdit had no branch for 'pumps' at all -- every pump edit silently
// did nothing).
const withPumpEdit = updatePumpAttributes(renamedTank, 'PU1', { parameters: 'POWER 15' });
assert.equal(withPumpEdit.pumps['PU1'].parameters, 'POWER 15');
const renamedPump = updatePumpAttributes(withPumpEdit, 'PU1', { id: 'PUMP_A' });
assert.equal(renamedPump.pumps['PU1'], undefined);
assert.ok(renamedPump.pumps['PUMP_A']);
assert.equal(
renamedPump.controls.find((c) => c.linkId === 'PUMP_A')?.linkId,
'PUMP_A',
'control reference must follow the pump rename'
);
// Duplicate pump id (colliding with an existing pipe/link id), self-loop,
// and nonexistent node must all be rejected.
assert.throws(() => updatePumpAttributes(renamedPump, 'PUMP_A', { id: 'P1' }), /이미 존재하는/);
assert.throws(() => updatePumpAttributes(renamedPump, 'PUMP_A', { node2: 'SOURCE' }), /달라야 합니다|존재하지 않는/);
assert.throws(() => updatePumpAttributes(renamedPump, 'PUMP_A', { node1: 'NOPE' }), /존재하지 않는/);
// Full round-trip: rename + parameter edit must survive export and re-parse.
const finalRegen = generateInp(renamedPump);
const finalReparsed = parseInp(finalRegen);
assert.ok(finalReparsed.pumps['PUMP_A']);
assert.match(finalReparsed.pumps['PUMP_A'].parameters, /POWER\s+15/);
});
test('62. INDEPENDENT ENGINE RESULTS (P1-2): a server (Native DLL) failure must not withhold a successful WASM result', () => {
const fakeResult = (tag: string) =>
({ engine: { runtime: 'wasm', version: '2.3.5' }, timeSeconds: 0, units: { flow: 'LPS', pressure: 'm' }, nodes: {}, links: {}, __tag: tag }) as unknown as SimulationResult;
// Bug scenario: server rejects, WASM succeeds. The old Promise.all-based
// code would have thrown on the server rejection and never surfaced the
// WASM result at all.
const wasmOk = fakeResult('wasm-ok');
const serverRejected: PromiseSettledResult<SimulationResult> = { status: 'rejected', reason: new Error('DLL 연결 실패') };
const wasmFulfilled: PromiseSettledResult<SimulationResult> = { status: 'fulfilled', value: wasmOk };
const outcome1 = resolveDualEngineOutcome(serverRejected, wasmFulfilled);
assert.equal(outcome1.server, null);
assert.equal(outcome1.wasm, wasmOk, 'WASM result must survive a server-side failure');
assert.equal(outcome1.failureMessages.length, 1);
assert.match(outcome1.failureMessages[0], /서버\(Native\)/);
// Symmetric case: WASM rejects, server succeeds.
const serverOk = fakeResult('server-ok');
const outcome2 = resolveDualEngineOutcome(
{ status: 'fulfilled', value: serverOk },
{ status: 'rejected', reason: new Error('워커 초기화 실패') }
);
assert.equal(outcome2.wasm, null);
assert.equal(outcome2.server, serverOk, 'server result must survive a WASM-side failure');
assert.match(outcome2.failureMessages[0], /WASM/);
// Both succeed: no failure messages, both results present (cross-validation possible).
const outcome3 = resolveDualEngineOutcome(
{ status: 'fulfilled', value: serverOk },
{ status: 'fulfilled', value: wasmOk }
);
assert.equal(outcome3.failureMessages.length, 0);
assert.equal(outcome3.server, serverOk);
assert.equal(outcome3.wasm, wasmOk);
// Both fail: both null, two failure messages, neither engine's absence masks the other's.
const outcome4 = resolveDualEngineOutcome(
{ status: 'rejected', reason: new Error('a') },
{ status: 'rejected', reason: new Error('b') }
);
assert.equal(outcome4.server, null);
assert.equal(outcome4.wasm, null);
assert.equal(outcome4.failureMessages.length, 2);
});
test('63. UNIFIED WARNING THRESHOLDS (P1-3): reports/tables converge on the same project-wide criteria as the map', () => {
// SI model: minPressureThreshold (15m) and maxVelocityThreshold (3 m/s,
// the map default) must pass through to 'm' and 'm/s' unchanged.
const si = resolveNativeThresholds(15, 3, 'm', 'LPS', 1.0);
assert.equal(si.minPressureNative, 15);
assert.equal(si.maxVelocityNative, 3);
// US model reporting psi: previously excelExport.ts hardcoded its OWN
// "2.5 m/s" (converted crudely to a flat US velocity number) and "20 psi"
// standard, independent of the map's user-adjustable setting. Now the
// same 15m/3m/s project setting must convert into the model's native units.
const us = resolveNativeThresholds(15, 3, 'psi', 'GPM', 1.0);
// 15m of water ~= 21.32 psi (via the same PSI_TO_M_WATER constant used
// throughout the app's pressure conversions).
assert.ok(Math.abs(us.minPressureNative - 21.3238) < 0.001);
// 3 m/s ~= 9.84 ft/s.
assert.ok(Math.abs(us.maxVelocityNative - 9.8425) < 0.001);
// A project override (e.g. a stricter 20m minimum) must also propagate,
// not just the defaults -- there is exactly one source of truth.
const overridden = resolveNativeThresholds(20, 2, 'm', 'LPS', 1.0);
assert.equal(overridden.minPressureNative, 20);
assert.equal(overridden.maxVelocityNative, 2);
// A kPa-reporting project must also convert correctly (specific gravity applies).
const kpa = resolveNativeThresholds(10, 3, 'kPa', 'LPS', 1.2);
assert.ok(Math.abs(kpa.minPressureNative - 10 * 9.80665 * 1.2) < 1e-6);
});
test('64. ENGINE ISSUES LINKED TO OBJECTS (P1-4): report warnings/errors are matched to the node/link they concern', () => {
const nodeIds = ['J1', 'J2', 'R1'];
const linkIds = ['P1', 'P2', 'PU1'];
const reportText = `EPANET Analysis
Hydraulic Status
WARNING: Node J2 disconnected at 2:00 hrs
Error 233: Pump PU1 not connected to any node
System unbalanced at 3:00 hrs
Everything else is fine here
`;
const issues = extractEngineIssues(reportText, nodeIds, linkIds);
const nodeIssue = issues.find((i) => i.message.includes('J2'));
assert.ok(nodeIssue, 'must find the WARNING line naming J2');
assert.deepEqual(nodeIssue!.target, { type: 'node', id: 'J2' });
assert.equal(nodeIssue!.severity, 'warning');
const linkIssue = issues.find((i) => i.message.includes('PU1'));
assert.ok(linkIssue, 'must find the Error line naming PU1');
assert.deepEqual(linkIssue!.target, { type: 'link', id: 'PU1' });
assert.equal(linkIssue!.severity, 'error');
// A flagged line naming no known id gets no clickable target, not a crash
// or a wrong guess.
const untargeted = issues.find((i) => i.message.includes('unbalanced'));
assert.ok(untargeted);
assert.equal(untargeted!.target, null);
// Ordinary report lines (no WARNING/Error/disconnected/etc keyword) are not
// reported as issues at all.
assert.ok(!issues.some((i) => i.message.includes('Everything else')));
assert.ok(!issues.some((i) => i.message.includes('Hydraulic Status')));
// No report text, or a model with no ids at all -- empty, no crash.
assert.deepEqual(extractEngineIssues(null, nodeIds, linkIds), []);
assert.deepEqual(extractEngineIssues(reportText, [], []), []);
});
test('65. ISOLATED CLUSTER DETECTION (P1-5): a group of mutually-connected nodes unreachable from any source must be flagged', () => {
// J3-J4-J5 form their own little loop (each has degree >= 1, so the old
// degree-0-only check never caught this), but that loop is never actually
// tied into the main R1-J1-J2 network at all.
const sampleInp = `[TITLE]
Isolated Cluster Test
[JUNCTIONS]
J1 10 5 ;
J2 10 5 ;
J3 10 5 ;
J4 10 5 ;
J5 10 5 ;
[RESERVOIRS]
R1 50 ;
[PIPES]
P1 R1 J1 100 200 100 0 Open ;
P2 J1 J2 100 200 100 0 Open ;
P3 J3 J4 100 200 100 0 Open ;
P4 J4 J5 100 200 100 0 Open ;
P5 J5 J3 100 200 100 0 Open ;
[COORDINATES]
J1 0 0
J2 1 0
J3 10 10
J4 11 10
J5 10 11
R1 -1 0
[OPTIONS]
UNITS LPS
[END]
`;
const model = parseInp(sampleInp);
const issues = validateTopology(model);
// None of J3/J4/J5 has degree 0, so the pre-existing isolated-node check
// must NOT fire for them.
const degreeZeroIssues = issues.filter((i) => i.message.includes('연결된 관로/밸브가 없습니다'));
assert.equal(degreeZeroIssues.length, 0, 'no node in the isolated loop has degree 0');
// A single clustered-isolation issue must be reported naming all three.
const clusterIssue = issues.find((i) => i.message.includes('고립 관망'));
assert.ok(clusterIssue, 'must detect the J3-J4-J5 loop as disconnected from any source');
assert.match(clusterIssue!.message, /3개/);
for (const id of ['J3', 'J4', 'J5']) {
assert.match(clusterIssue!.message, new RegExp(id));
}
assert.ok(clusterIssue!.target && ['J3', 'J4', 'J5'].includes(clusterIssue!.target.id));
// A model where every node IS reachable from the source must report none.
const connectedInp = sampleInp.replace(' P5 J5 J3 100 200 100 0 Open ;\n', ' P5 J2 J3 100 200 100 0 Open ;\n');
const connectedModel = parseInp(connectedInp);
const connectedIssues = validateTopology(connectedModel);
assert.ok(!connectedIssues.some((i) => i.message.includes('고립 관망')));
});
test('66. GIS IMPORT DISCLOSES ESTIMATED VALUES (P1-6): the conversion summary flags placeholders and fuzzy endpoint matches', () => {
// Two segments: the second's start point (126.9780001, 37.565) is NOT
// exactly equal to the first segment's end point (126.978, 37.565), but
// falls within the geographic snap threshold (~5m) -- a real shared
// endpoint from slightly noisy GIS digitizing, or two genuinely distinct
// points close enough to be merged by mistake. Either way it needs review.
const geojson = {
type: 'FeatureCollection',
features: [
{
type: 'Feature',
properties: { FTR_IDN: 'PIPE_A', PIP_DIA: 300 },
geometry: { type: 'LineString', coordinates: [[126.975, 37.565], [126.978, 37.565]] },
},
{
type: 'Feature',
properties: { FTR_IDN: 'PIPE_B' }, // no diameter field at all -> must fall back to default
geometry: { type: 'LineString', coordinates: [[126.9780001, 37.565], [126.981, 37.565]] },
},
],
};
const { model, summary } = buildNetworkFromGeoJSON(geojson as any, {
pipeIdField: 'FTR_IDN',
diameterField: 'PIP_DIA',
defaultDiameter: 200,
defaultRoughness: 120,
sourceHead: 60,
});
// PIPE_B's near-but-not-exact start point must have snapped onto PIPE_A's
// end node rather than creating a redundant new one.
assert.equal(Object.keys(model.nodes).length, 3, 'the fuzzy-matched endpoint must merge into 3 total nodes, not 4');
assert.equal(summary.autoSnappedEndpointCount, 1, 'exactly one fuzzy (non-exact) endpoint match occurred');
// PIPE_A had a real diameter field; PIPE_B did not (falls back to default).
assert.equal(summary.defaultDiameterPipeCount, 1);
assert.equal(summary.totalPipeCount, 2);
// Neither pipe has a roughness field or material field -- both estimated.
assert.equal(summary.defaultRoughnessPipeCount, 2);
// Every non-source node's elevation/demand is an undisclosed placeholder;
// one node became the source reservoir and is excluded from that count.
assert.equal(summary.totalNodeCount, 3);
assert.equal(summary.placeholderElevationCount, 2);
assert.equal(summary.placeholderDemandCount, 2);
// A model with no exact-duplicate endpoints at all must report zero
// auto-snapped merges (only genuinely fuzzy matches count).
const disjointGeojson = {
type: 'FeatureCollection',
features: [
{ type: 'Feature', properties: { FTR_IDN: 'X' }, geometry: { type: 'LineString', coordinates: [[10, 10], [20, 20]] } },
],
};
const disjoint = buildNetworkFromGeoJSON(disjointGeojson as any, {
defaultDiameter: 200,
defaultRoughness: 120,
sourceHead: 60,
});
assert.equal(disjoint.summary.autoSnappedEndpointCount, 0);
});
test('67. UNIFIED EDIT/VALIDATION ENGINE (P2-2): bottom and full attribute tables store the same value for the same edit', () => {
// UNIT_FIELD_KINDS is the single source of truth both AttributeTable.tsx
// (bottom table) and FullAttributeTableModal.tsx consult for "which
// physical-unit conversion does this column need" -- previously the bottom
// table had no unit-conversion concept at all (it always stored the raw
// typed number verbatim, unlike the full table), so the same elevation/
// demand/length/diameter edit produced two different stored INP values
// depending on which table was used.
assert.equal(UNIT_FIELD_KINDS['elevation'], 'head');
assert.equal(UNIT_FIELD_KINDS['invertElev'], 'head');
assert.equal(UNIT_FIELD_KINDS['demand'], 'demand');
assert.equal(UNIT_FIELD_KINDS['length'], 'length');
assert.equal(UNIT_FIELD_KINDS['diameter'], 'diameter');
assert.equal(UNIT_FIELD_KINDS['id'], undefined, 'non-unit-bearing fields must have no conversion kind');
const usInp = `[TITLE]
US Unit Edit Parity Test
[JUNCTIONS]
J1 10 5 ;
[RESERVOIRS]
R1 50 ;
[PIPES]
P1 R1 J1 100 8 100 0 Open ;
[OPTIONS]
UNITS GPM
[END]
`;
const model = parseInp(usInp);
const sourceUnits = model.options.units;
// Both tables show/accept "30.5" as a Korean-display elevation in meters;
// both must convert to the SAME native (feet) value before storing.
const displayedElevation = 30.5;
const modelElevation = metersToLength(displayedElevation, sourceUnits);
const viaFullTable = updateNodeAttributes(model, 'J1', { elevation: modelElevation });
const viaBottomTable = updateNodeAttributes(model, 'J1', {
elevation: UNIT_FIELD_KINDS['elevation'] === 'head' ? metersToLength(displayedElevation, sourceUnits) : displayedElevation,
});
assert.equal(viaFullTable.nodes['J1'].elevation, viaBottomTable.nodes['J1'].elevation);
assert.ok(Math.abs(viaFullTable.nodes['J1'].elevation - displayedElevation * 3.280839895013123) < 1e-9);
// Same for pipe diameter: "250mm" must store as the same inches value
// regardless of which table the edit came from.
const displayedDiameterMm = 250;
const modelDiameter = millimetersToDiameter(displayedDiameterMm, sourceUnits);
const pipeViaFullTable = updatePipeAttributes(model, 'P1', { diameter: modelDiameter });
const pipeViaBottomTable = updatePipeAttributes(model, 'P1', {
diameter: UNIT_FIELD_KINDS['diameter'] === 'diameter' ? millimetersToDiameter(displayedDiameterMm, sourceUnits) : displayedDiameterMm,
});
assert.equal(pipeViaFullTable.pipes['P1'].diameter, pipeViaBottomTable.pipes['P1'].diameter);
// Exported INP must be byte-identical regardless of which table produced it.
assert.equal(generateInp(viaFullTable), generateInp(viaBottomTable));
assert.equal(generateInp(pipeViaFullTable), generateInp(pipeViaBottomTable));
});
test('68. GIS IMPORT SPATIAL INDEX (P2-5b): endpoint snapping at scale still finds the exact same matches as a full scan', () => {
// A long chain of 1500 connected segments (1501 shared endpoints) --
// large enough that an O(n^2) full-scan snap match would be meaningfully
// slow, and exactly the shape that most stresses a grid index (every
// consecutive segment's start must snap onto the previous one's end).
const CHAIN_LENGTH = 1500;
const features = [];
for (let i = 0; i < CHAIN_LENGTH; i++) {
features.push({
type: 'Feature',
properties: { FTR_IDN: `SEG${i}` },
geometry: {
type: 'LineString',
// Each segment's start exactly matches the previous segment's end;
// spaced widely (100 units apart in a planar/meter-projected space)
// so no two DIFFERENT chain vertices ever fall within the snap
// threshold of each other.
coordinates: [[i * 100, 0], [(i + 1) * 100, 0]],
},
});
}
const geojson = { type: 'FeatureCollection', features };
const start = performance.now();
const { model, summary } = buildNetworkFromGeoJSON(geojson as any, {
defaultDiameter: 200,
defaultRoughness: 120,
sourceHead: 60,
});
const elapsedMs = performance.now() - start;
// Exactly CHAIN_LENGTH + 1 distinct vertices -- every interior junction
// shared between two segments must have been correctly merged into one
// node, never duplicated.
assert.equal(Object.keys(model.nodes).length, CHAIN_LENGTH + 1);
assert.equal(summary.totalPipeCount, CHAIN_LENGTH);
// Every interior connection is an EXACT coordinate match (not fuzzy), so
// the spatial index must report zero fuzzy auto-snaps here.
assert.equal(summary.autoSnappedEndpointCount, 0);
// Sanity performance bound: a spatial index should comfortably finish this
// in well under a second; a full O(n^2) scan over 1500 segments would be
// orders of magnitude slower. Generous threshold to avoid CI flakiness.
assert.ok(elapsedMs < 5000, `expected sub-5s for spatially-indexed snapping, got ${elapsedMs.toFixed(0)}ms`);
});
test('69. COMPOUND CONDITION FILTER (P2-1): "pressure < 15 AND velocity > 2" style filters and saved selection sets', () => {
assert.equal(evaluateFilterCondition(10, '<', 15), true);
assert.equal(evaluateFilterCondition(15, '<', 15), false);
assert.equal(evaluateFilterCondition(15, '<=', 15), true);
assert.equal(evaluateFilterCondition(20, '>', 15), true);
assert.equal(evaluateFilterCondition(20, '>=', 20), true);
assert.equal(evaluateFilterCondition(20, '==', 20), true);
assert.equal(evaluateFilterCondition(20 + 1e-12, '==', 20), true, 'exact-equality tolerates float noise');
assert.equal(evaluateFilterCondition(21, '==', 20), false);
const rows = [
{ id: 'J1', pressure: 10, velocity: 3 }, // pressure<15 AND velocity>2 -> match
{ id: 'J2', pressure: 20, velocity: 3 }, // pressure not <15 -> no match
{ id: 'J3', pressure: 10, velocity: 1 }, // velocity not >2 -> no match
{ id: 'J4', pressure: 5, velocity: 5 }, // matches both
];
const conditions: FilterCondition[] = [
{ field: 'pressure', operator: '<', value: 15 },
{ field: 'velocity', operator: '>', value: 2 },
];
const matched = rows.filter((r) => rowMatchesAllConditions(r, conditions));
assert.deepEqual(matched.map((r) => r.id), ['J1', 'J4']);
// No conditions -> every row matches (an empty AND is vacuously true).
assert.equal(rows.filter((r) => rowMatchesAllConditions(r, [])).length, rows.length);
// A condition referencing a field the row doesn't have (or a non-numeric
// value) must exclude the row, never throw or silently pass.
const missingFieldRows = [{ id: 'X1', pressure: 10 }, { id: 'X2', pressure: 10, velocity: 'n/a' as unknown as number }];
const withMissingField = missingFieldRows.filter((r) =>
rowMatchesAllConditions(r, [{ field: 'velocity', operator: '>', value: 0 }])
);
assert.equal(withMissingField.length, 0);
});
test('70. PATH TO NEAREST SOURCE (P2-3): a worst-condition node auto-traces the upstream path to its water source', () => {
// R1 -> J1 -> J2 -> J3 (a straight chain) plus a red-herring branch J2->J4
// that must NOT appear in the shortest path from J3 back to R1.
const sampleInp = `[TITLE]
Path To Source Test
[JUNCTIONS]
J1 10 5 ;
J2 10 5 ;
J3 10 5 ;
J4 10 5 ;
[RESERVOIRS]
R1 50 ;
[PIPES]
P1 R1 J1 100 200 100 0 Open ;
P2 J1 J2 100 200 100 0 Open ;
P3 J2 J3 100 200 100 0 Open ;
P4 J2 J4 100 200 100 0 Open ;
[OPTIONS]
UNITS LPS
[END]
`;
const model = parseInp(sampleInp);
const path = findPathToNearestSource(model, 'J3');
assert.ok(path);
assert.deepEqual(path!.nodeIds, ['J3', 'J2', 'J1', 'R1']);
assert.deepEqual(path!.linkIds, ['P3', 'P2', 'P1']);
assert.ok(!path!.nodeIds.includes('J4'), 'the unrelated branch must not appear in the shortest path');
// The source node itself: trivial one-node path, no links.
const trivial = findPathToNearestSource(model, 'R1');
assert.deepEqual(trivial, { nodeIds: ['R1'], linkIds: [] });
// A nonexistent node, or a network with no source at all, must return null
// rather than throwing.
assert.equal(findPathToNearestSource(model, 'NOPE'), null);
const noSourceModel = { ...model, nodes: Object.fromEntries(Object.entries(model.nodes).map(([id, n]) => [id, { ...n, type: n.type === 'RESERVOIR' ? 'JUNCTION' as const : n.type }])) };
assert.equal(findPathToNearestSource(noSourceModel, 'J3'), null);
// A node with no path to any source (disconnected cluster, mirroring the
// P1-5 isolated-cluster scenario) must also return null, not throw or find
// a phantom path.
const disconnectedInp = `[TITLE]
Disconnected Test
[JUNCTIONS]
J1 10 5 ;
J2 10 5 ;
[RESERVOIRS]
R1 50 ;
[PIPES]
P1 J1 J2 100 200 100 0 Open ;
[OPTIONS]
UNITS LPS
[END]
`;
const disconnectedModel = parseInp(disconnectedInp);
assert.equal(findPathToNearestSource(disconnectedModel, 'J1'), null);
});
test('71. SCENARIO COMPARISON METRICS (P2-4): common per-scenario summary stats match a hand-computed baseline', () => {
const sampleInp = `[TITLE]
Scenario Metrics Test
[JUNCTIONS]
J1 10 5 ;
J2 10 15 ;
[RESERVOIRS]
R1 50 ;
[PIPES]
P1 R1 J1 100 200 100 0 Open ;
P2 J1 J2 100 200 100 0 Open ;
[OPTIONS]
UNITS LPS
[END]
`;
const model = parseInp(sampleInp);
const fakeResult = {
engine: { runtime: 'wasm', version: '2.3.5' },
timeSeconds: 0,
units: { flow: 'LPS', pressure: 'm' },
nodes: {
J1: { id: 'J1', type: 0, coordinates: null, demand: 5, head: 40, pressure: 30 },
J2: { id: 'J2', type: 0, coordinates: null, demand: 15, head: 35, pressure: 25 },
R1: { id: 'R1', type: 1, coordinates: null, demand: 0, head: 50, pressure: 0 },
},
links: {
P1: { id: 'P1', type: 1, fromNode: 'R1', toNode: 'J1', vertices: [], flow: 20, velocity: 0.7, headloss: 10 },
P2: { id: 'P2', type: 1, fromNode: 'J1', toNode: 'J2', vertices: [], flow: 15, velocity: 1.3, headloss: 5 },
},
reportText: '',
} as unknown as SimulationResult;
const metrics = computeScenarioMetrics(model, fakeResult);
// Total demand comes from the MODEL's junction demands (5 + 15 = 20),
// independent of whatever the result reports.
assert.equal(metrics.totalDemand, 20);
// Junctions only: the reservoir's always-0 "pressure" must not become the
// network minimum (RES-1).
assert.equal(metrics.minPressure, 25, 'reservoir pressure (0) must be excluded');
assert.equal(metrics.maxPressure, 30);
assert.ok(Math.abs(metrics.avgPressure - (30 + 25) / 2) < 1e-9);
assert.equal(metrics.minVelocity, 0.7);
assert.equal(metrics.maxVelocity, 1.3);
assert.ok(Math.abs(metrics.avgVelocity - (0.7 + 1.3) / 2) < 1e-9);
// Empty result (no nodes/links at all) must not throw or divide by zero.
const emptyMetrics = computeScenarioMetrics(model, {
...fakeResult,
nodes: {},
links: {},
} as unknown as SimulationResult);
assert.equal(emptyMetrics.minPressure, 0);
assert.equal(emptyMetrics.maxVelocity, 0);
assert.equal(emptyMetrics.totalDemand, 20, 'total demand still comes from the model regardless of the result');
});