main
John Lauer Publish 0.2.58 e8180ab
"""`parts` through the CLI against a fake adom-wiki on PATH (no network):
issue #161: an ordering code with a "/" packaging suffix (MCP73831T-2ACI/OT) finds the part factory's page
for the MPN without it (adom/mcp73831t-2aci), says how it matched, and a page that keeps the suffix
(adom/sp485een-l-tr) is still found by its own slug first;
issue #157: a project-library entry for an MPN that also has a wiki page is named (page used), and with
"prefer": true in provenance.json the library entry wins (wiki null, wikiOverridden kept);
issue #178: a BOM `wiki` column names a row's page (adom/adom-1578), and a page under another slug that
declares the MPN in component.mpn (adom/adom-1317 for MPN 1317) is found when the slug forms miss.

  python3 tests/parts-sources-workflow.py <abs path to target/release/adom-aiflow>
"""
import json, os, pathlib, subprocess, sys, tempfile

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

# what `adom-wiki search <q> --json` answered on 2026-11-06: the factory page is ranked by phrase only
FAKE_WIKI = r'''#!/usr/bin/env python3
import json, sys
a = [x for x in sys.argv[1:] if x != "--json"]
H = lambda slug, title, tier: {"type": "hardware", "owner": "adom", "slug": slug, "title": title, "match": {"tier": tier}}
if a[:1] == ["search"]:
    q = a[1].upper()
    hits = []
    if q.startswith("MCP73831"):
        hits = [H("mcp73831", "MCP73831", "phrase"), H("mcp73831t-2aci", "MCP73831T-2ACI", "phrase")]
    elif q.startswith("SP485EEN"):
        hits = [H("sp485een-l", "SP485EEN-L", "phrase"), H("sp485een-l-tr", "SP485EEN-L/TR", "exact_title" if q == "SP485EEN-L/TR" else "phrase")]
    elif q == "ABC-123":
        hits = [H("abc-123", "ABC-123", "exact_slug")]
    elif q == "1317":
        # issue #178: the battery's page is adom/adom-1317 (a bare numeric slug is a poor page name)
        hits = [H("adom-1317", "Adafruit 1317 LiPo battery", "phrase"), H("adom-13170", "Other", "phrase")]
    print(json.dumps({"status": "ok", "data": {"results": hits}})); sys.exit(0)
if a[:2] == ["repo", "show"] and a[3] == "page.json":
    pages = {"adom/adom-1317": {"component": {"mpn": "1317"}}, "adom/adom-1578": {"component": {"mpn": "1578"}}}
    if a[2] in pages: print(json.dumps(pages[a[2]])); sys.exit(0)
    print(json.dumps({"status": "error", "error": "not found"})); sys.exit(1)
print(json.dumps({"status": "error", "error": "fake adom-wiki: " + " ".join(a)})); sys.exit(2)
'''

