main
John Lauer Publish 0.2.58 e8180ab
"""Issues #168 and #179 end to end, with no CAD and no desktop.

#168: on a one-copper-layer board whose GND and +5V pads only the planned pours join, `gate` before
`land pours` puts the pours `pour` planned into the gate copy, so the pads they join are not read as
unconnected. A fake kicad-cli reports every pad unconnected unless the board it checks carries zones.

#179: `place pack` on a two-sided board puts a bottom part straight under a top QFN (courtyards meet
only on their own side), keeps a top part out of the QFN, and `place check`'s bounding-box advisory
does not name a top/bottom pair.

Usage: python3 tests/sides-pours-workflow.py <adom-aiflow binary>"""
import json, os, pathlib, subprocess, sys, tempfile

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

FAKE_BRIDGE = r'''#!/usr/bin/env python3
import json
print(json.dumps({"success": True, "status": "ok", "revision": "r1"}))
'''
# DRC: every pad on GND or +5V is unconnected unless the board carries a zone of that net
FAKE_CLI = r'''#!/usr/bin/env python3
import sys, json, re
a = sys.argv[1:]
if "--help" in a: print("--refill-zones"); sys.exit(0)
out = a[a.index("--output") + 1]
text = open(a[-1]).read()
unc = []
for net in ("GND", "+5V"):
    if not re.search(r'\(zone\s*\(net \d+\)\s*\(net_name "' + re.escape(net) + '"', text):
        unc.append({"type": "unconnected_items", "severity": "error", "description": "Missing connection between items",
                    "items": [{"description": f"Pad 1 [{net}] of R1"}, {"description": f"Pad 1 [{net}] of R2"}]})
json.dump({"violations": [], "unconnected_items": unc}, open(out, "w"))
'''

ONE_LAYER = '''(kicad_pcb (version 20240108) (generator "pcbnew")
  (layers (0 "F.Cu" signal) (31 "B.Cu" signal) (44 "Edge.Cuts" user))
  (net 0 "") (net 1 "GND") (net 2 "+5V")
  (gr_rect (start 0 0) (end 40 20) (layer "Edge.Cuts"))
  (footprint "R" (layer "F.Cu") (at 5 5) (property "Reference" "R1") (fp_rect (start -1 -1) (end 1 11) (layer "F.CrtYd"))
    (pad "1" smd rect (at 0 0) (size 1 1) (layers "F.Cu") (net 1 "GND"))
    (pad "2" smd rect (at 0 10) (size 1 1) (layers "F.Cu") (net 2 "+5V")))
  (footprint "R" (layer "F.Cu") (at 35 5) (property "Reference" "R2") (fp_rect (start -1 -1) (end 1 11) (layer "F.CrtYd"))
    (pad "1" smd rect (at 0 0) (size 1 1) (layers "F.Cu") (net 1 "GND"))
    (pad "2" smd rect (at 0 10) (size 1 1) (layers "F.Cu") (net 2 "+5V"))))
'''


def smd(ref, layer, x, y, h, w):
    crt = layer.replace('.Cu', '.CrtYd')
    return (f'\t(footprint "T:{ref}"\n\t\t(layer "{layer}")\n\t\t(at {x} {y})\n\t\t(property "Reference" "{ref}")\n'
            f'\t\t(fp_rect (start {-w} {-h}) (end {w} {h}) (layer "{crt}"))\n'
            f'\t\t(pad "1" smd rect (at 0 0) (size 0.5 0.5) (layers "{layer}") (net "N{ref}"))\n\t)\n')


TWO_SIDED = ('(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)\n'
             + smd('U1', 'F.Cu', 10, 10, 2.0, 2.0) + smd('C1', 'B.Cu', 3, 3, 0.5, 0.9) + smd('C2', 'F.Cu', 3, 17, 0.5, 0.9)
             + '\t(gr_rect\n\t\t(start 0 0)\n\t\t(end 30 20)\n\t\t(layer "Edge.Cuts")\n\t)\n)\n')

passed = 0


def check(cond, what, out=''):
    global passed
    if not cond:
        sys.exit(f'FAIL: {what}\n{out}')
    passed += 1


