- README, 예제 설명, 코드 주석, CSS 머리말, 시험 임시 폴더 접두어의 예전 이름 정리 - 브라우저 저장 키·세션 DB 이름은 저장된 설정 보존을 위해 유지
3934 lines
154 KiB
TypeScript
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');
|
|
}); |