← Commit history

0.9.341: verb-test runner (full coverage, foreground victim rule, user discovery, pcbnew ping), routing/readiness/library tests, release tool push verification, notes

John Lauer ·4768cda282 ·1mo ago ·parent 7d6f20c
7 files changed +214−20
RELEASE_NOTES.md+16
@@ -10,6 +10,22 @@ unnamed. These entries were reconstructed after the fact from the work they contain, which is exactly the situation the mandatory-notes gate now prevents. Treat them as accurate but secondhand. +## 0.9.341 (2026-09-07): release-day fixes from the multi-host verification++- #80: `${KICAD10_SYMBOL_DIR}` and the other stock library variables now resolve to the installed+  KiCad's share directory (per-user installs included) with Configure Paths overrides honoured;+  `kicad_list_symbols {"libraryName":"Device"}` works on every box.+- #81: `kicad_screenshot_all` no longer dies with an internal sizeKB error when a capture reply+  carries no size.+- #82: `kicad_readiness` and `kicad_list_versions` re-detect when a cached kicad.exe has vanished+  (KiCad uninstalled while the bridge ran) instead of reporting ready until a bridge restart.+- Live routing (PR 82, Codex): DRC snapshots refill zones; capped violation lists are flagged and the+  error-delta check refuses to certify a truncated report (`drc_incomplete`, no copper changed).+- #79: the release tool verifies every source push and refuses to release on a failed chunk.+- #78: the verb test runner ships its rp2040 fixture, learns the Windows user from the box, covers every+  live verb (tests or a stated skip), pings pcbnew instead of eeschema (no scripting there on KiCad 9/10),+  and only counts a foreground steal when a user window was actually displaced.+ ## 0.9.340  (2026-09-07 16:56 UTC)  Live routing verbs through KiCad's IPC API (routing_state, route_net, remove_route, routing_validate), offline copper verbs (board_pads, add_track, add_via, route), kicad_ipc_api, KiCad 10 name-based nets, vendored routing deps
skills/kicad-bridge-test/run_verb_tests.py+82−17
@@ -100,7 +100,27 @@ def matrix(w: dict) -> dict:                                   note="SAFETY: diagnoseOnly only — never a real install"),         "uninstall":         dict(phase=1, args={"dryRun": True}, check="dry_report",                                   note="SAFETY: dryRun only — never a real removal"),+        # ---- phase 1b: more read-only verbs (#78 coverage)+        "get_settings":      dict(phase=1, args={}),+        "list_design_rules": dict(phase=1, args={}, timeout=90, note="rows come from the wiki at call time"),+        "list_footprints":   dict(phase=1, args={"library": "Resistor_SMD"}, timeout=90),+        "list_symbols":      dict(phase=1, args={"libraryName": "Device"}, timeout=90,+                                  note="the #80 regression: a STOCK library must resolve to KiCad's share dir"),+        "log_tail":          dict(phase=1, args={}),+        "progress":          dict(phase=1, args={}),+        "verb_times":        dict(phase=1, args={}),+        "errors":            dict(phase=1, args={}, note="win32 text read of KiCad message boxes; none open is fine"),+        "plugin_diagnose":   dict(phase=1, args={}),+        "pcm_list":          dict(phase=1, args={}),+        "ipc_api":           dict(phase=1, args={}, note="READ ONLY here: no enable arg, so nothing is written"),+        "adom_library_heal": dict(phase=1, args={"mode": "prune", "dryRun": True},+                                  note="SAFETY: dryRun only; reports what a prune would do"),         # ---- phase 2: headless exports on the sample board (kicad-cli)+        "extract_netlist":   dict(phase=2, args={"filePath": S, "includeNodes": False}, timeout=120),+        "analyze_connections": dict(phase=2, args={"filePath": S}, timeout=120),+        "find_connections":  dict(phase=2, args={"filePath": S, "reference": "U1"}, timeout=120),+        "trace_net":         dict(phase=2, args={"filePath": S, "netName": "GND"}, timeout=120,+                                  note="a net that exists in the rp2040 fixture; a miss is success:false with candidates"),         "run_drc":           dict(phase=2, args={"filePath": B}, timeout=200),         "run_erc":           dict(phase=2, args={"filePath": S}, timeout=200),         "lint_board":        dict(phase=2, args={"filePath": B}, timeout=200),@@ -149,14 +169,19 @@ def matrix(w: dict) -> dict:                                   note="escape to the KiCad window: safe, cancels nothing persistent"),         "click":             dict(phase=3, args={"__special": "click_3d_toolbar"},                                   note="clicks the 3D viewer's zoom-in toolbar button (window-targeted)"),-        "bridge_call":       dict(phase=3, args={"exeName": "eeschema", "method": "ping", "params": {}},-                                  note="plugin IPC liveness"),+        "bridge_call":       dict(phase=3, args={"exeName": "pcbnew", "method": "ping", "params": {}},+                                  note="plugin IPC liveness. pcbnew, not eeschema: KiCad 9/10 eeschema has no "+                                       "Python scripting, so the plugin can only ever live in the PCB side (#81)"),         # ---- phase 4a: library installs (write into the Adom lib, then verify)         "install_library":   dict(phase=4, args={"__special": "install_library"}, timeout=160),         "install_symbol":    dict(phase=4, args={"__special": "install_symbol"}, timeout=160),         "install_footprint": dict(phase=4, args={"__special": "install_footprint"}, timeout=160),         "install_plugin":    dict(phase=4, args={}, timeout=160, note="idempotent payload re-deploy"),         "place_footprint":   dict(phase=4, args={"__special": "place_footprint"}, timeout=220),+        "make_part_project": dict(phase=4, args={"partName": "ADOM_VERBTEST"}, timeout=220,+                                  note="after install_symbol/install_footprint: a pro/sch/pcb around the test part"),+        "export_part":       dict(phase=4, args={"partName": "ADOM_VERBTEST", "what": ["symbol", "footprint"]}, timeout=160,+                                  note="writes Documents/adom-part-exports/ADOM_VERBTEST/"),         # ---- phase 5: demo (one beat — the full 6-beat tour is --full)         "demo":              dict(phase=5, args={}, check="demo_beat", timeout=240,                                   note="beat 1 only by default; --full runs all six"),@@ -170,6 +195,16 @@ def matrix(w: dict) -> dict:         **{verb: dict(skip="Covered by tests/test_routing_ipc.py and demo/routing/run_demo.py; requires a dedicated unrouted IPC board")            for verb in ("routing_state", "route_net", "remove_route", "routing_validate",                         "board_pads", "add_track", "add_via", "route")},+        "install_library_bundle": dict(skip="needs a Cloud Library bundle zip staged on the box; covered by the "+                                            "install_library/install_symbol/install_footprint trio it wraps"),+        "pcm_install":       dict(skip="writes into %KICAD_3RD_PARTY% and installed_packages.json (a real PCM package "+                                       "download); run by hand when touching handlers/pcm.py"),+        "pcm_uninstall":     dict(skip="pair of pcm_install; same reason"),+        "rescan_libraries":  dict(skip="a full KiCad restart (~150 s, #51); exercised implicitly by the install verbs' "+                                       "staleEditorOpen path"),+        "set_design_rules":  dict(skip="rewrites the board's design rules from the wiki; run by hand on a copy when "+                                       "touching handlers/design_rules.py"),+        "set_settings":      dict(skip="mutates the user's bridge settings (overlay badges); get_settings covers the read side"),         # ---- explicit skips (reason required — counted as covered)         "enable_software_opengl": dict(skip="mutates the user's KiCad bin/ (mesa deploy) — only for GPU-less "                                             "VMs; run manually on ADOMBASELINE when testing that path"),@@ -233,10 +268,20 @@ def main():     # discover the Windows user for paths     user = "john"     if not a.plan:-        st = ab(a.target, "status", {}, timeout=60)-        raw = json.dumps(st)-        if "Users\\\\" in raw:-            user = raw.split("Users\\\\", 1)[1].split("\\\\", 1)[0]+        # The Windows user comes from the box, never from an assumption (#78):+        # kicad_diagnostics carries C:/Users/<name>/... paths in every reply.+        import re as _re+        found = None+        for probe in (("kicad_diagnostics", {}), ("status", {})):+            raw = json.dumps(ab(a.target, probe[0], probe[1], timeout=90))+            m = _re.search(r"Users[\\\\/]+([^\\\\/\"]+)[\\\\/]", raw)+            if m and m.group(1).lower() not in ("public", "default"):+                found = m.group(1)+                break+        if found:+            user = found+        else:+            print("FATAL: could not learn the Windows user from kicad_diagnostics/status; pass --plan"); return 2     base = WINDIR.format(user=user)     w = {         "board": f"{base}/rp2040-breakout.kicad_pcb",@@ -346,10 +391,20 @@ def main():         # v0.9.104 (John): NEVER raise the user's window from here. The bridge's         # own guardian pushes KiCad windows back; the suite only OBSERVES. -    def fg_assert(verb):+    fg_notes = []+    fg_before = {}++    def fg_assert(verb, before=""):         """After every GUI verb: the OS foreground window must NOT be KiCad.-        This is the enforcement of the ALWAYS-background contract — a verb that-        passes functionally but steals the foreground FAILS the run."""+        This is the enforcement of the ALWAYS-background contract: a verb that+        passes functionally but steals the foreground FAILS the run.++        `before` is the foreground title measured right before the verb. A steal+        needs a victim: when nothing non-KiCad owned the foreground beforehand+        (a headless VM with no console session, or KiCad already on top from an+        earlier verb) Windows hands a new window the foreground by itself, and+        that is the environment, not the bridge (issue #81, winvm 2026-09-07).+        Such cases are recorded in foreground_notes, never as failures."""         if a.skip_gui:             return         time.sleep(1.0)@@ -359,13 +414,18 @@ def main():             ws = (inner.get("data") or inner).get("windows") or []             top = next((x for x in ws if x.get("z") == 0), None)             title = (top or {}).get("title", "")-            if any(k in title for k in ("KiCad", "PCB Editor", "Schematic Editor",-                                        "Symbol Editor", "Footprint Editor", "3D Viewer")):-                fg_violations.append({"after_verb": verb, "foreground": title})-                if verb in results and results[verb]["result"] == "PASS":-                    results[verb]["result"] = "FAIL"-                    results[verb]["reason"] = f"FOREGROUND STEAL: '{title}' on top after verb"-                print(f"    !! FOREGROUND STEAL after kicad_{verb}: {title[:60]}")+            if any(k in title for k in _kw):+                had_victim = bool(before) and not any(k in before for k in _kw) and before not in ("Program Manager",)+                if had_victim:+                    fg_violations.append({"after_verb": verb, "foreground": title, "before": before})+                    if verb in results and results[verb]["result"] == "PASS":+                        results[verb]["result"] = "FAIL"+                        results[verb]["reason"] = f"FOREGROUND STEAL: '{title}' on top after verb (was '{before[:40]}')"+                    print(f"    !! FOREGROUND STEAL after kicad_{verb}: {title[:60]}")+                else:+                    fg_notes.append({"after_verb": verb, "foreground": title, "before": before,+                                     "note": "no user window owned the foreground before the verb; not counted"})+                    print(f"    .. KiCad on top after kicad_{verb} but nothing to steal from (before: {before[:40]!r})")         except Exception:             pass         _keep_background()   # push KiCad behind the user's window either way@@ -417,6 +477,10 @@ def main():         t = spec.get("timeout", 150)         if spec.get("phase") == 2 or verb in ("open_footprint_editor", "open_symbol_editor", "open_3d_viewer"):             args.setdefault("timeout", max(90, t - 20))   # relay-side per-call timeout override+        before_top = ""+        if not a.skip_gui and spec.get("phase", 9) >= 3:+            before_top = (_current_top() or {}).get("title") or ""+        fg_before[verb] = before_top         r = ab(a.target, f"kicad_{verb}", args, timeout=t)         if r.get("status") == "timeout":             time.sleep(5)   # transient relay/kicad-cli slowness — one retry@@ -450,7 +514,7 @@ def main():         for verb, spec in entries:             run(verb, spec)             if phase == 3:-                fg_assert(verb)+                fg_assert(verb, fg_before.get(verb, ''))         if phase == 3 and not a.skip_gui:             time.sleep(2) @@ -475,6 +539,7 @@ def main():         "totals": {"pass": len(passes), "fail": len(fails), "skip": len(skips)},         "coverage_gap": missing, "failures": fails, "results": results,         "foreground_violations": fg_violations,+        "foreground_notes": fg_notes,     }     (outdir / "report.json").write_text(json.dumps(report, indent=2))     md = [f"# KiCad bridge verb test — {a.target} — {report['started'][:16]}",
tests/test_library_uri.pyadded+26
@@ -0,0 +1,26 @@+import sys, unittest+from pathlib import Path+from unittest.mock import patch+sys.path.insert(0, str(Path(__file__).resolve().parents[1]))+import adom_library++INFO = {"version": "10.0", "base_dir": "C:/Users/arav/AppData/Local/Programs/KiCad/10.0", "user_dir": "C:/Users/arav/Documents/KiCad/10.0"}++class ExpandUri(unittest.TestCase):+    def test_stock_symbol_dir_is_the_install_share(self):+        with patch.object(adom_library, "_kicad_env_overrides", return_value={}):+            self.assertEqual(adom_library._expand_kicad_uri("${KICAD10_SYMBOL_DIR}/Device.kicad_sym", INFO),+                             "C:/Users/arav/AppData/Local/Programs/KiCad/10.0/share/kicad/symbols/Device.kicad_sym")+            self.assertEqual(adom_library._expand_kicad_uri("${KICAD10_FOOTPRINT_DIR}/Resistor_SMD.pretty", INFO),+                             "C:/Users/arav/AppData/Local/Programs/KiCad/10.0/share/kicad/footprints/Resistor_SMD.pretty")+    def test_user_override_wins(self):+        with patch.object(adom_library, "_kicad_env_overrides", return_value={"KICAD10_SYMBOL_DIR": "D:/libs/sym"}):+            self.assertEqual(adom_library._expand_kicad_uri("${KICAD10_SYMBOL_DIR}/Device.kicad_sym", INFO), "D:/libs/sym/Device.kicad_sym")+    def test_project_relative_and_unknown_refused(self):+        with patch.object(adom_library, "_kicad_env_overrides", return_value={}):+            self.assertIsNone(adom_library._expand_kicad_uri("${KIPRJMOD}/x.kicad_sym", INFO))+            self.assertIsNone(adom_library._expand_kicad_uri("${MYVAR}/x.kicad_sym", INFO))+            self.assertEqual(adom_library._expand_kicad_uri("C:\\\\plain\\\\Adom.kicad_sym", INFO), "C:/plain/Adom.kicad_sym")++if __name__ == "__main__":+    unittest.main()
tests/test_live_routing.py+46
@@ -19,6 +19,52 @@ def board():              'pads':[{'number':'1','x':0,'y':0,'net_name':'GND','layers':['B.Cu'],'type':'smd'}]}]}  +class DrcSnapshot(unittest.TestCase):+    def test_capped_opens_are_lower_bound_without_blocking_error_comparison(self):+        limits = live._report_limits([], [{}] * 499)+        self.assertTrue(limits['unconnectedCountIsLowerBound'])+        self.assertFalse(limits['violationReportMayBeTruncated'])+        self.assertEqual(live._new_errors({'violations': [], **limits}, {'violations': [], **limits}), [])+        self.assertFalse(live._report_limits([], [{}] * 498)['unconnectedCountIsLowerBound'])++    def test_per_type_caps_block_error_delta_even_when_reports_match(self):+        for kind, cap in [('clearance', 499), ('shorting_items', 199)]:+            v = {'type': kind, 'severity': 'error', 'items': []}+            self.assertFalse(live._report_limits([v] * (cap - 1), [])['violationReportMayBeTruncated'])+            report = {'violations': [v] * cap, **live._report_limits([v] * cap, [])}+            with self.assertRaises(live.RoutingError) as cm:+                live._new_errors(report, report)+            self.assertEqual(cm.exception.code, 'drc_incomplete')+            self.assertFalse(cm.exception.detail['mutated'])++    def test_refills_snapshot_with_project_rules_without_changing_source(self):+        import json+        from types import SimpleNamespace+        with tempfile.TemporaryDirectory() as tmp:+            source = Path(tmp) / 'board.kicad_pcb'+            source.write_text('original board')+            source.with_suffix('.kicad_pro').write_text('project rules')+            source.with_suffix('.kicad_dru').write_text('custom rules')+            exe = Path(tmp) / 'kicad-cli'+            exe.touch()+            def run(command, **kwargs):+                candidate = Path(command[-1])+                self.assertNotEqual(candidate, source)+                self.assertEqual(candidate.read_text(), 'live snapshot')+                self.assertEqual(candidate.with_suffix('.kicad_pro').read_text(), 'project rules')+                self.assertEqual(candidate.with_suffix('.kicad_dru').read_text(), 'custom rules')+                self.assertIn('--refill-zones', command)+                Path(command[command.index('--output') + 1]).write_text(json.dumps({+                    'violations': [], 'unconnected_items': [{'items': []}]}))+                return SimpleNamespace(returncode=0, stderr='', stdout='')+            with patch.object(live.subprocess, 'run', side_effect=run):+                result = live._drc({'kicad_cli_exe': str(exe)}, {'filePath': str(source)}, 'live snapshot')+            self.assertTrue(result['zonesRefilled'])+            self.assertEqual(result['unconnected'], 1)+            self.assertFalse(result['clean'])+            self.assertEqual(source.read_text(), 'original board')++ class Planning(unittest.TestCase):     def test_rotated_pad_coordinates_and_final_layer_transition(self):         p=live._plan(board(), {'net':'GND','points':['J1.1',{'pad':'J2.1','layer':'B.Cu'}]})
tests/test_readiness_refresh.pyadded+21
@@ -0,0 +1,21 @@+import sys, unittest+from pathlib import Path+from unittest.mock import patch+sys.path.insert(0, str(Path(__file__).resolve().parents[1]))+import server++class Refresh(unittest.TestCase):+    def test_vanished_exe_triggers_redetect(self):+        with patch.object(server, "all_kicad_versions", [{"version": "10.0", "kicad_exe": "/nonexistent/kicad.exe"}]), \+             patch.object(server, "detect_all_kicad_versions", return_value=[]):+            r = server._handle_readiness({}, {})+            self.assertFalse(r["detected"]); self.assertEqual(r["versionCount"], 0)+    def test_present_exe_keeps_cache(self):+        exe = str(Path(__file__).resolve())+        with patch.object(server, "all_kicad_versions", [{"version": "10.0", "kicad_exe": exe}]), \+             patch.object(server, "detect_all_kicad_versions", side_effect=AssertionError("must not re-detect")):+            r = server._handle_readiness({}, {})+            self.assertTrue(r["detected"]); self.assertEqual(r["versions"][0]["exePath"], exe)++if __name__ == "__main__":+    unittest.main()
tests/test_routing_ipc.py+7−1
@@ -140,7 +140,13 @@ class OfflineTests(unittest.TestCase):      def test_atomic_file_edit_and_open_board_refusal(self):         with tempfile.TemporaryDirectory() as d:-            path = make(d); before = path.read_bytes()+            # A user can have the demonstration's default "live-routing" board+            # open on the Windows test host. Give this disposable fixture a+            # unique title so the real open-editor guard does not match it.+            fixture = make(d)+            path = fixture.with_name(Path(d).name + '-offline-test.kicad_pcb')+            fixture.rename(path)+            before = path.read_bytes()             r = route.handle_route({}, {'filePath':str(path),'net':'NET_1','points':['J1.1','J2.1']})             self.assertTrue(r['success'], r)             self.assertEqual(Path(str(path)+'.adom-bak').read_bytes(), before)
tools/build_release.py+16−2
@@ -212,9 +212,23 @@ def main() -> int:     # -m matters twice: the page reuses the commit message as its changelog     # verbatim, so an unset message is where "- Update N file(s)" came from.     msg = f"{version}: {notes}" if notes else f"{version}"+    # Every push reply is checked (issue #79: five handlers silently never reached+    # master across twenty releases because a failed chunk was ignored).+    failed = []     for i in range(0, len(push_files), CHUNK):-        subprocess.run(["adom-wiki", "repo", "push", REF, "-m", msg, "--files"]-                       + push_files[i:i + CHUNK], check=False)+        chunk = push_files[i:i + CHUNK]+        proc = subprocess.run(["adom-wiki", "repo", "push", REF, "-m", msg, "--files"] + chunk,+                              capture_output=True, text=True)+        ok = '"ok": true' in proc.stdout+        print(f"  push {i // CHUNK + 1}: {len(chunk)} file(s) -> {'ok' if ok else 'FAILED'}")+        if not ok:+            print((proc.stdout + proc.stderr)[-600:])+            failed.extend(chunk)+    if failed:+        print("REFUSING TO RELEASE: source push failed for", len(failed), "file(s); the repo would not match the zip:")+        for f in failed[:20]:+            print("   ", f)+        return 1     subprocess.run(["adom-wiki", "release", "create", REF, version,                     "--changelog", notes], check=False)     subprocess.run(["adom-wiki", "release", "upload", REF, version, out], check=False)