Files
SPipe/tests/fixes.test.ts
T
bjkim1 21ebb16fb7 SPipe 데모 v2 (2026-10-30) 서버 배포본
- SPipe_server_2026-10-30.zip 내용을 풀어서 등록 (압축 파일은 올리지 않음)
- README에 데모 v2 서버 업데이트 순서 추가, 저장소에 없는 문서 목차 정리
- 패치내역을 docs/로 이동, _worklog.md 추가
- 임시 파일(backend/temp.*)과 API 키가 든 파일은 제외
2026-10-30 11:00:46 +09:00

848 lines
40 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));
});