main
Colby Knox Merge PR #279: 0.2.13: Altium library refuses a symbol spec without pin types bc2975b
"""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)