Executable
@@ -0,0 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
PYTHONPATH=backend .venv/bin/python -m pytest backend/tests -q
|
||||
npm test
|
||||
@@ -0,0 +1,28 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Concurrent actual HTTP uploads; run only against a disposable QA deployment."""
|
||||
import argparse
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import time
|
||||
import httpx
|
||||
p=argparse.ArgumentParser();p.add_argument('file',type=Path);p.add_argument('--url',default='http://127.0.0.1:8000');p.add_argument('--count',type=int,default=8);a=p.parse_args()
|
||||
raw=a.file.read_bytes();key=os.environ['API_KEY']
|
||||
|
||||
def one(_):
|
||||
with httpx.Client(base_url=a.url,timeout=30) as client:
|
||||
response=client.post('/api/v1/schematics/render',files={'file':(a.file.name,raw)},headers={'X-API-Key':key})
|
||||
assert response.status_code==202,response.text
|
||||
return response.json()
|
||||
with ThreadPoolExecutor(max_workers=a.count) as pool: jobs=list(pool.map(one,range(a.count)))
|
||||
assert len({job['id'] for job in jobs})==a.count
|
||||
with httpx.Client(base_url=a.url,timeout=30) as client:
|
||||
for job in jobs:
|
||||
deadline=time.monotonic()+90
|
||||
while time.monotonic()<deadline:
|
||||
body=client.get(f"/api/v1/schematics/{job['id']}/status",headers={'X-Viewer-Token':job['viewer_token']}).json()
|
||||
if body['status'] in ('completed','failed'):break
|
||||
time.sleep(.2)
|
||||
assert body['status']=='completed',body
|
||||
print(json.dumps({'concurrent_uploads':a.count,'unique_ids':a.count,'completed':a.count}))
|
||||
@@ -0,0 +1,24 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Audit a directory of real inputs without copying design files into source."""
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
sys.path.insert(0,str(Path(__file__).resolve().parents[1]/'backend'))
|
||||
from app.parser.schdoc_parser import parse_schdoc
|
||||
from app.render import svg_document
|
||||
p=argparse.ArgumentParser();p.add_argument('directory',type=Path);p.add_argument('--output',type=Path,default=Path('validation-output/corpus.json'));a=p.parse_args()
|
||||
results=[]
|
||||
for path in sorted(a.directory.glob('*')):
|
||||
if path.suffix.lower()!='.schdoc': continue
|
||||
raw=path.read_bytes()
|
||||
result={'filename':path.name,'sha256':hashlib.sha256(raw).hexdigest()}
|
||||
try:
|
||||
doc=parse_schdoc(raw,path.name,'audit'); svg=svg_document(doc)
|
||||
result.update(status='parsed',record_count=doc.metadata['record_count'],primitive_count=len(doc.primitives),component_count=len(doc.components),svg_bytes=len(svg),diagnostics=[d.model_dump() for d in doc.diagnostics])
|
||||
except Exception as e: result.update(status='failed',error=str(e))
|
||||
results.append(result)
|
||||
a.output.parent.mkdir(parents=True,exist_ok=True);a.output.write_text(json.dumps(results,indent=2))
|
||||
print(json.dumps({'files':len(results),'parsed':sum(r['status']=='parsed' for r in results),'failed':sum(r['status']=='failed' for r in results),'output':str(a.output)}))
|
||||
sys.exit(1 if not results or any(r['status']=='failed' for r in results) else 0)
|
||||
Executable
@@ -0,0 +1,9 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
export PYTHONPATH="$PWD/backend"
|
||||
if [[ -f .env ]]; then set -a; source .env; set +a; fi
|
||||
.venv/bin/python -m app.worker &
|
||||
worker_pid=$!
|
||||
trap 'kill "$worker_pid" 2>/dev/null || true' EXIT INT TERM
|
||||
.venv/bin/python -m uvicorn app.main:app --host 127.0.0.1 --port 8000 --no-access-log
|
||||
@@ -0,0 +1,14 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Fetch a real upstream native SchDoc; checksum-pin it and never redistribute."""
|
||||
import hashlib
|
||||
from pathlib import Path
|
||||
from urllib.request import urlopen
|
||||
URL='https://raw.githubusercontent.com/KiCad/kicad-source-mirror/master/qa/data/eeschema/plugins/altium/issue24861/Channel.SchDoc'
|
||||
SHA='e7dc42a4e983e6b716f6883eb0863fe47164d941636e11b1f2b5ec5d0143dca4'
|
||||
root=Path(__file__).resolve().parents[1]
|
||||
target=root/'.runtime/fixtures/Channel.SchDoc'
|
||||
if not target.exists() or hashlib.sha256(target.read_bytes()).hexdigest()!=SHA:
|
||||
with urlopen(URL,timeout=30) as response: data=response.read(4*1024*1024)
|
||||
if hashlib.sha256(data).hexdigest()!=SHA: raise SystemExit('Upstream fixture changed; review before updating expected hash')
|
||||
target.parent.mkdir(parents=True,exist_ok=True); target.write_bytes(data)
|
||||
print(target)
|
||||
@@ -0,0 +1,35 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Exercise the actual HTTP API and worker using an actual native file."""
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import time
|
||||
import httpx
|
||||
|
||||
p=argparse.ArgumentParser()
|
||||
p.add_argument('file',type=Path)
|
||||
p.add_argument('--url',default='http://127.0.0.1:8000')
|
||||
a=p.parse_args()
|
||||
key=os.getenv('API_KEY','')
|
||||
with httpx.Client(base_url=a.url,timeout=30) as client:
|
||||
response=client.post('/api/v1/schematics/render',files={'file':(a.file.name,a.file.read_bytes())},headers={'X-API-Key':key})
|
||||
assert response.status_code==202,response.text
|
||||
body=response.json(); id=body['id']; headers={'X-Viewer-Token':body['viewer_token']}
|
||||
base=f'/api/v1/schematics/{id}'
|
||||
assert client.get(base+'/data').status_code==404
|
||||
deadline=time.monotonic()+90
|
||||
while time.monotonic()<deadline:
|
||||
status=client.get(base+'/status',headers=headers).json()
|
||||
if status['status'] in ('completed','failed'): break
|
||||
time.sleep(.5)
|
||||
assert status['status']=='completed',status
|
||||
data=client.get(base+'/data',headers=headers)
|
||||
assert data.status_code==200 and data.json()['metadata']['format']=='ole'
|
||||
svg=client.get(base+'/svg',headers=headers)
|
||||
assert svg.status_code==200 and '<svg' in svg.text
|
||||
assert client.get('/view/'+id).status_code==200
|
||||
assert client.get('/docs').status_code==200
|
||||
print(json.dumps({'id':id,'status':status['status'],'compatibility':status['compatibility'],
|
||||
'records':status['record_count'],'primitives':status['primitive_count'],
|
||||
'private_access_enforced':True,'svg_bytes':len(svg.content)},indent=2))
|
||||
Executable
+61
@@ -0,0 +1,61 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Render actual SchDoc and compare to a trusted Altium page PNG, without alignment.
|
||||
|
||||
Images must represent the same sheet viewport. No registration, recoloring or
|
||||
cropping hides coordinate differences. Altium PDF exports can be rasterized to
|
||||
PNG with pdftoppm first. This tool does not manufacture reference images.
|
||||
"""
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
sys.path.insert(0,str(Path(__file__).resolve().parents[1]/'backend'))
|
||||
import cairosvg
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
from app.parser.schdoc_parser import parse_schdoc
|
||||
from app.render import svg_document
|
||||
|
||||
|
||||
def main():
|
||||
p=argparse.ArgumentParser(description=__doc__)
|
||||
p.add_argument('schematic',type=Path)
|
||||
p.add_argument('--reference',type=Path)
|
||||
p.add_argument('--output',type=Path,default=Path('validation-output'))
|
||||
p.add_argument('--width',type=int,default=2000)
|
||||
p.add_argument('--max-mae',type=float,default=2.0,help='Mean absolute RGB error threshold, 0..255')
|
||||
p.add_argument('--max-changed-fraction',type=float,default=.01)
|
||||
p.add_argument('--pixel-tolerance',type=int,default=12)
|
||||
args=p.parse_args(); args.output.mkdir(parents=True,exist_ok=True)
|
||||
raw=args.schematic.read_bytes(); doc=parse_schdoc(raw,args.schematic.name,'validation')
|
||||
svg=svg_document(doc); (args.output/'drawing.svg').write_bytes(svg)
|
||||
width=args.width; height=max(1,round(width*doc.sheet.bounds.height/doc.sheet.bounds.width))
|
||||
if args.reference:
|
||||
with Image.open(args.reference) as ref: width,height=ref.size
|
||||
expected_ratio=doc.sheet.bounds.width/doc.sheet.bounds.height
|
||||
if abs(width/height/expected_ratio-1)>.005:
|
||||
p.error('Reference aspect ratio differs from original sheet; export the same viewport')
|
||||
png=cairosvg.svg2png(bytestring=svg,output_width=width,output_height=height)
|
||||
(args.output/'rendered.png').write_bytes(png)
|
||||
report={'source_sha256':hashlib.sha256(raw).hexdigest(),'record_count':doc.metadata['record_count'],
|
||||
'component_count':len(doc.components),'primitive_count':len(doc.primitives),
|
||||
'diagnostics':[d.model_dump() for d in doc.diagnostics],
|
||||
'reference_compared':bool(args.reference),'fidelity_verified':False}
|
||||
passed=True
|
||||
if args.reference:
|
||||
reference=Image.open(args.reference).convert('RGB'); actual=Image.open(args.output/'rendered.png').convert('RGB')
|
||||
a=np.array(actual,dtype=np.int16); b=np.array(reference,dtype=np.int16); delta=np.abs(a-b)
|
||||
mae=float(delta.mean()); changed=float((delta.max(axis=2)>args.pixel_tolerance).mean())
|
||||
heat=np.zeros_like(a,dtype=np.uint8); heat[:,:,0]=np.clip(delta.max(axis=2)*4,0,255).astype(np.uint8)
|
||||
Image.fromarray(heat).save(args.output/'difference.png')
|
||||
Image.blend(reference,actual,.5).save(args.output/'overlay.png')
|
||||
passed=mae<=args.max_mae and changed<=args.max_changed_fraction
|
||||
report.update(reference_sha256=hashlib.sha256(args.reference.read_bytes()).hexdigest(),mae=mae,
|
||||
changed_pixel_fraction=changed,thresholds={'max_mae':args.max_mae,'max_changed_fraction':args.max_changed_fraction,'pixel_tolerance':args.pixel_tolerance},
|
||||
regression_passed=passed)
|
||||
(args.output/'report.json').write_text(json.dumps(report,indent=2))
|
||||
print(json.dumps({k:v for k,v in report.items() if k!='diagnostics'},indent=2))
|
||||
return 0 if passed else 1
|
||||
|
||||
if __name__=='__main__': sys.exit(main())
|
||||
مرجع در شماره جدید
مسدود کردن کاربر