with tempfile.TemporaryDirectory(prefix='aiflow-parts-sources-') as td:
    p = pathlib.Path(td)
    b = p / 'bin'; b.mkdir()
    (b / 'adom-wiki').write_text(FAKE_WIKI); (b / 'adom-wiki').chmod(0o755)
    (b / 'adom-cli').write_text('#!/bin/sh\nexit 0\n'); (b / 'adom-cli').chmod(0o755)
    env = dict(os.environ, AIFLOW_DASHBOARD='off', PATH=f"{b}:{os.environ['PATH']}")
    def call(run, *args):
        return subprocess.run([binary, '--run', str(run), '--ai-thread', 'parts-sources-test', *args], env=env, capture_output=True, text=True, timeout=120, stdin=subprocess.DEVNULL)
    def parts(run): return json.loads((run / 'run.json').read_text())['outcomes']['parts']

    bom = p / 'bom.csv'
    bom.write_text('ref,value,mpn,manufacturer\nU1,charger,MCP73831T-2ACI/OT,Microchip\nU2,RS-485,SP485EEN-L/TR,MaxLinear\nU3,thing,ABC-123,Acme\n')

    # --- #161: the "/OT" ordering code finds the factory page for its base MPN ---------------------------
    run = p / 'run'
    assert call(run, 'intake', '--prompt-time', '2026-11-06T00:00:00Z', '--engine', 'test').returncode == 0
    r = call(run, 'parts', '--bom', str(bom)); out = r.stdout + r.stderr
    assert r.returncode == 0, out
    rows = {x['mpn']: x for x in parts(run)['parts']}
    assert rows['MCP73831T-2ACI/OT']['wiki'] == 'adom/mcp73831t-2aci', rows
    assert 'without its /OT packaging suffix' in rows['MCP73831T-2ACI/OT']['wikiMatch'], rows
    assert rows['SP485EEN-L/TR']['wiki'] == 'adom/sp485een-l-tr' and 'wikiMatch' not in rows['SP485EEN-L/TR'], rows
    assert 'MCP73831T-2ACI/OT: adom/mcp73831t-2aci is the page for MCP73831T-2ACI' in out, out
    assert parts(run)['missing'] == [], parts(run)

    # --- #157: a project-library entry for an MPN that has a page --------------------------------------
    lib = p / 'lib'; (lib / 'lib.pretty').mkdir(parents=True); (lib / '3dmodels').mkdir()
    (lib / 'lib.kicad_sym').write_text('(kicad_symbol_lib (version 20241209) (generator "x")\n  (symbol "ABC-123" (property "Reference" "U") (property "Footprint" "lib:QFN-16"))\n)\n')
    (lib / 'lib.pretty' / 'QFN-16.kicad_mod').write_text('(footprint "QFN-16" (layer "F.Cu") (model "${KIPRJMOD}/3dmodels/ABC-123.step"))\n')
    (lib / '3dmodels' / 'ABC-123.step').write_text('ISO-10303-21;\nHEADER;\nENDSEC;\n')
    prov = lib / 'provenance.json'
    prov.write_text(json.dumps({'parts': [{'mpn': 'ABC-123', 'source': 'datasheet rev C, fig. 12'}]}))
    r = call(run, 'parts', '--bom', str(bom), '--project-lib', str(lib)); out = r.stdout + r.stderr
    assert r.returncode == 0, out
    rows = {x['mpn']: x for x in parts(run)['parts']}
    assert rows['ABC-123']['wiki'] == 'adom/abc-123' and 'projectLib' not in rows['ABC-123'], rows
    assert 'both the wiki page adom/abc-123 and a complete project-library entry' in out and '"prefer": true' in out, out

    prov.write_text(json.dumps({'parts': [{'mpn': 'ABC-123', 'source': 'datasheet rev C, fig. 12', 'prefer': True}]}))
    r = call(run, 'parts', '--bom', str(bom), '--project-lib', str(lib)); out = r.stdout + r.stderr
    assert r.returncode == 0, out
    rec = parts(run); rows = {x['mpn']: x for x in rec['parts']}
    assert rows['ABC-123']['wiki'] is None and rows['ABC-123']['wikiOverridden'] == 'adom/abc-123', rows
    assert rows['ABC-123']['projectLib']['footprint'] == 'QFN-16', rows
    assert rec['projectLibParts'] == ['ABC-123'], rec
    assert 'project library (preferred over adom/abc-123)' in out, out
    assert any('project library\'s entry is used' in n for n in rec['sourceNotes']), rec

    # --- #178: a BOM row names its page; else a page declaring the MPN in component.mpn is found ---------
    bom2 = p / 'bom2.csv'
    bom2.write_text('ref,mpn,placement,wiki\nBT1,1578,off-board,adom/adom-1578\nBT2,1317,off-board,\nBT3,9999,off-board,adom/no-such-page\n')
    run2 = p / 'run2'
    assert call(run2, 'intake', '--prompt-time', '2026-11-06T00:00:00Z', '--engine', 'test').returncode == 0
    r = call(run2, 'parts', '--bom', str(bom2)); out = r.stdout + r.stderr
    assert r.returncode == 0, out
    rec = parts(run2); rows = {x['mpn']: x for x in rec['parts']}
    assert rows['1578']['wiki'] == 'adom/adom-1578' and 'named in the BOM' in rows['1578']['wikiMatch'], rows
    assert rows['1317']['wiki'] == 'adom/adom-1317' and 'component.mpn' in rows['1317']['wikiMatch'], rows
    assert rows['9999']['wiki'] is None and 'could not be read' in rows['9999']['wikiMatch'], rows
    assert rec['missing'] == ['9999'], rec
    assert 'adom/adom-1578 is named in the BOM' in out, out

print('parts-sources-workflow: OK')