Files
SPipe EngineeringandClaude Opus 5.5 e3f386addf 도면 출력 추가: 수리 종단면도, 관망도(색인도·부분도·수압표), 범례·색상 선택, DXF(AutoCAD 2018)
- 데모 v2 이후 개발 PC의 변경을 옮김 (이 저장소의 이름 변경·README 정리는 유지)
- 서버 기본 DEM, DEM 등고선, 속성 테이블 범위 선택, WNTR 교차 검증 시험 포함
- 새 라이브러리: ezdxf(Python), maplibre-contour(npm)
- 변경 내역: docs/SPipe_패치내역_도면출력_2026-11-06.txt

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 15:03:21 +09:00

983 lines
49 KiB
TypeScript

// 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> = {}): 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<SimulationResult>);
const b = fakeResult({ engine: { runtime: 'wasm', version: '2.3.5' }, qualityInfo: failed } as Partial<SimulationResult>);
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, />유</);
assert.match(tbl, /#123456/);
const lst = toSvg(buildNetworkDrawing({ ...legBase, legendStyle: 'list' }).drawing);
assert.match(lst, />유속</);
const none = toSvg(buildNetworkDrawing({ ...legBase, legendStyle: 'none' }).drawing);
assert.doesNotMatch(none, /유속 0\.3m\/s 미만/);
const both = mergeDrawings([det.drawing, det.drawing]);
assert.equal(both.width, 420 * 2 + 30);
});