app
KiCad - the KiCad Bridge
Public Made by Adomby adom
Drive your own KiCad from an AI, in the background: open every editor, read the whole board as data (the EDA Bridge Standard board model), stack-up, part heights, netlists, place, route, pour, DRC and exports.
master
John Lauer
0.9.341: verb-test runner (full coverage, foreground victim rule, user discovery, pcbnew ping), routing/readiness/libra…
4768cda
2mo ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155
import copy
from pathlib import Path
import sys
import tempfile
import unittest
from unittest.mock import patch
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from handlers import live_routing as live, route
def board():
return {'net_format':'name', 'nets':[{'name':'GND'}, {'name':'VCC'}, {'name':'123'}],
'layers':[{'name':s} for s in ['F.Cu','In1.Cu','B.Cu']],
'footprints':[{'reference':'J1','layer':'F.Cu','position':{'x':10,'y':20,'rotation':90},
'pads':[{'number':'1','x':2,'y':0,'net_name':'GND','layers':['*.Cu'],'type':'thru_hole'},
{'number':'2','x':0,'y':0,'net_name':'VCC','layers':['F.Cu'],'type':'smd'}]},
{'reference':'J2','layer':'B.Cu','position':{'x':30,'y':20},
'pads':[{'number':'1','x':0,'y':0,'net_name':'GND','layers':['B.Cu'],'type':'smd'}]}]}
class DrcSnapshot(unittest.TestCase):
def test_capped_opens_are_lower_bound_without_blocking_error_comparison(self):
limits = live._report_limits([], [{}] * 499)
self.assertTrue(limits['unconnectedCountIsLowerBound'])
self.assertFalse(limits['violationReportMayBeTruncated'])
self.assertEqual(live._new_errors({'violations': [], **limits}, {'violations': [], **limits}), [])
self.assertFalse(live._report_limits([], [{}] * 498)['unconnectedCountIsLowerBound'])
def test_per_type_caps_block_error_delta_even_when_reports_match(self):
for kind, cap in [('clearance', 499), ('shorting_items', 199)]:
v = {'type': kind, 'severity': 'error', 'items': []}
self.assertFalse(live._report_limits([v] * (cap - 1), [])['violationReportMayBeTruncated'])
report = {'violations': [v] * cap, **live._report_limits([v] * cap, [])}
with self.assertRaises(live.RoutingError) as cm:
live._new_errors(report, report)
self.assertEqual(cm.exception.code, 'drc_incomplete')
self.assertFalse(cm.exception.detail['mutated'])
def test_refills_snapshot_with_project_rules_without_changing_source(self):
import json
from types import SimpleNamespace
with tempfile.TemporaryDirectory() as tmp:
source = Path(tmp) / 'board.kicad_pcb'
source.write_text('original board')
source.with_suffix('.kicad_pro').write_text('project rules')
source.with_suffix('.kicad_dru').write_text('custom rules')
exe = Path(tmp) / 'kicad-cli'
exe.touch()
def run(command, **kwargs):
candidate = Path(command[-1])
self.assertNotEqual(candidate, source)
self.assertEqual(candidate.read_text(), 'live snapshot')
self.assertEqual(candidate.with_suffix('.kicad_pro').read_text(), 'project rules')
self.assertEqual(candidate.with_suffix('.kicad_dru').read_text(), 'custom rules')
self.assertIn('--refill-zones', command)
Path(command[command.index('--output') + 1]).write_text(json.dumps({
'violations': [], 'unconnected_items': [{'items': []}]}))
return SimpleNamespace(returncode=0, stderr='', stdout='')
with patch.object(live.subprocess, 'run', side_effect=run):
result = live._drc({'kicad_cli_exe': str(exe)}, {'filePath': str(source)}, 'live snapshot')
self.assertTrue(result['zonesRefilled'])
self.assertEqual(result['unconnected'], 1)
self.assertFalse(result['clean'])
self.assertEqual(source.read_text(), 'original board')
class Planning(unittest.TestCase):
def test_rotated_pad_coordinates_and_final_layer_transition(self):
p=live._plan(board(), {'net':'GND','points':['J1.1',{'pad':'J2.1','layer':'B.Cu'}]})
self.assertEqual(p['segments'][0]['start'],[10,18])
self.assertEqual(p['vias'][0]['at'],[30,20])
self.assertEqual(p['vias'][0]['layers'],['F.Cu','B.Cu'])
def test_branches_on_one_net(self):
p=live._plan(board(), {'net':'GND','paths':[['J1.1',[20,18]],[[20,18],[20,25]]]})
self.assertEqual(len(p['segments']),2)
def test_reject_wrong_net_and_layer(self):
for args,code in [({'net':'GND','points':['J1.1','J1.2']},'net_mismatch'),
({'net':'GND','points':['J1.1','J2.1']},'pad_layer_mismatch'),
({'net':'GND','points':['J1.1',{'x':20,'y':20,'layer':'F.SilkS'}]},'unknown_layer')]:
with self.subTest(code=code),self.assertRaises(live.RoutingError) as cm: live._plan(board(),args)
self.assertEqual(cm.exception.code,code)
def test_invalid_geometry_is_rejected_before_mutation(self):
for args in [{'width':0},{'width':-1},{'width':float('nan')},{'width':True},{'points':[[True,0],[1,1]]},{'points':[[0,0,9],[1,1]]},
{'viaDrill':0.8,'viaSize':0.6},{'points':[[0,0],[float('inf'),0]]},
{'points':[[0,0],[0,0]]}]:
with self.subTest(args=args),self.assertRaises(live.RoutingError):
live._plan(board(),{'net':'GND','points':[[0,0],[1,1]],**args})
def test_numeric_net_name_in_kicad_10_file(self):
from parsers.pcb import parse_pcb_text
data=parse_pcb_text('(kicad_pcb (version 20260206) (footprint "X" (layer "F.Cu") (pad "1" thru_hole circle (net "123"))))')
self.assertEqual(data["net_format"],"name")
self.assertEqual(data["nets"],[{"number":None,"name":"123"}])
def test_exact_numeric_net_name(self):
self.assertEqual(live._plan(board(),{'net':'123','points':[[0,0],[1,1]]})['netName'],'123')
def test_quoted_net_serialization(self):
self.assertIn('"a\\"b\\\\c"',route._net_sexpr({'format':'name','name':'a"b\\c'}))
def test_stale_revision(self):
for rev in [None,'old']:
with self.assertRaises(live.RoutingError): live._revision({'expectedRevision':rev},'new')
def test_existing_error_does_not_mask_additional_same_error(self):
v={'type':'clearance','severity':'error','description':'collision','items':[]}
self.assertEqual(live._new_errors({'violations':[v]},{'violations':[v,v]}),[v])
def test_open_file_refused_even_with_private_arg(self):
with patch.object(route,'_lock_file',return_value=Path('lock')):
self.assertEqual(route._load({'filePath':'test.kicad_pcb','_readOnly':True})[-1]['errorCode'],'board_open')
class Transactions(unittest.TestCase):
class Board:
def __init__(self): self.ops=[]
def begin_commit(self): self.ops.append('begin'); return 'commit'
def push_commit(self,c,m): self.ops.append('push')
def drop_commit(self,c): self.ops.append('drop')
def test_success_is_one_undo_step(self):
b=self.Board()
self.assertEqual(live._transaction(b,lambda:42,'route'),42)
self.assertEqual(b.ops,['begin','push'])
def test_partial_create_failure_rolls_back(self):
b=self.Board()
def fail(): raise RuntimeError('partial create')
with self.assertRaises(live.RoutingError) as cm: live._transaction(b,fail,'route')
self.assertEqual(cm.exception.code,'mutation_rolled_back')
self.assertEqual(b.ops,['begin','drop'])
def test_push_timeout_is_unknown_even_if_drop_acknowledged(self):
b = self.Board()
def push(*args): raise TimeoutError('commit response lost')
b.push_commit = push
with self.assertRaises(live.RoutingError) as cm:
live._transaction(b, lambda: 42, 'route')
self.assertEqual(cm.exception.code, 'mutation_outcome_unknown')
self.assertIsNone(cm.exception.detail['mutated'])
def test_failed_rollback_is_unknown_not_safe_to_retry(self):
b=self.Board()
def fail(*a): raise RuntimeError('lost IPC')
b.drop_commit=fail
with self.assertRaises(live.RoutingError) as cm: live._transaction(b,fail,'route')
self.assertEqual(cm.exception.code,'mutation_outcome_unknown')
self.assertIsNone(cm.exception.detail['mutated'])
if __name__=='__main__': unittest.main()