app
AI Flow
Public Made by Adomby adom
Adom's AI Flow: a tool to help the AI follow all of the steps it takes to build a board.
main
Colby Knox
Merge PR #279: 0.2.13: Altium library refuses a symbol spec without pin types
bc2975b
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402
"""The Altium lane's symbol check and project-library parts, with no Altium and no bridge:
- check_symbols (tools/altium-lane.py) on plain data shaped as altium_schematic_data, altium_symbol_prims
and the altium-symbol-modes spec return them: pins off the body edge (the symbol_modes bug fixed
2026-10-19) are errors, pin types on active parts follow the KiCad check's rules, a diode drawn
backwards is an error, and a symbol with no kept spec is checked on its drawn mode only;
- project-library parts: the part's one symbol cut out of a many-symbol library, staged as the lane's
pages/<key>/, and `altium library` run end to end against fake adom-bridge / converter CLIs on PATH:
the project-library part goes through the same converters and altium_import_part as a wiki part.
python3 tests/altium-lane.py
"""
import importlib.util, json, os, subprocess, sys, tempfile, unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.dont_write_bytecode = True # no tools/__pycache__ from loading the lane helpers
_spec = importlib.util.spec_from_file_location('altium_lane', ROOT / 'tools' / 'altium-lane.py')
lane = importlib.util.module_from_spec(_spec); _spec.loader.exec_module(lane)
# a 6-pin IC in symbol_modes' 2-column look: body 0..800 x -400..0 mil, pins on its left and right edges
IC_GOOD = """META|TPS54202DDCR|buck|U
MODE|0|std
RECT|0|0|-400|800|0
PIN|0|1|GND|0|-100|left|300
PIN|0|2|SW|0|-200|left|300
PIN|0|3|VIN|0|-300|left|300
PIN|0|4|FB|800|-300|right|300
PIN|0|5|EN|800|-200|right|300
PIN|0|6|BOOT|800|-100|right|300
MODE|1|2col
RECT|1|0|-400|800|0
PIN|1|1|GND|0|-100|left|300
PIN|1|2|SW|0|-200|left|300
PIN|1|3|VIN|0|-300|left|300
PIN|1|4|FB|800|-100|right|300
PIN|1|5|EN|800|-200|right|300
PIN|1|6|BOOT|800|-300|right|300
"""
def typed(spec, etype='passive'):
"""a spec as altium-symbol-modes writes it since altium-bridge 0.2.53: every PIN row ends hide|etype"""
rows = []
for l in spec.splitlines():
f = l.split('|')
if f[0] == 'PIN': f = (f + [''] * 9)[:9] + [etype]
rows.append('|'.join(f))
return '\n'.join(rows) + '\n'
IC_UNTYPED = IC_GOOD # as an altium-symbol-modes before altium-bridge 0.2.53 wrote it: no pin types
IC_GOOD = typed(IC_GOOD)
# the same symbol as symbol_modes wrote it before 2026-10-19: every pin's OUTER end, one pin length off the box
IC_BAD = IC_GOOD.replace('|0|-100|left', '|-300|-100|left').replace('|0|-200|left', '|-300|-200|left').replace('|0|-300|left', '|-300|-300|left').replace('|800|-', '|1100|-')
# KiCad's Device:D as native_lines converts it: K left, bar and triangle apex at the K end (mil, y up)
DIODE_GOOD = """META|D-KEY|diode|D
MODE|0|native
LINE|0|-50|50|-50|-50
LINE|0|50|0|-50|0
LINE|0|50|50|50|-50
LINE|0|50|-50|-50|0
LINE|0|-50|0|50|50
PIN|0|1|K|-50|0|left|100|hide
PIN|0|2|A|50|0|right|100|hide
"""
DIODE_GOOD = typed(DIODE_GOOD)
# the drawing mirrored, pins kept: the triangle and the bar point at the anode
DIODE_BAD = """META|D-KEY|diode|D
MODE|0|native
LINE|0|50|50|50|-50
LINE|0|-50|0|50|0
LINE|0|-50|50|-50|-50
LINE|0|-50|-50|50|0
LINE|0|50|0|-50|50
PIN|0|1|K|-50|0|left|100|hide
PIN|0|2|A|50|0|right|100|hide
"""
def placed(ref, libref, spec_text, at=(1000, 2000), types=None, mode=0):
"""A placed part as altium_schematic_data reports it: mode-0 pins at their sheet position."""
sym = lane.parse_symbol_spec(spec_text)
pins = []
orient = {'right': 0, 'up': 1, 'left': 2, 'down': 3}
for p in sym['modes'][mode]['pins']:
pins.append({'number': p['number'], 'name': p['name'], 'electrical': (types or {}).get(p['name'], 4),
'xMil': at[0] + p['x'], 'yMil': at[1] + p['y'], 'orientation': orient[p['orient']], 'lengthMil': p['len']})
return {'designator': ref, 'libReference': libref, 'comment': '', 'xMil': at[0], 'yMil': at[1], 'orientation': 0, 'pins': pins}
def prims(spec_text):
"""altium_symbol_prims for a SchLib symbol built from this spec."""
sym = lane.parse_symbol_spec(spec_text)
rects = [r for m in sym['modes'].values() for r in m['rects']]
return {'ok': True, 'pins': sum(len(m['pins']) for m in sym['modes'].values()), 'rectangles': len(rects), 'displayModeCount': len(sym['modes']),
'bodyRectMil': [int(v) for v in lane._box(rects[0])] if rects else [0, 0, 0, 0]}
POWER = {'GND': 7, 'VIN': 7}
class SymbolCheck(unittest.TestCase):
def test_a_good_symbol_passes(self):
rep = lane.check_symbols([placed('U1', 'TPS54202DDCR', IC_GOOD, types=POWER)], {'TPS54202DDCR': IC_GOOD}, {'TPS54202DDCR': prims(IC_GOOD)})
self.assertTrue(rep['ok'], rep)
self.assertEqual(rep['warnings'], [])
self.assertEqual(rep['checked']['bodyEdgeModes'], 2)
self.assertEqual(rep['checked']['drawnSymbols'], 1)
self.assertEqual(rep['checked']['pinTypes'], 1)
def test_pins_one_pin_length_off_the_body_are_errors(self):
rep = lane.check_symbols([placed('U1', 'TPS54202DDCR', IC_BAD, types=POWER)], {'TPS54202DDCR': IC_BAD}, {'TPS54202DDCR': prims(IC_BAD)})
self.assertFalse(rep['ok'])
edge = [e for e in rep['errors'] if e.startswith('pins on the body')]
# both spec modes and the drawn sheet
self.assertEqual(len(edge), 3, rep['errors'])
self.assertIn('6 of 6 pin(s) not on the body edge', edge[0])
self.assertIn('pin 1 (GND) 300 mil off the body edge', edge[0])
self.assertTrue(any('as drawn on the sheet' in e for e in edge))
def test_a_pin_pointing_into_the_body_is_an_error(self):
bad = IC_GOOD.replace('PIN|0|4|FB|800|-300|right', 'PIN|0|4|FB|800|-300|left')
rep = lane.check_symbols([], {'X': bad}, {}, None)
self.assertTrue(rep['ok'], 'nothing placed: nothing checked')
rep = lane.check_symbols([placed('U1', 'X', bad, types=POWER)], {'X': bad}, {'X': prims(bad)})
self.assertIn('pin 4 (FB) points into the body', ' '.join(rep['errors']))
def test_pin_types_follow_the_kicad_rules(self):
ldo = IC_GOOD.replace('|SW|', '|VOUT|').replace('|BOOT|', '|NC|')
rep = lane.check_symbols([placed('U2', 'AP2112K', ldo)], {'AP2112K': ldo}, {'AP2112K': prims(ldo)})
self.assertFalse(rep['ok'])
self.assertEqual(rep['errors'], ['pin type: U2 pin 2 (AP2112K): output `VOUT` is passive; declare it Output (Power for a regulator output)'])
self.assertTrue(any('power pin `GND` is passive' in w for w in rep['warnings']), rep['warnings'])
# typed right in Altium: clean
rep = lane.check_symbols([placed('U2', 'AP2112K', ldo, types={'VOUT': 7, 'GND': 7, 'VIN': 7})], {'AP2112K': ldo}, {'AP2112K': prims(ldo)})
self.assertTrue(rep['ok'], rep)
self.assertEqual(rep['warnings'], [])
# the datasheet reason in spec.schematic.allowPassive turns the error into a warning; no reason does not count
why = {'schematic': {'allowPassive': [{'ref': 'U2', 'pin': 'VOUT', 'why': 'datasheet p.3: VOUT is a passive switch'}]}}
rep = lane.check_symbols([placed('U2', 'AP2112K', ldo)], {'AP2112K': ldo}, {'AP2112K': prims(ldo)}, why)
self.assertTrue(rep['ok'], rep)
self.assertTrue(any('allowed by spec.schematic.allowPassive' in w for w in rep['warnings']))
rep = lane.check_symbols([placed('U2', 'AP2112K', ldo)], {'AP2112K': ldo}, {'AP2112K': prims(ldo)}, {'schematic': {'allowPassive': [{'ref': 'U2', 'pin': '2'}]}})
self.assertFalse(rep['ok'])
# a passive part (R, C) is not an active device: its pin types are not judged
rep = lane.check_symbols([placed('R1', 'AP2112K', ldo)], {'AP2112K': ldo}, {'AP2112K': prims(ldo)})
self.assertTrue(rep['ok'], rep)
self.assertEqual(rep['checked']['pinTypes'], 0)
def test_an_op_amp_passive_input_is_an_error(self):
amp = IC_GOOD.replace('|SW|', '|IN+|').replace('|VIN|', '|IN-|').replace('|FB|', '|OUT|').replace('|EN|', '|V+|')
rep = lane.check_symbols([placed('A1', 'OPA', amp, types={'OUT': 2, 'V+': 7, 'GND': 7})], {'OPA': amp}, {'OPA': prims(amp)})
self.assertEqual(len([e for e in rep['errors'] if 'amplifier input' in e]), 2, rep['errors'])
def test_a_diode_drawn_backwards_is_an_error(self):
rep = lane.check_symbols([placed('D1', 'D-KEY', DIODE_GOOD)], {'D-KEY': DIODE_GOOD}, {'D-KEY': prims(DIODE_GOOD)})
self.assertTrue(rep['ok'], rep)
self.assertFalse([w for w in rep['warnings'] if 'polarity' in w], rep['warnings'])
self.assertEqual(rep['checked']['polarity'], 1)
rep = lane.check_symbols([placed('D1', 'D-KEY', DIODE_BAD)], {'D-KEY': DIODE_BAD}, {'D-KEY': prims(DIODE_BAD)})
self.assertFalse(rep['ok'])
self.assertIn('pointing at the anode', rep['errors'][0])
reviewed = {'schematic': {'polarityReviewed': [{'symbol': 'D-KEY', 'why': 'checked against the datasheet drawing'}]}}
self.assertTrue(lane.check_symbols([placed('D1', 'D-KEY', DIODE_BAD)], {'D-KEY': DIODE_BAD}, {'D-KEY': prims(DIODE_BAD)}, reviewed)['ok'])
def test_a_bjt_arrow_on_the_collector_is_an_error(self):
q = """META|Q|npn|Q
MODE|0|native
LINE|0|0|60|0|-60
LINE|0|0|20|50|100
LINE|0|0|-20|50|-100
LINE|0|40|-80|30|-95
LINE|0|30|-95|48|-97
LINE|0|48|-97|40|-80
PIN|0|1|B|0|0|left|100|hide
PIN|0|2|C|50|100|up|100|hide
PIN|0|3|E|50|-100|down|100|hide
"""
rep = lane.check_symbols([placed('Q1', 'Q', q)], {'Q': q}, {'Q': prims(q)})
self.assertTrue(rep['ok'], rep)
# the arrow head drawn at the collector end instead
flipped = q.replace('LINE|0|40|-80|30|-95\nLINE|0|30|-95|48|-97\nLINE|0|48|-97|40|-80', 'LINE|0|40|80|30|95\nLINE|0|30|95|48|97\nLINE|0|48|97|40|80')
self.assertNotEqual(flipped, q)
rep = lane.check_symbols([placed('Q1', 'Q', flipped)], {'Q': flipped}, {'Q': prims(flipped)})
self.assertIn('emitter arrow is drawn on the collector lead', ' '.join(rep['errors']))
def test_a_kept_spec_without_pin_types_is_named(self):
rep = lane.check_symbols([placed('U1', 'TPS54202DDCR', IC_UNTYPED, types=POWER)], {'TPS54202DDCR': IC_UNTYPED}, {'TPS54202DDCR': prims(IC_UNTYPED)})
self.assertTrue(rep['ok'], rep)
self.assertEqual(len(rep['warnings']), 1, rep['warnings'])
self.assertIn('carries no pin types', rep['warnings'][0])
self.assertIn('altium library --only', rep['warnings'][0])
def test_no_kept_spec_checks_the_drawn_mode_only(self):
rep = lane.check_symbols([placed('U1', 'TPS54202DDCR', IC_BAD, types=POWER)], {}, {'TPS54202DDCR': dict(prims(IC_BAD), bodyRectMil=[0, -400, 800, 0])})
self.assertFalse(rep['ok'])
self.assertEqual(rep['checked']['noSpec'], ['TPS54202DDCR'])
self.assertEqual(len(rep['errors']), 1)
self.assertIn('as drawn on the sheet', rep['errors'][0])
self.assertTrue(any('no kept symbol spec for TPS54202DDCR' in w for w in rep['warnings']))
# a SchLib that differs from the kept spec: the spec is set aside, said as a warning
rep = lane.check_symbols([placed('U1', 'TPS54202DDCR', IC_GOOD, types=POWER)], {'TPS54202DDCR': IC_GOOD}, {'TPS54202DDCR': dict(prims(IC_GOOD), displayModeCount=10)})
self.assertTrue(rep['ok'], rep)
self.assertTrue(any('not the one its kept spec describes' in w for w in rep['warnings']), rep['warnings'])
def test_a_rotated_part_is_read_back_in_its_symbol_frame(self):
c = placed('U1', 'TPS54202DDCR', IC_GOOD, types=POWER)
for p in c['pins']: # turn the part 90 degrees counterclockwise about its origin
dx, dy = p['xMil'] - 1000, p['yMil'] - 2000
p['xMil'], p['yMil'] = 1000 - dy, 2000 + dx
p['orientation'] = (p['orientation'] + 1) % 4
c['orientation'] = 1
rep = lane.check_symbols([c], {'TPS54202DDCR': IC_GOOD}, {'TPS54202DDCR': prims(IC_GOOD)})
self.assertTrue(rep['ok'], rep)
self.assertEqual(rep['warnings'], [])
SYM_LIB = '''(kicad_symbol_lib (version 20241209) (generator "kicad_symbol_editor")
(symbol "ABC-123" (property "Reference" "U") (property "Footprint" "lib:QFN-16") (property "Description" "a (tricky) \\"name\\"")
(symbol "ABC-123_1_1" (pin input line (at -5.08 0 0) (length 2.54) (name "IN" (effects (font (size 1.27 1.27)))) (number "1" (effects (font (size 1.27 1.27)))))))
(symbol "XYZ-9" (property "Reference" "U") (property "Footprint" "")
(symbol "XYZ-9_1_1" (pin output line (at 5.08 0 180) (length 2.54) (name "OUT" (effects (font (size 1.27 1.27)))) (number "2" (effects (font (size 1.27 1.27)))))))
(symbol "ABC-123-T" (extends "ABC-123") (property "Reference" "U"))
)
'''
def project_lib(d, with_model=True):
(d / 'lib.pretty').mkdir(parents=True); (d / '3dmodels').mkdir()
(d / 'lib.kicad_sym').write_text(SYM_LIB)
(d / 'lib.pretty' / 'QFN-16.kicad_mod').write_text('(footprint "QFN-16" (layer "F.Cu") (pad "1" smd rect (at 0 0) (size 1 1) (layers "F.Cu")) (model "${KIPRJMOD}/3dmodels/ABC-123.step"))\n')
if with_model: (d / '3dmodels' / 'ABC-123.step').write_text('ISO-10303-21;\nHEADER;\nENDSEC;\n')
return {'dir': str(d), 'symbol': 'ABC-123', 'symbolFile': str(d / 'lib.kicad_sym'), 'footprint': 'QFN-16', 'footprintFile': str(d / 'lib.pretty' / 'QFN-16.kicad_mod'),
'model': str(d / '3dmodels' / 'ABC-123.step'), 'source': 'datasheet rev C, p. 12',
'provenance': {'mpn': 'ABC-123', 'source': 'datasheet rev C, p. 12', 'manufacturer': 'Acme', 'lcsc': 'C12345'}}
class ProjectLibParts(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory(); self.d = Path(self.tmp.name)
def tearDown(self):
self.tmp.cleanup()
def test_origin_prefers_a_page_then_the_project_library(self):
pl = {'dir': '/lib'}
self.assertEqual(lane.part_origin({'mpn': 'ABC-123', 'page': 'me/abc'}, {'projectLib': pl}), ('wiki', 'me/abc'))
self.assertEqual(lane.part_origin({'mpn': 'ABC-123'}, {'wiki': 'adom/abc-123', 'projectLib': pl}), ('wiki', 'adom/abc-123'))
self.assertEqual(lane.part_origin({'mpn': 'ABC-123'}, {'wiki': None, 'projectLib': pl}), ('project-lib', pl))
self.assertEqual(lane.part_origin({'mpn': 'ABC-123'}, {}), ('wiki', 'abc-123'))
rows = lane.parts_rows({'outcomes': {'parts': {'parts': [{'mpn': 'abc-123 ', 'projectLib': pl}]}}})
self.assertIn('ABC-123', rows)
def test_one_symbol_is_cut_out_of_the_library(self):
one = lane.single_symbol_lib(SYM_LIB, 'lib:ABC-123')
self.assertIn('(symbol "ABC-123"', one)
self.assertNotIn('XYZ-9', one)
self.assertNotIn('(pin output', one, 'the converter reads every pin in its file')
self.assertIn('\\"name\\"', one, 'quoted parentheses and escapes survive')
child = lane.single_symbol_lib(SYM_LIB, 'ABC-123-T')
self.assertTrue(child.index('(symbol "ABC-123"') < child.index('(symbol "ABC-123-T"'), 'the parent it extends comes along, first')
self.assertIsNone(lane.single_symbol_lib(SYM_LIB, 'NOPE'))
def test_a_part_is_staged_like_a_wiki_page(self):
pl = project_lib(self.d / 'lib')
files, problems = lane.stage_project_part('ABC-123', pl, self.d / 'pages' / 'ABC-123')
self.assertEqual(problems, [])
self.assertEqual(sorted(p.name for p in (self.d / 'pages' / 'ABC-123').iterdir()), ['ABC-123.kicad_sym', 'ABC-123.step', 'QFN-16.kicad_mod'])
self.assertNotIn('XYZ-9', files['sym'].read_text())
facts = lane.project_part_facts(pl, {'value': 'ABC'})
self.assertEqual((facts['mfr'], facts['package'], facts['links'], facts['desc']), ('Acme', 'QFN-16', {'lcsc': 'C12345'}, 'ABC'))
pl2 = project_lib(self.d / 'lib2', with_model=False)
files, problems = lane.stage_project_part('ABC-123', pl2, self.d / 'pages' / 'X')
self.assertIsNone(files)
self.assertIn('STEP model', problems[0])
FAKE_BRIDGE = r'''#!/usr/bin/env python3
import json, os, sys
log = os.environ["FAKE_LOG"]
a = sys.argv[1:]
verb = a[a.index("--ai-thread") + 2]
args = json.loads(a[a.index("--ai-thread") + 3])
with open(log, "a") as f: f.write(json.dumps({"verb": verb, "args": args}) + "\n")
replies = json.load(open(os.environ["FAKE_REPLIES"])) if os.environ.get("FAKE_REPLIES") else {}
print(json.dumps(replies.get(verb, {"success": True})))
'''
FAKE_K2A = r'''#!/usr/bin/env python3
import json, os, sys
with open(os.environ["FAKE_LOG"], "a") as f: f.write(json.dumps({"tool": "kicad2altium", "argv": sys.argv[1:]}) + "\n")
open(sys.argv[2], "w").write("FP|%s\n" % sys.argv[3])
'''
FAKE_MODES = r'''#!/usr/bin/env python3
import json, os, sys
src = open(sys.argv[1]).read()
with open(os.environ["FAKE_LOG"], "a") as f: f.write(json.dumps({"tool": "symbol-modes", "argv": sys.argv[1:], "pins": src.count("(pin ")}) + "\n")
open(sys.argv[2], "w").write("META|%s|%s|U\nMODE|0|std\nRECT|0|0|-200|600|0\nPIN|0|1|IN|0|-100|left|300||input\n" % (sys.argv[3], sys.argv[4]))
'''
FAKE_WIKI = r'''#!/usr/bin/env python3
import sys; sys.exit(1)
'''
class LibraryCommand(unittest.TestCase):
"""`altium library` end to end with fake CLIs: a part with no wiki page, given by the project library."""
def test_a_symbol_tool_without_pin_types_is_refused(self):
# altium-symbol-modes before altium-bridge 0.2.53 wrote no pin types: the import would make every pin Passive
old = FAKE_MODES.replace('|300||input\\n', '|300\\n')
self.assertNotEqual(old, FAKE_MODES)
r = self.test_project_library_part_is_imported(old)
self.assertEqual(r.returncode, 1, r.stdout)
self.assertIn('altium-symbol-modes is older than altium-bridge 0.2.53', r.stdout)
self.assertIn('adom-wiki pkg install adom/altium-bridge', r.stdout)
def test_project_library_part_is_imported(self, modes=None):
with tempfile.TemporaryDirectory() as t:
d = Path(t); b = d / 'bin'; b.mkdir()
for name, src in (('adom-bridge', FAKE_BRIDGE), ('altium-kicad2altium', FAKE_K2A), ('altium-symbol-modes', modes or FAKE_MODES), ('adom-wiki', FAKE_WIKI)):
(b / name).write_text(src); (b / name).chmod(0o755)
run = d / 'run'; run.mkdir()
pl = project_lib(d / 'lib')
(run / 'run.json').write_text(json.dumps({'outcomes': {'parts': {'pass': True, 'projectLib': str(d / 'lib'), 'parts': [{'mpn': 'ABC-123', 'refs': ['U1'], 'wiki': None, 'projectLib': pl, 'value': 'ABC'}]}}}))
net = d / 'netlist.json'; net.write_text(json.dumps({'components': [{'ref': 'U1', 'mpn': 'ABC-123'}], 'nets': {}}))
script = d / 'altium.py'
script.write_text('\n'.join((ROOT / 'tools' / f).read_text() for f in ('project-common.py', 'silkscreen-plan.py', 'altium-lane.py', 'altium-project.py')))
log = d / 'log.jsonl'
env = dict(os.environ, PATH='%s:%s:/usr/bin:/bin' % (b, os.path.dirname(sys.executable)), AIFLOW_RUN=str(run), AIFLOW_TARGET='box', AIFLOW_BOX_DIR='C:/Users/t/Documents/AdomAiFlow/run', FAKE_LOG=str(log))
r = subprocess.run([sys.executable, str(script), 'library', '--parts', str(net), '--name', 'Board'], capture_output=True, text=True, env=env, stdin=subprocess.DEVNULL)
if modes: return r
self.assertEqual(r.returncode, 0, r.stdout + r.stderr)
self.assertIn('1 from the board\'s project library: ABC-123', r.stdout)
calls = [json.loads(l) for l in log.read_text().splitlines()]
modes = next(c for c in calls if c.get('tool') == 'symbol-modes')
self.assertEqual(modes['pins'], 1, 'the converter got the one symbol, not the whole library')
self.assertTrue(modes['argv'][0].endswith('pages/ABC-123/ABC-123.kicad_sym'))
k2a = next(c for c in calls if c.get('tool') == 'kicad2altium')
self.assertTrue(k2a['argv'][0].endswith('pages/ABC-123/QFN-16.kicad_mod'))
imp = next(c['args'] for c in calls if c.get('verb') == 'altium_import_part')
self.assertEqual(imp['footprintSpec'], 'C:\\Users\\t\\Documents\\AdomAiFlow\\run\\library\\stage\\ABC-123.fp.spec')
self.assertTrue(imp['symbolSpec'].endswith('ABC-123.sym.spec') and imp['step'].endswith('ABC-123.step'), imp)
self.assertEqual((imp['params']['MPN'], imp['params']['Manufacturer'], imp['params']['LCSC Part #']), ('ABC-123', 'Acme', 'C12345'))
sent = [c['args']['filePaths'] for c in calls if c.get('verb') == 'send_files'][0]
self.assertEqual(sorted(Path(p).name for p in sent), ['ABC-123.fp.spec', 'ABC-123.step', 'ABC-123.sym.spec'])
st = json.loads((run / 'altium' / 'project.json').read_text())
self.assertEqual(st['parts']['ABC-123']['source'], 'project-lib')
self.assertEqual(st['parts']['ABC-123']['projectLib']['dir'], str(d / 'lib'))
self.assertTrue((run / 'altium' / 'symbols' / 'ABC-123.sym.spec').is_file(), 'the symbol spec is kept for the schematic check')
self.assertTrue((run / 'altium' / 'pages' / 'ABC-123' / 'QFN-16.kicad_mod').is_file(), 'the board render finds its courtyard there')
proof = json.loads((run / 'proofs' / 'library.json').read_text())
self.assertEqual(proof['data']['projectLibParts'], ['ABC-123'])
class SymbolsCommand(unittest.TestCase):
"""`altium symbols` end to end with a fake bridge: the sheet and the SchLib read back, the check run,
the schematic proof's symbols replaced (its parity and ERC kept), the report written."""
def run_symbols(self, d, spec_text, sheet_spec_text):
b = d / 'bin'; b.mkdir(exist_ok=True)
(b / 'adom-bridge').write_text(FAKE_BRIDGE); (b / 'adom-bridge').chmod(0o755)
run = d / 'run'; (run / 'altium' / 'symbols').mkdir(parents=True, exist_ok=True); (run / 'proofs').mkdir(exist_ok=True)
(run / 'altium' / 'project.json').write_text(json.dumps({'parts': {}, 'schematic': 'C:\\p\\Buck.SchDoc', 'schLib': 'C:/p/library/Board.SchLib'}))
(run / 'altium' / 'symbols' / 'TPS54202DDCR.sym.spec').write_text(spec_text)
(run / 'proofs' / 'schematic.json').write_text(json.dumps({'at': 't', 'tool': 'altium', 'data': {'sch': 'C:\\p\\Buck.SchDoc', 'parity': True, 'erc': {'pass': True, 'errors': []}}}))
replies = {'altium_schematic_data': {'success': True, 'data': {'ok': True, 'document': 'Buck.SchDoc', 'count': 1, 'components': [placed('U1', 'TPS54202DDCR', sheet_spec_text, types=POWER)]}},
'altium_symbol_prims': {'success': True, 'data': dict(prims(spec_text), libref='TPS54202DDCR')}}
(d / 'replies.json').write_text(json.dumps(replies))
script = d / 'altium.py'
script.write_text('\n'.join((ROOT / 'tools' / f).read_text() for f in ('project-common.py', 'silkscreen-plan.py', 'altium-lane.py', 'altium-project.py')))
env = dict(os.environ, PATH='%s:%s:/usr/bin:/bin' % (b, os.path.dirname(sys.executable)), AIFLOW_RUN=str(run), AIFLOW_TARGET='box', AIFLOW_BOX_DIR='C:/p',
FAKE_LOG=str(d / 'log.jsonl'), FAKE_REPLIES=str(d / 'replies.json'))
r = subprocess.run([sys.executable, str(script), 'symbols'], capture_output=True, text=True, env=env, stdin=subprocess.DEVNULL)
return r, json.loads((run / 'proofs' / 'schematic.json').read_text())['data'], run
def test_symbols_are_checked_and_recorded(self):
with tempfile.TemporaryDirectory() as t:
d = Path(t)
r, proof, run = self.run_symbols(d, IC_GOOD, IC_GOOD)
self.assertEqual(r.returncode, 0, r.stdout + r.stderr)
self.assertIn('pins on their bodies, pin types and polarity drawings check out', r.stdout)
self.assertEqual(proof['symbols']['ok'], True)
self.assertEqual((proof['parity'], proof['erc']['pass']), (True, True), 'the rest of the proof is kept')
self.assertTrue((run / 'schematic-check.json').is_file())
calls = [json.loads(l) for l in (d / 'log.jsonl').read_text().splitlines()]
self.assertEqual(next(c['args'] for c in calls if c['verb'] == 'altium_schematic_data')['filePath'], 'C:\\p\\Buck.SchDoc')
self.assertEqual(next(c['args'] for c in calls if c['verb'] == 'altium_symbol_prims')['symbolName'], 'TPS54202DDCR')
def test_pins_off_the_body_fail_and_are_recorded(self):
with tempfile.TemporaryDirectory() as t:
r, proof, _ = self.run_symbols(Path(t), IC_BAD, IC_BAD)
self.assertEqual(r.returncode, 1, r.stdout + r.stderr)
self.assertIn('ERROR: schematic', r.stdout)
self.assertIn('300 mil off the body edge', r.stdout)
self.assertEqual(proof['symbols']['ok'], False)
self.assertEqual(len(proof['symbols']['errors']), 3)
if __name__ == '__main__':
unittest.main(verbosity=1)