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 = { 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[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[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 = { status: 'rejected', reason: new Error('DLL 연결 실패') }; const wasmFulfilled: PromiseSettledResult = { 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'); });