with tempfile.TemporaryDirectory(prefix='aiflow-sides-pours-') as td:
    p = pathlib.Path(td)
    bindir = p / 'bin'; bindir.mkdir()
    (bindir / 'adom-bridge').write_text(FAKE_BRIDGE); (bindir / 'adom-bridge').chmod(0o755)
    (bindir / 'kicad-cli').write_text(FAKE_CLI); (bindir / 'kicad-cli').chmod(0o755)
    env = dict(os.environ, AIFLOW_DASHBOARD='off', AIFLOW_NO_PART_SEARCH='1', AIFLOW_NO_PART_LOOKUP='1',
               ADOM_AIFLOW_KICAD_CLI=str(bindir / 'kicad-cli'), PATH=f"{bindir}:{os.environ['PATH']}")
    env.pop('KICAD_REMOTE_TARGET', None)

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

    # ---- issue #168: pour-only power nets on one copper layer
    (p / 'one.kicad_pcb').write_text(ONE_LAYER)
    (p / 'spec.json').write_text(json.dumps({"board": "one-layer pours", "clearance": 0.2, "pours": [
        {"net": "GND", "layers": ["F.Cu"], "polygon": [[1, 1], [39, 1], [39, 9], [1, 9]]},
        {"net": "+5V", "layers": ["F.Cu"], "polygon": [[1, 11], [39, 11], [39, 19], [1, 19]]}]}))
    (p / 'plan-ai.json').write_text(json.dumps({"engine": "ai", "nets": []}))
    run = p / 'run'
    r = call(run, 'start', '--board', str(p / 'one.kicad_pcb'), '--spec', str(p / 'spec.json'), '--engine', 'test', '--target', 'fakebox', '--remote-board', 'C:/boards/one.kicad_pcb')
    check(r.returncode == 0, 'start', r.stdout + r.stderr)
    r = call(run, 'gate', '--plan', str(p / 'plan-ai.json'))
    out = r.stdout + r.stderr
    check(r.returncode == 0 and '2 unconnected' in out, 'before pour: the gate reads the pour-only pads as unconnected (a finding)', out)
    r = call(run, 'pour')
    check(r.returncode == 0 and '2 zones' in r.stdout + r.stderr, 'pour plans the two zones', r.stdout + r.stderr)
    r = call(run, 'gate', '--plan', str(p / 'plan-ai.json'))
    out = r.stdout + r.stderr
    check(r.returncode == 0 and 'gate passed' in out and '0 unconnected' in out, 'after pour: the planned pours join the pads in the gate copy', out)
    g = json.loads((run / 'gate.json').read_text())
    check(g.get('plannedPours', {}).get('zones') == 2 and g['plannedPours']['nets'] == ['+5V', 'GND'], 'gate.json names the planned pours', json.dumps(g.get('plannedPours')))
    routed = (run / 'board-routed.kicad_pcb').read_text()
    check(routed.count('(zone') == 2, 'the gate copy carries the two zones', routed[-800:])
    check((p / 'one.kicad_pcb').read_text() == ONE_LAYER, 'the source board is untouched')

    # ---- issue #179: a bottom part under a top part
    (p / 'two.kicad_pcb').write_text(TWO_SIDED)
    (p / 'spec2.json').write_text(json.dumps({"board": "two-sided", "clearance": 0.2}))
    run2 = p / 'run2'
    r = call(run2, 'start', '--board', str(p / 'two.kicad_pcb'), '--spec', str(p / 'spec2.json'), '--engine', 'test', '--target', 'fakebox', '--remote-board', 'C:/boards/two.kicad_pcb')
    check(r.returncode == 0, 'start two-sided', r.stdout + r.stderr)
    (p / 'wish.json').write_text(json.dumps([{"ref": "C1", "x": 10, "y": 10, "rot": [0], "radius": 2.0}, {"ref": "C2", "x": 10, "y": 10, "rot": [0], "radius": 4.0}]))
    r = call(run2, 'place', 'pack', '--wish', str(p / 'wish.json'), '--out', str(p / 'moves.json'))
    out = r.stdout + r.stderr
    check(r.returncode == 0 and 'legal spots for 2 of 2' in out, 'pack places both parts', out)
    moves = json.loads((p / 'moves.json').read_text())
    check((moves['C1']['x'], moves['C1']['y']) == (10, 10), 'the bottom part sits at its wish under the QFN', json.dumps(moves))
    check(abs(moves['C2']['x'] - 10) > 1.0 or abs(moves['C2']['y'] - 10) > 1.0, 'the top part leaves the QFN courtyard', json.dumps(moves))
    r = call(run2, 'place', 'check', '--moves', str(p / 'moves.json'))
    out = r.stdout + r.stderr
    check(r.returncode == 0 and 'C1 U1' not in out, 'the advisory does not name the top/bottom pair', out)

print(f'OK sides-pours-workflow: {passed} checks passed')