// Regression tests for the 2026-10 review fixes (review_sections/수정순서.md). // Pure-logic only (no DLL), so they run anywhere: // node --experimental-strip-types --test tests/fixes.test.ts import assert from 'node:assert/strict'; import test from 'node:test'; import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { parseInp, generateInp, findOverlongIds, renameNode, idByteLength, EPANET_MAX_ID_BYTES, deletePipe, renamePipe, reversePipeDirection, splitPipe } from '../src/inpModel.ts'; import { inspectSimulation, engineWarningLines, findImplausiblePressureNodes } from '../src/convergence.ts'; import { computeComparison } from '../src/comparisonEngine.ts'; import { timeWeightedAverage, reportingStepIndices } from '../src/resultSync.ts'; import { convertNetworkUnits, formatEditableNumber } from '../src/unitSystem.ts'; import type { SimulationResult } from '../src/types.ts'; import { parseCalibrationFile, buildCalibrationReport, interpolateAt } from '../src/calibration.ts'; import { findProfilePath } from '../src/profilePath.ts'; const REPO = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const readPublic = (name: string) => fs.readFileSync(path.join(REPO, 'public', name), 'utf8'); const BASE_INP = `[JUNCTIONS] J1 10 5 J2 12 3 [RESERVOIRS] R1 50 [PIPES] P1 R1 J1 1000 200 100 P2 J1 J2 1000 150 100 [OPTIONS] Units LPS [END] `; function fakeResult(overrides: Partial = {}): SimulationResult { return { engine: { runtime: 'native', version: '2.3.5' }, timeSeconds: 0, timeSteps: [0, 3600], units: { flow: 'LPS', pressure: 'm' }, nodes: { J1: { id: 'J1', type: 0, coordinates: null, demand: 5, head: 40, pressure: 30, timeSeries: { demand: [5, 5], head: [40, 40], pressure: [30, 30], quality: [] } }, }, links: {}, reportText: 'Page 1\n Analysis begun\n Hydraulic Status:\n' + ' '.repeat(60), ...overrides, } as unknown as SimulationResult; } test('INP-1: IDs over 31 UTF-8 bytes are detected and rename is refused', () => { const longId = '상리배수지유출관로제일번'; // 12 chars, 36 UTF-8 bytes assert.equal(idByteLength(longId), 36); assert.ok(idByteLength(longId) > EPANET_MAX_ID_BYTES); const model = parseInp(BASE_INP.replace(/J2/g, longId)); assert.deepEqual(findOverlongIds(model), [longId]); assert.throws(() => renameNode(parseInp(BASE_INP), 'J1', longId), /31바이트/); assert.doesNotThrow(() => renameNode(parseInp(BASE_INP), 'J1', '배수지유출')); }); test('INP-2: missing OPTIONS keys regenerate as EPANET defaults (TRIALS 200, UNBALANCED STOP)', () => { const out = generateInp(parseInp(BASE_INP)); assert.match(out, /Trials\s+200/); assert.match(out, /Unbalanced\s+Stop/i); assert.doesNotMatch(out, /Continue 10/i); // Explicit values in the source are kept as-is. const explicit = generateInp(parseInp(BASE_INP.replace('[OPTIONS]', '[OPTIONS]\n Trials 40\n Unbalanced Continue 10'))); assert.match(explicit, /Trials\s+40/); assert.match(explicit, /Unbalanced\s+Continue 10/i); }); test('INP-3: PCV valves and their loss curve survive a round trip', () => { const inp = BASE_INP.replace('[OPTIONS]', '[VALVES]\n V1 J1 J2 150 PCV 50 0 CURVE1\n[CURVES]\n CURVE1 0 0\n CURVE1 100 1\n[OPTIONS]'); const model = parseInp(inp); assert.equal(model.valves.V1.valveType, 'PCV'); assert.equal(model.valves.V1.curve, 'CURVE1'); assert.match(generateInp(model), /V1\s+J1\s+J2\s+150\s+PCV\s+50\s+0 CURVE1/); }); test('ERR-1: engine warning codes count even when the report has no WARNING lines', () => { const clean = fakeResult(); assert.equal(inspectSimulation(clean).hasWarning, false); const warned = fakeResult({ engineWarnings: [{ time: 3600, code: 6 }] }); const verdict = inspectSimulation(warned); assert.equal(verdict.hasWarning, true); assert.match(verdict.note, /엔진 경고 1건/); assert.deepEqual(engineWarningLines(warned.engineWarnings), ['WARNING 6: Negative pressures (음압 발생) at 1:00 hrs.']); }); test('ERR-2: physically impossible pressures are flagged', () => { const r = fakeResult(); r.nodes.J1.timeSeries!.pressure = [30, -8072833]; assert.deepEqual(findImplausiblePressureNodes(r.nodes), ['J1']); assert.equal(inspectSimulation(r).hasWarning, true); }); test('RES-2: flow tolerance scales with the flow unit (CMD near-zero flows pass)', () => { const mk = (flow: number) => ({ ...fakeResult({ units: { flow: 'CMD', pressure: 'm' } }), links: { L1: { id: 'L1', type: 1, fromNode: 'J1', toNode: 'J1', vertices: [], flow, velocity: 0, headloss: 0, timeSeries: { flow: [flow, flow], velocity: [0, 0], headloss: [0, 0], quality: [], energy: [], efficiency: [] } } }, }) as unknown as SimulationResult; // 0.011 CMD apart: solver round-off on a near-zero CMD flow (seen on a real network). assert.equal(computeComparison(mk(-0.0155), mk(-0.0045)).passed, true); // The same absolute gap in LPS (86x larger in volume) must still fail. const lps = (flow: number) => ({ ...mk(flow), units: { flow: 'LPS', pressure: 'm' } }) as SimulationResult; assert.equal(computeComparison(lps(-0.0155), lps(-0.0045)).passed, false); }); test('RES-3: both engines failing water quality is not reported as thousands of mismatches', () => { const failed = { qualType: 1, typeName: 'CHEMICAL', chemName: 'Cl', chemUnits: 'mg/L', status: 'failed' }; const a = fakeResult({ qualityInfo: failed } as Partial); const b = fakeResult({ engine: { runtime: 'wasm', version: '2.3.5' }, qualityInfo: failed } as Partial); const cmp = computeComparison(a, b); assert.equal(cmp.missingIds.filter((m) => m.includes('quality')).length, 0); assert.equal(cmp.passed, true); }); test('RES-4: averages are time-weighted over uneven EPANET steps', () => { // 10 for 1 h, then 100 for 6 s: a plain mean says 55, time-weighted ~10.15. assert.ok(Math.abs(timeWeightedAverage([10, 100, 0], [0, 3600, 3606]) - (10 * 3600 + 100 * 6) / 3606) < 1e-9); assert.equal(timeWeightedAverage([5], [0]), 5); }); test('RES-5: reporting periods exclude intermediate event times and honour REPORT START', () => { const steps = [0, 3600, 7200, 10800, 14400, 16154, 18000]; assert.deepEqual(reportingStepIndices({ timeSteps: steps, reportStart: 0, reportStep: 3600 }), [0, 1, 2, 3, 4, 6]); assert.deepEqual(reportingStepIndices({ timeSteps: steps, reportStart: 7200, reportStep: 7200 }), [2, 4]); // Older results without the fields keep every step. assert.equal(reportingStepIndices({ timeSteps: steps }).length, steps.length); }); test('UNIT-1: convertNetworkUnits itself refuses models it cannot fully convert', () => { const net1 = parseInp(readPublic('Net1.inp')); // has a pump curve assert.throws(() => convertNetworkUnits(net1, 'LPS'), /단위 변환 불가/); assert.doesNotThrow(() => convertNetworkUnits(parseInp(BASE_INP), 'CMD')); }); test('ARCH-3: shared pump energy metrics reproduce the native engine on Net1 pump 9', async () => { const { computePumpEnergy } = await import('../src/resultPostProcess.ts'); // Net1 pump 9 power series (kW) and hydraulic steps as returned by EPANET 2.3.5. const t = [0, 3600, 7200, 10800, 14400, 18000, 21600, 25200, 28800, 32400, 36000, 39600, 43200, 45154, 46800, 50400, 54000, 57600, 61200, 64800, 68400, 72000, 75600, 79200, 81690, 82800, 86400]; const kw = [95.84, 96.07, 96.19, 96.31, 96.36, 96.41, 96.41, 96.42, 96.48, 96.52, 96.6, 96.65, 96.71, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 95.03, 95.16, 95.46]; const eff = kw.map((k) => (k > 0 ? 75 : 0)); const { summary } = computePumpEnergy(t, { '9': { energy: kw, efficiency: eff } }, { '9': { fromNode: '9', toNode: '10' } }); const p = summary!.pumps['9']; assert.equal(p.totalKwh, 1333.21); // same as backend/main.py assert.equal(p.peakKw, 96.71); assert.equal(p.utilization, 57.7); assert.ok(Math.abs(p.avgKw - 1333.21 / 13.8511) < 0.02, `time-weighted avgKw, got ${p.avgKw}`); assert.equal(summary!.peakKw, 96.71); }); test('QUAL-3: one display converter for every view (identity in original mode, exact inverse in korean mode)', async () => { const { createDisplayConverter } = await import('../src/unitSystem.ts'); const orig = createDisplayConverter('original', 'GPM', 'psi'); assert.equal(orig.head(710), 710); assert.equal(orig.toFlow(150), 150); const kr = createDisplayConverter('korean', 'GPM', 'psi'); assert.equal(formatEditableNumber(kr.head(710)), '216.408'); assert.ok(Math.abs(kr.flow(150) - 817.6) < 0.1); // GPM -> m3/day assert.ok(Math.abs(kr.pressure(127.54) - 89.72) < 0.01); // psi -> m (EPANET 0.4333 psi/ft) for (const v of [1, 216.4, 817.6]) { assert.ok(Math.abs(kr.toLength(kr.length(v)) - v) < 1e-9); assert.ok(Math.abs(kr.toFlow(kr.flow(v)) - v) < 1e-9); } }); test('UNIT-3: editable numbers drop floating-point residue but keep real precision', () => { assert.equal(formatEditableNumber(710 * 0.3048), '216.408'); assert.equal(formatEditableNumber(0.09000000000000001), '0.09'); assert.equal(formatEditableNumber(3.04516129), '3.04516129'); assert.equal(formatEditableNumber(Number.NaN), ''); }); test('CALIB-1: EPANET calibration file -- comments, continuation lines, h:mm times', () => { const parsed = parseCalibrationFile(';Location Time Value\nN1 0 50\n 6:30 44\nN2,1.5,60\nID,Time,Value\n'); assert.deepEqual(parsed.observations, [ { location: 'N1', timeSec: 0, value: 50 }, { location: 'N1', timeSec: 6.5 * 3600, value: 44 }, { location: 'N2', timeSec: 1.5 * 3600, value: 60 }, ]); assert.deepEqual(parsed.skippedLines, [5]); // the header row }); test('CALIB-2: report interpolates the simulation and computes EPANET calibration stats', () => { assert.equal(interpolateAt([0, 3600, 7200], [10, 20, 40], 5400), 30); assert.equal(interpolateAt([0, 3600], [10, 20], 9999), null); const result = { timeSteps: [0, 3600], nodes: { A: { id: 'A', pressure: 10, timeSeries: { pressure: [10, 20], head: [], demand: [], quality: [] } }, B: { id: 'B', pressure: 30, timeSeries: { pressure: [30, 30], head: [], demand: [], quality: [] } }, }, links: {}, } as unknown as SimulationResult; const obs = parseCalibrationFile('A 0 12\nA 1 18\nB 0.5 33\nZ 0 1\nB 5 1').observations; const r = buildCalibrationReport(obs, 'pressure', result, (v) => v); const a = r.locations.find((l) => l.location === 'A')!; assert.equal(a.count, 2); assert.equal(a.observedMean, 15); assert.equal(a.computedMean, 15); assert.equal(a.meanError, 2); assert.equal(a.rmsError, 2); assert.deepEqual(r.unknownLocations, ['Z']); assert.equal(r.outOfRange, 1); assert.equal(r.network.count, 3); }); test('PROFILE-1: profile path is the shortest by pipe length, through pumps/valves', () => { const model = parseInp(`[JUNCTIONS] A 0 0 B 0 0 C 0 0 D 0 0 [RESERVOIRS] R 100 [PIPES] P1 A B 100 100 100 0 Open P2 B D 100 100 100 0 Open P3 A C 50 100 100 0 Open P4 C D 500 100 100 0 Open P5 R A 10 100 100 0 Open [END] `); const path = findProfilePath(model, 'R', 'D')!; assert.deepEqual(path.nodeIds, ['R', 'A', 'B', 'D']); assert.deepEqual(path.linkIds, ['P5', 'P1', 'P2']); assert.equal(findProfilePath(model, 'A', 'NOPE'), null); }); const EXTRAS_INP = `[TITLE] extras round trip [JUNCTIONS] J1 10 5 ; J2 12 0 ; [RESERVOIRS] R1 60 ; [TANKS] T1 30 2 0 5 10 0 ; [PIPES] P1 R1 J1 100 200 100 0 Open ; P2 J1 J2 100 150 100 0 Open ; P3 J2 T1 100 150 100 0 Open ; [PUMPS] PU1 J2 T1 HEAD C1 ; [CURVES] ;PUMP: main pump C1 10 50 [PATTERNS] PAT1 1 1.2 [DEMANDS] J1 10 PAT1 ;Domestic J1 20 ;Commercial [EMITTERS] J2 0.5 [QUALITY] J1 0.3 [SOURCES] R1 CONCEN 1.2 PAT1 [MIXING] T1 2COMP 0.4 [STATUS] P2 CLOSED [ENERGY] Global Efficiency 75 Global Price 0.1 Demand Charge 2 Pump PU1 Efficiency C1 Pump PU1 Price 0.2 [REACTIONS] Order Bulk 1 Global Bulk -0.5 Limiting Potential 0 BULK P1 -0.3 TANK T1 -0.2 [LABELS] 5 6 "Main plant" R1 7 8 "Note" [COORDINATES] J1 1 1 J2 2 1 R1 0 0 T1 3 1 [END] `; test('EXTRAS-1: structured sections parse, regenerate and re-parse identically', async () => { const { curveTypeOf } = await import('../src/inpExtras.ts'); const m = parseInp(EXTRAS_INP); const ex = m.extras!; assert.deepEqual(ex.demands['J1'], [ { demand: 10, pattern: 'PAT1', category: 'Domestic' }, { demand: 20, pattern: undefined, category: 'Commercial' }, ]); assert.equal(ex.emitters['J2'], 0.5); assert.equal(ex.quality['J1'], 0.3); assert.deepEqual(ex.sources['R1'], { type: 'CONCEN', strength: 1.2, pattern: 'PAT1' }); assert.deepEqual(ex.mixing['T1'], { model: '2COMP', fraction: 0.4 }); assert.equal(ex.statusOverrides['P2'], 'CLOSED'); assert.deepEqual(ex.energy.global.map((k) => k.key), ['Global Efficiency', 'Global Price', 'Demand Charge']); assert.deepEqual(ex.energy.pumps['PU1'], { efficiency: 'C1', price: 0.2 }); assert.deepEqual(ex.reactionsGlobal.map((k) => `${k.key}=${k.value}`), ['Order Bulk=1', 'Global Bulk=-0.5', 'Limiting Potential=0']); assert.equal(ex.tankReactions['T1'], -0.2); assert.equal(m.pipes['P1'].bulkCoeff, -0.3); assert.deepEqual(ex.labels, [ { x: 5, y: 6, text: 'Main plant', anchor: 'R1' }, { x: 7, y: 8, text: 'Note', anchor: undefined }, ]); assert.equal(curveTypeOf(m.curves['C1'].description), 'PUMP'); const out = generateInp(m); const again = parseInp(out); assert.deepEqual(again.extras, ex, 'extras survive a generate/parse round trip'); assert.equal(again.pipes['P1'].bulkCoeff, -0.3); assert.equal(generateInp(again), out, 'regeneration is idempotent'); }); test('EXTRAS-2: rename and delete propagate into structured sections', async () => { const { renameNode, renamePipe, renamePump, deleteNode, deletePipe } = await import('../src/inpModel.ts'); let m = parseInp(EXTRAS_INP); m = renameNode(m, 'J1', 'JA'); m = renameNode(m, 'R1', 'RA'); m = renamePipe(m, 'P2', 'PB'); m = renamePump(m, 'PU1', 'PUMPX'); assert.equal(m.extras!.demands['JA'].length, 2); assert.equal(m.extras!.quality['JA'], 0.3); assert.equal(m.extras!.sources['RA'].type, 'CONCEN'); assert.equal(m.extras!.labels[0].anchor, 'RA'); assert.equal(m.extras!.statusOverrides['PB'], 'CLOSED'); assert.equal(m.extras!.energy.pumps['PUMPX'].price, 0.2); const out = generateInp(m); assert.doesNotMatch(out, /^\s*J1\s/m); assert.match(out, /\[ENERGY\][\s\S]*Pump PUMPX\s+Price 0\.2/); m = deleteNode(m, 'JA'); assert.equal(m.extras!.demands['JA'], undefined); assert.equal(m.extras!.quality['JA'], undefined); m = deletePipe(m, 'PB'); assert.equal(m.extras!.statusOverrides['PB'], undefined); assert.doesNotMatch(generateInp(m), /\bJA\b/); }); test('EXTRAS-3: a section the source lacked is appended once it holds data', () => { const plain = parseInp(`[JUNCTIONS] J1 10 5 ; [RESERVOIRS] R1 60 ; [PIPES] P1 R1 J1 100 200 100 0 Open ; [END] `); const ex = plain.extras!; const edited = { ...plain, extras: { ...ex, emitters: { J1: 0.7 }, demands: { J1: [{ demand: 3 }, { demand: 4, category: 'Leak' }] }, energy: { global: [{ key: 'Global Price', value: '0.15' }], pumps: {} }, reactionsGlobal: [{ key: 'Global Wall', value: '-0.1' }], }, }; const out = generateInp(edited); assert.match(out, /\[EMITTERS\][\s\S]*J1\s+0\.7/); assert.match(out, /\[DEMANDS\][\s\S]*J1\s+4\s+;Leak/); assert.match(out, /\[ENERGY\][\s\S]*Global Price\s+0\.15/); assert.match(out, /\[REACTIONS\][\s\S]*Global Wall\s+-0\.1/); const re = parseInp(out); assert.equal(re.extras!.emitters['J1'], 0.7); assert.equal(re.extras!.demands['J1'][1].category, 'Leak'); }); test('PROJECT-1: .spipe project file round trip keeps model, extras, assets, scenarios and view', async () => { const { buildProjectZip, readProjectZip } = await import('../src/projectFile.ts'); const { withAsset } = await import('../src/assets.ts'); const model = parseInp(EXTRAS_INP); const withAssets = { ...model, assets: withAsset(withAsset(undefined, 'link', 'P1', 'material', 'DCIP'), 'link', 'P1', 'installYear', 1998) }; const result = { nodes: { J1: { id: 'J1', pressure: 1, timeSeries: { pressure: new Float64Array([1, 2]) } } }, links: {}, timeSteps: [0, 3600] }; const snapshot = { version: 2, savedAt: '2026-10-22T00:00:00Z', fileName: 'x.inp', networkModel: withAssets, crs: 'EPSG:5187', basemap: 'osm', displayUnitMode: 'korean', styleSettings: {}, isModified: true, rawInp: EXTRAS_INP, scenarios: [{ id: 's1', name: '대안1', inpStr: 'x', result: result, createdAt: 't' }], activeScenarioId: 's1', wasmResult: result, serverResult: null, resultInpSnapshot: 'snap', userLayers: [], } as any; const view = { layerLabels: { nodes: { showLabel: true, field: 'id_tag' } } } as any; const full = await readProjectZip(buildProjectZip(snapshot, view, true).buffer as ArrayBuffer); assert.equal(full.snapshot.networkModel.extras!.demands['J1'].length, 2); assert.equal(full.snapshot.networkModel.assets!.links['P1'].material, 'DCIP'); assert.equal(full.snapshot.networkModel.assets!.links['P1'].installYear, 1998); assert.deepEqual((full.snapshot.wasmResult as any).nodes.J1.timeSeries.pressure, [1, 2], 'typed arrays saved as arrays'); assert.equal(full.snapshot.scenarios[0].name, '대안1'); assert.equal(full.view.layerLabels!.nodes.field, 'id_tag'); // generateInp works on the restored model (it is a plain parsed model again) assert.match(generateInp(full.snapshot.networkModel), /\[DEMANDS\]/); const lean = await readProjectZip(buildProjectZip(snapshot, view, false).buffer as ArrayBuffer); assert.equal(lean.snapshot.wasmResult, null); assert.equal(lean.snapshot.scenarios[0].result, null); await assert.rejects(() => readProjectZip(new Uint8Array([1, 2, 3]).buffer), /프로젝트/); }); test('ASSETS-1: asset attributes follow rename, split and delete', async () => { const { withAsset } = await import('../src/assets.ts'); const { renamePipe, splitPipe, deletePipe } = await import('../src/inpModel.ts'); let m = parseInp(EXTRAS_INP); m = { ...m, assets: withAsset(undefined, 'link', 'P1', 'material', 'SP') }; m = renamePipe(m, 'P1', 'MAIN'); assert.equal(m.assets!.links['MAIN'].material, 'SP'); const split = splitPipe(m, 'MAIN', 0.5); assert.equal(split.model.assets!.links[split.newPipeId].material, 'SP'); const del = deletePipe(split.model, 'MAIN'); assert.equal(del.assets!.links['MAIN'], undefined); assert.equal(del.assets!.links[split.newPipeId].material, 'SP'); }); const BLOCK_INP = `[JUNCTIONS] A1 0 1 ; A2 0 1 ; B1 0 1 ; B2 0 1 ; C1 0 1 ; [RESERVOIRS] R 50 ; [PIPES] P0 R A1 10 100 100 0 Open ; P1 A1 A2 10 100 100 0 Open ; P2 A2 B1 10 100 100 0 Open ; P3 B1 B2 10 100 100 0 Open ; P4 A1 B2 10 100 100 0 Closed ; P5 B2 C1 10 100 100 0 Closed ; [END] `; test('GROUPS-1: connected components, closed links split groups, source-less group flagged', async () => { const { findNetworkGroups } = await import('../src/networkGroups.ts'); const m = parseInp(BLOCK_INP); const open = findNetworkGroups(m, true); assert.equal(open.length, 2); assert.deepEqual(open[0].nodeIds.sort(), ['A1', 'A2', 'B1', 'B2', 'R']); assert.equal(open[0].sources, 1); assert.deepEqual(open[1].nodeIds, ['C1']); assert.equal(open[1].sources, 0, 'C1 only hangs on a closed pipe: no source'); assert.equal(findNetworkGroups(m, false).length, 1); }); test('BLOCKS-1: block boundary links, single inlet vs multiple open connections, flow direction', async () => { const { checkBlockIsolation } = await import('../src/blockIsolation.ts'); const m = parseInp(BLOCK_INP); const blockOf = { R: 'A', A1: 'A', A2: 'A', B1: 'B', B2: 'B', C1: 'C' }; const reports = checkBlockIsolation(m, blockOf, null); const b = reports.find((r) => r.block === 'B')!; assert.deepEqual(b.boundary.map((x) => x.id).sort(), ['P2', 'P4', 'P5']); assert.equal(b.openCount, 1); assert.equal(b.status, 'single-inlet'); const c = reports.find((r) => r.block === 'C')!; assert.equal(c.status, 'closed-off'); // Opening P4 gives block B a second open connection. const opened = { ...m, pipes: { ...m.pipes, P4: { ...m.pipes.P4, status: 'Open' } } }; assert.equal(checkBlockIsolation(opened, blockOf, null).find((r) => r.block === 'B')!.status, 'multi-open'); // Flow A2 -> B1 on P2 (node1=A2) is inflow for B, outflow for A. const result = { links: { P2: { flow: 2, timeSeries: { flow: [2, 3] } } }, nodes: {} } as any; const withFlow = checkBlockIsolation(m, blockOf, result); assert.equal(withFlow.find((r) => r.block === 'B')!.boundary.find((x) => x.id === 'P2')!.direction, 'in'); assert.equal(withFlow.find((r) => r.block === 'A')!.boundary.find((x) => x.id === 'P2')!.direction, 'out'); }); test('BLOCKS-2: polygon assignment with holes', async () => { const { blocksFromPolygons } = await import('../src/blockIsolation.ts'); const m = parseInp(`[JUNCTIONS]\n J1 0 0 ;\n J2 0 0 ;\n J3 0 0 ;\n[COORDINATES]\n J1 1 1\n J2 5 5\n J3 20 20\n[END]\n`); const fc = { type: 'FeatureCollection', features: [{ type: 'Feature', properties: { name: 'BLK1' }, geometry: { type: 'Polygon', coordinates: [[[0, 0], [10, 0], [10, 10], [0, 10], [0, 0]], [[4, 4], [6, 4], [6, 6], [4, 6], [4, 4]]] } }] } as any; const out = blocksFromPolygons(m, fc, (x, y) => [x, y], 'name'); assert.deepEqual(out, { J1: 'BLK1' }, 'J2 is in the hole, J3 outside'); }); test('TOOLS-1: pipe length from coordinates + [VERTICES]; elevation fill from the nearest node', async () => { const { recalcPipeLengths, fillZeroElevations, nearestNodeElevation } = await import('../src/networkTools.ts'); const m = parseInp(`[JUNCTIONS] A 10 0 ; B 0 0 ; C 30 0 ; [PIPES] P1 A B 1 100 100 0 Open ; P2 B C 999 100 100 0 Open ; [COORDINATES] A 0 0 B 300 400 C 300 1000 [VERTICES] P2 600 400 [OPTIONS] UNITS CMD [END] `); const r = recalcPipeLengths(m, 'EPSG:5187'); assert.equal(r.model.pipes.P1.length, 500, '3-4-5 triangle in metres'); assert.equal(r.model.pipes.P2.length, 300 + Math.round(Math.hypot(300, 600) * 100) / 100, 'through the vertex'); assert.equal(r.changes.length, 2); const only = recalcPipeLengths(m, 'EPSG:5187', ['P1']); assert.equal(only.model.pipes.P2.length, 999); const usM = { ...m, options: { ...m.options, units: 'GPM' } }; assert.ok(Math.abs(recalcPipeLengths(usM, 'EPSG:5187').model.pipes.P1.length - 1640.42) < 0.01, 'feet for US units'); const f = fillZeroElevations(m); assert.deepEqual(f.filled, ['B']); assert.equal(f.model.nodes.B.elevation, 10, 'A (10) is nearer to B than C (30)'); assert.equal(nearestNodeElevation(m, 300, 990), 30); }); test('LEGEND-1: overrides, class breaks and size-by-value expression', async () => { const { applyLegendOverride, classBreaks, sizeExpression, stepColor } = await import('../src/legendScale.ts'); const base = { title: '관로 유속', unit: 'm/s', colors: ['a', 'b', 'c', 'd', 'e'], breaks: [0, 0.5, 1, 2, 3], absolute: true }; const ov = { unit: 'm/s', breaks: [0, 0.2, 0.4, 0.6, 0.8], colors: ['e', 'd', 'c', 'b', 'a'], sizeByValue: true }; const s = applyLegendOverride(base, ov); assert.deepEqual(s.breaks, ov.breaks); assert.deepEqual(s.colors, ov.colors); assert.equal(s.sizeByValue, true); assert.deepEqual(applyLegendOverride(base, { ...ov, unit: 'ft/s' }), base, 'override made in other units is ignored'); assert.deepEqual(applyLegendOverride(base, { ...ov, breaks: [1, 2] }).breaks, base.breaks, 'wrong length ignored'); // map colouring uses the overridden edges assert.deepEqual(stepColor('velocity', s), ['step', ['abs', ['to-number', ['get', 'velocity'], 0]], 'e', 0.2, 'd', 0.4, 'c', 0.6, 'b', 0.8, 'a']); assert.deepEqual(classBreaks([0, 10, 20, 30, 40, 50], 5, 'equal'), [0, 10, 20, 30, 40]); assert.deepEqual(classBreaks([1, 1, 1, 2, 3, 100], 3, 'quantile'), [1, 1, 3]); assert.equal(classBreaks([5, 5], 5, 'equal'), null); assert.deepEqual(sizeExpression('velocity', s, 2), ['interpolate', ['linear'], ['abs', ['to-number', ['get', 'velocity'], 0]], 0.2, 1.2, 0.8, 4.8]); }); test('CURVES-1: curve references, inferred type, warnings', async () => { const { curveReferences, inferCurveType, curveWarnings } = await import('../src/curveTools.ts'); const m = parseInp(`[JUNCTIONS] J1 0 1 ; [RESERVOIRS] R1 10 ; [TANKS] T1 5 1 0 4 10 0 VC1 ; [PUMPS] PU1 R1 J1 HEAD HC1 SPEED 1 ; [VALVES] V1 J1 T1 100 GPV GC1 0 ; [CURVES] HC1 0 60 HC1 10 40 VC1 0 0 VC1 4 100 GC1 0 0 GC1 10 2 EC1 10 70 [ENERGY] Pump PU1 Efficiency EC1 [END] `); assert.deepEqual(curveReferences(m, 'HC1'), ['펌프 PU1 양정곡선']); assert.equal(inferCurveType(m, 'HC1'), 'PUMP'); assert.equal(inferCurveType(m, 'VC1'), 'VOLUME'); assert.equal(inferCurveType(m, 'GC1'), 'HEADLOSS'); assert.equal(inferCurveType(m, 'EC1'), 'EFFICIENCY'); assert.deepEqual(curveWarnings('PUMP', [{ x: 0, y: 60 }, { x: 10, y: 40 }]), []); assert.ok(curveWarnings('PUMP', [{ x: 0, y: 40 }, { x: 10, y: 60 }]).length === 1); assert.ok(curveWarnings('PUMP', [{ x: 10, y: 60 }, { x: 0, y: 40 }]).some((w) => w.includes('X'))); }); test('EXTRAS-ACTIONS-1: demand categories, emitters, sources, mixing, status, energy, reactions', async () => { const A = await import('../src/extrasActions.ts'); let m = parseInp(EXTRAS_INP); // J1 has two [DEMANDS] categories assert.equal(A.totalBaseDemand(m, 'J1'), 30); m = A.setDemandCategories(m, 'J1', [{ demand: 7, pattern: 'PAT1' }]); assert.equal(m.extras!.demands['J1'], undefined, 'single unnamed category goes back to [JUNCTIONS]'); assert.equal(m.nodes.J1.demand, 7); m = A.setDemandCategories(m, 'J2', [{ demand: 1, category: 'Home' }, { demand: 2, category: 'Shop' }]); assert.equal(m.extras!.demands['J2'].length, 2); assert.equal(m.nodes.J2.demand, 1, 'junction line mirrors the primary category'); m = A.setEmitter(m, 'J2', 0.8); m = A.setEmitter(m, 'J2', 0); assert.equal(m.extras!.emitters['J2'], undefined, 'zero removes the emitter'); m = A.setSource(m, 'J1', { type: 'SETPOINT', strength: 0.5 }); m = A.setMixing(m, 'T1', { model: 'FIFO' }); m = A.setMixing(m, 'T1', { model: 'MIXED' }); assert.equal(m.extras!.mixing['T1'], undefined, 'MIXED is the default: not written'); m = A.setStatusOverride(m, 'P3', 'closed'); m = A.setPumpEnergy(m, 'PU1', { price: 0.3 }); m = A.setEnergyGlobal(m, 'Global Price', '0.12'); m = A.setReactionGlobal(m, 'Global Wall', '-0.2'); m = A.setReactionGlobal(m, 'Limiting Potential', null); const out = generateInp(m); const re = parseInp(out); assert.deepEqual(re.extras!.demands['J2'].map((c) => c.category), ['Home', 'Shop']); assert.equal(re.extras!.sources['J1'].type, 'SETPOINT'); assert.equal(re.extras!.statusOverrides['P3'], 'CLOSED'); assert.equal(re.extras!.energy.pumps['PU1'].price, 0.3); assert.equal(A.getKeyValue(re.extras!.energy.global, 'GLOBAL PRICE'), '0.12'); assert.equal(A.getKeyValue(re.extras!.reactionsGlobal, 'Global Wall'), '-0.2'); assert.equal(A.getKeyValue(re.extras!.reactionsGlobal, 'Limiting Potential'), undefined); }); test('EXTRAS-ACTIONS-2: pasted demand rows, distribution and emitter (leak) distribution', async () => { const A = await import('../src/extrasActions.ts'); const parsed = A.parseDemandRows('절점\t수요\t패턴\t범주\nJ1\t10\tPAT1\t가정\nJ1\t5\t\t상업\nJ2\t3\nX\tabc\n'); assert.equal(parsed.rows.length, 3); assert.deepEqual(parsed.bad, [5]); let m = parseInp(EXTRAS_INP); const applied = A.applyDemandRows(m, parsed.rows, 'replace', (v) => v); assert.deepEqual(applied.updated.sort(), ['J1', 'J2']); assert.equal(A.totalBaseDemand(applied.model, 'J1'), 15); m = A.distributeDemand(applied.model, ['J1', 'J2'], 18, 'demand', '누수'); assert.ok(Math.abs(A.totalBaseDemand(m, 'J1') - (15 + 18 * 15 / 18)) < 1e-6); assert.ok(Math.abs(A.totalBaseDemand(m, 'J2') - (3 + 18 * 3 / 18)) < 1e-6); // emitters: sum of C * P^0.5 equals the total leak at the reference pressure const { coefficients } = A.distributeEmitters(m, ['J1', 'J2'], 10, 25, 0.5); const leak = Object.values(coefficients).reduce((a, c) => a + c * Math.sqrt(25), 0); assert.ok(Math.abs(leak - 10) < 1e-3, `total leak ${leak}`); // J1 serves half of P1 (100) + half of P2 (100) = 100; J2 serves half of P2 + half of P3 = 100 assert.ok(Math.abs(coefficients.J1 - coefficients.J2) < 1e-9); }); test('ANALYSIS-1: flow reversals, contour grid/isolines, pipe report groups', async () => { const { analyzeFlowDirection, idwGrid, isolineSegments, joinSegments, contourLevels, niceInterval, pipeReport } = await import('../src/analysisTools.ts'); const link = (flow: number[], velocity = flow.map((q) => q / 10)) => ({ id: '', type: 1, fromNode: 'a', toNode: 'b', vertices: [], flow: flow[0], velocity: velocity[0], headloss: 0, timeSeries: { flow, velocity, headloss: [], quality: [] } }); const res = { links: { A: link([5, 4, -3, -2, 6]), B: link([1, 1, 1e-7, 1]), C: link([0, 0]) }, nodes: {} } as unknown as SimulationResult; const rows = analyzeFlowDirection(res); assert.deepEqual(rows.map((r) => [r.id, r.reversals]), [['A', 2], ['B', 0], ['C', 0]]); assert.equal(rows[0].dominant, 1); assert.ok(Math.abs(rows[0].reversedShare - 40) < 1e-9); assert.equal(rows.find((r) => r.id === 'C')!.dominant, 0); // Plane v = x: isolines are vertical lines at x = level. const pts = []; for (let x = 0; x <= 10; x += 2) for (let y = 0; y <= 10; y += 2) pts.push({ x, y, v: x }); const g = idwGrid(pts, 21); const segs = isolineSegments(g, 5); assert.ok(segs.length > 0); for (const s of segs) for (const p of s) assert.ok(Math.abs(p[0] - 5) < 0.6, `x ${p[0]}`); assert.equal(joinSegments(segs).length, 1); assert.deepEqual(contourLevels(0.3, 2.1, 0.5), [0.5, 1, 1.5, 2]); assert.equal(niceInterval(0, 100), 10); // Far-away cells are masked. const sparse = idwGrid([{ x: 0, y: 0, v: 1 }, { x: 1, y: 0, v: 2 }, { x: 100, y: 100, v: 3 }], 50); assert.ok([...sparse.values].some(Number.isNaN)); const m = parseInp(`[JUNCTIONS] J1 0 0 ; J2 0 0 ; J3 0 0 ; [PIPES] P1 J1 J2 100 150 100 0 Open ; P2 J2 J3 300 150 100 0 Open ; P3 J1 J3 100 200 120 0 Open ; [END]`); m.assets = { nodes: {}, links: { P1: { material: 'DCIP', installYear: 1995 }, P2: { material: 'PE', installYear: 2005 } } }; const byD = pipeReport(m, 'diameter', null, 2025); assert.deepEqual(byD.rows.map((r) => [r.key, r.count, r.length]), [['150', 2, 400], ['200', 1, 100]]); assert.equal(byD.totalLength, 500); const byM = pipeReport(m, 'material', null, 2025); assert.deepEqual(byM.rows.map((r) => r.key), ['DCIP', 'PE', '(미입력)']); assert.equal(byM.rows[0].meanAge, 30); assert.deepEqual(pipeReport(m, 'installDecade', null, 2025).rows.map((r) => r.key), ['1990년대', '2000년대', '(미입력)']); }); test('VERTICES-1: vertices follow delete, rename, split (along the polyline) and reverse', () => { const m = parseInp(`[JUNCTIONS] J1 0 1 ; J2 0 1 ; [RESERVOIRS] R1 50 ; [PIPES] P1 R1 J1 100 150 100 0 Open ; P2 J1 J2 100 150 100 0 Open ; [COORDINATES] R1 0 0 J1 10 0 J2 20 0 [VERTICES] P1 5 5 P2 15 5 P2 17 5 [END]`); const vlines = (inp: string) => inp.slice(inp.indexOf('[VERTICES]'), inp.indexOf('[', inp.indexOf('[VERTICES]') + 1)).split(/\r?\n/).filter((l) => l.startsWith(' ')).map((l) => l.trim()); assert.deepEqual(vlines(generateInp(m)), ['P1 5 5', 'P2 15 5', 'P2 17 5']); // A deleted link's vertices go; a later pipe reusing the ID must not inherit them. assert.deepEqual(vlines(generateInp(deletePipe(m, 'P1'))), ['P2 15 5', 'P2 17 5']); assert.equal(deletePipe(m, 'P1').vertices?.P1, undefined); assert.deepEqual(vlines(generateInp(renamePipe(m, 'P2', 'PX'))), ['P1 5 5', 'PX 15 5', 'PX 17 5']); assert.deepEqual(vlines(generateInp(reversePipeDirection(m, 'P2'))), ['P1 5 5', 'P2 17 5', 'P2 15 5']); // Polyline J1(10,0)-(15,5)-(17,5)-J2(20,0): halfway lies on the middle leg. const sp = splitPipe(m, 'P2'); const j = sp.model.nodes[sp.newJunctionId]; assert.equal(j.y, 5); assert.ok(j.x > 15 && j.x < 17, `split x ${j.x}`); assert.deepEqual(sp.model.vertices?.P2, [{ x: 15, y: 5 }]); assert.deepEqual(sp.model.vertices?.[sp.newPipeId], [{ x: 17, y: 5 }]); // Clicked split point snaps onto the polyline. const sc = splitPipe(m, 'P2', 0.5, { x: 16, y: 6 }); assert.deepEqual([sc.model.nodes[sc.newJunctionId].x, sc.model.nodes[sc.newJunctionId].y], [16, 5]); // A model without the section gains one only when there is data. const plain = parseInp('[JUNCTIONS]\n J1 0 1 ;\n[END]'); assert.ok(!generateInp(plain).includes('[VERTICES]')); }); test('EDITTOOLS-1: transform, equal split, dissolve/merge, connection split, auto-check, trace', async () => { const t = await import('../src/editTools.ts'); const base = parseInp(`[JUNCTIONS] J1 0 5 ; J2 0 0 ; J3 0 0 ; J9 0 0 ; [RESERVOIRS] R1 50 ; [PIPES] P1 R1 J1 100 150 100 0 Open ; P2 J1 J2 100 150 100 0.2 Open ; P3 J2 J3 300 150 100 0.3 Open ; P4 J3 J3 10 150 100 0 Open ; [COORDINATES] R1 0 0 J1 10 0 J2 20 0 J3 30 0 J9 50 50 [VERTICES] P3 25 5 [END]`); // Rotate 90° about the origin: (10,0) -> (0,10); P3's vertex moves with its ends. const rot = t.transformNetwork(base, t.transformFn({ rotate: 90 })).model; assert.deepEqual([rot.nodes.J1.x, rot.nodes.J1.y], [0, 10]); assert.deepEqual(rot.vertices?.P3, [{ x: -5, y: 25 }]); const sim = t.similarityFrom2Points({ x: 0, y: 0 }, { x: 10, y: 0 }, { x: 100, y: 100 }, { x: 100, y: 120 }); assert.deepEqual(sim({ x: 5, y: 0 }), { x: 100, y: 110 }); const eq = t.splitPipeEqual(base, 'P1', 4); assert.equal(eq.junctions.length, 3); assert.deepEqual(eq.pipes.map((id) => eq.model.pipes[id].length), [25, 25, 25, 25]); assert.equal(t.splitPipeByLength(base, 'P1', 30).pipes.length, 4); // J2 joins P2 + P3 with equal diameter -> one pipe, J2 becomes a vertex. const d = t.dissolveNode(base, 'J2'); assert.equal(d.nodes.J2, undefined); assert.equal(d.pipes.P3, undefined); assert.deepEqual([d.pipes.P2.node1, d.pipes.P2.node2, d.pipes.P2.length, d.pipes.P2.minorLoss], ['J1', 'J3', 400, 0.5]); assert.deepEqual(d.vertices?.P2, [{ x: 20, y: 0 }, { x: 25, y: 5 }]); assert.throws(() => t.dissolveNode(base, 'J1'), /수요/); const merged = t.mergeNodes(base, 'J1', 'J2'); assert.equal(merged.model.nodes.J2, undefined); assert.deepEqual(merged.removedLinks, ['P2']); assert.equal(merged.model.pipes.P3.node1, 'J1'); assert.equal(t.mergeNodes(base, 'J1', 'J2').model.nodes.J1.demand, 5); const issues = t.findPipeIssues(base); assert.deepEqual(issues.selfLoops, ['P4']); assert.deepEqual(issues.orphanNodes, ['J9']); const fixed = t.autoFixPipes(base); assert.deepEqual([fixed.removedPipes, fixed.removedNodes], [['P4'], ['J9']]); // Connect J9 onto P1 (moved next to it): the split junction merges into J9. const near = { ...base, nodes: { ...base.nodes, J9: { ...base.nodes.J9, x: 5, y: 1 } } }; const c = t.connectNodeToPipe(near, 'J9', 'P1'); assert.equal(c.model.pipes.P1.node2, 'J9'); assert.equal(c.model.pipes[c.newPipeId].node1, 'J9'); assert.equal(Object.keys(c.model.nodes).length, Object.keys(near.nodes).length); const link = (flow: number) => ({ flow, velocity: 0, headloss: 0, id: '', type: 1, fromNode: '', toNode: '', vertices: [] }); const res = { nodes: {}, links: { P1: link(10), P2: link(-4), P3: link(0), P4: link(0) } } as unknown as SimulationResult; assert.deepEqual(t.traceFlow(base, res, 'R1', 'down').nodes.sort(), ['J1', 'R1']); assert.deepEqual(t.traceFlow(base, res, 'J1', 'up').nodes.sort(), ['J1', 'J2', 'R1']); }); test('DEM-1: ground / invert elevations, asset depth, US units, reservoirs skipped, check', async () => { const { applyGroundElevations, checkElevations, crsProj4 } = await import('../src/demTools.ts'); const m = parseInp(`[JUNCTIONS] J1 0 1 ; J2 50 1 ; [RESERVOIRS] R1 80 ; [TANKS] T1 0 1 0 4 10 0 ; [OPTIONS] Units LPS [END]`); m.assets = { nodes: { J2: { depth: 2 } }, links: {} }; const ground = { J1: 30, J2: 51.5, R1: 99, T1: 40, J9: 1 }; const inv = applyGroundElevations(m, { ...ground, J1: null }, { mode: 'invert', defaultDepth: 1.2, useAssetDepth: true }); assert.deepEqual(inv.changed.sort(), ['J2', 'T1']); assert.deepEqual(inv.missing, ['J1']); assert.equal(inv.model.nodes.J2.elevation, 49.5); assert.equal(inv.model.nodes.T1.elevation, 38.8); assert.equal(inv.model.nodes.R1.elevation, 80); const onlyZero = applyGroundElevations(m, ground, { mode: 'ground', defaultDepth: 0, useAssetDepth: false }, true); assert.deepEqual(onlyZero.changed.sort(), ['J1', 'T1']); const us = parseInp('[JUNCTIONS]\n J1 0 1 ;\n[OPTIONS]\n Units GPM\n[END]'); assert.equal(applyGroundElevations(us, { J1: 10 }, { mode: 'ground', defaultDepth: 0, useAssetDepth: false }).model.nodes.J1.elevation, 32.808); const chk = checkElevations(m, ground, { mode: 'ground', defaultDepth: 0, useAssetDepth: false }, 1); assert.deepEqual(chk.rows.map((r) => [r.id, r.diff]), [['T1', -40], ['J1', -30], ['J2', -1.5]]); assert.equal(crsProj4('LOCAL'), null); assert.match(crsProj4('EPSG:5186') ?? '', /tmerc/); }); test('GISTOOLS-1: valves from points, meter assignment, blocks, asset table', async () => { const g = await import('../src/gisTools.ts'); const m = parseInp(`[JUNCTIONS] J1 0 0 ; J2 0 0 ; [RESERVOIRS] R1 50 ; [PIPES] P1 R1 J1 100 150 100 0 Open ; P2 J1 J2 100 200 100 0 Open ; [COORDINATES] R1 0 0 J1 100 0 J2 200 0 [END]`); const v = g.insertValves(m, [{ x: 150, y: 3, id: 'GV1', closed: true }, { x: 150, y: 500 }], 10); assert.deepEqual([v.added, v.skipped], [['GV1'], 1]); const valve = v.model.valves.GV1; assert.equal(valve.valveType, 'TCV'); assert.equal(valve.diameter, 200); assert.equal(v.model.extras?.statusOverrides.GV1, 'CLOSED'); const [a, b] = [v.model.nodes[valve.node1], v.model.nodes[valve.node2]]; assert.deepEqual([a.x, a.y, b.x, b.y], [150, 0, 150, 0]); // The second half now starts at the valve's downstream node. assert.ok(Object.values(v.model.pipes).some((p) => p.node1 === valve.node2 && p.node2 === 'J2')); assert.equal(Object.values(v.model.pipes).reduce((s, p) => s + p.length, 0), 200); const mt = g.assignMeters(m, [{ x: 98, y: 1, flow: 2 }, { x: 103, y: -2, flow: 3 }, { x: 199, y: 0, flow: 1 }, { x: 1000, y: 0, flow: 9 }], 20); assert.deepEqual(mt.nodes, { J1: { count: 2, flow: 5 }, J2: { count: 1, flow: 1 } }); assert.equal(mt.unassigned, 1); assert.deepEqual(mt.model.extras?.demands.J1, [{ demand: 5, category: '계량기', pattern: undefined }]); const bl = g.writeBlocks(m, { R1: 'A', J1: 'A', J2: 'B' }); assert.deepEqual([bl.nodes, bl.links], [3, 1]); assert.equal(bl.model.assets?.links.P1?.block, 'A'); const t = g.applyAssetTable(m, 'ID\t관재질\t설치년도\nP1\tDCIP\t1998\nP9\tPE\t2000', 'link'); assert.deepEqual([t.updated, t.unknown, t.fields], [1, ['P9'], ['material', 'installYear']]); assert.deepEqual(t.model.assets?.links.P1, { material: 'DCIP', installYear: 1998 }); }); test('RASTER-1: world file and extent corners', async () => { const { worldFileCorners, extentCorners } = await import('../src/gisTools.ts'); // 0.5 m pixels, top-left pixel centre at (1000.25, 2000.75), 200 x 100 px const c = worldFileCorners('0.5\n0\n0\n-0.5\n1000.25\n2000.75\n', 200, 100); assert.deepEqual(c, [{ x: 1000, y: 2001 }, { x: 1100, y: 2001 }, { x: 1100, y: 1951 }, { x: 1000, y: 1951 }]); assert.throws(() => worldFileCorners('1 2 3', 10, 10)); assert.deepEqual(extentCorners(0, 0, 10, 5)[0], { x: 0, y: 5 }); assert.throws(() => extentCorners(5, 0, 1, 1)); }); test('DRAWING-1: profile/plan sheets from a model, nice scales, SVG and DXF writers', async () => { const { buildProfileDrawing, labelledStations } = await import('../src/profileDrawing.ts'); const { buildNetworkDrawing } = await import('../src/networkDrawing.ts'); const { toSvg, toDxf, niceScale } = await import('../src/drawing.ts'); const { profileInput, planInput } = await import('../src/drawingData.ts'); const m = parseInp(`[JUNCTIONS] J1 50 1 ; J2 40 1 ; [RESERVOIRS] R1 100 ; [PIPES] P1 R1 J1 1000 300 120 0 Open ; P2 J1 J2 500 200 120 0 Open ; [COORDINATES] R1 0 0 J1 1000 0 J2 1500 0 [OPTIONS] Units LPS [END]`); const id = (v: number) => v; const u = { head: id, length: id, diameter: id, flow: id, pressure: id, velocity: id }; const link = (flow: number, headloss: number) => ({ id: '', type: 1, fromNode: '', toNode: '', vertices: [], flow, velocity: 0.5, headloss, timeSeries: { flow: [flow, flow * 2], velocity: [0.5, 1], headloss: [headloss, headloss * 3], quality: [] } }); const node = (h: number, p: number) => ({ id: '', type: 0, coordinates: null, demand: 0, head: h, pressure: p, timeSeries: { head: [h, h - 5], pressure: [p, p - 5], demand: [], quality: [] } }); const res = { nodes: { R1: node(100, 0), J1: node(98, 48), J2: node(97, 57) }, links: { P1: link(10, 2), P2: link(5, 1) } } as unknown as SimulationResult; const path = { nodeIds: ['R1', 'J1', 'J2'], linkIds: ['P1', 'P2'], linkLengths: [1000, 500] }; const d = profileInput(m, res, path, { elevationIs: 'invert', depth: 1.2, series: ['now', 'min'], timeIndex: 0 }, u, 'L/s'); assert.deepEqual(d.stations.map((s) => s.x), [0, 1000, 1500]); assert.deepEqual([d.stations[1].ground, d.stations[1].invert], [51.2, 50]); assert.equal(d.stations[0].ground, 100); // reservoir: water level, no burial depth assert.deepEqual(d.stations[2].heads, [97, 92]); assert.deepEqual(d.runs.map((r) => [r.diameter, r.gradient]), [[300, 2], [200, 2]]); assert.deepEqual(d.marks, [{ x: 0, text: '배수지 R1' }]); const info = { project: '시험', title: '수리 종단면도', number: 'W-1', client: '○○시', company: '(주)삼안', date: '2026.11' }; const prof = buildProfileDrawing({ sheet: 'A3', info, ...d }); assert.ok(prof.hScale >= 1500 * 1000 / 400 && prof.vScale > 0); const svg = toSvg(prof.drawing); assert.match(svg, /수리 종단면도/); assert.match(svg, /D300mm\s+L=1,000\.0m/); const dxf = toDxf(prof.drawing); assert.ok(dxf.startsWith('0\r\nSECTION') && dxf.trimEnd().endsWith('EOF')); assert.ok(dxf.includes('\\U+C218\\U+B9AC')); // 수리 assert.match(dxf, /\r\nHGL_2\r\n/); assert.equal(niceScale(3080, 350), 10000); // ends always; a priority station (run end) beats a plain one competing for the same space assert.deepEqual(labelledStations([0, 4, 6, 10], [false, false, true, false], 3), [true, false, true, true]); const plan = planInput(m, res, { mode: 'hydraulic', timeIndex: 1, minPressure: 50, maxPressure: 56, maxVelocity: 2 }, u); // t=1: J1 43 m (< 50 red), J2 52 m (ok, blue) const color = Object.fromEntries(plan.nodes.map((n) => [n.id, n.color])); assert.deepEqual([color.J1, color.J2], ['#dc2626', '#2563eb']); // t=1: P1 velocity 1.0 m/s -> 1.0~max band (amber) assert.equal(plan.links.find((l) => l.id === 'P1')!.color, '#f59e0b'); const sheetPlan = buildNetworkDrawing({ sheet: 'A3', info, metric: true, ...plan, pipeLabels: 'DL', nodeLabels: 'sources' }); assert.ok(sheetPlan.scale >= 1500 * 1000 / 380); assert.match(toSvg(sheetPlan.drawing), /D300 L=1,000m/); // Equal-diameter pipes through a plain junction are labelled as one stretch. const { pipeChains } = await import('../src/networkDrawing.ts'); const L = (id: string, a: [number, number], c: [number, number], d: number) => ({ id, kind: 'pipe' as const, pts: [a, c], diameter: d, length: Math.hypot(c[0] - a[0], c[1] - a[1]), color: '#000' }); const ch = pipeChains([L('a', [0, 0], [100, 0], 300), L('b', [100, 0], [250, 0], 300), L('c', [250, 0], [300, 0], 200)]); assert.deepEqual(ch.map((c) => [c.links.map((l) => l.id).sort().join(','), c.length]), [['a,b', 250], ['c', 50]]); // A reservoir/tank point stops the chain. assert.equal(pipeChains([L('a', [0, 0], [100, 0], 300), L('b', [100, 0], [250, 0], 300)], new Set(['100.000,0.000'])).length, 2); // Pressures below the design minimum are drawn red; reservoir gets ▽ H.W.L. const red = buildProfileDrawing({ sheet: 'A3', info, ...d, minPressure: 50 }); const redTexts = red.drawing.items.filter((it) => it.kind === 'text' && it.color === '#dc2626').map((it) => (it as { text: string }).text); assert.ok(redTexts.includes('48.00') && !redTexts.includes('57.00')); assert.match(toSvg(red.drawing), /H\.W\.L 100\.00/); assert.equal(d.stations[0].kind, 'reservoir'); }); test('DRAWING-2: split plan sheets (색인도 + 부분도), clipping, pressure tags, font', async () => { const { buildNetworkDrawing, planLayout, tileNeighbours, clipPolyline } = await import('../src/networkDrawing.ts'); const { toSvg, mergeDrawings } = await import('../src/drawing.ts'); // clip: a line crossing the box keeps only the inside part assert.deepEqual(clipPolyline([[-5, 5], [15, 5]], { x0: 0, y0: 0, x1: 10, y1: 10 }), [[[0, 5], [10, 5]]]); assert.deepEqual(clipPolyline([[-5, -5], [-1, -1]], { x0: 0, y0: 0, x1: 10, y1: 10 }), []); // A 6 km straight main with junctions every 100 m. const nodes = Array.from({ length: 61 }, (_, i) => ({ id: `J${i}`, type: 'JUNCTION' as const, x: 200000 + i * 100, y: 500000, color: '#334155', head: 120 - i * 0.1, elevation: 60, pressure: 60 - i * 0.1, demand: i % 2 ? 0.5 : 0 })); const links = nodes.slice(1).map((n, i) => ({ id: `P${i + 1}`, kind: 'pipe' as const, pts: [[nodes[i].x, nodes[i].y], [n.x, n.y]] as Array<[number, number]>, diameter: 200, length: 100, color: '#1d4ed8' })); const info = { project: '시험', title: '관망도', number: 'W', client: '', company: '', date: '' }; const lay = planLayout({ sheet: 'A3', info, links, nodes }, { scale: 2000 }); // map window ~ 375 mm wide at 1:2000 = ~750 m: 6 km needs about 8-9 sheets, numbered left to right assert.ok(lay.tiles.length >= 8 && lay.tiles.length <= 10, String(lay.tiles.length)); assert.deepEqual(lay.tiles.map((t) => t.no), lay.tiles.map((_, i) => i + 1)); assert.ok(lay.tiles.every((t, i) => i === 0 || t.minX > lay.tiles[i - 1].minX)); // every node is inside some sheet assert.ok(nodes.every((n) => lay.tiles.some((t) => n.x >= t.minX && n.x <= t.maxX && n.y >= t.minY && n.y <= t.maxY))); assert.deepEqual(tileNeighbours(lay.tiles, lay.tiles[0]).map((n) => [n.no, n.dx, n.dy]), [[2, 1, 0]]); // regular grid: equal steps, neighbours overlap by the same strip const stepX = lay.tiles[1].minX - lay.tiles[0].minX; assert.ok(lay.tiles.every((t, i) => i === 0 || Math.abs(t.minX - lay.tiles[i - 1].minX - stepX) < 1e-6)); assert.ok(lay.tiles[0].maxX > lay.tiles[1].minX); assert.ok(lay.tiles.every((t) => t.minY === lay.tiles[0].minY)); // grid names: one row -> A-1, A-2, ... const g = planLayout({ sheet: 'A3', info, links, nodes }, { scale: 2000, naming: 'grid' }); assert.deepEqual(g.tiles.slice(0, 2).map((t) => t.name), ['A-1', 'A-2']); // an L-shaped network: two rows, numbered from the north-west, corner neighbour reported const lnodes = [...nodes.slice(0, 20), ...Array.from({ length: 15 }, (_, i) => ({ ...nodes[0], id: `K${i}`, x: 200000, y: 500000 - (i + 1) * 100 }))]; const llinks = [...links.slice(0, 19), ...lnodes.slice(20).map((n, i) => ({ ...links[0], id: `Q${i}`, pts: [[i ? lnodes[19 + i].x : 200000, i ? lnodes[19 + i].y : 500000], [n.x, n.y]] as Array<[number, number]> }))]; const L = planLayout({ sheet: 'A3', info, links: llinks, nodes: lnodes }, { scale: 2000, naming: 'grid' }); assert.equal(L.tiles[0].name, 'A-1'); assert.ok(L.tiles.some((t) => t.name.startsWith('B-'))); const a1 = L.tiles[0]; assert.ok(tileNeighbours(L.tiles, a1).some((n) => n.dy === -1)); const base = { sheet: 'A3' as const, info, metric: true, links, nodes, legend: [], legendTitle: '범 례', pipeLabels: 'DL' as const, nodeLabels: 'none' as const }; const t = lay.tiles[1]; const det = buildNetworkDrawing({ ...base, view: t, scale: lay.scale, sheetLabel: `(2/${lay.tiles.length})`, pressureTags: 'kgf', keyMap: { lines: links.map((l) => l.pts), tiles: lay.tiles, current: 2 }, neighbours: tileNeighbours(lay.tiles, t) }); const svg = toSvg(det.drawing); assert.match(svg, /관망도 \(2\/\d+\)/); assert.match(svg, /kg\/cm²/); assert.match(svg, /위 치 도/); assert.match(svg, /◀ 도면 1 연결/); assert.match(svg, /▶ 도면 3 연결/); assert.match(svg, /HY울릉도M/); // default font // nothing drawn outside the map frame on the paper sheet for (const it of det.drawing.items) if (it.kind === 'line' && it.layer === 'PIPE') assert.ok(it.pts.every(([x, y]) => x >= 0 && x <= 420 && y >= 0 && y <= 297)); // pressure 60 m -> 6.00 kg/cm²; head / elevation in the circle assert.match(svg, /5\.\d\d kg\/cm²/); // legend styles: table (default) has the ruled group cell, list has headings, none has no legend const legBase = { ...base, legend: [{ label: '유속 0.3m/s 미만', color: '#123456', shape: 'line' as const }], legendTitle: '범 례 (수리 판정)' }; const tbl = toSvg(buildNetworkDrawing({ ...legBase, legendBand: '#d6e4f0' }).drawing); assert.match(tbl, /#d6e4f0/); assert.match(tbl, />유유속