main
John Lauer Publish 0.2.25 68f933c
"""route --variants N and route --pick K --why on a small synthetic board (issue #55). A fake
kicad-cli answers DRC with no violations, so the gate column is exercised without CAD; no
dashboard, no bridge. Usage: python3 tests/route-variants-workflow.py <adom-aiflow binary>"""
import hashlib, json, os, pathlib, subprocess, sys, tempfile

binary = str(pathlib.Path(sys.argv[1]).resolve())


def board_text():
    pads = [("J1", "A", 2, 2), ("J2", "A", 6, 2), ("J3", "B", 2, 8), ("J4", "B", 18, 8), ("J5", "C", 10, 2), ("J6", "C", 14, 6)]
    t = '(kicad_pcb\n\t(version 20260206)\n\t(generator "test")\n\t(layers\n\t\t(0 "F.Cu" signal)\n\t\t(2 "B.Cu" signal)\n\t\t(25 "Edge.Cuts" user)\n\t\t(31 "F.CrtYd" user)\n\t)\n'
    for r, net, x, y in pads:
        t += f'\t(footprint "T:{r}"\n\t\t(layer "F.Cu")\n\t\t(at {x} {y})\n\t\t(property "Reference" "{r}")\n\t\t(fp_rect\n\t\t\t(start -0.9 -0.9)\n\t\t\t(end 0.9 0.9)\n\t\t\t(stroke\n\t\t\t\t(width 0.05)\n\t\t\t\t(type solid)\n\t\t\t)\n\t\t\t(fill no)\n\t\t\t(layer "F.CrtYd")\n\t\t)\n\t\t(pad "1" thru_hole circle\n\t\t\t(at 0 0)\n\t\t\t(size 1.2 1.2)\n\t\t\t(drill 0.6)\n\t\t\t(layers "*.Cu")\n\t\t\t(net "{net}")\n\t\t)\n\t)\n'
    t += '\t(gr_rect\n\t\t(start 0 0)\n\t\t(end 20 10)\n\t\t(layer "Edge.Cuts")\n\t)\n)\n'
    return t


with tempfile.TemporaryDirectory(prefix='aiflow-variants-') as td:
    p = pathlib.Path(td)
    (p / 'b.kicad_pcb').write_text(board_text())
    (p / 'spec.json').write_text(json.dumps({"board": "variants fixture", "clearance": 0.2}))
    fake = p / 'kicad-cli'
    fake.write_text('#!/usr/bin/env python3\nimport sys, json\na = sys.argv[1:]\n'
                    'if "--help" in a: print("--refill-zones"); sys.exit(0)\n'
                    'out = a[a.index("--output") + 1]\njson.dump({"violations": [], "unconnected_items": []}, open(out, "w"))\n')
    fake.chmod(0o755)
    run = p / 'run'
    env = dict(os.environ, AIFLOW_DASHBOARD='off', ADOM_AIFLOW_KICAD_CLI=str(fake))
    env.pop('KICAD_REMOTE_TARGET', None)

    def call(*args):
        return subprocess.run([binary, '--run', str(run), '--ai-thread', 'variants-test', *args], env=env, capture_output=True, text=True, timeout=120)

    def sha(f):
        return hashlib.sha256(pathlib.Path(f).read_bytes()).hexdigest()

    r = call('start', '--board', str(p / 'b.kicad_pcb'), '--spec', str(p / 'spec.json'), '--engine', 'test')
    assert r.returncode == 0, r.stderr
    # a plain route: no variants on the outcome
    r = call('route'); assert r.returncode == 0, r.stdout + r.stderr
    plain = sha(run / 'plan-route.json')
    o = json.loads((run / 'run.json').read_text())['outcomes']['route']
    assert 'variants' not in o, o
    # --pick before any variants is refused
    assert call('route', '--pick', '1', '--why', 'x').returncode != 0
    # three variants: files, scores, a pick by the gates, the table
    r = call('route', '--variants', '3'); assert r.returncode == 0, r.stdout + r.stderr
    if os.environ.get('SHOW'): print(r.stderr)
    assert 'chosen: v' in r.stderr and 'levers' in r.stderr, r.stderr
    o = json.loads((run / 'run.json').read_text())['outcomes']['route']
    v = o['variants']
    assert v['count'] == 3 and v['chosenBy'] == 'the gates' and v['drc'].startswith('kicad-cli'), v
    for k, x in enumerate(v['variants'], 1):
        assert x['variant'] == k and (run / f'plan-route-v{k}.json').is_file() and (run / f'plan-route-v{k}-report.json').is_file()
        assert x['gate'] is not None and x['gate']['issues'] == 0, x
        assert x['levers']['order'] and x['copperLengthMm'] > 0 and x['rank'] >= 1
    assert sorted(x['rank'] for x in v['variants']) == [1, 2, 3]
    win = v['chosen']
    assert [x for x in v['variants'] if x['rank'] == 1][0]['variant'] == win
    assert sha(run / 'plan-route.json') == sha(run / f'plan-route-v{win}.json')
    # variant 1 is a plain route: the same plan
    assert sha(run / 'plan-route-v1.json') == plain
    # deterministic: the same variants again give the same plans
    before = [sha(run / f'plan-route-v{k}.json') for k in (1, 2, 3)]
    assert call('route', '--variants', '3').returncode == 0
    assert before == [sha(run / f'plan-route-v{k}.json') for k in (1, 2, 3)]
    # the ledger holds every variant
    led = [json.loads(l) for l in (run / 'run.jsonl').read_text().splitlines()]
    assert any(e.get('event') == 'route-variants' and len(e['variants']) == 3 for e in led)
    # --pick needs --why, and records it
    other = 1 if win != 1 else 2
    assert call('route', '--pick', str(other)).returncode != 0
    assert call('route', '--pick', '9', '--why', 'none such').returncode != 0
    r = call('route', '--pick', str(other), '--why', 'the shorter loop'); assert r.returncode == 0, r.stdout + r.stderr
    o = json.loads((run / 'run.json').read_text())['outcomes']['route']
    assert o['variants']['chosen'] == other and o['variants']['chosenBy'] == 'the AI' and o['variants']['why'] == 'the shorter loop'
    assert o['variants']['gatePick'] == win
    assert sha(run / 'plan-route.json') == sha(run / f'plan-route-v{other}.json')
    led = [json.loads(l) for l in (run / 'run.jsonl').read_text().splitlines()]
    assert any(e.get('event') == 'route-pick' and e['why'] == 'the shorter loop' for e in led)
    # the gate takes the picked plan as usual
    r = call('gate'); assert r.returncode == 0, r.stdout + r.stderr
    # a plain route again: the baseline plan, no variants
    assert call('route').returncode == 0
    assert sha(run / 'plan-route.json') == plain
    assert 'variants' not in json.loads((run / 'run.json').read_text())['outcomes']['route']
    print('PASS: route --variants writes, gates and ranks every variant, the winner is the route plan, v1 equals a plain route, reruns are identical, --pick needs --why and is recorded, plain route unchanged')