131 lines
5.6 KiB
Python
131 lines
5.6 KiB
Python
#!/usr/bin/env python3
|
|
"""Behavioral checks for ordering, semantics, timing, geometry and CLI diagnostics."""
|
|
import copy
|
|
import json
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import unittest
|
|
import xml.etree.ElementTree as ET
|
|
from pathlib import Path
|
|
from render_map import Renderer, smooth, overlaps
|
|
|
|
ROOT=Path(__file__).resolve().parents[1]
|
|
|
|
|
|
def demo(name):
|
|
return json.loads((ROOT/'assets'/f'{name}-demo.json').read_text())
|
|
|
|
|
|
class MapTests(unittest.TestCase):
|
|
def test_examples_are_valid_deterministic_and_labels_clear(self):
|
|
for name in ('metro','timeline'):
|
|
with self.subTest(name=name):
|
|
d=demo(name)
|
|
renderer=Renderer(copy.deepcopy(d))
|
|
svg,report=renderer.render()
|
|
svg2,report2=Renderer(copy.deepcopy(d)).render()
|
|
self.assertEqual(svg,svg2)
|
|
self.assertEqual(report,report2)
|
|
root=ET.fromstring(svg)
|
|
self.assertEqual(len(root.findall('.//{*}g[@data-node]')),len(d['nodes']))
|
|
self.assertEqual(len(root.findall('.//{*}g[@data-route]')),len(d['routes']))
|
|
self.assertFalse([w for w in report['warnings'] if w['kind']!='overpass'])
|
|
labels=[b for b in renderer.boxes if b[-1]=='label']
|
|
for i,a in enumerate(labels):
|
|
self.assertFalse(any(overlaps(a[:4],b[:4]) for b in labels[i+1:]))
|
|
for a in labels:
|
|
self.assertFalse(any(overlaps(a[:4],b[:4]) for b in renderer.boxes if b[-1] in ('station','endpoint')))
|
|
|
|
def test_metro_order_respects_all_route_edges(self):
|
|
d=demo('metro')
|
|
_,report=Renderer(d).render()
|
|
for route in d['routes']:
|
|
ys=[report['positions'][n]['y'] for n in route['stops']]
|
|
self.assertTrue(all(a<b for a,b in zip(ys,ys[1:])))
|
|
|
|
def test_shared_station_has_exact_membership(self):
|
|
r=Renderer(demo('metro'))
|
|
_,report=r.render()
|
|
self.assertEqual(set(report['positions']['pilot']['xs']),{'design','qual'})
|
|
self.assertIn({'kind':'overpass','node':'pilot','route':'recruit'},report['warnings'])
|
|
self.assertNotIn('recruit',r.members['pilot'])
|
|
|
|
def test_time_scale_and_label_adjustments_preserve_event_time(self):
|
|
d=demo('timeline')
|
|
r=Renderer(copy.deepcopy(d))
|
|
_,report=r.render()
|
|
for node in ('s1','p1','d1','p6'):
|
|
fraction={'s1':0,'p1':.28,'d1':.59,'p6':1}[node]
|
|
self.assertAlmostEqual(report['positions'][node]['y'],r.top+fraction*(r.bottom-r.top))
|
|
d['nodes'][0]['label_dy']=52
|
|
_,changed=Renderer(d).render()
|
|
self.assertEqual(report['positions'],changed['positions'])
|
|
|
|
def test_easing_geometry_and_station_positions(self):
|
|
r=Renderer(demo('metro'))
|
|
_,report=r.render()
|
|
for pos in report['positions'].values():
|
|
for rid,x in pos['xs'].items():
|
|
self.assertAlmostEqual(x,r.rx(rid,pos['t']))
|
|
value,derivative=smooth([[0,0],[1,90]],.25)
|
|
self.assertAlmostEqual(value,14.0625)
|
|
self.assertAlmostEqual(derivative,101.25)
|
|
|
|
def test_cycle_is_rejected(self):
|
|
d={'routes':[{'id':'a','stops':['x','y']},{'id':'b','stops':['y','x']}], 'nodes':[{'id':'x'},{'id':'y'}]}
|
|
with self.assertRaisesRegex(ValueError,'cycle'):
|
|
Renderer(d).render()
|
|
|
|
def test_invalid_membership_and_ids_are_rejected(self):
|
|
changes=[lambda d:d['routes'][0]['stops'].append('missing'),lambda d:d['nodes'].append(copy.deepcopy(d['nodes'][0])),lambda d:d['nodes'].append({'id':'orphan'})]
|
|
for change in changes:
|
|
d=demo('metro')
|
|
change(d)
|
|
with self.assertRaises(ValueError):
|
|
Renderer(d).render()
|
|
|
|
def test_invalid_time_scale_and_offsets_are_rejected(self):
|
|
for scale in ([[1800,0],[1900,.5],[2000,.9]],[[1800,0],[1900,.8],[1950,.7],[2000,1]]):
|
|
d=demo('timeline')
|
|
d['layout']['time_scale']=scale
|
|
with self.assertRaises(ValueError):
|
|
Renderer(d).render()
|
|
d=demo('metro')
|
|
d['routes'][0]['offsets']=[[-1,0],[1,0]]
|
|
with self.assertRaises(ValueError):
|
|
Renderer(d).render()
|
|
|
|
def test_svg_text_is_escaped(self):
|
|
d=demo('metro')
|
|
d['title']='<script>& "示例"'
|
|
svg,_=Renderer(d).render()
|
|
root=ET.fromstring(svg)
|
|
self.assertEqual(root.find('{*}title').text,d['title'])
|
|
self.assertNotIn('<script>',svg)
|
|
|
|
def test_cli_strict_preserves_reviewable_output_and_returns_failure(self):
|
|
d=demo('metro')
|
|
d['nodes'][0]['label']='超长标签'*100
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
src=Path(tmp)/'map.json'
|
|
out=Path(tmp)/'map.svg'
|
|
src.write_text(json.dumps(d))
|
|
result=subprocess.run([sys.executable,str(ROOT/'scripts'/'render_map.py'),str(src),'-o',str(out),'--strict'],capture_output=True,text=True)
|
|
self.assertEqual(result.returncode,2,result.stderr)
|
|
self.assertTrue(out.exists())
|
|
report=json.loads(out.with_suffix('.report.json').read_text())
|
|
self.assertTrue(any(w['kind']=='label_needs_review' for w in report['warnings']))
|
|
|
|
def test_single_node_route_and_extra_legend_rows(self):
|
|
d={'routes':[{'id':str(i),'stops':['x']} for i in range(5)],'nodes':[{'id':'x','label':'共享'}]}
|
|
r=Renderer(d)
|
|
svg,report=r.render()
|
|
ET.fromstring(svg)
|
|
self.assertEqual(report['positions']['x']['y'],r.top)
|
|
self.assertEqual(len(report['positions']['x']['xs']),5)
|
|
|
|
|
|
if __name__=='__main__':
|
|
unittest.main()
|