원본 SWMM 자료와 화면 제원 자동 검증 추가 (#1) #2
No files matched your search
@@ -38,3 +38,24 @@ python3 start.py
|
||||
- 맨홀 내경 1.2m는 표현용 가정이며 실제 벽 두께·시공 선형·측량 지반고는 복원하지 않습니다.
|
||||
- 좌표 누락·겹침, 입력 길이와 좌표 거리 차이, 입력 방향 기준 역경사 등이 남아 있습니다. 화면의 기존 데이터 점검 항목은 오류 확정이나 검증 완료를 뜻하지 않습니다.
|
||||
- 표시·계산의 정확성과 브라우저별 동작에 대한 정식 검증은 별도 과제입니다. 개선·검증 계획은 향후 Gitea 이슈로 관리합니다.
|
||||
|
||||
## 검증
|
||||
|
||||
원본 INP는 저장소에 포함하지 않습니다. 별도로 준비한 파일 경로를 지정합니다.
|
||||
|
||||
```bash
|
||||
python3 tools/validate_network.py --inp "/path/to/HC_A.inp" --output test-results/data-validation.json
|
||||
python3 -m unittest discover -s tests -p 'test_*.py' -v
|
||||
```
|
||||
|
||||
첫 명령의 종료 코드는 0=일치, 1=불일치, 2=입력 오류입니다. ID·수량·좌표·표고·접속·길이·단면 및 배치 제외 사유를 비교합니다. 수리 계산이나 실측 형상의 정확성을 인증하는 검사는 아닙니다.
|
||||
|
||||
화면 검증은 개발용 Playwright를 별도로 설치하고, 다른 터미널에서 `python3 start.py`를 실행한 상태로 수행합니다. 웹사이트 실행 자체에는 Node.js가 필요하지 않습니다.
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npx playwright install chromium
|
||||
npm run test:ui
|
||||
```
|
||||
|
||||
Linux에서 브라우저 실행에 필요한 시스템 패키지가 없으면 Playwright의 설치 안내를 따릅니다. 다른 주소는 `VIEWER_URL`, 설치된 Chrome/Edge는 `PLAYWRIGHT_CHANNEL=chrome` 또는 `msedge` 환경변수로 지정합니다. 결과 JSON과 화면 이미지는 Git에서 제외되는 `test-results/`에 저장합니다. 검사 범위와 실행 결과는 이슈 #1에서 관리합니다.
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "hwacheon-network-viewer-validation",
|
||||
"private": true,
|
||||
"scripts": { "test:ui": "node tests/viewer.cjs", "test:runner": "node tests/launch-failure.cjs" },
|
||||
"devDependencies": { "playwright": "1.62.1" }
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
// Reproduce an unavailable-browser failure without requiring a browser/server.
|
||||
const assert=require('node:assert/strict');
|
||||
const fs=require('node:fs');
|
||||
const path=require('node:path');
|
||||
const {spawnSync}=require('node:child_process');
|
||||
const folder=path.resolve(__dirname,'../test-results/launch-failure-case');
|
||||
fs.mkdirSync(folder,{recursive:true});
|
||||
const result=path.join(folder,'ui-validation.json');
|
||||
fs.writeFileSync(result,JSON.stringify({status:'passed',previous_run:true}));
|
||||
const run=spawnSync(process.execPath,[path.join(__dirname,'viewer.cjs')],{
|
||||
env:{...process.env,PLAYWRIGHT_CHANNEL:'intentionally-invalid-channel',VIEWER_RESULTS_DIR:folder},
|
||||
encoding:'utf8',timeout:20000
|
||||
});
|
||||
if(run.error)throw run.error;
|
||||
assert.equal(run.status,1,run.stderr||run.stdout);
|
||||
const report=JSON.parse(fs.readFileSync(result,'utf8'));
|
||||
assert.equal(report.status,'failed');
|
||||
assert.match(report.error,/intentionally-invalid-channel/);
|
||||
assert.equal(report.previous_run,undefined);
|
||||
console.log('PASS: browser launch failure returns 1 and overwrites stale success JSON.');
|
||||
@@ -0,0 +1,139 @@
|
||||
"""Synthetic fixtures: no company INP or network data is embedded in tests."""
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import json
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
spec=importlib.util.spec_from_file_location('validator',Path(__file__).resolve().parents[1]/'tools/validate_network.py')
|
||||
validator=importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(validator)
|
||||
|
||||
INP='''[OPTIONS]
|
||||
FLOW_UNITS CMS
|
||||
LINK_OFFSETS DEPTH
|
||||
[JUNCTIONS]
|
||||
A 10 3 0 0 0
|
||||
B 9 4 0 0 0
|
||||
[CONDUITS]
|
||||
P A B 5 0.015 0.2 0.1 0 0
|
||||
[XSECTIONS]
|
||||
P CIRCULAR 0.6 0 0 0 1
|
||||
[COORDINATES]
|
||||
A 0 0
|
||||
B 3 4
|
||||
'''
|
||||
|
||||
|
||||
def fixture(text=INP):
|
||||
return {'meta':{'sha256':hashlib.sha256(text.encode()).hexdigest(),'flowUnits':'CMS',
|
||||
'offsetMode':'DEPTH','physicalConversion':1},
|
||||
'nodes':[{'id':'A','type':'junction','line':5,'raw':'A 10 3 0 0 0'.split(),
|
||||
'invert':10,'maxDepth':3,'top':13,'xy':[0,0],'connections':['P']},
|
||||
{'id':'B','type':'junction','line':6,'raw':'B 9 4 0 0 0'.split(),
|
||||
'invert':9,'maxDepth':4,'top':13,'xy':[3,4],'connections':['P']}],
|
||||
'links':[{'id':'P','type':'conduit','line':8,'raw':'P A B 5 0.015 0.2 0.1 0 0'.split(),
|
||||
'fromNode':'A','toNode':'B','vertices':[],'pathXY':[[0,0],[3,4]],
|
||||
'planDistance':5,'length':5,'roughness':.015,'inletOffset':.2,'outletOffset':.1,
|
||||
'invertStart':10.2,'invertEnd':9.1,'centerStart':10.5,'centerEnd':9.4,
|
||||
'lengthDelta':0,'lengthDifferenceRatio':0,'slopeInput':.22,'slopePlan':.22,
|
||||
'renderable':True,'section':{'shape':'CIRCULAR','height':.6,'width':.6,
|
||||
'diameter':.6,'barrels':1,'raw':'P CIRCULAR 0.6 0 0 0 1'.split()}}],
|
||||
'summary':{'counts':{'junction':2,'outfall':0,'storage':0,'conduit':1,'pump':0,'weir':0},
|
||||
'coordinateCount':2,'shapes':{'CIRCULAR':1},'renderedConduits':1,'vertexCount':0},'issues':[]}
|
||||
|
||||
|
||||
class ValidationTests(unittest.TestCase):
|
||||
def run_case(self,data=None,text=INP):
|
||||
with tempfile.TemporaryDirectory() as folder:
|
||||
root=Path(folder)
|
||||
(root/'source.inp').write_bytes(text.encode())
|
||||
(root/'network.json').write_text(json.dumps(data or fixture(text)),encoding='utf8')
|
||||
return validator.validate(root/'source.inp',root/'network.json')
|
||||
|
||||
def test_matching_fixture(self):
|
||||
self.assertEqual(self.run_case()['status'],'passed')
|
||||
|
||||
def test_changed_diameter_is_caught(self):
|
||||
data=fixture();data['links'][0]['section']['diameter']=.9
|
||||
result=self.run_case(data)
|
||||
self.assertEqual(result['status'],'failed')
|
||||
self.assertIn('section.diameter',[e['field'] for e in result['mismatches']])
|
||||
|
||||
def test_changed_connection_is_caught(self):
|
||||
data=fixture();data['links'][0]['toNode']='A'
|
||||
self.assertEqual(self.run_case(data)['status'],'failed')
|
||||
|
||||
def test_missing_facility_is_caught(self):
|
||||
data=fixture();data['nodes'].pop()
|
||||
self.assertEqual(self.run_case(data)['status'],'failed')
|
||||
|
||||
def test_null_is_not_zero(self):
|
||||
data=fixture();data['nodes'][0]['invert']=None
|
||||
self.assertEqual(self.run_case(data)['status'],'failed')
|
||||
|
||||
def test_duplicate_inp_id_is_caught(self):
|
||||
text=INP+'A 5 6\n'
|
||||
self.assertEqual(self.run_case(fixture(text),text)['status'],'failed')
|
||||
|
||||
def test_source_hash_is_checked(self):
|
||||
data=fixture();data['meta']['sha256']='wrong'
|
||||
self.assertEqual(self.run_case(data)['status'],'failed')
|
||||
|
||||
def test_elevation_offsets_are_absolute(self):
|
||||
text=INP.replace('LINK_OFFSETS DEPTH','LINK_OFFSETS ELEVATION')
|
||||
data=fixture(text);data['meta']['offsetMode']='ELEVATION'
|
||||
data['links'][0].update(invertStart=.2,invertEnd=.1,centerStart=.5,centerEnd=.4)
|
||||
data['links'][0].update(slopeInput=.02,slopePlan=.02)
|
||||
self.assertEqual(self.run_case(data,text)['status'],'passed')
|
||||
|
||||
def test_rectangular_width_and_height_are_distinct(self):
|
||||
text=INP.replace('P CIRCULAR 0.6 0 0 0 1','P RECT_CLOSED 0.6 1.2 0 0 1')
|
||||
data=fixture(text)
|
||||
data['links'][0]['section'].update(shape='RECT_CLOSED',width=1.2,diameter=None,
|
||||
raw='P RECT_CLOSED 0.6 1.2 0 0 1'.split())
|
||||
data['summary']['shapes']={'RECT_CLOSED':1}
|
||||
self.assertEqual(self.run_case(data,text)['status'],'passed')
|
||||
data['links'][0]['section']['width']=.6
|
||||
self.assertEqual(self.run_case(data,text)['status'],'failed')
|
||||
|
||||
def test_missing_coordinate_requires_documented_exclusion(self):
|
||||
text=INP.replace('B 3 4\n','')
|
||||
data=fixture(text);data['nodes'][1]['xy']=None
|
||||
data['links'][0].update(pathXY=None,planDistance=None,renderable=False,
|
||||
lengthDelta=None,lengthDifferenceRatio=None,slopePlan=None)
|
||||
data['summary']['coordinateCount']=1
|
||||
data['summary']['renderedConduits']=0
|
||||
self.assertEqual(self.run_case(data,text)['status'],'failed')
|
||||
data['issues']=[{'code':'missing_coordinate','ids':['B']},{'code':'unplaced_link','ids':['P']}]
|
||||
self.assertEqual(self.run_case(data,text)['status'],'passed')
|
||||
|
||||
def test_unsupported_shape_does_not_pass(self):
|
||||
text=INP.replace('CIRCULAR','CUSTOM')
|
||||
with self.assertRaisesRegex(ValueError,'unsupported conduit shape'):
|
||||
self.run_case(fixture(text),text)
|
||||
|
||||
def test_coordinate_roundoff_inside_tolerance_passes(self):
|
||||
data=fixture();data['nodes'][1]['xy'][0]+=0.0000005
|
||||
data['links'][0]['pathXY'][1][0]+=0.0000005
|
||||
self.assertEqual(self.run_case(data)['status'],'passed')
|
||||
|
||||
def test_coordinate_change_outside_tolerance_fails(self):
|
||||
data=fixture();data['nodes'][1]['xy'][0]+=0.000002
|
||||
self.assertEqual(self.run_case(data)['status'],'failed')
|
||||
|
||||
def test_nested_comparison_rejects_nonfinite_boolean_and_null(self):
|
||||
for bad in (float('nan'),float('inf'),True,None):
|
||||
with self.subTest(bad=bad):
|
||||
self.assertFalse(validator.values_match({'xy':[bad,2]}, {'xy':[1,2]},1e-6))
|
||||
self.assertFalse(validator.values_match({'flag':1},{'flag':True},1e-6))
|
||||
|
||||
def test_nested_comparison_keeps_id_and_order(self):
|
||||
self.assertFalse(validator.values_match(['A','B'],['B','A'],1e-6))
|
||||
self.assertFalse(validator.values_match('1',1,1e-6))
|
||||
self.assertTrue(validator.values_match({'xy':[1.0000005,2]}, {'xy':[1,2]},1e-6))
|
||||
|
||||
|
||||
if __name__=='__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,90 @@
|
||||
// Run start.py first. VIEWER_URL defaults to http://127.0.0.1:8080.
|
||||
const {chromium}=require('playwright');
|
||||
const fs=require('node:fs/promises');
|
||||
const path=require('node:path');
|
||||
const assert=require('node:assert/strict');
|
||||
const base=process.env.VIEWER_URL||'http://127.0.0.1:8080';
|
||||
const out=process.env.VIEWER_RESULTS_DIR?path.resolve(process.env.VIEWER_RESULTS_DIR):path.resolve(__dirname,'../test-results');
|
||||
|
||||
(async()=>{
|
||||
await fs.mkdir(out,{recursive:true});
|
||||
let browser,page;
|
||||
const errors=[];
|
||||
let report={status:'failed',url:base};
|
||||
try{
|
||||
browser=await chromium.launch({headless:true,...(process.env.PLAYWRIGHT_CHANNEL?{channel:process.env.PLAYWRIGHT_CHANNEL}:{})});
|
||||
page=await browser.newPage({viewport:{width:1440,height:1000}});
|
||||
page.on('pageerror',e=>errors.push(String(e)));
|
||||
await page.goto(base,{waitUntil:'networkidle'});
|
||||
await page.waitForFunction(()=>document.querySelector('#loading')?.hidden && document.querySelector('#scene'));
|
||||
const data=await (await page.request.get(new URL('data/network.json',base).href)).json();
|
||||
assert.equal(await page.locator('#node-count').textContent(),String(data.summary.counts.junction));
|
||||
assert.equal(await page.locator('#pipe-count').textContent(),String(data.summary.counts.conduit));
|
||||
// Check every facility through the same selectFacility function used by UI events.
|
||||
// Separate click/search checks below exercise the actual event wiring.
|
||||
const inspected=await page.evaluate(async()=>{
|
||||
const {app,selectFacility}=await import('./app.js');
|
||||
const failures=[];
|
||||
const fmt=n=>Number(n).toLocaleString('ko-KR',{minimumFractionDigits:3,maximumFractionDigits:3});
|
||||
const prop=(label)=>[...document.querySelectorAll('#details .properties > div')]
|
||||
.find(r=>r.querySelector('dt')?.textContent===label)?.querySelector('dd')?.textContent;
|
||||
for(const f of app.facilities){
|
||||
selectFacility(f);
|
||||
const check=(label,expected)=>{if(prop(label)!==expected)failures.push({id:f.id,label,actual:prop(label),expected});};
|
||||
if(document.querySelector('#selected-id')?.textContent!==f.id)failures.push({id:f.id,label:'selected-id'});
|
||||
if(['junction','outfall','storage'].includes(f.type)){
|
||||
check('바닥 표고',`${fmt(f.invert)} m`);
|
||||
check('원본 X / Y',f.xy?`${fmt(f.xy[0])}${fmt(f.xy[1])}`:'좌표 없음 · 미배치');
|
||||
if(f.maxDepth!=null){check('최대 깊이',`${fmt(f.maxDepth)} m`);check('모델상 상단',`${fmt(f.top)} m`);}
|
||||
}else if(f.type==='conduit'){
|
||||
check('입력 길이',`${fmt(f.length)} m`);
|
||||
check('시작 관저고',`${fmt(f.invertStart)} m`);check('도착 관저고',`${fmt(f.invertEnd)} m`);
|
||||
check('단면',f.section.shape);
|
||||
if(f.section.shape==='CIRCULAR')check('안지름',`Ø ${fmt(f.section.diameter)} m`);
|
||||
else check('폭 × 높이',`${fmt(f.section.width)} × ${fmt(f.section.height)} m`);
|
||||
check('병렬 관 수',String(f.section.barrels));
|
||||
}
|
||||
const placed=f.xy!=null||f.renderable;
|
||||
if(document.querySelector('#focus-selected').disabled===Boolean(placed))failures.push({id:f.id,label:'placement button'});
|
||||
}
|
||||
return {count:app.facilities.length,failures};
|
||||
});
|
||||
assert.deepEqual(inspected.failures,[]);
|
||||
const samples=[data.nodes.find(n=>n.type==='junction'&&n.xy),
|
||||
data.links.find(l=>l.type==='conduit'&&l.renderable&&l.section.shape==='CIRCULAR'),
|
||||
data.links.find(l=>l.type==='conduit'&&l.renderable&&l.section.shape==='RECT_CLOSED'),
|
||||
...data.nodes.filter(n=>!n.xy),...data.links.filter(l=>!l.renderable)];
|
||||
for(let i=0;i<samples.length;i++){
|
||||
const f=samples[i];
|
||||
await page.locator('#search').fill(f.id);
|
||||
const result=page.locator('#search-results button').filter({has:page.locator('span',{hasText:new RegExp('^'+f.id.replace(/[.*+?^${}()|[\]\\]/g,'\\$&')+'$')})});
|
||||
await result.click();
|
||||
assert.equal(await page.locator('#selected-id').textContent(),f.id);
|
||||
if(i<3)await page.screenshot({path:path.join(out,`facility-${i}.png`),fullPage:true});
|
||||
}
|
||||
await page.locator('#search').fill('__NO_SUCH_FACILITY__');
|
||||
assert.equal(await page.locator('#search-results button').count(),0);
|
||||
await page.locator('#actual-scale').click();
|
||||
assert.equal(await page.locator('#height-scale').inputValue(),'1');
|
||||
assert.equal(await page.locator('#size-scale').inputValue(),'1');
|
||||
await page.locator('[data-view="plan"]').click();
|
||||
await page.waitForFunction(()=>document.querySelector('[data-view="plan"]').getAttribute('aria-pressed')==='true');
|
||||
await page.screenshot({path:path.join(out,'plan.png'),fullPage:true});
|
||||
for(const box of await page.locator('[data-layer]').all()){await box.uncheck();await box.check();}
|
||||
await page.locator('#open-audit').click();
|
||||
for(const id of [...data.nodes.filter(n=>!n.xy),...data.links.filter(l=>!l.renderable)].map(f=>f.id))
|
||||
assert.ok((await page.locator('#audit-list').textContent()).includes(id),`audit missing ${id}`);
|
||||
await page.locator('#audit-dialog .close-dialog').click();
|
||||
await page.locator('#reset').click();
|
||||
await page.screenshot({path:path.join(out,'overview.png'),fullPage:true});
|
||||
assert.deepEqual(errors,[]);
|
||||
report={status:'passed',facilities_checked:inspected.count,search_click_samples:samples.map(f=>f.id),
|
||||
checks:['all facility information panels','summary counts','real search/selection clicks','missing-facility explanations',
|
||||
'unknown ID','scale reset','plan view','layer toggles','audit dialog','reset','no page errors'],
|
||||
browser:browser.version(),limitations:['Information consistency; screenshot review is separate from dimensional geometry verification.']};
|
||||
}catch(e){report.error=e.stack;if(page)await page.screenshot({path:path.join(out,'failure.png'),fullPage:true}).catch(()=>{});process.exitCode=1;}
|
||||
finally{
|
||||
try{await fs.writeFile(path.join(out,'ui-validation.json'),JSON.stringify(report,null,2));console.log(JSON.stringify(report,null,2));}
|
||||
finally{if(browser)await browser.close();}
|
||||
}
|
||||
})().catch(e=>{console.error(e);process.exitCode=1;});
|
||||
@@ -0,0 +1,251 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Read-only INP/network.json comparison. Exit 0=match, 1=mismatch, 2=input error."""
|
||||
import argparse
|
||||
from collections import Counter, defaultdict
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
SECTIONS = {'JUNCTIONS':'junction', 'OUTFALLS':'outfall', 'STORAGE':'storage',
|
||||
'CONDUITS':'conduit', 'PUMPS':'pump', 'WEIRS':'weir'}
|
||||
|
||||
|
||||
def values_match(actual, expected, tolerance):
|
||||
"""Apply the same numeric contract at every nesting level."""
|
||||
if isinstance(expected, bool):
|
||||
return isinstance(actual, bool) and actual == expected
|
||||
if isinstance(expected, (float, int)):
|
||||
return (isinstance(actual, (float, int)) and not isinstance(actual, bool)
|
||||
and math.isfinite(actual) and math.isfinite(expected)
|
||||
and abs(actual - expected) <= tolerance)
|
||||
if isinstance(expected, list):
|
||||
return (isinstance(actual, list) and len(actual) == len(expected)
|
||||
and all(values_match(a, e, tolerance) for a, e in zip(actual, expected)))
|
||||
if isinstance(expected, dict):
|
||||
return (isinstance(actual, dict) and actual.keys() == expected.keys()
|
||||
and all(values_match(actual[k], v, tolerance) for k, v in expected.items()))
|
||||
return type(actual) is type(expected) and actual == expected
|
||||
|
||||
|
||||
def parse_inp(path):
|
||||
raw = Path(path).read_bytes()
|
||||
for encoding in ('utf-8-sig', 'cp949'):
|
||||
try:
|
||||
text = raw.decode(encoding)
|
||||
break
|
||||
except UnicodeDecodeError:
|
||||
continue
|
||||
else:
|
||||
raise ValueError('INP encoding must be UTF-8 or CP949')
|
||||
sections = defaultdict(list)
|
||||
section = None
|
||||
for line, value in enumerate(text.splitlines(), 1):
|
||||
value = value.split(';', 1)[0].strip()
|
||||
if not value:
|
||||
continue
|
||||
if value.startswith('[') and value.endswith(']'):
|
||||
section = value[1:-1].upper()
|
||||
elif section:
|
||||
sections[section].append((line, value.split()))
|
||||
return raw, sections
|
||||
|
||||
|
||||
def validate(inp, network, tolerance=1e-6):
|
||||
raw, sections = parse_inp(inp)
|
||||
data = json.loads(Path(network).read_text(encoding='utf-8-sig'))
|
||||
errors, exclusions = [], []
|
||||
checks = 0
|
||||
|
||||
def compare(entity, field, actual, expected):
|
||||
nonlocal checks
|
||||
checks += 1
|
||||
if not values_match(actual, expected, tolerance):
|
||||
errors.append({'id': entity, 'field': field, 'actual': actual, 'expected': expected})
|
||||
|
||||
def number(tokens, index, default=None):
|
||||
if index >= len(tokens):
|
||||
if default is not None:
|
||||
return default
|
||||
raise ValueError(f'missing numeric field: {tokens}')
|
||||
value = float(tokens[index])
|
||||
if not math.isfinite(value):
|
||||
raise ValueError(f'non-finite input: {tokens}')
|
||||
return value
|
||||
|
||||
def indexed(section):
|
||||
result = {}
|
||||
for line, tokens in sections[section]:
|
||||
if tokens[0] in result:
|
||||
errors.append({'id': tokens[0], 'field': section, 'error': 'duplicate INP ID'})
|
||||
result[tokens[0]] = (line, tokens)
|
||||
return result
|
||||
|
||||
options = {t[0]: t[1] for _, t in sections['OPTIONS']}
|
||||
units = options.get('FLOW_UNITS', 'CFS').upper()
|
||||
if units not in ('CFS', 'GPM', 'MGD', 'CMS', 'LPS', 'MLD'):
|
||||
raise ValueError('unsupported FLOW_UNITS: ' + units)
|
||||
factor = .3048 if units in ('CFS', 'GPM', 'MGD') else 1.0
|
||||
offset = options.get('LINK_OFFSETS', 'DEPTH').upper()
|
||||
if offset not in ('DEPTH', 'ELEVATION'):
|
||||
raise ValueError('unsupported LINK_OFFSETS: ' + offset)
|
||||
compare('meta', 'sha256', data['meta'].get('sha256'), hashlib.sha256(raw).hexdigest())
|
||||
compare('meta', 'flowUnits', data['meta'].get('flowUnits'), units)
|
||||
compare('meta', 'offsetMode', data['meta'].get('offsetMode'), offset)
|
||||
compare('meta', 'physicalConversion', data['meta'].get('physicalConversion'), factor)
|
||||
coordinates = {id: [number(t, 1), number(t, 2)]
|
||||
for id, (_, t) in indexed('COORDINATES').items()}
|
||||
xs = indexed('XSECTIONS')
|
||||
vertices = defaultdict(list)
|
||||
for _, t in sections['VERTICES']:
|
||||
vertices[t[0]].append([number(t, 1), number(t, 2)])
|
||||
source_nodes, source_links = {}, {}
|
||||
expected_counts = {}
|
||||
for section, kind in SECTIONS.items():
|
||||
source = indexed(section)
|
||||
expected_counts[kind] = len(source)
|
||||
destination = source_nodes if kind in ('junction','outfall','storage') else source_links
|
||||
for id, record in source.items():
|
||||
if id in destination:
|
||||
errors.append({'id': id, 'field': 'id', 'error': 'duplicate across facility types'})
|
||||
destination[id] = (kind, *record)
|
||||
if not source_nodes or not source_links:
|
||||
raise ValueError('INP has no supported network nodes or links')
|
||||
nodes = {n['id']: n for n in data['nodes']}
|
||||
links = {l['id']: l for l in data['links']}
|
||||
compare('nodes', 'unique IDs', len(nodes), len(data['nodes']))
|
||||
compare('links', 'unique IDs', len(links), len(data['links']))
|
||||
compare('nodes', 'IDs', sorted(nodes), sorted(source_nodes))
|
||||
compare('links', 'IDs', sorted(links), sorted(source_links))
|
||||
expected_connections = defaultdict(list)
|
||||
for id, (_, _, t) in source_links.items():
|
||||
expected_connections[t[1]].append(id)
|
||||
expected_connections[t[2]].append(id)
|
||||
for id, (kind, line, t) in source_nodes.items():
|
||||
if id not in nodes:
|
||||
continue
|
||||
n = nodes[id]
|
||||
depth = number(t, 2) * factor if kind != 'outfall' else None
|
||||
inv = number(t, 1) * factor
|
||||
for field, expected in {'type':kind, 'line':line, 'raw':t, 'invert':inv,
|
||||
'maxDepth':depth, 'top':None if depth is None else inv+depth,
|
||||
'xy':coordinates.get(id)}.items():
|
||||
compare(id, field, n.get(field), expected)
|
||||
if kind == 'outfall':
|
||||
has_stage=t[2].upper() not in ('FREE','NORMAL')
|
||||
gate_index=4 if has_stage else 3
|
||||
for field, expected in {'boundary':t[2], 'stage':t[3] if has_stage else None,
|
||||
'flapGate':t[gate_index] if len(t)>gate_index else 'NO'}.items():
|
||||
compare(id,field,n.get(field),expected)
|
||||
elif kind == 'storage':
|
||||
compare(id,'storageShape',n.get('storageShape'),t[4])
|
||||
compare(id,'storageCurve',n.get('storageCurve'),t[5] if t[4]=='TABULAR' else None)
|
||||
compare(id, 'connections', sorted(n.get('connections', [])), sorted(expected_connections[id]))
|
||||
if id not in coordinates:
|
||||
exclusions.append({'id':id, 'type':kind, 'reason':'missing_coordinate'})
|
||||
|
||||
rendered_conduits=0
|
||||
shape_counts=Counter()
|
||||
for id, (kind, line, t) in source_links.items():
|
||||
if id not in links:
|
||||
continue
|
||||
link = links[id]
|
||||
for field, expected in {'type':kind, 'line':line, 'raw':t,
|
||||
'fromNode':t[1], 'toNode':t[2], 'vertices':vertices[id]}.items():
|
||||
compare(id, field, link.get(field), expected)
|
||||
missing = t[1] not in coordinates or t[2] not in coordinates
|
||||
path = None if missing else [coordinates[t[1]], *vertices[id], coordinates[t[2]]]
|
||||
compare(id, 'pathXY', link.get('pathXY'), path)
|
||||
distance = None if missing else sum(math.dist(a,b) for a,b in zip(path,path[1:]))
|
||||
compare(id, 'planDistance', link.get('planDistance'), distance)
|
||||
reason = 'missing_coordinate' if missing else None
|
||||
if kind == 'pump':
|
||||
for field, expected in {'pumpCurve':t[3],'initialStatus':t[4],
|
||||
'startupDepth':number(t,5,0)*factor,
|
||||
'shutoffDepth':number(t,6,0)*factor}.items():
|
||||
compare(id,field,link.get(field),expected)
|
||||
elif kind == 'weir':
|
||||
crest=number(t,4)*factor
|
||||
elevation=crest if offset=='ELEVATION' else number(source_nodes[t[1]][2],1)*factor+crest
|
||||
for field, expected in {'weirType':t[3],'crestOffset':crest,'crestElevation':elevation,
|
||||
'dischargeCoefficient':number(t,5)}.items():
|
||||
compare(id,field,link.get(field),expected)
|
||||
if kind == 'conduit':
|
||||
if id not in xs:
|
||||
raise ValueError('missing XSECTIONS: ' + id)
|
||||
_, x = xs[id]
|
||||
shape = x[1].upper()
|
||||
shape_counts[shape]+=1
|
||||
if shape not in ('CIRCULAR', 'RECT_CLOSED', 'RECT_OPEN', 'TRAPEZOIDAL'):
|
||||
raise ValueError('unsupported conduit shape: ' + shape)
|
||||
height = number(x,2) * factor
|
||||
width = height if shape == 'CIRCULAR' else number(x,3) * factor
|
||||
section = link.get('section') or {}
|
||||
for field, expected in {'shape':shape,'height':height,'width':width,
|
||||
'diameter':height if shape=='CIRCULAR' else None,
|
||||
'barrels':number(x,6,1),'raw':x}.items():
|
||||
compare(id, 'section.'+field, section.get(field), expected)
|
||||
if shape == 'TRAPEZOIDAL':
|
||||
compare(id, 'section.sideSlopes', section.get('sideSlopes'), [number(x,4),number(x,5)])
|
||||
start = number(t,5,0)*factor
|
||||
end = number(t,6,0)*factor
|
||||
inv_start = start if offset=='ELEVATION' else number(source_nodes[t[1]][2],1)*factor+start
|
||||
inv_end = end if offset=='ELEVATION' else number(source_nodes[t[2]][2],1)*factor+end
|
||||
for field, expected in {'length':number(t,3)*factor,'roughness':number(t,4),
|
||||
'inletOffset':start,'outletOffset':end,'invertStart':inv_start,
|
||||
'invertEnd':inv_end,'centerStart':inv_start+height/2,
|
||||
'centerEnd':inv_end+height/2}.items():
|
||||
compare(id, field, link.get(field), expected)
|
||||
length=number(t,3)*factor
|
||||
for field, expected in {'lengthDelta':None if distance is None else distance-length,
|
||||
'lengthDifferenceRatio':None if distance is None or length<=0 else abs(distance-length)/length,
|
||||
'slopeInput':None if length<=0 else (inv_start-inv_end)/length,
|
||||
'slopePlan':None if distance is None or distance<=1e-9 else (inv_start-inv_end)/distance}.items():
|
||||
compare(id,field,link.get(field),expected)
|
||||
if distance is not None and distance <= 1e-9:
|
||||
reason = 'coincident_endpoints'
|
||||
if reason is None:
|
||||
rendered_conduits+=1
|
||||
compare(id, 'renderable', link.get('renderable'), reason is None)
|
||||
if reason:
|
||||
exclusions.append({'id':id,'type':kind,'reason':reason})
|
||||
compare('summary','counts',data['summary'].get('counts'),expected_counts)
|
||||
compare('summary','shapes',data['summary'].get('shapes'),dict(shape_counts))
|
||||
compare('summary','renderedConduits',data['summary'].get('renderedConduits'),rendered_conduits)
|
||||
compare('summary','vertexCount',data['summary'].get('vertexCount'),sum(len(v) for v in vertices.values()))
|
||||
compare('summary','coordinateCount',data['summary'].get('coordinateCount'),
|
||||
sum(id in coordinates for id in source_nodes))
|
||||
for excluded in exclusions:
|
||||
ids = excluded['id']
|
||||
allowed = ('missing_coordinate',) if ids in nodes else ('unplaced_link','coincident_endpoints')
|
||||
compare(ids,'exclusion documented',any(ids in i.get('ids',[]) and i.get('code') in allowed
|
||||
for i in data['issues']),True)
|
||||
return {'status':'passed' if not errors else 'failed','checks':checks,
|
||||
'counts':expected_counts,'mismatches':errors,'excluded_facilities':exclusions,
|
||||
'tolerance_absolute':tolerance,
|
||||
'scope':'Input/JSON consistency only; no hydraulic or surveyed geometry validation.'}
|
||||
|
||||
|
||||
def main():
|
||||
parser=argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--inp',required=True,type=Path)
|
||||
parser.add_argument('--network',type=Path,default=Path(__file__).resolve().parents[1]/'web/data/network.json')
|
||||
parser.add_argument('--output',type=Path)
|
||||
args=parser.parse_args()
|
||||
try:
|
||||
report=validate(args.inp,args.network)
|
||||
code=0 if report['status']=='passed' else 1
|
||||
except (OSError,ValueError,KeyError,IndexError,TypeError) as exc:
|
||||
report={'status':'input_error','error':str(exc)}
|
||||
code=2
|
||||
text=json.dumps(report,ensure_ascii=False,indent=2)
|
||||
if args.output:
|
||||
args.output.parent.mkdir(parents=True,exist_ok=True)
|
||||
args.output.write_text(text+'\n',encoding='utf-8')
|
||||
print(text)
|
||||
return code
|
||||
|
||||
|
||||
if __name__=='__main__':
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,87 @@
|
||||
{
|
||||
"data": {
|
||||
"status": "passed",
|
||||
"checks": 29378,
|
||||
"counts": {
|
||||
"junction": 805,
|
||||
"outfall": 9,
|
||||
"storage": 1,
|
||||
"conduit": 843,
|
||||
"pump": 3,
|
||||
"weir": 1
|
||||
},
|
||||
"mismatches": [],
|
||||
"excluded_facilities": [
|
||||
{
|
||||
"id": "OUT_PUMP",
|
||||
"type": "outfall",
|
||||
"reason": "missing_coordinate"
|
||||
},
|
||||
{
|
||||
"id": "UHR1-01411",
|
||||
"type": "conduit",
|
||||
"reason": "coincident_endpoints"
|
||||
},
|
||||
{
|
||||
"id": "C_OUT_PUMP",
|
||||
"type": "conduit",
|
||||
"reason": "missing_coordinate"
|
||||
}
|
||||
],
|
||||
"tolerance_absolute": 0.000001,
|
||||
"scope": "Input/JSON consistency only; no hydraulic or surveyed geometry validation."
|
||||
},
|
||||
"browser": {
|
||||
"status": "passed",
|
||||
"facilities_checked": 1662,
|
||||
"search_click_samples": [
|
||||
"MH3",
|
||||
"UHR1-0058",
|
||||
"UHR1-02901",
|
||||
"OUT_PUMP",
|
||||
"UHR1-01411",
|
||||
"C_OUT_PUMP"
|
||||
],
|
||||
"checks": [
|
||||
"all facility information panels",
|
||||
"summary counts",
|
||||
"real search/selection clicks",
|
||||
"missing-facility explanations",
|
||||
"unknown ID",
|
||||
"scale reset",
|
||||
"plan view",
|
||||
"layer toggles",
|
||||
"audit dialog",
|
||||
"reset",
|
||||
"no page errors"
|
||||
],
|
||||
"browser": "156.0.4314.42",
|
||||
"limitations": [
|
||||
"Information consistency; screenshot review is separate from dimensional geometry verification."
|
||||
]
|
||||
},
|
||||
"validator_unit_tests": {
|
||||
"passed": 15,
|
||||
"failed": 0
|
||||
},
|
||||
"tested_source_sha256": "1919a11dbb7a4b1d6988da1b9a4bda4529655fd89abc7285cf10703a1d7c1843",
|
||||
"tested_network_blob": "55f77ce41696118dcf1e0c15b7eafb60271ff9d5",
|
||||
"tested_base_commit": "5e81e9cfe9a67c1589f50ff087e80c4893e01b64",
|
||||
"environment": "Windows Python 3.12 / Playwright 1.62.1 / headless Edge; WSL execution not yet verified",
|
||||
"visual_review": [
|
||||
"overview.png",
|
||||
"facility-2.png"
|
||||
],
|
||||
"application_files_changed": false,
|
||||
"followup_issues": {
|
||||
"3": {
|
||||
"status": "tested",
|
||||
"change": "Recursive coordinate tolerance; 4 additional regression tests"
|
||||
},
|
||||
"4": {
|
||||
"status": "tested",
|
||||
"runner_regression": "Invalid channel overwrites prior success JSON; exit 1 verified",
|
||||
"normal_browser_rerun": "passed; 1662 facility panels"
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user