app
KiCad - the KiCad Bridge
Public Made by Adomby adom
Drive your own KiCad from an AI, in the background: open every editor, read the whole board as data (the EDA Bridge Standard board model), stack-up, part heights, netlists, place, route, pour, DRC and exports.
master
Colby Knox
Merge PR #399: 1.0.100: update_pcb sets every reference (closes #138); the verbs ruled on standard #29
85f32a2
8d ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100
#!/usr/bin/env python3
"""run_verb_tests.py — end-to-end verb test for the KiCad bridge, ALL 48 verbs.
Runs from an Adom container against a real Windows box over the relay, driving
the bridge exactly the way a user's AI does: `adom-bridge --target <box> kicad_*`.
Sample data is the rp2040 tour pack (the same board users see in the README),
pushed to the box at the start of the run.
python3 run_verb_tests.py --target AdomLapper # full run
python3 run_verb_tests.py --plan # print the matrix, run nothing
python3 run_verb_tests.py --target X --only export_gerber,run_drc
python3 run_verb_tests.py --target X --skip-gui # headless subset (exports/meta)
Coverage is ENFORCED: the script fetches kicad_describe's live verb list and
fails the run if any verb is neither tested nor on the explicit SKIP list with
a reason. A new verb added to the bridge makes the daily run red until a test
(or a justified skip) exists — that is the point.
Safety hard rules (public-release posture):
- kicad_upgrade -> diagnoseOnly:true ONLY (never installs anything)
- kicad_uninstall-> dryRun:true ONLY (never removes anything)
- every GUI verb runs BACKGROUND (the bridge's own no-foreground contract);
the run never foregrounds, never types into unknown windows
- ends with kicad_close {force:true} so the box is left clean
Output: JSON report + markdown summary in --out (default /tmp/kicad-verbtest/),
exit 0 only if every non-skipped verb passed AND coverage is complete.
"""
import argparse
import json
import re
import math
import os
import shlex
import subprocess
import sys
import time
from datetime import datetime, timezone
from pathlib import Path
# Every run used to call itself "kicad-bridge daily-test", so two runs on one box were
# indistinguishable in kicad_status.operations and in the bridge log, and a second battery looked
# like a stranger (2026-09-23). The name now carries who and where, and --ai-thread overrides it.
AI_THREAD = "kicad-bridge verbtest %s/%d" % (os.uname().nodename if hasattr(os, "uname") else "box", os.getpid())
REPO = Path(__file__).resolve().parents[2]
PACK = REPO / "tour-pack-rp2040" / "template"
WINDIR = "C:/Users/{user}/Documents/adom-kicad-verbtest"
# The sample PCM repository on this page (docs/pcm.md): three anonymous files, no login.
PCM_REPO_URL = "https://wiki.adom.inc/api/pages/adom/kicad-bridge/files/pcm/repository.json"
# ---------------------------------------------------------------- CLI plumbing
def ab(target: str, verb: str, args: dict, timeout: int = 150) -> dict:
cmd = ["adom-bridge", "--ai-thread", AI_THREAD, "--target", target,
verb, json.dumps(args)]
try:
p = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
out = p.stdout.strip() or p.stderr.strip()
try:
return json.loads(out)
except Exception:
return {"status": "parse_error", "raw": out[:500]}
except subprocess.TimeoutExpired:
return {"status": "cli_timeout", "error": f"CLI timeout after {timeout}s"}
def ok(r: dict) -> bool:
if r.get("success") is True:
return True
if r.get("success") is False:
return False
return r.get("status") == "ok" and not r.get("error")
# ------------------------------------------------------------------ the matrix
# Each entry: verb -> dict(phase, args, check (callable name), timeout, note)
# Checks beyond ok(): named lambdas in CHECKS below.
CHECKS = {
# 1.0.92 (standard log_tail #29): entries and next beside lines and nextOffset
"log_entries": lambda r: ok(r) and isinstance(r.get("entries"), list) and "next" in r and r.get("next") == r.get("nextOffset"),
"ok": ok,
"verbs>=47": lambda r: ok(r) and len(r.get("verbs") or []) >= 47,
"editors_listed": lambda r: ok(r) and "summary" in r,
"windows_listed": lambda r: ok(r) and isinstance((r.get("data") or r).get("editors", None), list),
"screenshot_files": lambda r: ok(r) and (r.get("screenshots") or r.get("savedTo") or (r.get("data") or {}).get("screenshots")),
"symbol_verified": lambda r: ok(r) and (r.get("via") == "plugin-background" or "loaded" in str(r.get("output", "")).lower()),
"dry_report": lambda r: r.get("dryRun") is True and "removed" in r,
# 1.0.90 (#29): a standard verb KiCad has no form of refuses with the reason and what to call instead
"explains_unsupported": lambda r: r.get("errorCode") == "not_supported" and str(r.get("instead") or "").startswith("kicad_") and len(str(r.get("error") or "")) > 20,
"diagnose_facts": lambda r: ok(r) or "facts" in json.dumps(r)[:2000] or "uacEnableLUA" in json.dumps(r),
"launched_or_running": lambda r: ok(r) and (r.get("launched") or r.get("alreadyRunning")),
"demo_beat": lambda r: ok(r) and (r.get("say") or (r.get("data") or {}).get("say") or r.get("stage")),
"state_shots": lambda r: ok(r) and r.get("running") and any(s.get("image") for s in (r.get("shots") or [])),
# pcm_list from 1.0.2 carries the update-check fields; a bridge without them fails this check on purpose
"pcm_listed": lambda r: ok(r) and isinstance(r.get("packages"), list) and "updatesAvailable" in r,
"repo_added": lambda r: ok(r) and (r.get("added") is True or r.get("alreadyRegistered") is True),
# placement (1.0.3): live when the PCB editor has the board on the IPC API, the file otherwise
"placement_state": lambda r: ok(r) and "revision" in r and isinstance(r.get("footprints"), list)
and len(r["footprints"]) > 0 and "totalMm" in (r.get("ratsnest") or {}),
# the EDA Bridge Standard board model and its companions (1.0.59), read from the fixture's file
"standard_model": lambda r: ok(r) and r.get("units") == "mm" and len(r.get("pads") or []) > 0
and len(r.get("revision") or "") == 16 and isinstance(r.get("unconnected"), int)
and len(r.get("outlineEdges") or []) >= 3,
"layer_stack": lambda r: ok(r) and (r.get("copperLayers") or 0) >= 2 and "dielectricUm" in (r.get("layers") or [{}])[0],
"pour_state": lambda r: ok(r) and isinstance(r.get("zones"), list) and "filledAreaByLayerMm2" in r,
"bodies": lambda r: ok(r) and (r.get("parts") or 0) > 0 and isinstance(r.get("bodies"), list)
and len(r["bodies"]) + len(r.get("noBody") or []) >= r["parts"],
"std_netlist": lambda r: ok(r) and (r.get("netCount") or 0) > 0 and isinstance(r.get("nets"), dict)
and any(p.get("pins") for p in (r.get("parts") or [])),
"board_summary": lambda r: ok(r) and (r.get("summary") or {}).get("components", 0) > 0,
"count_list": lambda r: ok(r) and (r.get("count") or 0) > 0,
"no_connect_placed": lambda r: ok(r) and bool(r.get("uuid")) and isinstance(r.get("at"), list) and r.get("pin") == "U1.1",
"net_list": lambda r: ok(r) and isinstance(r.get("nets"), list) and r.get("count") == len(r["nets"]),
"layers_in_use": lambda r: ok(r) and len(r.get("copperLayersInUse") or []) > 0,
"component_data": lambda r: ok(r) and (r.get("component") or {}).get("pads", 0) > 0,
"component_pins": lambda r: ok(r) and (r.get("count") or 0) > 0,
"move_dry": lambda r: ok(r) and r.get("dryRun") is True and r.get("mutated") is False
and "checks" in r and r.get("undoSteps") == 0,
"taskbar_report": lambda r: ok(r) and r.get("errors") == 0 and ("already" in r or "written" in r),
"viewer_flags": lambda r: ok(r) and r.get("mutated") is False and "flags" in (r.get("viewer") or {}),
"placement_validated": lambda r: ok(r) and isinstance(r.get("courtyardOverlaps"), list)
and "placed" in r and "drc" in r,
}
def matrix(w: dict) -> dict:
"""w: paths on the Windows box (filled at runtime)."""
B, S, P = w["board"], w["sch"], w["pro"]
OUT = w["out"]
M = {
# ---- phase 1: read-only / meta (no KiCad needed)
"describe": dict(phase=1, args={}, check="verbs>=47"),
**{v: dict(phase=1, args={}, check="explains_unsupported") for v in (
"license", "signin", "connect_workspace", "agent_start", "agent_stop",
"agent_status", "agent_recover", "selftest_script_error", "extension_ping", "install_extension", "install_receipt", "physical_properties")},
"status": dict(phase=1, args={}),
"readiness": dict(phase=1, args={}),
"list_versions": dict(phase=1, args={}),
"diagnostics": dict(phase=1, args={}),
"bridge_status": dict(phase=1, args={}),
"check_for_updates": dict(phase=1, args={}),
"adom_library_status": dict(phase=1, args={}),
"fix_keyboard": dict(phase=1, args={}, note="clears stuck modifiers; safe no-op when none"),
"upgrade": dict(phase=1, args={"diagnoseOnly": True}, check="diagnose_facts",
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={}, check="log_entries"),
"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={}, check="pcm_listed", timeout=90,
note="checkUpdates defaults on: fetches each registered repository (1.0.2 fields "
"updatesAvailable, latestVersion, updateCheck)"),
"prewarm": dict(phase=1, args={}, timeout=150,
note="what ab calls after an install (bridge.json prewarm.verb); idempotent, config steps skipped while KiCad runs"),
"taskbar_repair": dict(phase=1, args={}, check="taskbar_report",
note="idempotent: writes KiCad's own AUMID onto its standalone shortcuts only when blank"),
"3d_viewer_settings": dict(phase=1, args={}, check="viewer_flags", note="READ ONLY here: no toggle passed"),
"set_settings": dict(phase=1, args={"__special": "set_settings_same"},
note="SAFETY: writes foregroundToastMinutes back at the value get_settings just read"),
"ipc_api": dict(phase=1, args={}, note="READ ONLY here: no enable arg, so nothing is written"),
"adom_library_heal": dict(phase=4.5, args={"mode": "prune", "dryRun": True},
note="SAFETY: dryRun only; after the installs, because a fresh box has no Adom "
"library to prune yet (winvm 2026-09-08)"),
# ---- 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={"__special": "trace_net"}, timeout=120,
note="netName is the first real net kicad_extract_netlist reports for the fixture"),
"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),
"lint_schematic": dict(phase=2, args={"filePath": S}, timeout=200),
# ---- phase 2a: the EDA Bridge Standard board model and companions (1.0.59), from the file
"layer_stack": dict(phase=2, args={"filePath": B, "source": "file"}, check="layer_stack"),
"pour_state": dict(phase=2, args={"filePath": B, "source": "file", "geometry": False}, check="pour_state"),
"bodies": dict(phase=2, args={"filePath": B, "source": "file"}, check="bodies", timeout=120,
note="3D model bounds from the files the footprints name; every part is a body or in noBody"),
"netlist": dict(phase=2, args={"filePath": S, "parts": True}, check="std_netlist", timeout=120),
"board_summary": dict(phase=2, args={"filePath": B, "source": "file"}, check="board_summary"),
"list_nets": dict(phase=2, args={"filePath": B, "source": "file"}, check="net_list",
note="the rp2040 fixture board carries no nets: an empty list is the right answer"),
"list_designators": dict(phase=2, args={"filePath": B, "source": "file"}, check="count_list"),
"copper_layers": dict(phase=2, args={"filePath": B, "source": "file"}, check="count_list"),
"layers_in_use": dict(phase=2, args={"filePath": B, "source": "file"}, check="layers_in_use"),
"component_data": dict(phase=2, args={"filePath": B, "source": "file", "designator": "U1"}, check="component_data"),
"component_pins": dict(phase=2, args={"filePath": B, "source": "file", "designator": "U1"}, check="component_pins"),
# ---- phase 9: the standard's core verbs added in 1.0.64, run in order by std_phase() on scratch files
**{v: dict(phase=9, scenario=True) for v in ("copy_footprint", "rename_footprint", "xcopy_footprint", "remove_footprint", "add_body",
"clone_symbol", "rename_symbol", "set_params", "set_visibility", "symbol_prims", "place_on_sch", "doc_params", "sheet_params",
"part_parameters", "layers", "silk_state", "ripup_nets", "compile", "list_installed_libraries", "replicate_layout", "diff_pairs")},
"teardrops": dict(phase=8, scenario=True),
# ---- phase 10: the checks tier added in 1.0.67, run in order by checks_phase() on a scratch board
**{v: dict(phase=10, scenario=True) for v in ("fab_check", "copper_balance", "return_path", "impedance", "current_capacity", "net_lengths",
"check_orientation", "find_space", "height_check", "placement_check", "pin_pad_check", "auto_classes", "thermal_vias", "via_fence", "board_report",
"bom", "assembly_check", "jlcpcb_order", "footprint_from_land_pattern", "silk_designators",
"read_sheet_file", "build_board", "test_board", "length_tune", "silkscreen_prep", "component_classes", "build_schematic",
"library_views", "generate_body", "plan_route", "export_spice", "simulate",
"license_status", "eval", "run_script", "dialog_log", "foreground", "schematic_review", "review_board", "variants", "sch_zoom", "swap_footprint", "plate_vias", "normalize_bodies", "symbol_mode", "add_symbol_art", "build_symbol_modes", "cleanup_libs",
"export_3d", "edit_pours", "import_design", "sch_part_verbs_standard", "tool_update", "import_step", "cross_check",
"list_library", "render_board_layers", "export_bom", "open_document", "activate_document", "open_project", "routing_drc", "set_track_width", "footprint_primitives", "rules_by_kind", "net_connections", "remove_items", "document_info", "read_board", "read_schematic", "schematic_data", "pcb_report", "instances", "ensure_licensed", "signin_status", "setup_trial", "set_models", "add_models", "check_dialogs", "save", "close_document", "close_all_documents", "cancel_run", "place_parts", "edit_tracks", "extensions", "preferences")},
"panelize": dict(phase=8, scenario=True),
# ---- phase 8: the 1.0.61 verbs (Konnect parity), run in order by k161_phase() on scratch files
"add_footprint": dict(phase=8, scenario=True),
"add_mounting_hole": dict(phase=8, scenario=True),
"align_footprints": dict(phase=8, scenario=True),
"board_outline": dict(phase=8, scenario=True),
"board_rules": dict(phase=8, scenario=True),
"check_clearance": dict(phase=8, scenario=True),
"copy_routing": dict(phase=8, scenario=True),
"cross_probe": dict(phase=8, scenario=True),
"custom_rules": dict(phase=8, scenario=True),
"delete_footprint": dict(phase=8, scenario=True),
"delete_graphics": dict(phase=8, scenario=True),
"design_review": dict(phase=8, scenario=True),
"edit_footprint": dict(phase=8, scenario=True),
"estimate_cost": dict(phase=8, scenario=True),
"export_dxf": dict(phase=8, scenario=True),
"export_fab_package": dict(phase=8, scenario=True),
"export_gencad": dict(phase=8, scenario=True),
"export_ipc2581": dict(phase=8, scenario=True),
"export_ipc356": dict(phase=8, scenario=True),
"export_odb": dict(phase=8, scenario=True),
"flip_footprint": dict(phase=8, scenario=True),
"footprint_graphics": dict(phase=8, scenario=True),
"footprint_models": dict(phase=8, scenario=True),
"fp_create": dict(phase=8, scenario=True),
"fp_edit": dict(phase=8, scenario=True),
"fp_info": dict(phase=8, scenario=True),
"get_selection": dict(phase=8, scenario=True),
"import_svg": dict(phase=8, scenario=True),
"modify_track": dict(phase=8, scenario=True),
"netclasses": dict(phase=8, scenario=True),
"place_decoupling": dict(phase=8, scenario=True),
"plan_fanout": dict(phase=8, scenario=True),
"project_create": dict(phase=8, scenario=True),
"project_info": dict(phase=8, scenario=True),
"project_rename": dict(phase=8, scenario=True),
"project_snapshot": dict(phase=8, scenario=True),
"refill_zones": dict(phase=8, scenario=True),
"render_schematic": dict(phase=8, scenario=True),
"route_diff_pair": dict(phase=8, scenario=True),
"save_board": dict(phase=8, scenario=True),
"sch_annotate": dict(phase=8, scenario=True),
"sch_batch": dict(phase=8, scenario=True),
"sch_bus": dict(phase=8, scenario=True),
"sch_bus_entry": dict(phase=8, scenario=True),
"sch_check": dict(phase=8, scenario=True),
"sch_components": dict(phase=8, scenario=True),
"sch_connect_pins": dict(phase=8, scenario=True),
"sch_connect_to_bus": dict(phase=8, scenario=True),
"sch_connect_to_net": dict(phase=8, scenario=True),
"sch_create": dict(phase=8, scenario=True),
"sch_delete_wire": dict(phase=8, scenario=True),
"sch_edit_label": dict(phase=8, scenario=True),
"sch_fix_connectivity": dict(phase=8, scenario=True),
"sch_hierarchy": dict(phase=8, scenario=True),
"sch_items": dict(phase=8, scenario=True),
"sch_junction": dict(phase=8, scenario=True),
"sch_move_region": dict(phase=8, scenario=True),
"sch_page": dict(phase=8, scenario=True),
"sch_pins": dict(phase=8, scenario=True),
"sch_replace_symbol": dict(phase=8, scenario=True),
"sch_reset_fields": dict(phase=8, scenario=True),
"sch_rotate": dict(phase=8, scenario=True),
"sch_sheet": dict(phase=8, scenario=True),
"sch_sheet_pins": dict(phase=8, scenario=True),
"sch_split_wire": dict(phase=8, scenario=True),
"sch_text": dict(phase=8, scenario=True),
"sch_trace_point": dict(phase=8, scenario=True),
"sch_update_from_library": dict(phase=8, scenario=True),
"score_placement": dict(phase=8, scenario=True),
"search_library": dict(phase=8, scenario=True),
"select_items": dict(phase=8, scenario=True),
"set_layer_count": dict(phase=8, scenario=True),
"sym_create": dict(phase=8, scenario=True),
"sym_delete": dict(phase=8, scenario=True),
"sym_info": dict(phase=8, scenario=True),
"update_footprints_from_library": dict(phase=8, scenario=True),
"update_pcb_from_schematic": dict(phase=8, scenario=True),
"validate_for_fab": dict(phase=8, scenario=True),
# ---- phase 2b: native-build verbs (Rust port). All read-only or on the fixture copy.
"freerouting": dict(phase=1, args={"action": "status"}, timeout=60,
note="status only: install is on demand and asks the user first"),
"model_check": dict(phase=2, args={"boardPath": B}, timeout=120,
note="file diagnostics only; renderVerified is always false here"),
"autoroute": dict(phase=2, args={"filePath": B, "engine": "ai", "dryRun": True}, timeout=120,
note="ai engine returns a plan request and mutates nothing"),
# placement (1.0.3): a real read on the scratch board (file fallback here, since no PCB editor
# has it on the IPC API in phase 2), a dryRun move checked against that read, and the validate.
"placement_state": dict(phase=2, args={"filePath": B}, check="placement_state", timeout=120,
note="footprints with courtyards, nets and the MST ratsnest; source file or live-editor"),
"move_footprint": dict(phase=2.6, args={"__special": "move_footprint_dry"}, check="move_dry", timeout=120,
note="SAFETY: dryRun only. Moves the first footprint placement_state reported to its own "
"position, expectedRevision from that read; the checks run, nothing is written"),
"placement_validate": dict(phase=2, args={"filePath": B}, check="placement_validated", timeout=200,
note="courtyard overlaps, outline, parked, ratsnest, KiCad DRC filtered to placement types"),
# schematic edit in place, on the fixture copy: each write keeps a .bak beside the file
"sch_place_symbol": dict(phase=2, args={"filePath": S, "libId": "Device:R", "reference": "R_VERBTEST",
"at": [200, 150], "value": "1k"}, timeout=150,
note="fixture copy only; the .bak is removed with the fixture dir"),
"sch_wire": dict(phase=2.2, args={"filePath": S, "points": [[200, 146.19], [200, 140]]}, timeout=150),
"sch_label": dict(phase=2.2, args={"filePath": S, "text": "VERBTEST_NET", "at": [200, 140], "kind": "local"}, timeout=150),
"sch_no_connect": dict(phase=2.2, args={"filePath": S, "pin": "U1.1"}, check="no_connect_placed", timeout=150,
note="wiki #131: the flag lands on U1 pin 1's connection point, worked out from the symbol"),
"sch_set_property": dict(phase=2.3, args={"filePath": S, "reference": "R_VERBTEST", "name": "Value", "value": "2k2"}, timeout=150),
"sch_move": dict(phase=2.4, args={"filePath": S, "reference": "R_VERBTEST", "to": [210, 150]}, timeout=150),
"sch_delete": dict(phase=2.5, args={"__special": "sch_delete"}, timeout=150,
note="deletes the R_VERBTEST symbol placed above, by the uuid sch_place_symbol returned"),
"lint_library": dict(phase=2, args={"filePath": w["symlib"]}, timeout=120,
note="Adom.kicad_sym installed in phase 4a; runs after it"),
"export_gerber": dict(phase=2, args={"filePath": B, "outputDir": OUT + "/gerbers"}, timeout=220),
"export_pdf": dict(phase=2, args={"filePath": B, "outputPath": OUT + "/board.pdf"}, timeout=220),
"export_svg": dict(phase=2, args={"filePath": B, "outputDir": OUT + "/svg"}, timeout=220),
"export_pos": dict(phase=2, args={"filePath": B, "outputDir": OUT + "/pos", "fab": "jlcpcb"}, timeout=150),
"render_board": dict(phase=2, args={"filePath": B, "outputDir": OUT + "/render", "side": "top", "width": 800, "height": 450}, timeout=220,
note="kicad-cli pcb render (KiCad 9+), headless"),
"export_step": dict(phase=2, args={"filePath": B, "outputPath": OUT + "/board.step"}, timeout=320),
"export_bom_csv": dict(phase=2, args={"filePath": S, "outputPath": OUT + "/bom.csv"}, timeout=200),
"export_molecule": dict(phase=2, args={"filePath": B, "outputDir": OUT + "/molecule"}, timeout=320),
"set_design_rules": dict(phase=2.7, args={"fab": "jlcpcb", "boardPath": w["board_copy_src"], "runDrc": False},
timeout=200, note="on the COPY of the board (format_upgrade's), staged only, never the fixture"),
"format_upgrade": dict(phase=2, args={"filePath": w["board_copy"]}, timeout=200,
note="runs on a COPY of the board, never the fixture itself"),
# ---- phase 3: GUI lifecycle (background windows on the real box)
"launch": dict(phase=3, args={"waitSeconds": 25}, check="launched_or_running", timeout=60),
"open_board": dict(phase=3, args={"filePath": B, "reason": "daily verb test"}, timeout=160),
"open_schematic": dict(phase=3, args={"filePath": S, "reason": "daily verb test"}, timeout=160),
"open_symbol_editor": dict(phase=3, args={"symbolName": "R", "libraryName": "Device"},
check="symbol_verified", timeout=160,
note="the #20 acceptance test: verified Device:R load"),
"open_footprint_editor": dict(phase=3,
args={"footprintName": "R_0603_1608Metric", "library": "Resistor_SMD"},
timeout=160,
note="e2e load of a stock footprint (needs the 0.9.84 lib self-heal)"),
"open_3d_viewer": dict(phase=3, args={"reason": "daily verb test"}, timeout=200),
"open_editors": dict(phase=3, args={}, check="editors_listed"),
# ---- phase 3b: show_* surface verbs (Fusion-parity clean names)
"show_project": dict(phase=3, args={}, check="ok", timeout=90, note="project manager (idempotent)"),
"show_symbol": dict(phase=3, args={"symbolName": "R", "libraryName": "Device"}, check="ok", timeout=160),
"show_footprint": dict(phase=3, args={"footprintName": "R_0603_1608Metric", "library": "Resistor_SMD"}, timeout=180),
"show_3d_chip": dict(phase=3, args={}, timeout=200, note="3D of the loaded footprint"),
"show_schematic": dict(phase=3, args={"filePath": S, "reason": "verb test"}, timeout=160),
"show_2d_board": dict(phase=3, args={"filePath": B, "reason": "verb test"}, timeout=160),
"show_3d_board": dict(phase=3, args={"reason": "verb test"}, timeout=200),
"show_library": dict(phase=3, args={"libraryName": "Device"}, timeout=160),
"state": dict(phase=3, args={"maxWidth": 640}, check="state_shots", timeout=160,
note="#22: every KiCad window as inline base64 + logical state"),
"window_info": dict(phase=3, args={}),
"screenshot_all": dict(phase=3, args={}, check="screenshot_files", timeout=160),
"dismiss_dialogs": dict(phase=3, args={}),
"send_key": dict(phase=3, args={"key": "escape"},
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": "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"),
"uninstall_library": dict(phase=4, args={"libraryName": "AdomVerbTest", "deleteFiles": True}, timeout=60,
note="removes the scratch library install_library registered above, so daily runs stop piling it up"),
"place_footprint": dict(phase=4, args={"__special": "place_footprint"}, timeout=220),
"pcm_add_repository": dict(phase=4, args={"url": PCM_REPO_URL}, check="repo_added", timeout=90,
note="a REAL add of the sample repository (docs/pcm.md) into kicad.json, backup kept; "
"added or alreadyRegistered both pass. KiCad may be running here, so the row "
"can be overwritten when it exits; the verb says restartNeeded either way"),
"make_part_project": dict(phase=4.5, 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.5, 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"),
# ---- phase 6: teardown
"close_symbol_editor": dict(phase=6, args={}),
"close_footprint_editor": dict(phase=6, args={}),
"close_3d_viewer": dict(phase=6, args={}),
"close": dict(phase=6, args={"force": True}, timeout=120,
note="final teardown — leaves the box with no KiCad windows"),
"pcm_remove_repository": dict(phase=6, args={"__special": "pcm_remove_repository"}, timeout=90,
note="cleanup pair of pcm_add_repository, after close (KiCad closed, so the "
"kicad.json edit sticks): removes the row ONLY when the add wrote it "
"(added:true); alreadyRegistered means the user had it and it stays"),
# ---- phase 6.5: the live board (KiCad IPC). One scratch copy of the unrouted ESC board, opened alone,
# revisions chained from reply to reply, every edit undone or discarded. Run by live_phase() below.
**{verb: dict(phase=6.5, live=True) for verb in (
"routing_state", "board_pads", "routing_validate", "route_net", "remove_route",
"add_zone", "zone_state", "remove_zone", "apply_zone_plan", "select_net", "select_clear",
"text_bounds", "silk_text_batch", "silk_graphics_batch", "silk_fields_batch",
"menu_items", "3d_view", "refresh_board_views", "set_layer_visibility", "zoom_to", "animate_view")},
# ---- phase 6.6: offline copper editing on a second copy that no editor has open
**{verb: dict(phase=6.6, live=True) for verb in ("add_track", "add_via", "route")},
"install_library_bundle": dict(phase=4, args={"__special": "install_bundle"}, timeout=200,
note="a generated two-file bundle (AdomVerbBundle: one symbol, one footprint), installed "
"for real, then removed again with kicad_uninstall_library {deleteFiles:true}"),
# ---- phase 6.7: a REAL install of this page's tiny sample package, then its removal (KiCad closed)
"pcm_install": dict(phase=6.7, live=True),
"pcm_uninstall": dict(phase=6.7, live=True),
"rescan_libraries": dict(phase=3, args={"footprints": ["Resistor_SMD:R_0603_1608Metric"]}, timeout=200,
note="no force: refreshes the open Footprint Editor's tree in place and proves the load; "
"the restart path is proven by hand (wiki #48, #107)"),
# ---- 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"),
}
return M
SPECIALS_DOC = """Specials are composed calls the runner fills at runtime:
click_3d_toolbar -> find 3D viewer hwnd via window_info, click zoom-in at (280,100) relative:false
install_library -> installs the tour pack's AdomRP2040 lib dir if present, else a generated 1-symbol lib
install_symbol -> installs a generated test symbol ADOM_VERBTEST into the Adom lib
install_footprint -> installs a generated 2-pad test footprint ADOM_VERBTEST into Adom.pretty
install_bundle -> installs a generated AdomVerbBundle.zip (one symbol, one footprint), then uninstalls it with deleteFiles
place_footprint -> places Adom:ADOM_VERBTEST on a scratch copy of the template board
pcm_remove_repository -> removes the sample PCM repository row only if pcm_add_repository added it this run
move_footprint_dry -> dryRun move of the first footprint placement_state listed, to its own position, with that read's revision
"""
TEST_SYMBOL = '''(symbol "ADOM_VERBTEST" (pin_numbers hide) (in_bom yes) (on_board yes)
(property "Reference" "U" (at 0 5.08 0) (effects (font (size 1.27 1.27))))
(property "Value" "ADOM_VERBTEST" (at 0 -5.08 0) (effects (font (size 1.27 1.27))))
(symbol "ADOM_VERBTEST_0_1" (rectangle (start -5.08 2.54) (end 5.08 -2.54)
(stroke (width 0.254) (type default)) (fill (type background))))
(symbol "ADOM_VERBTEST_1_1"
(pin passive line (at -7.62 0 0) (length 2.54) (name "A" (effects (font (size 1.27 1.27)))) (number "1" (effects (font (size 1.27 1.27)))))
(pin passive line (at 7.62 0 180) (length 2.54) (name "B" (effects (font (size 1.27 1.27)))) (number "2" (effects (font (size 1.27 1.27)))))))'''
# NOTE: the bridge's lib parser matches KiCad's own formatting — top-level
# (symbol ...) blocks must be INDENTED (regex ^\s+\(symbol). Run #3 failure.
SYMBOL_LIB_WRAP = ('(kicad_symbol_lib (version 20231120) (generator "verbtest")\n '
+ TEST_SYMBOL.replace('\n', '\n ') + '\n)\n')
TEST_FOOTPRINT = '''(footprint "ADOM_VERBTEST" (version 20240108) (generator "verbtest")
(layer "F.Cu")
(attr smd)
(fp_text reference "REF**" (at 0 -2) (layer "F.SilkS") (effects (font (size 1 1) (thickness 0.15))))
(fp_text value "ADOM_VERBTEST" (at 0 2) (layer "F.Fab") (effects (font (size 1 1) (thickness 0.15))))
(pad "1" smd roundrect (at -1 0) (size 1 1.2) (layers "F.Cu" "F.Paste" "F.Mask") (roundrect_rratio 0.25))
(pad "2" smd roundrect (at 1 0) (size 1 1.2) (layers "F.Cu" "F.Paste" "F.Mask") (roundrect_rratio 0.25)))'''
# -------------------------------------------------------------------- running
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--target", default="AdomLapper")
ap.add_argument("--plan", action="store_true", help="print the matrix and exit")
ap.add_argument("--only", default="", help="comma-separated verb subset")
ap.add_argument("--skip-gui", action="store_true", help="phases 1-2 only")
ap.add_argument("--full", action="store_true", help="run all six demo beats")
ap.add_argument("--no-stealth", action="store_true",
help="don't keep KiCad behind the user's window during GUI phases")
ap.add_argument("--out", default="/tmp/kicad-verbtest")
ap.add_argument("--ai-thread", default="", help="caller identity (default: kicad-bridge verbtest <host>/<pid>)")
ap.add_argument("--close-open-kicad", action="store_true",
help="allow the run to close KiCad editors that are already open (never ones with unsaved changes)")
ap.add_argument("--wait-minutes", type=float, default=30.0,
help="how long to wait for another battery/thread to finish driving this box (0 = do not wait)")
a = ap.parse_args()
outdir = Path(a.out); outdir.mkdir(parents=True, exist_ok=True)
global AI_THREAD
if a.ai_thread.strip():
AI_THREAD = a.ai_thread.strip()
print(f"[caller] {AI_THREAD}")
# live verb list for coverage enforcement
live = None
if not a.plan:
d = ab(a.target, "kicad_describe", {}, timeout=90)
live = sorted(v if isinstance(v, str) else v.get("name", "").replace("kicad_", "")
for v in (d.get("verbs") or []))
live = [v.replace("kicad_", "") for v in live if v]
# an EDA Bridge Standard alias runs its target's code: covered when the target is
aliases = {v["name"].replace("kicad_", ""): v["aliasOf"].replace("kicad_", "") for v in (d.get("verbs") or [])
if isinstance(v, dict) and v.get("aliasOf")}
else:
aliases = {}
# discover the Windows user for paths
user = "john"
if not a.plan:
# 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",
"sch": f"{base}/rp2040-breakout.kicad_sch",
"pro": f"{base}/rp2040-breakout.kicad_pro",
"board_copy": f"{base}/format-upgrade-copy.kicad_pcb",
"symlib": f"C:/Users/{user}/Documents/KiCad/10.0/symbols/Adom.kicad_sym",
"out": base + "/out",
"templib": base + "/AdomVerbTest.kicad_sym",
"board_copy_src": base + "/copy/rp2040-breakout.kicad_pcb",
"live": base + "/live/esc-g431-unrouted.kicad_pcb",
"offline": base + "/offline/esc-g431-unrouted.kicad_pcb",
}
M = matrix(w)
if a.plan:
for verb, spec in sorted(M.items(), key=lambda kv: (kv[1].get("phase", 9), kv[0])):
tag = f"phase {spec['phase']}" if "phase" in spec else "SKIP"
print(f"{tag:8} kicad_{verb:24} {json.dumps(spec.get('args', {}))[:70]}"
f" {('# ' + spec['note']) if spec.get('note') else ''}"
f"{('# SKIP: ' + spec['skip']) if spec.get('skip') else ''}")
print(f"\n{len(M)} verbs in matrix. {SPECIALS_DOC}")
return 0
# coverage check against the LIVE bridge
missing = [v for v in (live or []) if v not in M and not (v in aliases and aliases[v] in M)]
if missing:
print(f"COVERAGE GAP: live verbs with no test and no skip reason: {missing}")
# keep going — report at the end, fail exit code
only = set(x.strip() for x in a.only.split(",") if x.strip())
results, t0 = {}, time.time()
# phase -1: PREFLIGHT — wait for the bridge to actually be listening before
# judging verbs. A just-(re)spawned bridge takes ~10-20s (port bind + KiCad
# detection); starting the suite inside that window produced 26 bogus
# "STARTUP hang" failures on 2026-08-15. Poll up to 90s, fail fast after.
print("[preflight] waiting for the bridge to answer ...")
_ok = False
for _ in range(30):
pr = ab(a.target, "kicad_status", {}, timeout=20)
if ok(pr):
_ok = True
break
time.sleep(3)
if not _ok:
print("FATAL: bridge never became reachable — aborting (0 verbs judged). "
"Check adom-bridge#56 / the manual-instance workaround.")
(outdir / "report.json").write_text(json.dumps(
{"target": a.target, "totals": {"pass": 0, "fail": 0, "skip": 0},
"fatal": "bridge unreachable in preflight", "failures": [],
"coverage_gap": [], "results": {}}, indent=2))
return 2
# phase -0.5: EXCLUSIVITY. One KiCad, one UI: two batteries (or any other thread driving KiCad)
# on the same box at once make each other slow and produce failures that are not the bridge's.
# On 2026-09-22 a run on ConfRoomROG took 1133 s instead of ~440 and its kicad_lint_board timed
# out at 120 s; the verb passes in 9-11 s when the box is free. So: wait for the box, do not
# race it. `kicad-dashboard` polls are read-only and never count.
IGNORE_CALLERS = {"kicad-dashboard", AI_THREAD}
# Read-only polls touch no window and take no time: the dashboard, the daily cron's idle check
# (kicad_state every 15 min) and any AI glancing at the box must not count as driving it.
READ_ONLY = {"state", "status", "window_info", "get_settings", "readiness", "progress", "verb_times",
"describe", "check_for_updates", "bridge_status", "list_versions", "log_tail", "diagnostics",
"open_editors", "errors"}
def others_driving():
st = ab(a.target, "kicad_status", {}, timeout=60)
ops = st.get("operations") or {}
now = time.time()
busy = [(x.get("caller"), x.get("verb"), 0.0) for x in (ops.get("active") or [])
if x.get("caller") not in IGNORE_CALLERS and x.get("verb") not in READ_ONLY]
for r in (ops.get("recent") or []):
ago = now - float(r.get("finishedAt") or 0)
if r.get("caller") not in IGNORE_CALLERS and r.get("verb") not in READ_ONLY and 0 <= ago <= 120:
busy.append((r.get("caller"), r.get("verb"), round(ago, 1)))
# another bridge's battery on the same box (altium-lane's, fusion-lane's), started from this machine: KiCad
# windows rising during our run can take its clicks and keys (#112), so the two never overlap
for pid in os.listdir("/proc") if os.path.isdir("/proc") else []:
if not pid.isdigit() or int(pid) == os.getpid():
continue
try:
argv = Path(f"/proc/{pid}/cmdline").read_bytes().split(b"\0")
except OSError:
continue
args = [x.decode(errors="replace") for x in argv if x]
if not args or "python" not in Path(args[0]).name:
continue
script = next((x for x in args if x.endswith("run_verb_tests.py")), "")
if not script or "kicad-bridge-test" in script:
continue
tgt = next((args[i + 1] for i, x in enumerate(args[:-1]) if x == "--target"), "")
if tgt == a.target:
busy.append((f"another battery ({Path(script).parent.name}, pid {pid})", "run_verb_tests", 0.0))
return busy
waited = 0.0
while True:
busy = others_driving()
if not busy:
break
who = sorted({b[0] for b in busy})
if a.wait_minutes <= 0 or waited >= a.wait_minutes * 60:
print(f"REFUSING: {who} is driving KiCad on {a.target} right now "
f"(last: {busy[0][1]}, {busy[0][2]}s ago). Two batteries on one box fail each other. "
f"Wait for it, or re-run with --wait-minutes to wait longer.")
(outdir / "report.json").write_text(json.dumps(
{"target": a.target, "caller": AI_THREAD, "totals": {"pass": 0, "fail": 0, "skip": 0},
"fatal": f"another caller is driving this box: {who}", "busy": busy,
"failures": [], "coverage_gap": [], "results": {}}, indent=2))
return 2
print(f"[exclusivity] {who} is driving {a.target}; waiting ({int(waited)}s of {int(a.wait_minutes * 60)}s) ...")
time.sleep(30)
waited += 30
# phase -0.4: SOMEONE'S WORK IS OPEN. The battery force-closes KiCad (phase 6.5 and teardown). On
# 2026-09-29 it did that on ConfRoomROG to another thread's board with UNSAVED live edits
# ("*buck-12v5v-molecule — PCB Editor"): the exclusivity guard had waited for that thread to go
# quiet, which is not the same as its work being saved. So: an unsaved KiCad window stops the run,
# always; any other open editor stops it unless --close-open-kicad. A lone project manager is fine.
wi = ab(a.target, "kicad_window_info", {}, timeout=90)
data = wi.get("data") or wi
editors = [e for e in (data.get("editors") or []) if isinstance(e, dict)]
unsaved = [e.get("title") for e in editors if str(e.get("title") or "").startswith("*")]
if unsaved:
print(f"REFUSING: KiCad on {a.target} has UNSAVED work open: {unsaved}. The battery force-closes KiCad and "
f"would throw it away. Whoever owns it must save or close it first; this is never overridden.")
(outdir / "report.json").write_text(json.dumps({"target": a.target, "caller": AI_THREAD,
"totals": {"pass": 0, "fail": 0, "skip": 0}, "fatal": f"unsaved KiCad work open: {unsaved}",
"failures": [], "coverage_gap": [], "results": {}}, indent=2))
return 2
others = [e.get("title") for e in editors if e.get("kind") not in (None, "other", "project_manager")]
if others and not a.close_open_kicad:
print(f"REFUSING: KiCad editors are open on {a.target}: {others}. The battery closes KiCad; they are someone's "
f"work. Close them, or re-run with --close-open-kicad if they are known to be disposable.")
(outdir / "report.json").write_text(json.dumps({"target": a.target, "caller": AI_THREAD,
"totals": {"pass": 0, "fail": 0, "skip": 0}, "fatal": f"KiCad editors open: {others}",
"failures": [], "coverage_gap": [], "results": {}}, indent=2))
return 2
if others:
# --close-open-kicad: they are known to be disposable, so close them now; an editor left open (one board
# served by the API while another matches by title) made show_2d_board refuse in phase 3 (1.0.74 run)
print(f"[preflight] closing disposable KiCad editors: {others}")
ab(a.target, "kicad_close", {"force": True, "reason": "battery --close-open-kicad: the open editors are known to be disposable"}, timeout=120)
time.sleep(4)
# the KiCad and the bridge this run tests: the release records them (testedWith)
_lv = ab(a.target, "kicad_list_versions", {}, timeout=120)
_inst = (_lv.get("installs") or _lv.get("versions") or [{}])[0]
tested = {"kicad": _inst.get("cliVersion") or _inst.get("version"), "kicadSource": _inst.get("source"), "bridge": _lv.get("bridgeVersion")}
print(f"[tested] KiCad {tested['kicad']} ({tested['kicadSource']}), bridge {tested['bridge']}")
# phase 0: push fixtures
print(f"[0] pushing rp2040 fixture to {base} ...")
files = [str(PACK / f) for f in ("rp2040-breakout.kicad_pcb", "rp2040-breakout.kicad_sch",
"rp2040-breakout.kicad_pro", "rp2040-breakout.kicad_prl")]
r = ab(a.target, "send_files", {"filePaths": files, "dest": base}, timeout=200)
if not ok(r):
print("FATAL: fixture push failed:", json.dumps(r)[:300]); return 2
# board copy for format_upgrade (server-side copy via read+write is overkill; re-send under new name)
ab(a.target, "send_files", {"filePaths": [files[0]], "dest": base + "/copy"}, timeout=120)
w_copy_src = base + "/copy/rp2040-breakout.kicad_pcb"
live_src = str(REPO / "demo" / "placement" / "esc-g431-unrouted.kicad_pcb")
ab(a.target, "send_files", {"filePaths": [live_src], "dest": base + "/live"}, timeout=200)
ab(a.target, "send_files", {"filePaths": [live_src], "dest": base + "/offline"}, timeout=200)
ab(a.target, "send_files", {"filePaths": [live_src], "dest": base + "/live2"}, timeout=200)
# the checks phase's scratch board: the ESC with its planes, closed, vias written into it
ab(a.target, "send_files", {"filePaths": [str(REPO / "demo" / "routing" / "esc" / "esc-g431-fable-placed-planes.kicad_pcb")], "dest": base + "/checks"}, timeout=200)
# and a small routed board for length tuning
ab(a.target, "send_files", {"filePaths": [str(REPO / "rust" / "crates" / "kicad-core" / "tests" / "fixtures" / "konnect-route" / "route-fixture.kicad_pcb")], "dest": base + "/checks"}, timeout=200)
# an EAGLE board for import_design (1.0.94)
ab(a.target, "send_files", {"filePaths": [str(REPO / "rust" / "crates" / "kicad-core" / "tests" / "fixtures" / "eagle" / "divider.brd")], "dest": base + "/import"}, timeout=120)
# scratch symbol library for install_library (written locally, pushed over)
import tempfile as _tf
tl = Path(_tf.gettempdir()) / "AdomVerbTest.kicad_sym"
tl.write_text(SYMBOL_LIB_WRAP)
ab(a.target, "send_files", {"filePaths": [str(tl)], "dest": base}, timeout=120)
# a Cloud-Library-shaped bundle for install_library_bundle: <Lib>.kicad_sym + <Lib>.pretty/<fp>.kicad_mod
import zipfile as _zf
bz = Path(_tf.gettempdir()) / "AdomVerbBundle.zip"
with _zf.ZipFile(bz, "w", _zf.ZIP_DEFLATED) as z:
z.writestr("AdomVerbBundle.kicad_sym", SYMBOL_LIB_WRAP)
z.writestr("AdomVerbBundle.pretty/ADOM_VERBTEST.kicad_mod", TEST_FOOTPRINT)
ab(a.target, "send_files", {"filePaths": [str(bz)], "dest": base}, timeout=120)
fg_violations = []
# The background guard is OFF by default from 1.0.50 (wiki #114): KiCad then comes to the front the
# way it does on its own, and that is not a failure. Foreground findings become notes unless the
# guard is on for this run.
_gs = ab(a.target, "kicad_get_settings", {}, timeout=60)
guard_on = bool(((_gs.get("settings") or _gs) or {}).get("backgroundGuard", False))
print(f"[guard] backgroundGuard is {'ON: foreground steals fail the run' if guard_on else 'OFF: foreground findings are notes only'}")
# STEALTH (John, 2026-08-14: "keep the kicad windows in the background when
# you test — it doesn't have to be minimized, just background"). Capture the
# window the user has on top RIGHT NOW; after every GUI verb we re-raise it,
# which pushes any KiCad window that surfaced back BEHIND the user's work.
# Not minimized, not moved — just never on top of what the user is doing.
def _current_top():
r = ab(a.target, "desktop_list_windows", {}, timeout=30)
try:
inner = json.loads(r["output"]) if isinstance(r.get("output"), str) else r
ws = (inner.get("data") or inner).get("windows") or []
top = next((x for x in ws if x.get("z") == 0), None)
return top or {}
except Exception:
return {}
_kw = ("KiCad", "PCB Editor", "Schematic Editor", "Symbol Editor",
"Footprint Editor", "3D Viewer")
_user_win = {}
if not a.skip_gui and not a.no_stealth:
_tw = _current_top() # NB: do NOT name this `w` — it shadowed the paths
# dict and crashed phase 4 with KeyError 'templib' (2026-08-15 run).
if _tw and not any(k in (_tw.get("title") or "") for k in _kw):
_user_win = _tw
print(f"[stealth] keeping KiCad behind your window: {(_user_win.get('title') or '')[:50]}")
def _keep_background():
"""Re-raise the user's window so KiCad sits behind it. If the user has
since switched to another (non-KiCad) window, adopt THAT — never fight
the user's own choices."""
nonlocal _user_win
if a.skip_gui or a.no_stealth:
return
top = _current_top()
tt = top.get("title") or ""
if top and not any(k in tt for k in _kw):
_user_win = top # user moved on; protect their new window
return # already on top — nothing to do
# 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.
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.
`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)
r = ab(a.target, "desktop_list_windows", {}, timeout=45)
try:
inner = json.loads(r["output"]) if isinstance(r.get("output"), str) else r
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 _kw):
_kicad_dialogs = ("Information", "Confirmation", "Save Changes?", "KiCad Setup", "Load ", "Loading",
"Progress", "Warning", "Error")
had_victim = (bool(before) and not any(k in before for k in _kw)
and not any(before.startswith(k) for k in _kicad_dialogs)
and before not in ("Program Manager",))
if had_victim and not guard_on:
fg_notes.append({"after_verb": verb, "foreground": title, "before": before,
"note": "guard OFF: KiCad came to the front by itself; expected, not counted"})
elif 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
def run(verb, spec):
if only and verb not in only:
return
if spec.get("skip"):
results[verb] = {"result": "SKIP", "reason": spec["skip"]}
return
if spec.get("live"):
return # run by live_phase(), in a fixed order with chained revisions
if spec.get("scenario"):
return # run by k161_phase(), each step building on the last
if a.skip_gui and spec.get("phase", 9) >= 3:
results[verb] = {"result": "SKIP", "reason": "--skip-gui"}
return
args = dict(spec.get("args", {}))
special = args.pop("__special", None)
if special == "click_3d_toolbar":
wi = ab(a.target, "kicad_window_info", {}, timeout=60)
eds = (wi.get("data") or {}).get("editors") or []
v3 = next((e for e in eds if "3D" in (e.get("title") or "")), None)
if not v3:
results[verb] = {"result": "SKIP", "reason": "no 3D viewer window to click"}
return
args = {"hwnd": v3["hwnd"], "x": 280, "y": 100, "relative": False,
"reason": "daily verb test: zoom-in toolbar"}
elif special == "trace_net":
nl = ab(a.target, "kicad_extract_netlist", {"filePath": w["sch"], "includeNodes": False}, timeout=120)
names = [n.get("name") if isinstance(n, dict) else n for n in (nl.get("nets") or [])]
real = next((n for n in names if n and not str(n).startswith("unconnected-")), None)
if not real:
results[verb] = {"result": "FAIL", "reason": "extract_netlist reported no nets to trace"}
return
args = {"filePath": w["sch"], "netName": real}
elif special == "sch_delete":
placed = (results.get("sch_place_symbol") or {}).get("response") or {}
uid = placed.get("uuid")
if not uid:
results[verb] = {"result": "SKIP", "reason": "sch_place_symbol returned no uuid to delete"}
return
args = {"filePath": w["sch"], "uuid": uid}
elif special == "move_footprint_dry":
st = (results.get("placement_state") or {}).get("response") or {}
fps = st.get("footprints") or []
if not st.get("revision") or not fps:
results[verb] = {"result": "SKIP", "reason": "placement_state returned no revision or no footprints to move"}
return
first = sorted(fps, key=lambda f: f.get("ref") or "")[0]
args = {"filePath": w["board"], "expectedRevision": st["revision"], "ref": first["ref"],
"x": first["x"], "y": first["y"], "dryRun": True}
elif special == "set_settings_same":
cur = ab(a.target, "kicad_get_settings", {}, timeout=60)
val = (cur.get("settings") or cur).get("foregroundToastMinutes")
if val is None:
results[verb] = {"result": "FAIL", "reason": "get_settings carries no foregroundToastMinutes to write back"}
return
args = {"foregroundToastMinutes": val}
elif special == "pcm_remove_repository":
added = (results.get("pcm_add_repository") or {}).get("response") or {}
if added.get("added") is not True:
why = ("the user already had the sample repository registered; left in place"
if added.get("alreadyRegistered") else "pcm_add_repository did not add the row this run")
results[verb] = {"result": "SKIP", "reason": why}
return
args = {"url": PCM_REPO_URL}
elif special == "install_symbol":
import base64 as _b64
args = {"fileName": "ADOM_VERBTEST.kicad_sym",
"fileContent": _b64.b64encode(SYMBOL_LIB_WRAP.encode()).decode(),
"symbolName": "ADOM_VERBTEST", "quietInstall": True}
elif special == "install_footprint":
import base64 as _b64
args = {"fileName": "ADOM_VERBTEST.kicad_mod",
"fileContent": _b64.b64encode(TEST_FOOTPRINT.encode()).decode(),
"footprintName": "ADOM_VERBTEST", "quietInstall": True}
elif special == "install_library":
# own temp lib file (pushed in phase 0) — NEVER the live Adom.kicad_sym,
# which an open KiCad holds locked (WinError 32, run #2)
args = {"libraryPath": w["templib"], "libraryName": "AdomVerbTest",
"description": "daily verb test scratch library"}
elif special == "install_bundle":
args = {"zipPath": base + "/AdomVerbBundle.zip", "libraryName": "AdomVerbBundle"}
elif special == "place_footprint":
import base64 as _b64
args = {"fileName": "ADOM_VERBTEST.kicad_mod",
"fileContent": _b64.b64encode(TEST_FOOTPRINT.encode()).decode(),
"footprintName": "ADOM_VERBTEST", "x": 30, "y": 30, "open": False}
if verb == "format_upgrade":
args = {"filePath": w_copy_src}
if verb == "demo" and a.full:
args = {"all": True}
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
r = ab(a.target, f"kicad_{verb}", args, timeout=t)
check = CHECKS.get(spec.get("check", "ok"), ok)
passed = False
try:
passed = bool(check(r))
except Exception:
passed = False
results[verb] = {"result": "PASS" if passed else "FAIL",
"elapsed": None,
"response_head": json.dumps(r)[:400],
# #85: the 400-char head hid errors behind long hints; keep the whole reply.
"response": r}
print(f" kicad_{verb:24} {'PASS' if passed else 'FAIL'}")
if special == "install_bundle":
# wiki #106: planOnly lists the steps and evidence slots, installs nothing; the real run used the same step ids
pl = ab(a.target, "kicad_install_library_bundle", dict(args, planOnly=True), timeout=90)
plan_ids = [s.get("id") for s in ((pl.get("plan") or {}).get("steps") or [])]
run_ids = [s.get("id") for s in (((r.get("data") or {}).get("steps")) or [])]
plan_ok = ok(pl) and pl.get("mutated") is False and bool(plan_ids) and set(run_ids) <= set(plan_ids) and ((pl.get("plan") or {}).get("expectedEvidence") or [{}])[0].get("slots", [None])[0] == "symbol"
# (record() is defined after the phase loop: write the result the way it does)
results["install_library_bundle_plan"] = {"result": "PASS" if plan_ok else "FAIL", "elapsed": None, "response_head": json.dumps(pl)[:400],
"response": {"plan": pl, "runSteps": run_ids}, **({} if plan_ok else {"reason": f"plan steps {plan_ids}, run steps {run_ids}"})}
print(f" kicad_{'install_library_bundle_plan':24} {'PASS' if plan_ok else 'FAIL'}")
# leave nothing behind: the bundle library comes out again whether or not the install passed
cr = ab(a.target, "kicad_uninstall_library", {"libraryName": "AdomVerbBundle", "deleteFiles": True}, timeout=90)
results[verb]["cleanup"] = {k: cr.get(k) for k in ("success", "errorCode", "removed", "filesDeleted")}
if passed and not ok(cr):
results[verb]["result"] = "FAIL"
results[verb]["reason"] = f"installed, but kicad_uninstall_library could not remove it again: {cr.get('errorCode') or cr.get('error')}"
print(f" !! bundle cleanup failed: {json.dumps(cr)[:200]}")
PHASE3_ORDER = ["launch", "show_project", "open_board", "open_schematic", "open_symbol_editor",
"show_schematic", "show_2d_board", "show_symbol", "show_footprint",
"show_3d_chip", "show_3d_board", "show_library", "state",
"open_footprint_editor", "rescan_libraries", "open_3d_viewer", "open_editors",
"window_info", "screenshot_all", "dismiss_dialogs", "send_key",
"click", "bridge_call"]
for phase in (1, 2, 3, 4, 5, 6):
# Fractional phases (2.2 .. 2.5, 4.5) belong to the whole phase they sit in and run
# after its integer entries, in fractional order. The old `== phase` filter never
# matched them, so the six sch edit steps, adom_library_heal, make_part_project and
# export_part were silently never run (2026-09-12).
entries = [(v, s) for v, s in M.items() if int(s.get("phase", 9)) == phase]
if not entries:
continue
if phase == 3:
entries.sort(key=lambda kv: PHASE3_ORDER.index(kv[0]) if kv[0] in PHASE3_ORDER else 99)
else:
entries.sort(key=lambda kv: (kv[1].get("phase", 9), kv[0]))
print(f"[{phase}] {len(entries)} verbs")
for verb, spec in entries:
run(verb, spec)
if phase == 3:
fg_assert(verb, fg_before.get(verb, ''))
if phase == 3 and not a.skip_gui:
time.sleep(2)
# ------------------------------------------------------------ phase 6.5-6.7: the live board
def record(verb, r, passed, reason=None):
results[verb] = {"result": "PASS" if passed else "FAIL", "elapsed": None,
"response_head": json.dumps(r)[:400], "response": r}
if reason:
results[verb]["reason"] = reason
print(f" kicad_{verb:24} {'PASS' if passed else 'FAIL'}{(' (' + reason + ')') if reason else ''}")
def live_phase():
import math, re as _re
L, WHY = w["live"], "daily verb test: live board phase"
wanted = [v for v, sp in M.items() if sp.get("live")]
if only and not (only & set(wanted)):
return
if a.skip_gui:
for v in wanted:
results[v] = {"result": "SKIP", "reason": "--skip-gui"}
return
print(f"[6.5] live board: {len(wanted)} verbs")
ab(a.target, "kicad_close", {"force": True}, timeout=120)
time.sleep(4)
o = ab(a.target, "kicad_open_board", {"filePath": L, "reason": WHY}, timeout=200)
if not ok(o):
for v in wanted:
if v not in ("add_track", "add_via", "route", "pcm_install", "pcm_uninstall"):
record(v, o, False, "the live board did not open")
else:
pcb_hwnd = o.get("hwnd")
time.sleep(8)
st = {}
for _ in range(6): # the IPC server comes up a few seconds after the window
st = ab(a.target, "kicad_routing_state", {"filePath": L}, timeout=150)
if ok(st) and st.get("revision"):
break
time.sleep(5)
# the standard board model, live: the same board, the revision the edit verbs accept
sm = ab(a.target, "kicad_routing_state", {"filePath": L, "model": "standard"}, timeout=150)
std_ok = CHECKS["standard_model"](sm) and sm.get("source") == "live-editor"
record("routing_state", {"legacy": st, "standard": {k: sm.get(k) for k in ("success", "source", "revision", "unconnected", "counts", "errorCode", "error")}},
ok(st) and bool(st.get("revision")) and isinstance(st.get("pads"), (list, str)) and std_ok,
None if std_ok else "the standard model did not read live")
rev = [st.get("revision")]
pads = st.get("pads") or []
if isinstance(pads, str):
pads = json.loads(pads)
def step(verb, args, check=ok, chain=True, timeout=200):
r = ab(a.target, f"kicad_{verb}", args, timeout=timeout)
if chain and r.get("revision"):
rev[0] = r["revision"]
return r
r = step("board_pads", {"filePath": L}, chain=False)
record("board_pads", r, ok(r) and len(r.get("pads") or []) > 0)
r = step("routing_validate", {"filePath": L}, chain=False, timeout=300)
record("routing_validate", r, ok(r) and "unconnected" in r)
# view state only: hide the front silkscreen, read it back, show it again
off = step("set_layer_visibility", {"layer": "F.SilkS", "displayed": False}, chain=False)
on = step("set_layer_visibility", {"layer": "F.SilkS", "displayed": True}, chain=False)
# the view framed on a part, a net and the board through KiCad's own action over the API (wiki #121), and the
# API answers right after (the menu route left an interactive tool running: AS_BUSY)
z1 = step("zoom_to", {"filePath": L, "refs": ["U5"]}, chain=False)
z2 = step("zoom_to", {"filePath": L, "net": (st.get("netsRemaining") or ["GND"])[0]}, chain=False)
z3 = step("zoom_to", {"filePath": L}, chain=False)
after = ab(a.target, "kicad_routing_state", {"filePath": L}, timeout=150)
record("zoom_to", {"part": z1, "net": z2, "board": z3}, all(ok(z) and z.get("actionStatus") == "Ok" for z in (z1, z2, z3)) and ok(after),
"the view was not framed on a part, a net and the board, or the API was left busy")
record("set_layer_visibility", {"off": off, "on": on}, ok(off) and (off.get("data") or {}).get("displayed") is False and ok(on) and (on.get("data") or {}).get("displayed") is True,
"F.SilkS was not hidden and shown again")
# route_net: the shortest same-side two-pad hop among the unrouted nets, tried as a dry run first
remaining = set(st.get("netsRemaining") or [])
by_net = {}
for p in pads:
if p.get("netName") in remaining and p.get("type") == "smd":
by_net.setdefault(p["netName"], []).append(p)
cands = []
for net, ps in by_net.items():
for i in range(len(ps)):
for k in range(i + 1, len(ps)):
if ps[i].get("side") == ps[k].get("side"):
cands.append((math.hypot(ps[i]["x"] - ps[k]["x"], ps[i]["y"] - ps[k]["y"]), net, ps[i], ps[k]))
cands.sort(key=lambda c: c[0])
routed, last = None, {}
for dist, net, p1, p2 in cands[:12]:
args = {"filePath": L, "expectedRevision": rev[0], "net": net, "points": [p1["name"], p2["name"]],
"layer": p1["side"], "reason": WHY}
last = step("route_net", dict(args, dryRun=True), chain=False)
if ok(last):
routed = step("route_net", args)
break
if routed is not None:
record("route_net", routed, ok(routed) and bool(routed.get("itemIds")))
ids = routed.get("itemIds") or []
# wiki #134: a dry run must leave the copper where it is
d = step("remove_route", {"filePath": L, "expectedRevision": rev[0], "itemIds": ids, "dryRun": True, "reason": WHY}, chain=False)
after = ab(a.target, "kicad_routing_state", {"filePath": L}, timeout=150)
still = {x.get("uuid") for x in (after.get("segments") or [])} | {x.get("uuid") for x in (after.get("vias") or [])}
dry_ok = ok(d) and d.get("mutated") is False and all(i in still for i in ids)
r = step("remove_route", {"filePath": L, "expectedRevision": rev[0], "itemIds": ids, "reason": WHY})
record("remove_route", {"dryRun": d, "remove": r}, dry_ok and ok(r),
None if dry_ok else "the dry run moved copper or did not answer mutated:false")
else:
well_formed = bool(last.get("errorCode")) and last.get("mutated") is not True
record("route_net", last, well_formed, "no straight pad-to-pad hop passed DRC; the refusal path was exercised")
results["remove_route"] = {"result": "SKIP", "reason": "route_net committed nothing to remove"}
# zones: a small GND pour in the corner of the pad field, measured, then removed; then the plan verb
xs, ys = [p["x"] for p in pads], [p["y"] for p in pads]
x0, y0 = min(xs), min(ys)
poly = [[x0, y0], [x0 + 6, y0], [x0 + 6, y0 + 6], [x0, y0 + 6]]
nets_all = {p.get("netName") for p in pads}
znet = "GND" if "GND" in nets_all else sorted(n for n in nets_all if n)[0]
r = step("add_zone", {"filePath": L, "expectedRevision": rev[0], "net": znet, "layers": ["B.Cu"],
"polygon": poly, "priority": 40, "reason": WHY}, timeout=300)
zids = r.get("itemIds") or r.get("zoneIds") or [z.get("id") for z in (r.get("zones") or []) if isinstance(z, dict)]
record("add_zone", r, ok(r) and bool(zids))
z = step("zone_state", {"filePath": L}, timeout=200)
record("zone_state", z, ok(z) and "filledAreaByLayerMm2" in z)
if zids:
r = step("remove_zone", {"filePath": L, "expectedRevision": rev[0], "itemIds": zids, "reason": WHY}, timeout=300)
record("remove_zone", r, ok(r))
else:
results["remove_zone"] = {"result": "SKIP", "reason": "add_zone returned no zone id to remove"}
plan = {"net": znet, "name": "verbtest-plan", "layers": ["B.Cu"], "polygon": poly}
r1 = step("apply_zone_plan", {"filePath": L, "expectedRevision": rev[0], "ownedItemIds": [], "zones": [plan], "reason": WHY}, timeout=300)
owned = r1.get("ownedItemIds") or []
r2 = step("apply_zone_plan", {"filePath": L, "expectedRevision": rev[0], "ownedItemIds": owned, "zones": [plan], "reason": WHY}, timeout=300)
r3 = step("apply_zone_plan", {"filePath": L, "expectedRevision": rev[0], "ownedItemIds": owned, "zones": [], "reason": WHY}, timeout=300)
good = ok(r1) and bool(owned) and ok(r2) and r2.get("mutated") is False and ok(r3)
record("apply_zone_plan", {"create": r1, "unchanged": r2, "remove": r3}, good,
None if good else "create, exact no-op, then remove: one of the three did not hold")
r = step("select_net", {"filePath": L, "net": znet}, chain=False)
record("select_net", r, ok(r))
r = step("select_clear", {"filePath": L}, chain=False)
record("select_clear", r, ok(r))
r = step("text_bounds", {"filePath": L, "texts": [{"text": "VERBTEST", "height": 0.8, "align": "left"}]}, chain=False)
record("text_bounds", r, ok(r) and len(r.get("texts") or []) == 1 and r.get("mutated") is False)
# A clear spot INSIDE the outline. KiCad's DRC refuses silk over the edge, a pad, the mask or other
# silk, and enforces the board's own minimum text height, so each candidate is a dry run first.
x1, y1 = max(xs), max(ys)
spots = [(100.0, 44.0)] + [(x0 + (x1 - x0) * fx, y0 + (y1 - y0) * fy)
for fy in (0.5, 0.3, 0.7, 0.15, 0.85) for fx in (0.5, 0.3, 0.7, 0.15, 0.85)]
def silk_round_trip(verb, make):
first = {}
for cx, cy in spots:
t = step(verb, {"filePath": L, "expectedRevision": rev[0], "dryRun": True, "reason": WHY, "create": [make(cx, cy)]},
chain=False, timeout=300)
first = first or t
if not ok(t):
continue
c = step(verb, {"filePath": L, "expectedRevision": rev[0], "reason": WHY, "create": [make(cx, cy)]}, timeout=300)
cid = (c.get("createdIds") or [None])[0]
d = step(verb, {"filePath": L, "expectedRevision": rev[0], "remove": [cid], "reason": WHY}, timeout=300) if cid else {}
good = ok(c) and bool(cid) and ok(d) and d.get("deletionVerified") is not False
record(verb, {"create": c, "remove": d, "at": [cx, cy]}, good)
return
record(verb, first, False, f"no clean spot among {len(spots)} dry runs on this board")
silk_round_trip("silk_text_batch", lambda cx, cy: {"text": "VT", "layer": "F.SilkS", "x": cx, "y": cy,
"height": 0.8, "stroke": 0.12, "align": "left"})
silk_round_trip("silk_graphics_batch", lambda cx, cy: {"kind": "line", "layer": "F.SilkS",
"points": [[cx, cy], [cx + 1.5, cy]], "stroke": 0.15})
# fields: KiCad 10.0.x refuses the write over IPC; the documented refusal is the contract there
text = open(live_src, encoding="utf-8", errors="replace").read()
m = _re.search(r'\(property "Reference" "[^"]*"\s*\(at ([-\d.]+) ([-\d.]+)[^)]*\)\s*(?:\(unlocked \w+\)\s*)?\(layer "F\.SilkS"\)(?:\s*\(hide \w+\))?\s*\(uuid "([^"]+)"\)', text)
if m:
r = step("silk_fields_batch", {"filePath": L, "expectedRevision": rev[0], "dryRun": True, "reason": WHY,
"update": [{"id": m.group(3), "x": float(m.group(1)), "y": float(m.group(2)), "layer": "F.SilkS", "height": 0.8, "stroke": 0.12}]}, timeout=300)
fine = ok(r) or r.get("errorCode") in ("native_field_update_unsupported", "drc_rejected")
record("silk_fields_batch", r, fine, None if ok(r) else f"answered {r.get('errorCode')}: a classified refusal, nothing changed")
else:
results["silk_fields_batch"] = {"result": "SKIP", "reason": "no F.SilkS Reference field with a uuid in the fixture"}
v = ab(a.target, "kicad_open_3d_viewer", {"reason": WHY}, timeout=220)
time.sleep(4)
r = step("menu_items", {"hwnd": pcb_hwnd, "contains": "zoom"}, chain=False)
record("menu_items", r, ok(r) and len(r.get("items") or []) > 0)
r = step("3d_view", {"action": "rotate_z_cw", "count": 1, "pauseMs": 500, "reason": WHY}, chain=False)
record("3d_view", r, ok(r))
# 1.0.89: the standard's animate_view: the board's zooms through KiCad's API, then the 3D Viewer's orbit, in one
# call with the timing kept in the bridge; isometric has no KiCad command and is reported, not faked
r = step("animate_view", {"steps": ["zoom_all", {"step": "zoom_in", "count": 2}, "view_3d", {"step": "orbit_right", "count": 3}, "isometric", "zoom_all"],
"pauseMs": 300, "reason": WHY}, chain=False)
record("animate_view", r, ok(r) and r.get("stepsRun") == 7 and [x.get("step") for x in r.get("skipped") or []] == ["isometric"])
r = step("refresh_board_views", {"pcbHwnd": pcb_hwnd, "reason": WHY}, chain=False)
record("refresh_board_views", r, ok(r) and r.get("refreshRequested") is not False)
ab(a.target, "kicad_close", {"force": True}, timeout=120) # discard: the scratch copy is never saved
time.sleep(4)
# 6.6 offline copper editing: a copy nobody has open
print("[6.6] offline copper editing")
F = w["offline"]
bp = ab(a.target, "kicad_board_pads", {"filePath": F}, timeout=150)
ops = [p for p in (bp.get("pads") or []) if p.get("net") or p.get("netName")]
groups = {}
for p in ops:
groups.setdefault(p.get("netName") or p.get("net"), []).append(p)
pair = next(((n, g[0], g[1]) for n, g in sorted(groups.items()) if len(g) >= 2), None)
if not pair:
for v in ("add_track", "add_via", "route"):
record(v, bp, False, "board_pads listed no net with two pads on the offline copy")
else:
net, p1, p2 = pair
n1, n2 = p1.get("name") or f"{p1.get('ref')}.{p1.get('pad')}", p2.get("name") or f"{p2.get('ref')}.{p2.get('pad')}"
r = ab(a.target, "kicad_add_track", {"filePath": F, "start": n1, "end": n2, "net": net, "layer": "F.Cu"}, timeout=200)
record("add_track", r, ok(r))
r = ab(a.target, "kicad_add_via", {"filePath": F, "at": [p1["x"], p1["y"]], "net": net, "layers": ["F.Cu", "B.Cu"]}, timeout=200)
record("add_via", r, ok(r))
# down to B.Cu a third of the way along and back up at two thirds: both layer changes drop a via
m1 = {"x": p1["x"] + (p2["x"] - p1["x"]) / 3, "y": p1["y"] + (p2["y"] - p1["y"]) / 3, "layer": "B.Cu"}
m2 = {"x": p1["x"] + (p2["x"] - p1["x"]) * 2 / 3, "y": p1["y"] + (p2["y"] - p1["y"]) * 2 / 3, "layer": "F.Cu"}
r = ab(a.target, "kicad_route", {"filePath": F, "net": net, "points": [n1, m1, m2, n2]}, timeout=200)
record("route", r, ok(r))
# 6.7 PCM: install this page's sample package for real, then take it out again
print("[6.7] PCM install and uninstall")
ident = "com.adom.kicad-bridge.sample-lib"
before = ab(a.target, "kicad_pcm_list", {"checkUpdates": False}, timeout=90)
had = any((pk.get("identifier") == ident) for pk in (before.get("packages") or []) if isinstance(pk, dict))
if had:
for v in ("pcm_install", "pcm_uninstall"):
results[v] = {"result": "SKIP", "reason": "the user already has the sample package installed; left alone"}
else:
r = ab(a.target, "kicad_pcm_install", {"repositoryUrl": PCM_REPO_URL, "identifier": ident}, timeout=200)
record("pcm_install", r, ok(r))
r = ab(a.target, "kicad_pcm_uninstall", {"identifier": ident}, timeout=120)
record("pcm_uninstall", r, ok(r))
# ------------------------------------------------------------ phase 8: the 1.0.61 verbs
def k161_phase():
wanted = [v for v, sp in M.items() if sp.get("scenario")]
if only and not (only & set(wanted)):
return
if a.skip_gui:
for v in wanted:
results[v] = {"result": "SKIP", "reason": "--skip-gui"}
return
WHY = "daily verb test: 1.0.61 verbs"
K = lambda verb, args, t=200: ab(a.target, f"kicad_{verb}", dict(args, reason=WHY), timeout=t)
def rec(verb, r, good, why=None):
record(verb, r, bool(good), None if good else (why or f"{r.get('errorCode')}: {str(r.get('error'))[:160]}"))
refused_cleanly = lambda r: bool(r.get("errorCode")) and r.get("mutated") is not True
base = w["out"].rsplit("/", 1)[0]
# names unique per run: a run with --only skips the final cleanup, and these verbs rightly refuse to overwrite
tag = datetime.now().strftime("%m%d%H%M%S")
P0, OUT = base + "/k161-" + tag, w["out"]
pd = P0 + "/kvt"
pro, sch, pcb = pd + "/kvt.kicad_pro", pd + "/kvt.kicad_sch", pd + "/kvt.kicad_pcb"
# 8.1 a project and its schematic, built and edited step by step
print("[8.1] project and schematic")
r = K("project_create", {"parentDir": P0, "name": "kvt", "layers": 2})
rec("project_create", r, ok(r))
r = K("project_info", {"projectPath": pro}); rec("project_info", r, ok(r))
r = K("sch_page", {"filePath": sch, "paper": "A4", "title": "kvt"}); rec("sch_page", r, ok(r))
# two resistors: Device:R's pins sit 3.81 mm above and below its anchor
for ref, x in (("R1", 50.8), ("R2", 76.2)):
ab(a.target, "kicad_sch_place_symbol", {"filePath": sch, "libId": "Device:R", "reference": ref, "at": [x, 50.8]}, timeout=150)
r = K("sch_components", {"filePath": sch}); rec("sch_components", r, ok(r) and "R1" in json.dumps(r) and "R2" in json.dumps(r))
r = K("sch_pins", {"filePath": sch, "references": ["R1", "R2"]}); rec("sch_pins", r, ok(r) and "R2" in json.dumps(r))
r = K("sch_connect_pins", {"filePath": sch, "from": "R1.2", "to": "R2.2"}); rec("sch_connect_pins", r, ok(r))
r = K("sch_items", {"filePath": sch, "kinds": ["wires"]}); rec("sch_items", r, ok(r) and "wire" in json.dumps(r).lower())
r = K("sch_trace_point", {"filePath": sch, "at": [63.5, 54.61]}); rec("sch_trace_point", r, ok(r))
r = K("sch_split_wire", {"filePath": sch, "at": [63.5, 54.61], "junction": False}); rec("sch_split_wire", r, ok(r))
r = K("sch_junction", {"filePath": sch, "at": [63.5, 54.61]}); rec("sch_junction", r, ok(r))
r = K("sch_delete_wire", {"filePath": sch, "at": [57.15, 54.61]}); rec("sch_delete_wire", r, ok(r))
r = K("sch_connect_to_net", {"filePath": sch, "pin": "R1.1", "net": "VIN"}); rec("sch_connect_to_net", r, ok(r))
r = K("sch_edit_label", {"filePath": sch, "net": "VIN", "by": [0, -2.54]}); rec("sch_edit_label", r, ok(r))
r = K("sch_text", {"filePath": sch, "text": "verb test", "at": [30.48, 30.48]}); rec("sch_text", r, ok(r))
r = K("sch_bus", {"filePath": sch, "points": [[101.6, 38.1], [101.6, 76.2]], "label": "D[0..1]"}); rec("sch_bus", r, ok(r))
r = K("sch_bus_entry", {"filePath": sch, "at": [99.06, 68.58], "direction": "up-right"}); rec("sch_bus_entry", r, ok(r))
r = K("sch_connect_to_bus", {"filePath": sch, "pins": ["R2.1"], "prefix": "D", "busLabel": "D[0..1]"}); rec("sch_connect_to_bus", r, ok(r))
r = K("sch_move_region", {"filePath": sch, "box": [70, 40, 85, 60], "by": [0, 2.54]}); rec("sch_move_region", r, ok(r))
r = K("sch_rotate", {"filePath": sch, "reference": "R1", "rotation": 90}); rec("sch_rotate", r, ok(r))
ab(a.target, "kicad_sch_place_symbol", {"filePath": sch, "libId": "Device:R", "reference": "R?", "at": [50.8, 88.9]}, timeout=150)
r = K("sch_annotate", {"filePath": sch}); rec("sch_annotate", r, ok(r) and "R3" in json.dumps(r))
r = K("sch_replace_symbol", {"filePath": sch, "reference": "R2", "libId": "Device:R_Small"}); rec("sch_replace_symbol", r, ok(r))
r = K("sch_reset_fields", {"filePath": sch, "references": ["R2"]}); rec("sch_reset_fields", r, ok(r))
r = K("sch_update_from_library", {"filePath": sch}); rec("sch_update_from_library", r, ok(r))
r = K("sch_fix_connectivity", {"filePath": sch, "dryRun": True}); rec("sch_fix_connectivity", r, ok(r) and r.get("mutated") is not True)
r = K("sch_check", {"filePath": sch}); rec("sch_check", r, ok(r))
# 1.0.92 (standard sch_batch #29): KiCad's op names and the standard's together; a failing op is named and nothing is written
r = K("sch_batch", {"filePath": sch, "ops": [{"op": "text", "text": "batch 1", "at": [30.48, 35.56]}, {"op": "sch_text", "text": "batch 2", "at": [30.48, 40.64]},
{"op": "sch_junction", "at": [30.48, 45.72]}]})
rb = K("sch_batch", {"filePath": sch, "ops": [{"op": "sch_text", "text": "never", "at": [30.48, 50.8]}, {"op": "sch_no_such_verb"}]})
rec("sch_batch", {"ok": r, "bad": rb}, ok(r) and len(r.get("results") or []) == 3 and "ercBefore" in r and rb.get("failedOp") == 1)
r = K("sch_sheet", {"filePath": sch, "action": "add", "name": "Child", "file": "child.kicad_sch", "at": [127, 30.48], "size": [25.4, 20.32], "projectPath": pro})
rec("sch_sheet", r, ok(r))
r = K("sch_sheet_pins", {"filePath": sch, "sheet": "Child", "action": "add", "name": "VIN", "shape": "input", "side": "left"})
rec("sch_sheet_pins", r, ok(r))
r = K("sch_hierarchy", {"projectPath": pro}); rec("sch_hierarchy", r, ok(r) and "Child" in json.dumps(r))
r = K("sch_create", {"filePath": pd + "/extra.kicad_sch", "title": "extra"}); rec("sch_create", r, ok(r))
r = K("render_schematic", {"filePath": sch, "outputDir": OUT + "/render-sch"}, 220); rec("render_schematic", r, ok(r))
r = K("design_review", {"projectPath": pro}, 220); rec("design_review", r, ok(r))
r = K("update_pcb_from_schematic", {"filePath": pcb, "schematicPath": sch}, 220)
rec("update_pcb_from_schematic", r, ok(r) and r.get("mutated") is not True and r.get("executed") is False and isinstance(r.get("preview"), dict) and isinstance((r.get("preview") or {}).get("addParts"), list) and isinstance(r.get("changeOrder"), list) and r.get("board"))
r = K("netclasses", {"projectPath": pro, "set": {"name": "PWR", "trackWidth": 0.5}, "assign": {"netclass": "PWR", "patterns": ["VIN"]}})
rec("netclasses", r, ok(r))
al = ab(a.target, "kicad_net_classes", {"projectPath": pro}, timeout=120) # the standard's name for it
if not (ok(al) and "PWR" in json.dumps(al)):
record("netclasses", {"netclasses": r, "alias": al}, False, "the alias kicad_net_classes did not read the class back")
r = K("board_rules", {"projectPath": pro, "rules": {"minClearance": 0.15}}); rec("board_rules", r, ok(r))
r = K("custom_rules", {"projectPath": pro, "add": {"name": "vt", "condition": "A.NetClass == 'PWR'", "constraints": [{"type": "clearance", "min": 0.3}]}})
rec("custom_rules", r, ok(r))
r = K("project_snapshot", {"projectPath": pro, "label": "verbtest"}, 220); rec("project_snapshot", r, ok(r))
r = K("project_rename", {"projectPath": pro, "newName": "kvt2"}); rec("project_rename", r, ok(r))
# 8.2 a library
print("[8.2] symbol and footprint library")
lib, pretty = f"{OUT}/KVT{tag}.kicad_sym", f"{OUT}/KVT{tag}.pretty"
r = K("sym_create", {"libraryPath": lib, "name": "KVT1", "pins": [{"number": 1, "name": "A"}, {"number": 2, "name": "B", "side": "right"}]})
rec("sym_create", r, ok(r))
r = K("sym_info", {"libraryPath": lib, "name": "KVT1"}); rec("sym_info", r, ok(r) and "KVT1" in json.dumps(r))
r = K("sym_delete", {"libraryPath": lib, "name": "KVT1"}); rec("sym_delete", r, ok(r))
r = K("fp_create", {"libraryPath": pretty, "name": "KVT_FP", "pads": [{"number": 1, "size": [1, 1], "at": [-1, 0]}, {"number": 2, "size": [1, 1], "at": [1, 0]}]})
rec("fp_create", r, ok(r))
r = K("fp_info", {"libraryPath": pretty, "name": "KVT_FP"}); rec("fp_info", r, ok(r))
r = K("fp_edit", {"libraryPath": pretty, "name": "KVT_FP", "pads": [{"number": "1", "set": {"size": [1.2, 1]}}]}); rec("fp_edit", r, ok(r))
r = K("search_library", {"query": "R_0603", "kind": "footprint", "limit": 5}); rec("search_library", r, ok(r))
# 8.3 the fixture board: fab exports and checks, and a layer count on the offline copy
print("[8.3] fab exports and checks")
B = w["board"]
for verb, extra in (("export_dxf", {"layers": ["Edge.Cuts"]}), ("export_gencad", {}), ("export_ipc2581", {}), ("export_ipc356", {}),
("export_odb", {}), ("export_fab_package", {"fab": "jlcpcb"})):
r = K(verb, dict({"filePath": B, "outputDir": OUT + "/" + verb}, **extra), 300); rec(verb, r, ok(r))
r = K("validate_for_fab", {"filePath": B, "fab": "jlcpcb"}, 300); rec("validate_for_fab", r, ok(r))
r = K("estimate_cost", {"filePath": B, "quantities": [5, 50]}); rec("estimate_cost", r, ok(r))
r = K("set_layer_count", {"filePath": w["offline"], "count": 4}); rec("set_layer_count", r, ok(r))
# 1.0.66: a V-score panel (a shaped board is refused cleanly: that needs mouse bites)
r = K("panelize", {"pcbPath": B, "panelPath": OUT + "/panel/panel.kicad_pcb", "rows": 2, "cols": 2, "overwrite": True}, 300)
rec("panelize", r, (ok(r) and (r.get("drc") or {}).get("newErrors", 1) <= 0) or r.get("errorCode") == "not_rectangular", "the panel failed or adds DRC errors beyond its copies")
# 8.4 the live board (a second scratch copy, never saved over a fixture)
print("[8.4] live board edits")
L2 = base + "/live2/esc-g431-unrouted.kicad_pcb"
ab(a.target, "kicad_close", {"force": True}, timeout=120)
time.sleep(4)
o = ab(a.target, "kicad_open_board", {"filePath": L2, "reason": WHY}, timeout=200)
live_verbs = ["check_clearance", "score_placement", "plan_fanout", "place_decoupling", "cross_probe", "select_items", "get_selection",
"footprint_graphics", "footprint_models", "add_footprint", "edit_footprint", "flip_footprint", "align_footprints",
"update_footprints_from_library", "delete_footprint", "add_mounting_hole", "import_svg", "delete_graphics", "board_outline",
"modify_track", "copy_routing", "route_diff_pair", "refill_zones", "save_board"]
if not ok(o):
for v in live_verbs:
record(v, o, False, "the live board did not open")
return
time.sleep(8)
st = {}
for _ in range(6):
st = ab(a.target, "kicad_routing_state", {"filePath": L2}, timeout=150)
if ok(st) and st.get("revision"):
break
time.sleep(5)
rev = [st.get("revision")]
def E(verb, args, t=300, chain=True):
r = K(verb, dict({"filePath": L2, "expectedRevision": rev[0]}, **args), t)
if chain and r.get("revision"):
rev[0] = r["revision"]
return r
pads = st.get("pads") or []
xs, ys = [p["x"] for p in pads], [p["y"] for p in pads]
cx, cy = (min(xs) + max(xs)) / 2, (min(ys) + max(ys)) / 2
r = K("check_clearance", {"filePath": L2, "refs": ["U5"], "mode": "pads"}); rec("check_clearance", r, ok(r))
r = K("score_placement", {"filePath": L2}); rec("score_placement", r, ok(r))
r = K("plan_fanout", {"filePath": L2, "ref": "U5"}); rec("plan_fanout", r, ok(r) and isinstance(r.get("conflicts"), list) and isinstance(r.get("crossesPours"), list) and str(r.get("engine") or "").startswith("adom-eda-checks") and all(c.get("expectedRevision") for c in r.get("calls") or []), "no engine fanout plan with conflicts, crossesPours and route_net-ready calls")
r = K("place_decoupling", {"filePath": L2, "ic": "U5", "dryRun": True}); rec("place_decoupling", r, ok(r) and r.get("mutated") is not True and isinstance(r.get("leftOut"), list) and r.get("leaveFanout") is True, "no plan, or no leftOut / leaveFanout (std #22)")
r = K("cross_probe", {"filePath": L2, "reference": "U5"}); rec("cross_probe", r, ok(r) or refused_cleanly(r), "no answer for U5")
r = K("select_items", {"filePath": L2, "refs": ["U5"]}); rec("select_items", r, ok(r))
r = K("get_selection", {"filePath": L2}); rec("get_selection", r, ok(r) and "U5" in json.dumps(r))
ab(a.target, "kicad_select_clear", {"filePath": L2}, timeout=60)
r = K("footprint_graphics", {"filePath": L2, "ref": "U5"}); rec("footprint_graphics", r, ok(r))
# a model binding changed, read back and put back (wiki #101), on the offline copy (no editor holds it):
# KiCad 10 answers a live footprint replace empty (live_create_unsupported), so the closed file is the path
r = K("footprint_models", {"filePath": L2, "ref": "U5"}); live_read = ok(r)
OFFC = w["offline"]
r = K("footprint_models", {"filePath": OFFC, "ref": "U5", "source": "file"})
m0 = ((r.get("models") or [{}])[0]) if ok(r) else {}
off = list(m0.get("offset") or [0, 0, 0])
moved = K("footprint_models", {"filePath": OFFC, "ref": "U5", "source": "file", "expectedRevision": r.get("revision"), "ops": [{"action": "replace", "index": 0, "offset": [off[0], off[1], round(off[2] + 0.2, 4)]}]}) if m0 else {}
back = K("footprint_models", {"filePath": OFFC, "ref": "U5", "source": "file"}) if ok(moved) else {}
z = (((back.get("models") or [{}])[0]).get("offset") or [0, 0, 0])[2] if back else None
undo = K("footprint_models", {"filePath": OFFC, "ref": "U5", "source": "file", "expectedRevision": back.get("revision"), "ops": [{"action": "replace", "index": 0, "offset": off}]}) if ok(moved) else {}
rec("footprint_models", {"read": r, "moved": moved, "back": back, "undo": undo}, live_read and ok(r) and bool(m0) and ok(moved) and z is not None and abs(z - off[2] - 0.2) < 1e-6 and ok(undo),
"U5's model offset was not changed in the file, read back and restored")
# creating items live needs KiCad's ParseAndCreateItemsFromString, which KiCad 10.0.4 answers empty: one live
# add must either land or refuse with live_create_unsupported and nothing changed; the edit sequence itself
# runs on the closed copy below (the file path), which works on every KiCad
lv = E("add_footprint", {"libraryId": "Capacitor_SMD:C_0402_1005Metric", "x": round(cx, 2), "y": round(cy, 2), "reference": "C", "value": "100n", "validate": False})
live_create = "landed" if ok(lv) else ("refused" if lv.get("errorCode") == "live_create_unsupported" and lv.get("mutated") is False else "broken")
# 1.0.64: board graphics are created live through KiCad's protobuf CreateItems
svg0 = "<svg xmlns='http://www.w3.org/2000/svg' width='10' height='10'><rect width='10' height='10'/></svg>"
r = E("import_svg", {"svg": svg0, "x": round(cx, 2), "y": round(cy, 2), "width": 2, "layer": "Dwgs.User", "validate": False})
record("import_svg_live", r, ok(r) and r.get("commit") == "create-items" and r.get("verified") is True, "a live SVG was not created through CreateItems")
# 1.0.65: a footprint the board does not have is built from the library text and created live
r = E("add_footprint", {"libraryId": "Connector_PinHeader_2.54mm:PinHeader_1x02_P2.54mm_Vertical", "x": round(cx, 2), "y": round(cy + 5, 2), "reference": "J", "value": "HDR2", "validate": False})
record("add_footprint_built_live", r, ok(r) and r.get("commit") == "create-items" and r.get("verified") is True, "a footprint the board lacks was not built and created live")
# 1.0.66: teardrops through KiCad's own Edit Teardrops dialog, added then removed again
r = E("teardrops", {"filePath": L2}, 240)
r2 = E("teardrops", {"filePath": L2, "remove": True}, 240) if ok(r) else {}
r3 = E("teardrops", {"filePath": L2, "force": True}, 60, chain=False)
record("teardrops", {"add": r, "remove": r2, "force": r3}, ok(r) and ok(r2) and (r.get("teardropsAfter") or 0) >= (r.get("teardropsBefore") or 0) and r2.get("teardropsAfter") == 0
and r.get("saved") is True and "save" in (r.get("steps") or []) and isinstance(r.get("dialogsLeft"), list) and r3.get("errorCode") == "not_supported",
"teardrops were not added, saved and removed again, or force:true was not refused")
# 1.0.67: thermal vias in Q1's drain tab, live as one Undo step after a DRC preflight
r = E("thermal_vias", {"ref": "Q1"}, 300)
record("thermal_vias_live", r, ok(r) and r.get("source") == "live-editor" and (r.get("applied") or 0) > 0 and r.get("undoSteps") == 1, "thermal vias were not committed live")
r = E("via_fence", {"viaNet": "GND", "edge": True, "dryRun": True}, 300, chain=False)
record("via_fence_live_plan", r, ok(r) and (r.get("planned") or {}).get("vias", 0) > 0 and r.get("mutated") is not True, "no live fence plan")
# 1.0.68: designators moved live clear of the pads, then read back
# KiCad 10.0.x drops a footprint's units block when a field moves over its API: on such a board the
# live move must refuse cleanly with nothing changed; on any other it moves the labels and reads them back
r = E("silk_designators", {"size": 1.0}, 300, chain=False)
record("silk_designators_live", r, (ok(r) and (r.get("placed") or 0) > 0 and r.get("onPadsAfter") == [])
or (r.get("errorCode") == "live_move_drops_units" and r.get("mutated") is False and r.get("planned")), "designators were neither moved live nor refused cleanly")
r = E("add_mounting_hole", {"x": round(cx, 2), "y": round(cy, 2), "drill": 2.2, "dryRun": True}, chain=False)
rec("add_mounting_hole", r, ok(r) or refused_cleanly(r), "no answer")
r = E("board_outline", {"shape": "rectangle", "width": 70, "height": 60, "x": 100, "y": 125, "replace": True, "dryRun": True}, chain=False)
rec("board_outline", r, ok(r) and r.get("mutated") is not True)
# copper to work on: the shortest same-side two-pad hop the bridge will route
remaining = set(st.get("netsRemaining") or [])
by_net = {}
for p in pads:
if p.get("netName") in remaining and p.get("type") == "smd":
by_net.setdefault(p["netName"], []).append(p)
cands = sorted(((math.hypot(a1["x"] - b1["x"], a1["y"] - b1["y"]), n, a1, b1) for n, g in by_net.items()
for i, a1 in enumerate(g) for b1 in g[i + 1:] if a1.get("side") == b1.get("side")), key=lambda c: c[0])
ids = []
for _d, net, p1, p2 in cands[:12]:
rr = E("route_net", {"net": net, "points": [p1["name"], p2["name"]], "layer": p1["side"]})
if ok(rr):
ids = rr.get("itemIds") or []
break
if ids:
r = E("modify_track", {"itemIds": ids[:1], "width": 0.3}); rec("modify_track", r, ok(r))
r = E("copy_routing", {"source": {"minX": cx - 40, "minY": cy - 40, "maxX": cx + 40, "maxY": cy + 40}, "offset": {"dx": 0, "dy": 0.5}, "dryRun": True}, chain=False)
rec("copy_routing", r, ok(r) or refused_cleanly(r), "no answer")
else:
for v in ("modify_track", "copy_routing"):
results[v] = {"result": "FAIL", "reason": "route_net landed no copper to work on"}
nets = sorted(by_net) or sorted({p.get("netName") for p in pads if p.get("netName")})
r = E("route_diff_pair", {"netP": nets[0], "netN": nets[1], "centerline": [[round(cx, 2), round(cy, 2)], [round(cx + 3, 2), round(cy, 2)]], "dryRun": True}, chain=False)
rec("route_diff_pair", r, ok(r) or refused_cleanly(r), "no answer")
r = K("refill_zones", {"filePath": L2}, 300); rec("refill_zones", r, ok(r))
r = K("save_board", {"filePath": L2}); rec("save_board", r, ok(r))
ab(a.target, "kicad_close", {"force": True}, timeout=120)
time.sleep(4)
# 8.5 the same board closed: add a capacitor, edit, flip, align, update and delete it, an SVG and its removal
print("[8.5] closed-board edits")
F = lambda verb, args, t=200: K(verb, dict({"filePath": L2}, **args), t)
before = set(re.findall(r'"(C\d{1,3})"', json.dumps(st)))
added, r = None, {}
for fx, fy in ((0.5, 0.5), (0.3, 0.3), (0.7, 0.7), (0.3, 0.7), (0.7, 0.3)):
x, y = min(xs) + (max(xs) - min(xs)) * fx, min(ys) + (max(ys) - min(ys)) * fy
r = F("add_footprint", {"libraryId": "Capacitor_SMD:C_0402_1005Metric", "x": round(x, 2), "y": round(y, 2), "reference": "C", "value": "100n"})
if ok(r):
found = re.findall(r'"(C\d{1,3})"', json.dumps(r))
added = next((c for c in found if c not in before), None)
break
rec("add_footprint", {"file": r, "live": lv, "liveCreate": live_create}, ok(r) and added and live_create != "broken",
"the closed-file add failed or named no new part, or the live add neither landed nor refused cleanly")
if added:
r = F("edit_footprint", {"ref": added, "value": "1u"}); rec("edit_footprint", r, ok(r))
r = F("flip_footprint", {"ref": added}); rec("flip_footprint", r, ok(r))
r = F("align_footprints", {"refs": [added, "C7"], "mode": "top", "dryRun": True, "allowOverlap": True}); rec("align_footprints", r, ok(r) and r.get("mutated") is not True)
r = F("update_footprints_from_library", {"refs": [added]}); rec("update_footprints_from_library", r, ok(r))
r = F("delete_footprint", {"refs": [added]}); rec("delete_footprint", r, ok(r))
else:
for v in ("edit_footprint", "flip_footprint", "align_footprints", "update_footprints_from_library", "delete_footprint"):
results[v] = {"result": "FAIL", "reason": "add_footprint placed nothing to work on"}
svg = "<svg xmlns='http://www.w3.org/2000/svg' width='10' height='10'><rect width='10' height='10'/></svg>"
r = F("import_svg", {"svg": svg, "x": round(cx, 2), "y": round(cy, 2), "width": 3, "layer": "Dwgs.User"}); rec("import_svg", r, ok(r))
r = F("delete_graphics", {"layer": "Dwgs.User"}); rec("delete_graphics", r, ok(r))
def std_phase():
"""Phase 9: the EDA Bridge Standard's shapes for the 47 verbs KiCad proposed to it (standard 0.3.0):
the standard names, sheet/project paths, id, lists as one write, frame:"standard" (y up) read back
against KiCad's own frame. Recorded as std_<verb>; the verbs themselves are covered by phase 8."""
if a.skip_gui or (only and not any(o.startswith("std") for o in only)):
return
WHY = "daily verb test: EDA Bridge Standard shapes"
S = lambda verb, args, t=200: ab(a.target, f"kicad_{verb}", dict(args, reason=WHY), timeout=t)
def rec(name, r, good, why=None):
record("std_" + name, r, bool(good), None if good else (why or f"{r.get('errorCode')}: {str(r.get('error'))[:160]}"))
clean = lambda r: bool(r.get("errorCode")) and r.get("mutated") is not True
tag = datetime.now().strftime("%m%d%H%M%S")
base, OUT = w["out"].rsplit("/", 1)[0], w["out"]
P0 = base + "/std-" + tag
pd = P0 + "/kstd"
pro, sch = pd + "/kstd.kicad_pro", pd + "/kstd.kicad_sch"
print("[9.0] describe marks the standard shapes")
d = ab(a.target, "kicad_describe", {}, timeout=90)
marked = {v["name"] for v in (d.get("verbs") or []) if isinstance(v, dict) and v.get("standardShape")}
rec("describe", {"marked": len(marked)}, "kicad_sch_text" in marked and "kicad_edit_parts" in marked and len(marked) >= 47, f"{len(marked)} verbs marked")
print("[9.1] a sheet in the standard's shapes")
r = S("project_create", {"parentDir": P0, "name": "kstd"}); rec("project_create", r, ok(r))
r = S("project_info", {"project": pro}); rec("project_info", r, ok(r))
r = S("sch_page", {"sheet": sch, "paper": "A4", "orientation": "landscape", "title": "kstd"}); rec("sch_page", r, ok(r))
for ref, x in (("R1", 50.8), ("R2", 76.2)):
ab(a.target, "kicad_sch_place_symbol", {"filePath": sch, "libId": "Device:R", "reference": ref, "at": [x, 50.8]}, timeout=150)
k = S("sch_pins", {"sheet": sch, "references": ["R1"]})
u = S("sch_pins", {"sheet": sch, "references": ["R1"], "frame": "standard"})
def pin_at(rr, num):
return next((p.get("at") for p in (rr.get("pins") or []) if str(p.get("number")) == num), None)
ka, ua = pin_at(k, "2"), pin_at(u, "2")
rec("sch_pins_frame", {"kicad": ka, "standard": ua}, ka and ua and abs(ua[0] - ka[0]) < 1e-6 and abs(ua[1] + ka[1]) < 1e-6 and u.get("frame") == "standard",
f"R1.2 at {ka} in KiCad's frame but {ua} in the standard's (y should be negated)")
r = S("sch_connect_pins", {"sheet": sch, "pairs": [{"from": "R1.2", "to": "R2.2"}, {"from": "R1.1", "to": "R2.1", "first": "v"}]})
rec("sch_connect_pins", r, ok(r) and r.get("batchedAs") == "kicad_sch_batch" and len(r.get("routes") or []) >= 2)
r = S("sch_trace_point", {"sheet": sch, "at": ua, "toleranceMm": 0.1, "frame": "standard"}) if ua else {"error": "no pin"}
rec("sch_trace_point", r, ok(r) and "R1.2" in json.dumps(r) and "R2.2" in json.dumps(r), "the y-up point did not find R1.2 joined to R2.2")
r = S("sch_text", {"sheet": sch, "frame": "standard", "texts": [{"text": "std up", "at": [30.48, -30.48], "sizeMm": 2}, {"text": "std two", "at": [30.48, -35.56]}]})
rec("sch_text", r, ok(r) and r.get("batchedAs") == "kicad_sch_batch")
r = S("sch_items", {"sheet": sch, "kinds": ["texts"], "frame": "standard"})
t = next((x for x in (r.get("texts") or []) if x.get("text") == "std up"), {})
rec("sch_items", r, ok(r) and t.get("id") and t.get("at") == [30.48, -30.48], f"text read back as {t}")
r = S("sch_junction", {"sheet": sch, "points": [[30.48, 60.96]]})
jid = next((j.get("id") for j in (S("sch_items", {"sheet": sch, "kinds": ["junctions"]}).get("junctions") or []) if j.get("at") == [30.48, 60.96]), None)
r = S("sch_junction", {"sheet": sch, "points": [[35.56, 60.96]], "remove": [jid] if jid else []}) if jid else {"error": "the added junction has no id"}
rec("sch_junction", r, ok(r) and r.get("batchedAs") == "kicad_sch_batch")
ab(a.target, "kicad_sch_connect_to_net", {"filePath": sch, "pin": "R1.1", "net": "VIN"}, timeout=120)
r = S("sch_edit_label", {"sheet": sch, "frame": "standard", "edits": [{"net": "VIN", "by": [0, 2.54]}]}); rec("sch_edit_label", r, ok(r))
r = S("sch_create", {"sheet": pd + "/std-extra.kicad_sch", "orientation": "portrait", "title": "extra"}); rec("sch_create", r, ok(r))
r = S("sch_hierarchy", {"project": pro}); rec("sch_hierarchy", r, ok(r))
r = S("render_schematic", {"sheet": sch, "outputDir": OUT + "/std-render-sch"}, 220); rec("render_schematic", r, ok(r))
r = S("project_snapshot", {"project": pro, "label": "std", "pdfs": False}, 220); rec("project_snapshot", r, ok(r))
r = S("project_rename", {"project": pro, "newName": "kstd2", "dryRun": True}); rec("project_rename", r, ok(r) and r.get("mutated") is not True)
print("[9.2] board, library and outputs in the standard's shapes")
B, F = w["board"], w["offline"]
st = ab(a.target, "kicad_routing_state", {"filePath": F, "model": "standard"}, timeout=150)
refs = [p.get("ref") for p in (st.get("components") or st.get("parts") or []) if str(p.get("ref", "")).startswith(("C", "R"))][:2]
if len(refs) == 2:
r = S("edit_parts", {"filePath": F, "source": "file", "edits": [{"ref": refs[0], "value": "2u2"}, {"ref": refs[1], "value": "4k7", "dnp": True}]})
rec("edit_parts", r, ok(r) and len(r.get("edited") or []) == 2)
r = S("align_parts", {"filePath": F, "refs": refs, "mode": "top", "dryRun": True, "allowOverlap": True, "frame": "standard"})
rec("align_parts", r, ok(r) and r.get("inputFrame") == "standard")
r = S("remove_parts", {"filePath": F, "refs": refs[:1], "dryRun": True, "source": "file"}); rec("remove_parts", r, ok(r) and r.get("mutated") is not True)
else:
rec("edit_parts", st, False, "no two C/R parts on the offline board")
svg = "<svg xmlns='http://www.w3.org/2000/svg' width='10' height='10'><rect width='10' height='10'/></svg>"
r = S("import_svg", {"filePath": F, "svg": svg, "at": [100, -100], "widthMm": 3, "layer": "Dwgs.User", "frame": "standard", "dryRun": True, "source": "file"})
rec("import_svg", r, ok(r) and r.get("inputFrame") == "standard")
r = S("remove_graphics", {"filePath": F, "layer": "Dwgs.User", "dryRun": True, "source": "file"}); rec("remove_graphics", r, ok(r) and r.get("mutated") is not True)
r = S("check_clearance", {"filePath": B, "mode": "pads", "thresholdMm": 0.2}); rec("check_clearance", r, ok(r))
r = S("cross_probe", {"filePath": B, "ref": "U1"}); rec("cross_probe", r, ok(r) or clean(r))
r = S("estimate_cost", {"filePath": B, "quantities": [5, 50]})
rec("estimate_cost", r, ok(r) and (r.get("source") or {}).get("name") and "read" in (r.get("source") or {}) and len(r.get("prices") or []) == 2)
r = S("export_ipc2581", {"filePath": B, "outputDir": OUT + "/std-ipc2581", "bomFields": {"mpn": "MPN"}}, 300); rec("export_ipc2581", r, ok(r))
r = S("export_dxf", {"filePath": B, "outputDir": OUT + "/std-dxf", "layers": ["Edge.Cuts"], "contours": True}, 300); rec("export_dxf", r, ok(r))
r = S("render_board_3d", {"filePath": B, "side": "top", "outputDir": OUT + "/std-render3d"}, 600); rec("render_board_3d", r, ok(r) and r.get("image"))
r = S("read_footprint", {"libId": "Resistor_SMD:R_0603_1608Metric"}); rec("read_footprint", r, ok(r))
pretty = f"{OUT}/KSTD{tag}.pretty"
S("create_footprint", {"libraryPath": pretty, "name": "KSTD_FP", "pads": [{"number": 1, "size": [1, 1], "at": [-1, 0]}, {"number": 2, "size": [1, 1], "at": [1, 0]}]})
r = S("edit_footprint", {"libraryPath": pretty, "name": "KSTD_FP", "pads": [{"number": "1", "set": {"sizeMm": [1.2, 1]}}]})
rec("edit_footprint_library", r, ok(r), "the standard's edit_footprint with library arguments did not reach kicad_fp_edit")
r = S("export_library_part", {"part": "__no_such_part__"}); rec("export_library_part", r, r.get("success") is False)
r = S("create_part_project", {"part": "__no_such_part__"}); rec("create_part_project", r, r.get("success") is False)
# 9.3 the standard's core verbs (1.0.64), recorded under their own names
print("[9.3] library housekeeping, part parameters, layers, silkscreen, rip-up")
def own(verb, r, good, why=None):
record(verb, r, bool(good), None if good else (why or f"{r.get('errorCode')}: {str(r.get('error'))[:160]}"))
lib, lib2, slib = f"{OUT}/KHK{tag}.pretty", f"{OUT}/KHK2{tag}.pretty", f"{OUT}/KHK{tag}.kicad_sym"
S("create_footprint", {"libraryPath": lib, "name": "KHK_FP", "pads": [{"number": 1, "size": [1, 1], "at": [-1, 0]}, {"number": 2, "size": [1, 1], "at": [1, 0]}]})
r = S("copy_footprint", {"libraryPath": lib, "from": "KHK_FP", "to": "KHK_FP2"}); own("copy_footprint", r, ok(r))
r = S("rename_footprint", {"libraryPath": lib, "from": "KHK_FP2", "to": "KHK_FP3"}); own("rename_footprint", r, ok(r))
r = S("xcopy_footprint", {"fromLib": lib, "from": "KHK_FP3", "toLib": lib2}); own("xcopy_footprint", r, ok(r))
r = S("add_body", {"libraryPath": lib, "footprintName": "KHK_FP", "step": "${KICAD10_3DMODEL_DIR}/Resistor_SMD.3dshapes/R_0603_1608Metric.step"})
own("add_body", r, ok(r) and r.get("bodiesNow") == 1)
r = S("remove_footprint", {"libraryPath": lib, "footprintName": "KHK_FP3"}); own("remove_footprint", r, ok(r))
S("create_symbol", {"libraryPath": slib, "name": "KHK1", "pins": [{"number": 1, "name": "A"}, {"number": 2, "name": "B", "side": "right"}]})
r = S("clone_symbol", {"libraryPath": slib, "from": "KHK1", "to": "KHK2"}); own("clone_symbol", r, ok(r))
r = S("rename_symbol", {"libraryPath": slib, "from": "KHK2", "to": "KHK3"}); own("rename_symbol", r, ok(r))
r = S("set_params", {"libraryPath": slib, "symbolName": "KHK3", "params": {"MPN": "KHK-3"}, "footprint": "KHK:KHK_FP"}); own("set_params", r, ok(r) and (r.get("fields") or {}).get("MPN") == "added")
r = S("set_visibility", {"libraryPath": slib, "symbolName": "KHK3", "param": "MPN", "visible": True}); own("set_visibility", r, ok(r))
r = S("symbol_prims", {"libraryPath": slib, "symbolName": "KHK3"}); own("symbol_prims", r, ok(r) and r.get("pins") == 2)
r = S("place_on_sch", {"libraryPath": slib, "symbolName": "KHK1;KHK3", "schPath": f"{OUT}/khk-{tag}.kicad_sch", "title": "housekeeping"})
own("place_on_sch", r, ok(r) and len(r.get("placed") or []) == 2)
r = S("doc_params", {"sheet": sch, "set": {"Title": "kstd params", "Revision": "B"}}); own("doc_params", r, ok(r) and (r.get("parameters") or {}).get("Revision") == "B")
r = S("sheet_params", {"projectPath": pro, "designators": ["R1"], "params": {"MPN": "KSTD-R1"}}); own("sheet_params", r, ok(r) and not r.get("notFound"))
r = S("part_parameters", {"projectPath": pro, "name": "MPN"}); own("part_parameters", r, ok(r) and any(v.get("value") == "KSTD-R1" for v in r.get("values") or []))
r = S("layers", {"filePath": B, "source": "file"}); own("layers", r, ok(r) and (r.get("copper") or 0) >= 2)
r = S("silk_state", {"filePath": B, "source": "file"}); own("silk_state", r, ok(r) and (r.get("counts") or {}).get("parts", 0) > 0 and isinstance(r.get("components"), list) and isinstance(r.get("lines"), list))
ab(a.target, "kicad_close", {"force": True}, timeout=120)
time.sleep(4)
o = ab(a.target, "kicad_open_board", {"filePath": B, "reason": WHY}, timeout=200)
time.sleep(8)
st2 = ab(a.target, "kicad_routing_state", {"filePath": B}, timeout=150)
net = next((t.get("netName") for t in (st2.get("tracks") or st2.get("segments") or []) if t.get("netName")), None) or "GND"
r = S("ripup_nets", {"filePath": B, "expectedRevision": st2.get("revision"), "nets": [net], "dryRun": True}) if ok(o) else o
own("ripup_nets", r, ok(r) and r.get("mutated") is not True, "the board did not open live, or the dry run changed something")
ab(a.target, "kicad_close", {"force": True}, timeout=120)
# 9.4 the standard's native composites (1.0.65)
print("[9.4] compile, installed libraries, replicate layout, diff pairs")
r = S("compile", {"projectPath": pro}, 300); own("compile", r, ok(r) and "counts" in r and r.get("nets") is not None)
r = S("list_installed_libraries", {"projectPath": pro}); own("list_installed_libraries", r, ok(r) and len(r.get("libraries") or []) > 10)
st3 = ab(a.target, "kicad_placement_state", {"filePath": F}, timeout=150)
caps = [p.get("ref") for p in (st3.get("footprints") or []) if str(p.get("ref", "")).startswith("C") and p.get("side") == "F.Cu"][:4]
if len(caps) == 4:
r = S("replicate_layout", {"filePath": F, "source": caps[:2], "targets": [caps[2:]], "dryRun": True, "allowOverlap": True, "allowOutside": True})
own("replicate_layout", r, (ok(r) or r.get("errorCode") in ("courtyard_overlap", "outside_outline")) and len(r.get("moves") or []) == 1, "no plan for the target channel")
else:
own("replicate_layout", st3, False, "fewer than four front capacitors on the offline board")
r = S("diff_pairs", {"filePath": B, "source": "file"}); own("diff_pairs", r, ok(r) and isinstance(r.get("pairs"), list))
def checks_phase():
"""Phase 10: the checks tier (1.0.67): the shared engine's checks and the bridge's own, from the file;
thermal vias and a via fence written into the closed scratch board after the DRC preflight; the report."""
if only and not any(o.startswith(("checks", "board_report")) for o in only):
return
WHY = "daily verb test: board checks"
C = lambda verb, args, t=200: ab(a.target, f"kicad_{verb}", dict(args, reason=WHY), timeout=t)
def own(verb, r, good, why=None):
record(verb, r, bool(good), None if good else (why or f"{r.get('errorCode')}: {str(r.get('error'))[:160]}"))
CK = base + "/checks/esc-g431-fable-placed-planes.kicad_pcb"
fb = {"filePath": CK, "source": "file"}
print("[10] board checks")
r = C("fab_check", dict(fb, fab="jlcpcb"), 120)
own("fab_check", r, ok(r) and isinstance(r.get("outsideLimits"), list) and (r.get("fab") or {}).get("source") and (r.get("measured") or {}).get("copperLayers") == 4 and (r.get("measured") or {}).get("smdPad"), "no fab record read, or nothing measured")
r = C("copper_balance", fb); own("copper_balance", r, ok(r) and len(r.get("layers") or []) == 4 and "balanced" in r)
r = C("return_path", fb); own("return_path", r, ok(r) and isinstance(r.get("nets"), list) and r.get("referenceNets"))
r = C("impedance", dict(fb, z0=50, diff=90, apply={"scope": "A.NetClass == 'USB'"}))
own("impedance", r, ok(r) and (r.get("layers") or [{}])[0].get("width") and (r.get("apply") or {}).get("verb") == "kicad_custom_rules")
r = C("current_capacity", dict(fb, nets={"GND": 2})); own("current_capacity", r, ok(r) and (r.get("nets") or [{}])[0].get("net") == "GND")
r = C("net_lengths", fb); own("net_lengths", r, ok(r) and isinstance(r.get("nets"), list) and isinstance(r.get("pairs"), list))
r = C("check_orientation", fb); own("check_orientation", r, ok(r) and (r.get("checked") or 0) > 10)
st = ab(a.target, "kicad_list_designators", fb, timeout=120)
cap = next((d for d in (st.get("designators") or []) if str(d).startswith("C")), "C1")
r = C("find_space", dict(fb, part=cap, radiusMm=15)); own("find_space", r, ok(r) and isinstance(r.get("spots"), list))
# 1.0.72: height_check, placement_check, thermal_vias and via_fence answered by the standard's engine, which names itself
eng = lambda r: str(r.get("engine") or "").startswith("adom-eda-checks ")
r = C("height_check", dict(fb, maxTopMm=20), 240); own("height_check", r, ok(r) and r.get("heights") and eng(r), "no heights, or not from the engine")
# 1.0.73: a part framed in KiCad's own render (wiki #105): the pivot is J1's offset from the board centre, in cm
r = ab(a.target, "kicad_render_board", {"filePath": CK, "ref": "J1", "rotate": "-45,0,45", "width": 800, "height": 600, "outputDir": w["out"] + "/framed"}, timeout=300)
fr = (r.get("data") or {}).get("framed") or {}
piv = fr.get("pivotCm") or [0, 0]
record("render_board_framed", r, ok(r) and abs(piv[0] + 2.15) < 0.15 and (fr.get("zoom") or 0) > 3 and (r.get("data") or {}).get("bytes", 0) > 10000,
"J1 was not framed (pivot, zoom) or no image was written")
r = C("placement_check", fb, 240)
own("placement_check", r, ok(r) and isinstance(r.get("courtyardOverlaps"), list) and isinstance(r.get("stacked"), list) and r.get("bodiesUsed") is True and (r.get("parts") or 0) > 10 and eng(r),
"no placement review with the bodies, or not from the engine")
r = C("pin_pad_check", {"projectPath": w["pro"]}, 240); own("pin_pad_check", r, ok(r) and "matching" in r and isinstance(r.get("mismatches"), list))
r = C("auto_classes", fb); own("auto_classes", r, ok(r) and ((r.get("proposal") or {}).get("classes") or {}).get("GND"))
r = C("thermal_vias", dict(fb, ref="Q1"), 300)
r2 = C("thermal_vias", dict(fb, ref="Q1", dryRun=True)) if ok(r) else {}
own("thermal_vias", {"apply": r, "again": r2}, ok(r) and eng(r) and (r.get("applied") or 0) > 0 and (r.get("drc") or {}).get("newErrors") == 0 and r2.get("applied") == 0,
"thermal vias were not written after a clean DRC, or a second run placed more")
r = C("via_fence", dict(fb, viaNet="GND", edge=True), 300)
own("via_fence", r, ok(r) and eng(r) and (r.get("applied") or 0) > 0 and (r.get("drc") or {}).get("newErrors") == 0, "no fence written after a clean DRC")
r = C("board_report", {"filePath": CK}, 600)
own("board_report", r, ok(r) and r.get("report") and r.get("picture") is True and len(r.get("sections") or []) >= 8, "no report with its picture")
# 1.0.68: ordering, land patterns, designators
r = C("bom", {"projectPath": w["pro"], "outputDir": w["out"] + "/bom"}, 300)
own("bom", r, ok(r) and isinstance(r.get("lines"), list) and len(r.get("files") or []) == 6 and "missingMpn" in (r.get("checks") or {}), "no BOM lines or not all six files")
r = C("assembly_check", {"filePath": CK, "source": "file"}, 300)
own("assembly_check", r, ok(r) and (r.get("economic") or {}).get("rules") and (r.get("standard") or {}).get("rules") and "service" in r)
r = C("jlcpcb_order", {"filePath": CK, "outputDir": w["out"] + "/jlcpcb-order"}, 600)
own("jlcpcb_order", r, ok(r) and (r.get("gerberZip") or {}).get("path") and isinstance(r.get("issues"), list) and (r.get("orderForm") or {}).get("layers") == 4, "no Gerber zip or order form")
lp = {"libraryPath": w["out"] + "/LandTest.pretty", "name": "SOIC-8_test", "family": "dual", "pins": 8, "pitch": 1.27, "span": 5.4, "padLength": 1.55, "padWidth": 0.6,
"body": {"w": 3.9, "l": 4.9, "height": 1.75}, "overwrite": True}
# 1.0.86 (engine 0.19.1, standard #25): SOT-23-5's pins 4 and 5 line up with 1 and 3; a BGA's courtyard holds pads
# and body only
sot = C("footprint_from_land_pattern", {"name": "SOT-23-5", "family": "sot", "leftPins": 3, "rightPins": 2, "pitch": 0.95, "span": 2.6,
"padLength": 1.1, "padWidth": 0.6, "dryRun": True})
bga = C("footprint_from_land_pattern", {"name": "BGA", "family": "bga", "rows": 8, "cols": 8, "pitch": 0.8, "ballPad": 0.4, "body": {"w": 7, "l": 7}, "dryRun": True})
r0 = C("footprint_from_land_pattern", dict(lp, dryRun=True))
r = C("footprint_from_land_pattern", lp)
# 1.0.89 (#28): the library it wrote passes the standard's lint rules (engine lint_library) with no errors
ll = C("lint_library", {"libraryPath": w["out"] + "/LandTest.pretty"}, 200)
own("footprint_from_land_pattern", {"plan": r0, "write": r}, ok(r0) and (r0.get("summary") or {}).get("padTable", [[None]])[0] == ["1", -2.7, 1.905, 1.55, 0.6]
and str(r0.get("engine") or "").startswith("adom-eda-checks ") and ok(r) and (r.get("body3d") or {}).get("success") is True
and [p[2] for p in ((sot.get("summary") or {}).get("padTable") or [])][3:] == [-0.95, 0.95]
and (bga.get("summary") or {}).get("courtyardMm") == [-3.75, -3.75, 3.75, 3.75]
and ok(ll) and ll.get("errors") == 0 and len(ll.get("rules") or []) >= 18 and str(ll.get("engine") or "").startswith("adom-eda-checks "),
"no pad table from the engine (1.0.85: the standard's engine lays it out), or the footprint or its body was not written")
# 1.0.69: a sheet read from its file, boards built from footprints, a mouse-bite panel
r = C("read_sheet_file", {"path": w["sch"], "params": True})
own("read_sheet_file", r, ok(r) and r.get("count", 0) > 0 and any(c.get("pins") for c in r.get("components") or []), "no parts with pins read from the sheet")
r = C("build_board", {"pcbPath": w["out"] + "/built/built.kicad_pcb", "outline": {"rect": [0, 0, 30, 20], "cornerRadius": 1.5},
"parts": [{"ref": "R1", "footprint": "Resistor_SMD:R_0603_1608Metric", "x": 8, "y": 10}, {"ref": "C1", "footprint": "Capacitor_SMD:C_0603_1608Metric", "x": 16, "y": 10, "rotation": 90}],
"nets": {"VIN": ["R1.1"], "OUT": ["R1.2", "C1.1"], "GND": ["C1.2"]}})
own("build_board", r, ok(r) and r.get("padsWired") == 4 and r.get("wrongPins") == [] and (r.get("counts") or {}).get("components") == 2, "the board was not built and wired")
r = C("test_board", {"libraryPath": lp["libraryPath"], "footprintName": "SOIC-8_test", "pcbPath": w["out"] + "/built/test.kicad_pcb", "assigns": "1=GND"})
own("test_board", r, ok(r) and r.get("count") == 1 and r.get("saved") is True and r.get("wrongPins") == [], "the footprint was not placed on a test board")
# 1.0.71: pictures of library parts: KiCad's own regulators (symbol, footprint, 3d, device), and a footprint with no body
inst = (ab(a.target, "kicad_status", {}, timeout=60).get("install") or {})
reg = (inst.get("baseDir") or "") + "/share/kicad/symbols/Regulator_Linear.kicad_sym"
r = C("library_views", {"libraryPath": reg, "parts": ["AMS1117-3.3"], "outputDir": w["out"] + "/views"}, 300)
cap = {c.get("view"): c for c in r.get("captured") or []}
C("footprint_from_land_pattern", dict(lp, name="SOIC-8_nobody", body={"w": 3.9, "l": 4.9}))
nb = C("library_views", {"libraryPath": lp["libraryPath"], "parts": ["SOIC-8_nobody"], "views": ["footprint", "3d"], "outputDir": w["out"] + "/views"}, 300)
nbc = {c.get("view"): c for c in nb.get("captured") or []}
own("library_views", {"stock": r, "nobody": nb}, ok(r) and all((cap.get(v) or {}).get("ok") for v in ("symbol", "footprint", "3d", "device")) and (cap["device"].get("matches") is True)
and (nbc.get("footprint") or {}).get("ok") and (nbc.get("3d") or {}).get("ok") is False and "no 3D body" in str((nbc.get("3d") or {}).get("error")),
"not every view of a stock part was captured, or a body-less footprint drew a 3d view")
# 1.0.74: a body made by the standard's engine (generate_body), attached and pictured; a route planned by it (plan_route)
C("footprint_from_land_pattern", dict(lp, name="SOIC-8_gen", body={"w": 3.9, "l": 4.9}))
gb = C("generate_body", {"family": "soic", "dimensionsMm": {"pins": 8}, "marking": "LM358", "libraryPath": lp["libraryPath"], "footprintName": "SOIC-8_gen", "overwrite": True})
gv = C("library_views", {"libraryPath": lp["libraryPath"], "parts": ["SOIC-8_gen"], "views": ["3d"], "outputDir": w["out"] + "/views"}, 300)
g3 = next((c for c in gv.get("captured") or [] if c.get("view") == "3d"), {})
own("generate_body", {"body": gb, "view": gv}, ok(gb) and eng(gb) and abs(((gb.get("bboxMm") or {}).get("z") or 0) - 1.75) < 0.01 and (gb.get("attached") or {}).get("success") and g3.get("ok") and abs((g3.get("bodyHeightMm") or 0) - 1.75) < 0.1,
"no SOIC-8 body written, attached and rendered at its 1.75 mm height")
# 1.0.78: SPICE (wiki #89): an RC low-pass (1k, 100n) drawn from a netlist, exported, and swept: -3 dB at 1/(2 pi RC)
rc = w["out"] + "/sim/rc.kicad_sch"
C("build_schematic", {"schPath": rc, "overwrite": True, "title": "RC", "parts": [{"ref": "V1", "symbol": "Simulation_SPICE:VSIN"},
{"ref": "R1", "symbol": "Device:R", "comment": "1k"}, {"ref": "C1", "symbol": "Device:C", "comment": "100n"}],
"nets": {"IN": ["V1.1", "R1.1"], "OUT": ["R1.2", "C1.1"], "GND": ["V1.2", "C1.2"]}}, 400)
ex = C("export_spice", {"schPath": rc}, 200)
own("export_spice", ex, ok(ex) and ex.get("elements") == 3 and (ex.get("path") or "").endswith(".cir") and ex.get("dialect") == "ngspice", "no 3-element ngspice netlist of the RC")
# the standard's shape (#23, 1.0.79): structured analysis, probes by net, signals[{signal, x, y}]
ac = C("simulate", {"schematic": rc, "analysis": {"type": "ac", "sweep": "dec", "points": 20, "start": 10, "stop": 100000}, "probes": [{"net": "OUT"}], "points": 500}, 300)
sig = ((ac.get("signals") or [{}])[0])
f, db = sig.get("x") or [], sig.get("y") or []
# the crossing interpolated in log frequency: at 20 points a decade the grid steps past it (1584.9 Hz is -2.99 dB)
f3 = None
for i in range(len(min(f, db, key=len)) - 1):
if db[i] > -3.0 >= db[i + 1] and f[i] > 0 and f[i + 1] > 0:
t = (-3.0 - db[i]) / (db[i + 1] - db[i])
f3 = 10 ** (math.log10(f[i]) + t * (math.log10(f[i + 1]) - math.log10(f[i])))
break
# 1.0.83: the standard's schematic_review from KiCad's netlist (engine 0.16.0): the wired RC is clean, the rp2040 sample
# (mostly unwired) has power pins on no net
rv1 = C("schematic_review", {"filePath": rc}, 200)
rv2 = C("schematic_review", {"filePath": w["sch"]}, 200)
own("schematic_review", {"rc": rv1, "rp2040": rv2}, ok(rv1) and rv1.get("ok") is True and ok(rv2) and rv2.get("ok") is False
and any(e.get("check") == "unconnected_pin" and e.get("type") == "power" for e in rv2.get("errors") or []), "the RC was not clean, or the unwired sample's power pins were not caught")
# 1.0.93: the review also checks the drawing (sheetDrawing): its overlap pairs agree with KiCad's own sch_check, and the
# clean RC raises no drawing or net-name warning
oc = C("sch_check", {"filePath": w["sch"], "checks": ["overlap"]}, 200)
# two parts drawn over each other (KiCad's overlap check also names one part whose text runs over its own body)
pairs = lambda fs: sorted(tuple(sorted(x.get("refs") or [])) for x in fs if x.get("check") == "overlap" and len(x.get("refs") or []) == 2)
po, pr = pairs(oc.get("findings") or []), pairs(rv2.get("warnings") or [])
own("schematic_review_drawing", {"kicadOverlaps": po, "engineOverlaps": pr, "rcWarnings": rv1.get("warnings")},
ok(oc) and rv1.get("drawingChecked") is True and rv2.get("drawingChecked") is True and po == pr and not (rv1.get("warnings") or []),
"the drawing was not checked, the engine's overlaps differ from KiCad's sch_check, or the clean RC raised a warning")
# 1.0.88: KiCad 10 design variants on a copy of the rp2040 sample: make Lite with two parts not fitted, put one back,
# KiCad's own netlist for the variant agreeing each time, then remove it
vp = base + "/variants/rp2040-breakout.kicad_pro"
ab(a.target, "shell_execute", {"command": "rmdir /s /q \"" + (base + "/variants").replace("/", "\\") + "\" 2>nul & xcopy /i /q /y \"" + base.replace("/", "\\") + "\\rp2040-breakout.*\" \"" + (base + "/variants").replace("/", "\\") + "\"",
"reason": "kicad-bridge verb test: a scratch copy of the rp2040 sample for kicad_variants"}, timeout=120)
v1 = C("variants", {"projectPath": vp, "name": "Lite", "notFitted": ["C1", "C2"], "description": "verb test"}, 200)
v2 = C("variants", {"projectPath": vp, "name": "Lite", "fitted": ["C2"]}, 200)
v3 = C("variants", {"projectPath": vp, "name": "Lite", "remove": True}, 200)
vl = C("variants", {"projectPath": vp}, 200)
own("variants", {"make": v1, "fit": v2, "remove": v3, "list": vl},
ok(v1) and (v1.get("verify") or {}).get("kicadNotFitted") == ["C1", "C2"] and ok(v2) and (v2.get("verify") or {}).get("kicadNotFitted") == ["C1"]
and ok(v3) and ok(vl) and not any(x.get("name") == "Lite" for x in vl.get("variants") or []),
"KiCad's netlist for the variant did not match the parts marked not fitted, or the variant was not removed")
# 1.0.90 (#29): swap_footprint on a copy of the rp2040 board (C1 onto a 0402 capacitor, nets kept by pad number);
# plate_vias and normalize_bodies on the land-pattern library; symbol_mode's two body styles on KiCad's 74LS00
sw_dir = base + "/swap"
ab(a.target, "shell_execute", {"command": "rmdir /s /q \"" + sw_dir.replace("/", "\\") + "\" 2>nul & xcopy /i /q /y \"" + base.replace("/", "\\") + "\\rp2040-breakout.*\" \"" + sw_dir.replace("/", "\\") + "\"",
"reason": "kicad-bridge verb test: a scratch copy of the rp2040 board for kicad_swap_footprint"}, timeout=120)
sp = C("swap_footprint", {"filePath": sw_dir + "/rp2040-breakout.kicad_pcb", "ref": "C1", "footprint": "Capacitor_SMD:C_0402_1005Metric"}, 200)
own("swap_footprint", sp, ok(sp) and sp.get("to") == "Capacitor_SMD:C_0402_1005Metric" and sp.get("netsKeptOnPads") == ["1", "2"], "C1 did not take the 0402 footprint with its nets")
# 1.0.94 (standard 0.29.0): export_3d (glb with the parts, a bare-board stl, obj refused by name), edit_pours on a
# closed copy of the ESC's planes (refilled by kicad-cli), import_design of a small EAGLE board
sb = sw_dir + "/rp2040-breakout.kicad_pcb"
g3 = C("export_3d", {"filePath": sb, "format": "glb", "outputPath": w["out"] + "/3d/board.glb"}, 320)
s3 = C("export_3d", {"filePath": sb, "format": "stl", "components": False, "outputPath": w["out"] + "/3d/bare.stl"}, 320)
o3 = C("export_3d", {"filePath": sb, "format": "obj"}, 60)
own("export_3d", {"glb": g3, "stl": s3, "obj": o3}, ok(g3) and g3.get("bytes", 0) > 10000 and g3.get("components") is True
and ok(s3) and s3.get("components") is False and s3.get("bytes", 0) > 1000 and o3.get("errorCode") == "not_supported" and o3.get("instead"),
"the glb or bare stl was not written, or obj was not refused by name")
pd = base + "/pours"
ab(a.target, "shell_execute", {"command": "rmdir /s /q \"" + pd.replace("/", "\\") + "\" 2>nul & mkdir \"" + pd.replace("/", "\\") + "\" & copy /y \"" + CK.replace("/", "\\") + "\" \"" + pd.replace("/", "\\") + "\"",
"reason": "kicad-bridge verb test: a scratch copy of the ESC board's planes for kicad_edit_pours"}, timeout=120)
pb = pd + "/esc-g431-fable-placed-planes.kicad_pcb"
ps = ab(a.target, "kicad_pour_state", {"filePath": pb, "source": "file", "geometry": False}, timeout=120)
gz = next((z for z in ps.get("zones") or [] if z.get("net") == "GND" and z.get("id")), {})
ep = C("edit_pours", {"filePath": pb, "source": "file", "pours": [{"id": gz.get("id"), "clearanceMm": 0.3, "thermals": {"spokeMm": 0.6}}]}, 300)
ed = (ep.get("edited") or [{}])[0]
after = [z for z in (ep.get("pourState") or {}).get("zones") or [] if z.get("id") == gz.get("id")]
own("edit_pours", ep, ok(ep) and gz and ed.get("after", {}).get("clearanceMm") == 0.3 and (ed.get("after", {}).get("thermals") or {}).get("spokeMm") == 0.6
and ep.get("refilled") is True and after and all(z.get("filledMm2", 0) > 0 for z in after),
"the GND pour's clearance and spoke did not change, or kicad-cli did not refill it")
# the standard's schematic part verbs in their own arguments (mils from the lower-left, designators, lists), on the
# swap copy's sheet with KiCad's own Device library
ss = sw_dir + "/rp2040-breakout.kicad_sch"
pp = C("sch_place_part", {"sheet": ss, "library": "Device", "symbol": "R", "designator": "R99", "comment": "10k", "x": 1000, "y": 1000}, 120)
pc = C("sch_connect", {"sheet": ss, "connections": [{"designator": "R99", "pin": 1, "net": "TEST_NET"}, {"designator": "R99", "pin": 2, "net": "GND", "kind": "power"}]}, 120)
pe = C("sch_edit", {"sheet": ss, "edits": [{"designator": "R99", "action": "comment", "text": "4k7"}, {"designator": "R99", "action": "moveBy", "dx": 0, "dy": 0}]}, 120)
pw = C("sch_swap_part", {"sheet": ss, "designator": "R99", "library": "Device", "symbol": "R_Small"}, 120)
own("sch_part_verbs_standard", {"place": pp, "connect": pc, "edit": pe, "swap": pw},
ok(pp) and pp.get("atMil") == [1000, 1000] and ok(pc) and pc.get("connected") == 2 and ok(pe) and pe.get("applied") == 2
and ok(pw) and (pw.get("now") or {}).get("libReference") == "Device:R_Small" and isinstance(pw.get("pinsJoined"), list),
"a standard-shaped schematic part verb failed, or answered outside the standard's terms (atMil, connected, applied, now, pinsJoined)")
# 1.0.95 (engine 0.24.0): cross_check on a copy of the rp2040 project with C1 taken off the board: C1 is on the
# sheet but not the board, and every other part matches
xd = base + "/xcheck"
ab(a.target, "shell_execute", {"command": "rmdir /s /q \"" + xd.replace("/", "\\") + "\" 2>nul & xcopy /i /q /y \"" + base.replace("/", "\\") + "\\rp2040-breakout.*\" \"" + xd.replace("/", "\\") + "\"",
"reason": "kicad-bridge verb test: a scratch copy of the rp2040 project for kicad_cross_check"}, timeout=120)
x0 = C("cross_check", {"projectPath": xd + "/rp2040-breakout.kicad_pro", "source": "file"}, 200)
ab(a.target, "kicad_delete_footprint", {"filePath": xd + "/rp2040-breakout.kicad_pcb", "refs": ["C1"], "source": "file"}, timeout=120)
x1 = C("cross_check", {"projectPath": xd + "/rp2040-breakout.kicad_pro", "source": "file"}, 200)
own("cross_check", {"before": x0, "after": x1}, ok(x0) and ok(x1) and (x0.get("matched") or 0) > 5 and set(x1.get("onSheetNotOnBoard") or []) == set(x0.get("onSheetNotOnBoard") or []) | {"C1"}
and x1.get("matched") == x0.get("matched", 0) - 1 and x1.get("sameProject") is True,
"cross_check did not find C1 missing from the board after it was deleted, or the counts did not follow")
# tool_update: check and the auto setting read (never apply: that installs KiCad); import_step refused with why
tu = C("tool_update", {"action": "check"}, 120)
ta = C("tool_update", {"action": "auto"}, 60)
own("tool_update", {"check": tu, "auto": ta}, ok(tu) and tu.get("current") and "offered" in tu and isinstance(tu.get("auto"), bool) and ok(ta) and isinstance(ta.get("auto"), bool),
"tool_update did not report the installed version, the offered one and the auto setting")
st = C("import_step", {"filePath": "C:/x/enclosure.step"}, 30)
own("import_step", st, st.get("errorCode") == "not_supported" and st.get("instead"), "import_step did not refuse by name with what to use instead")
# the standard's list_library, render_board_layers, export_bom and open/activate verbs under their own names
kb = ((ab(a.target, "kicad_readiness", {}, timeout=60).get("installs") or [{}])[0]).get("baseDir") or "C:/Program Files/KiCad/10.0"
ls = C("list_library", {"libraryPath": kb + "/share/kicad/symbols/74xx.kicad_sym", "limit": 5}, 60)
lf = C("list_library", {"libraryPath": kb + "/share/kicad/footprints/Resistor_SMD.pretty"}, 60)
own("list_library", {"sym": ls, "pretty": lf}, ok(ls) and ls.get("kind") == "sch" and "7400" in ((ls.get("data") or {}).get("names") or []) and ok(lf) and lf.get("kind") == "pcb" and ((lf.get("data") or {}).get("count") or 0) > 20,
"the symbol or footprint library was not listed by its path")
rb = C("render_board_layers", {"filePath": xd + "/rp2040-breakout.kicad_pcb", "preset": "bottom_copper", "dpi": 150, "outputPath": w["out"] + "/layers/bottom.png"}, 200)
rl = C("render_board_layers", {"listPresets": True}, 30)
own("render_board_layers", {"png": rb, "presets": rl}, ok(rb) and (rb.get("widthPx") or 0) > 100 and rb.get("mirrored") is True and rb.get("layers") == ["B.Cu", "Edge.Cuts"] and len(rl.get("presets") or {}) == 14,
"the bottom copper was not rendered mirrored to a PNG, or the presets were not listed")
rd = C("routing_drc", {"filePath": CK, "source": "file"}, 300)
own("routing_drc", rd, ok(rd) and rd.get("clean") is False and (rd.get("unconnected") or 0) > 0 and all(v.get("rule") and v.get("at") for v in rd.get("violations") or []),
"the ESC board's DRC did not come back not-clean with every violation placed")
twd = base + "/tw"
ab(a.target, "shell_execute", {"command": "rmdir /s /q \"" + twd.replace("/", "\\") + "\" 2>nul & mkdir \"" + twd.replace("/", "\\") + "\" & copy /y \"" + (base + "/checks/route-fixture.kicad_pcb").replace("/", "\\") + "\" \"" + twd.replace("/", "\\") + "\"",
"reason": "kicad-bridge verb test: a scratch copy of the routed fixture for kicad_set_track_width"}, timeout=120)
tw = C("set_track_width", {"filePath": twd + "/route-fixture.kicad_pcb", "nets": ["SIG_A"], "widthMm": 0.4, "layer": "top", "source": "file"}, 300)
tr = ab(a.target, "kicad_routing_state", {"filePath": twd + "/route-fixture.kicad_pcb", "source": "file", "model": "standard"}, timeout=120)
widths = {(t.get("layer"), t.get("width")) for t in tr.get("tracks") or [] if t.get("net") == "SIG_A"}
own("set_track_width", {"edit": tw, "after": sorted(map(str, widths))}, ok(tw) and (tw.get("changed") or 0) > 0 and "drcAfter" in tw and ("F.Cu", 0.4) in widths and not any(l == "F.Cu" and w != 0.4 for l, w in widths),
"SIG_A's top-layer tracks did not all read back at 0.4 mm")
fpp = C("footprint_primitives", {"filePath": xd + "/rp2040-breakout.kicad_pcb", "designator": "U1", "source": "file"}, 120)
prim = (fpp.get("data") or {}).get("primitives") or []
own("footprint_primitives", fpp, ok(fpp) and sum(1 for p in prim if p.get("kind") == "pad") >= 56 and all(p.get("layerId") for p in prim) and (fpp.get("data") or {}).get("pattern"),
"U1's pads and graphics did not come back placed with their layers")
rk = C("rules_by_kind", {"filePath": xd + "/rp2040-breakout.kicad_pcb"}, 60)
kinds = rk.get("rulesByKind") or {}
own("rules_by_kind", rk, ok(rk) and "Clearance" in kinds and "Width" in kinds and all(r.get("priority") == i + 1 for l in kinds.values() for i, r in enumerate(l)),
"the rules did not come back grouped by kind in priority order")
# 1.0.96: net_connections (pads per net and airline lengths) and remove_items (a via and a track by geometry,
# as routing_state reports them) on the set_track_width copy
tb = twd + "/route-fixture.kicad_pcb"
nc = C("net_connections", {"filePath": tb, "net": "SIG_A", "source": "file"}, 120)
ncd = nc.get("data") or {}
own("net_connections", nc, ok(nc) and ncd.get("count", 0) >= 2 and all(p.get("net") == "SIG_A" for p in ncd.get("pads") or []) and (ncd.get("nets") or [{}])[0].get("airlineMm", 0) > 0,
"SIG_A's pads and airline length did not come back")
st0 = ab(a.target, "kicad_routing_state", {"filePath": tb, "source": "file", "model": "standard"}, timeout=120)
tr0 = (st0.get("tracks") or [None])[0]
n0 = len(st0.get("tracks") or [])
ri = C("remove_items", {"filePath": tb, "source": "file", "tracks": [{"start": tr0["start"], "end": tr0["end"], "layer": tr0.get("layer")}] if tr0 else [], "vias": [{"x": -999, "y": -999}]}, 200)
st1 = ab(a.target, "kicad_routing_state", {"filePath": tb, "source": "file", "model": "standard"}, timeout=120)
own("remove_items", {"remove": ri, "tracksBefore": n0, "tracksAfter": len(st1.get("tracks") or [])}, ok(ri) and ri.get("results") == ["ok", "err:not found"] and len(st1.get("tracks") or []) == n0 - 1,
"the track was not removed by its geometry, or the missing via was not reported")
eb = C("export_bom", {"projectPath": xd + "/rp2040-breakout.kicad_pro", "outputDir": w["out"] + "/bom-std"}, 200)
own("export_bom", eb, ok(eb) and eb.get("files") and eb.get("surface") == "bom", "no BOM file was written")
od = C("open_document", {"filePath": xd + "/rp2040-breakout.kicad_pcb"}, 200)
ad = C("activate_document", {"path": xd + "/rp2040-breakout.kicad_pcb"}, 200)
own("open_document", od, ok(od) and (od.get("data") or {}).get("objectKind") == "Board", "the scratch board did not open")
own("activate_document", ad, ok(ad) and (ad.get("data") or {}).get("kind") == "pcb", "the open scratch board was not activated")
di = C("document_info", {}, 60)
own("document_info", di, ok(di) and di.get("kind") in ("board", "schematic", "project") and str(di.get("path") or "").startswith(xd) and di.get("pathFrom") != "none" and isinstance(di.get("modified"), bool),
"the active KiCad document was not named with its kind and its path in the scratch project")
# 1.0.99: the standard's read_board, read_schematic, schematic_data, pcb_report and instances
ins = C("instances", {}, 60)
own("instances", ins, ok(ins) and ins.get("count", 0) >= 1 and all(i.get("pid") and i.get("licensed") is True and "KiCad" in str(i.get("edition")) for i in ins.get("instances") or []), "the running KiCad was not listed")
xpcb, xsch = xd + "/rp2040-breakout.kicad_pcb", xd + "/rp2040-breakout.kicad_sch"
rb = C("read_board", {"filePath": xpcb, "source": "file"}, 120)
rbd = rb.get("data") or {}
rb2 = C("read_board", {"filePath": xpcb, "source": "file", "sections": "components,rules", "limit": 3}, 120)
rb2d = rb2.get("data") or {}
own("read_board", {"all": {"counts": rb.get("counts"), "sections": sorted(rbd)}, "some": sorted(rb2d)}, ok(rb) and all(k in rbd for k in ("components", "nets", "layers", "copper", "rules", "occupancy", "outline")) and len(rbd["components"]) > 3
and ok(rb2) and len(rb2d.get("components") or []) == 3 and "copper" not in rb2d and "rulesByKind" in (rb2d.get("rules") or {}) and rb2.get("truncated") == ["components"], "the board's sections did not come back, or sections and limit were ignored")
rs = C("read_schematic", {"filePath": xsch}, 120)
rsr = next((c for c in rs.get("components") or [] if c.get("designator") == "U1"), {})
own("read_schematic", rs, ok(rs) and rs.get("count", 0) >= 2 and "U1" in (rs.get("designators") or []) and rs.get("document") == xsch and rsr.get("libReference") and rsr.get("xMil", 0) > 0 and rsr.get("yMil", 0) > 0 and "pins" not in rsr,
"the sheet's parts did not come back with designator, library reference and position in mils")
sd = C("schematic_data", {"filePath": xsch, "designator": "U1"}, 120)
sdc = ((sd.get("data") or {}).get("components") or [{}])[0]
spins = sdc.get("pins") or []
own("schematic_data", {"count": (sd.get("data") or {}).get("count"), "pins": len(spins), "first": spins[:2], "error": sd.get("error")}, ok(sd) and sdc.get("designator") == "U1" and len(spins) >= 56 and all(isinstance(q.get("electrical"), int) and q.get("orientation") in (0, 90, 180, 270) and q.get("lengthMil", 0) > 0 for q in spins) and any(q.get("electrical") == 7 for q in spins),
"U1's pins did not come back with the standard's electrical ordinal, orientation and length")
pr = C("pcb_report", {"path": twd + "/route-fixture.kicad_pcb", "source": "file"}, 120)
prl = C("pcb_report", {"path": kb + "/share/kicad/footprints/Resistor_SMD.pretty", "name": "R_0603"}, 120)
prd, prld = pr.get("data") or {}, prl.get("data") or {}
own("pcb_report", {"boardCount": len(prd.get("components") or []), "census": prd.get("census"), "lib": (prld.get("components") or [])[:2], "error": pr.get("error") or prl.get("error")},
ok(pr) and prd.get("kind") == "board" and any(c.get("pads", 0) > 0 and c.get("padsBboxMil") and c.get("nets") for c in prd.get("components") or []) and ok(prl) and prld.get("kind") == "library" and prld.get("count", 0) >= 1 and all(c.get("pads") == 2 and c.get("padsBboxMil") for c in prld.get("components") or []),
"the board or library report did not give pads, boxes and nets")
# 1.0.99: the standard's account answers, library models, dialogs, save and close, with KiCad up on the scratch board
cdg = C("check_dialogs", {"screenshots": False}, 60)
own("check_dialogs", cdg, ok(cdg) and cdg.get("dialogsDetected") == len(cdg.get("dialogs") or []) and all(d.get("category") and d.get("safeAction") for d in cdg.get("dialogs") or []), "the dialog list did not come back classified")
el = C("ensure_licensed", {}, 120)
own("ensure_licensed", el, ok(el) and el.get("alreadyLicensed") is True and "already running" in (el.get("steps") or []), "KiCad was not reported ready")
sgn = C("signin_status", {}, 30)
own("signin_status", sgn, ok(sgn) and sgn.get("licensed") is True and sgn.get("workspaceConnected") is False and sgn.get("running") is True, "KiCad's account state was wrong")
stt = C("setup_trial", {}, 30)
own("setup_trial", stt, ok(stt) and stt.get("stage") == "done" and stt.get("needsHuman") is None, "a running KiCad was not at stage done")
mlib = w["out"] + "/ModelTest.kicad_sym"
msc = C("sym_create", {"libraryPath": mlib, "name": "MT", "pins": [{"number": 1, "name": "A"}, {"number": 2, "name": "B"}]}, 60)
am = C("add_models", {"libraryPath": mlib, "symbolName": "MT", "models": [{"name": "R_0603_1608Metric", "pcblib": "Resistor_SMD"}, {"name": "R_0805_2012Metric"}], "params": {"MPN": "X1"}, "comment": "10k"}, 60)
smi = C("sym_info", {"libraryPath": mlib, "name": "MT"}, 60)
own("add_models", {"create": msc, "add": am, "fields": smi.get("fields")}, ok(am) and (am.get("data") or {}).get("models") == 2 and (smi.get("fields") or {}).get("Footprint") == "Resistor_SMD:R_0603_1608Metric" and (smi.get("fields") or {}).get("ki_fp_filters") == "R_0603_1608Metric R_0805_2012Metric",
"the footprint link and filters were not written")
sm = C("set_models", {"libraryPath": mlib, "symbolName": "MT", "footprint": "Resistor_SMD:R_0805_2012Metric"}, 60)
smi2 = C("sym_info", {"libraryPath": mlib, "name": "MT"}, 60)
own("set_models", sm, ok(sm) and sm.get("removed") == 1 and (smi2.get("fields") or {}).get("Footprint") == "Resistor_SMD:R_0805_2012Metric", "the footprint link was not replaced")
osch = C("open_document", {"filePath": xsch}, 200)
sv = C("save", {"filePath": xsch}, 120)
sv2 = C("save", {"filePath": w["out"] + "/nothing-open-here.kicad_sch"}, 60)
own("save", {"open": osch.get("success"), "sheet": sv, "notOpen": sv2}, ok(sv) and sv.get("saved") is True and (sv.get("data") or {}).get("process") and ok(sv2) and sv2.get("saved") is False, "the open sheet was not saved, or a document not open was not told apart")
cd1 = C("close_document", {"path": xsch}, 60)
cd2 = C("close_document", {"path": xsch}, 60)
own("close_document", {"first": cd1, "second": cd2}, ok(cd1) and (cd1.get("data") or {}).get("closed") is True and ok(cd2) and (cd2.get("data") or {}).get("closed") is False and "not open" in str((cd2.get("data") or {}).get("note")), "the sheet did not close, or closing it again did not say it was not open")
# 1.0.100: the verbs ruled on #29, with KiCad still up on the scratch board
cr = C("cancel_run", {}, 30)
own("cancel_run", cr, ok(cr) and cr.get("cancelRequested") is False and cr.get("reason"), "with nothing running it did not say so")
ext = C("extensions", {"action": "list", "checkUpdates": False}, 60)
ext2 = C("extensions", {"action": "nope"}, 30)
own("extensions", {"list": {"count": ext.get("count")}, "bad": ext2.get("errorCode")}, ok(ext) and isinstance(ext.get("extensions"), list) and ext.get("count") == len(ext.get("extensions")) and ext2.get("errorCode") == "bad_args", "the plugin list did not come back")
prf = C("preferences", {"key": "kicad_common"}, 30)
prs = C("preferences", {"set": {"kicad_common.adom_verb_test": 1}}, 30)
own("preferences", {"read": list((prf.get("current") or {}).keys()), "set": prs.get("reason")}, ok(prf) and isinstance((prf.get("current") or {}).get("kicad_common"), dict) and prs.get("reason") == "tool_running", "the settings did not read, or set was not refused while KiCad runs")
pl = C("place_parts", {"filePath": xpcb, "parts": [{"library": "Resistor_SMD", "footprint": "R_0603_1608Metric", "ref": "R", "x": 150, "y": 90}], "validate": False}, 200)
pst = ab(a.target, "kicad_routing_state", {"filePath": xpcb, "model": "standard"}, timeout=120)
own("place_parts", {"reply": {k: pl.get(k) for k in ("results", "placed", "errorCode", "error")}, "parts": (pst.get("counts") or {}).get("components")}, ok(pl) and pl.get("results") == ["ok"] and len(pl.get("placed") or []) == 1 and abs((pl["placed"][0].get("y") or 0) + 90) < 0.01, "the part did not land where asked")
et = C("edit_tracks", {"filePath": xpcb, "tracks": [{"id": "not-a-track", "width": 0.5}]}, 60)
own("edit_tracks", {"reply": {k: et.get(k) for k in ("success", "errorCode", "error")}}, et.get("success") is False and et.get("errorCode") and "tracks" not in str(et.get("error")), "a track that does not exist was not refused by name (edit_drill.py proves a real edit)")
cad = C("close_all_documents", {"discardUnsaved": True}, 180)
left = ab(a.target, "kicad_window_info", {}, timeout=60).get("editors") or []
own("close_all_documents", {"reply": cad, "editorsLeft": [e.get("title") for e in left]}, ok(cad) and "rp2040-breakout" in (cad.get("closed") or []) and not [e for e in left if "PCB Editor" in str(e.get("title")) or "Schematic Editor" in str(e.get("title"))], "the scratch board was not closed")
ab(a.target, "kicad_close", {"force": True, "reason": "kicad-bridge verb test: close the scratch board open_document opened"}, timeout=120)
op = C("open_project", {"filePath": xd + "/rp2040-breakout.kicad_pro"}, 200)
own("open_project", op, ok(op) and (op.get("data") or {}).get("objectKind") == "Project", "the scratch project did not open in the project manager")
ab(a.target, "kicad_close", {"force": True, "reason": "kicad-bridge verb test: close the scratch project open_project opened"}, timeout=120)
im = C("import_design", {"format": "eagle", "filePath": base + "/import/divider.brd", "overwrite": True}, 300)
io = C("import_design", {"format": "orcad", "filePath": base + "/import/divider.brd"}, 60)
own("import_design", {"eagle": im, "orcad": io}, ok(im) and im.get("parts") == 3 and im.get("nets") == 3 and im.get("schematicImported") is False
and str(im.get("design", "")).endswith("divider.kicad_pro") and io.get("errorCode") == "not_supported",
"the EAGLE divider did not come over as 3 parts on 3 nets, or orcad was not refused by name")
pv = C("plate_vias", {"libraryPath": w["out"] + "/LandTest.pretty", "dryRun": True}, 120)
own("plate_vias", pv, ok(pv) and pv.get("plated") == 0 and isinstance(pv.get("holes"), int), "the plate_vias dry run did not answer")
nb = C("normalize_bodies", {"libraryPath": w["out"] + "/LandTest.pretty", "dryRun": True}, 120)
own("normalize_bodies", nb, ok(nb) and nb.get("moved") == [], "a body the land-pattern verb seated on its pads was moved")
kbase = ((ab(a.target, "kicad_readiness", {}, timeout=60).get("installs") or [{}])[0]).get("baseDir") or "C:/Program Files/KiCad/10.0"
m1 = C("symbol_mode", {"libraryPath": kbase + "/share/kicad/symbols/74xx.kicad_sym", "symbol": "74LS00", "mode": 1}, 120)
own("symbol_mode", m1, ok(m1) and m1.get("modes") == [0, 1] and str(m1.get("png") or "").endswith(".png"), "74LS00's De Morgan body was not plotted")
# 1.0.91 (#29): a scratch symbol library: two identical symbols (cleanup_libs finds the pair), a De Morgan body
# KiCad then plots as mode 1, and art fitted inside the body
sl = w["out"] + "/Modes.kicad_sym"
for nm in ("NAND_A", "NAND_B"):
C("sym_create", {"libraryPath": sl, "name": nm, "reference": "U", "overwrite": True,
"pins": [{"number": 1, "name": "A"}, {"number": 2, "name": "B"}, {"number": 3, "name": "Y", "side": "right"}]}, 60)
cl = C("cleanup_libs", {"libraryPath": sl}, 60)
own("cleanup_libs", cl, ok(cl) and cl.get("plan") is True and ["NAND_A", "NAND_B"] in (cl.get("duplicates") or []), "the two identical symbols were not found as duplicates")
bm = C("build_symbol_modes", {"libraryPath": sl, "symbolName": "NAND_A", "graphics": [{"circle": {"center": [0, 0], "radius": 2.54}, "fill": "background"}],
"pinShapes": {"1": "inverted", "2": "inverted"}, "replace": True}, 60)
bm1 = C("symbol_mode", {"libraryPath": sl, "symbol": "NAND_A", "mode": 1}, 120)
own("build_symbol_modes", {"build": bm, "plot": bm1}, ok(bm) and bm.get("units") == [1] and ok(bm1) and bm1.get("modes") == [0, 1], "KiCad did not plot the alternate body written")
art = C("add_symbol_art", {"libraryPath": sl, "symbolName": "NAND_B", "art": [[[0, 0], [40, 0], [40, 20], [0, 20], [0, 0]]]}, 60)
own("add_symbol_art", art, ok(art) and art.get("polylines") == 1 and isinstance(art.get("bodyRectMm"), list), "the art was not drawn in the body")
# 1.0.88: the standard's sch_zoom: where parts sit on a sheet and KiCad's plot framed on them
sz = C("sch_zoom", {"sheet": w["sch"], "designators": ["U1", "C1", "ZZ9"]}, 240)
own("sch_zoom", sz, ok(sz) and {p.get("designator") for p in sz.get("parts") or []} == {"U1", "C1"} and sz.get("notOnSheet") == ["ZZ9"]
and str((sz.get("shot") or {}).get("png") or "").endswith(".png") and 1000 <= ((sz.get("shot") or {}).get("width") or 0) <= 1400,
"the parts were not found, ZZ9 was not reported missing, or no framed plot came back")
# 1.0.84: the standard's review_board (engine 0.17.2): Board Setup's rules and the sheet go in, no rule class reads as
# missing on a KiCad board, and the rp2040 sample's 12 PIN** test points are a real duplicate designator
rb = C("review_board", {"filePath": w["board"]}, 300)
# 1.0.86: KiCad's per-pin unconnected-(...) nets are no net, so a board full of unused MCU pins has no one-pad nets
rbc = C("review_board", {"filePath": CK}, 300)
own("review_board", rb, ok(rb) and isinstance(rb.get("findings"), list) and rb.get("engine")
and not any(x.get("check") == "missing_critical_rules" for x in rb.get("findings") or [])
and any(x.get("check") == "duplicate_designators" and x.get("designator") == "PIN**" for x in rb.get("findings") or [])
and ok(rbc) and not any(str(x.get("net") or "").startswith("unconnected-(") for x in rbc.get("findings") or []),
"no review, a rule class read as missing, or the duplicate PIN** designator was not found")
bad = C("simulate", {"schPath": rc, "analysis": ".dc V9 0 1 0.1"}, 200)
# 1.0.85 (#23 notSimulated): a connector with no model on the RC's output is left out and named; a sheet of
# only unmodelled parts refuses no_model
rcj = w["out"] + "/sim/rcj.kicad_sch"
C("build_schematic", {"schPath": rcj, "overwrite": True, "title": "RC+J", "parts": [{"ref": "V1", "symbol": "Simulation_SPICE:VSIN"},
{"ref": "R1", "symbol": "Device:R", "comment": "1k"}, {"ref": "C1", "symbol": "Device:C", "comment": "100n"}, {"ref": "J1", "symbol": "Connector_Generic:Conn_01x02"}],
"nets": {"IN": ["V1.1", "R1.1"], "OUT": ["R1.2", "C1.1", "J1.1"], "GND": ["V1.2", "C1.2", "J1.2"]}}, 400)
part = C("simulate", {"schematic": rcj, "analysis": {"type": "op"}, "probes": [{"net": "OUT"}]}, 300)
jo = w["out"] + "/sim/jonly.kicad_sch"
C("build_schematic", {"schPath": jo, "overwrite": True, "title": "J", "parts": [{"ref": "J1", "symbol": "Connector_Generic:Conn_01x02"}, {"ref": "J2", "symbol": "Connector_Generic:Conn_01x02"}],
"nets": {"A": ["J1.1", "J2.1"], "B": ["J1.2", "J2.2"]}}, 400)
none = C("simulate", {"schematic": jo, "analysis": {"type": "op"}}, 300)
# 1.0.85 (#23 sweep): R1 at 1k and 2k halves the RC's corner; three temperatures are three runs; an unknown part
# is refused, never one run passed off as a sweep
sw = C("simulate", {"schematic": rc, "analysis": {"type": "ac", "sweep": "dec", "points": 20, "start": 10, "stop": 100000}, "probes": [{"net": "OUT"}],
"sweep": {"parameter": "R1", "start": 1000, "stop": 2000, "step": 1000}, "points": 500}, 300)
def corner(sg):
f, db = sg.get("x") or [], sg.get("y") or []
for i in range(len(min(f, db, key=len)) - 1):
if db[i] > -3.0 >= db[i + 1] and f[i] > 0 and f[i + 1] > 0:
t = (-3.0 - db[i]) / (db[i + 1] - db[i])
return 10 ** (math.log10(f[i]) + t * (math.log10(f[i + 1]) - math.log10(f[i])))
cs = {sg.get("step"): corner(sg) for sg in sw.get("signals") or []}
# a diode's forward voltage falls about 2 mV/°C, and doubling its emission coefficient (altermod) about doubles it
dio = w["out"] + "/sim/diode.kicad_sch"
C("build_schematic", {"schPath": dio, "overwrite": True, "title": "D", "parts": [{"ref": "V1", "symbol": "Simulation_SPICE:VDC", "comment": "5"},
{"ref": "R1", "symbol": "Device:R", "comment": "1k"}, {"ref": "D1", "symbol": "Simulation_SPICE:D"}],
"nets": {"IN": ["V1.1", "R1.1"], "A": ["R1.2", "D1.2"], "GND": ["V1.2", "D1.1"]}}, 400)
tt = C("simulate", {"schematic": dio, "analysis": {"type": "op"}, "probes": [{"net": "A"}], "temperatures": {"start": -10, "stop": 60, "step": 35}}, 300)
tv = [((sg.get("y") or [0])[0]) for sg in tt.get("signals") or []]
nn = C("simulate", {"schematic": dio, "analysis": {"type": "op"}, "probes": [{"net": "A"}], "sweep": {"parameter": "D1.N", "start": 1, "stop": 2, "step": 1}}, 300)
nv = [((sg.get("y") or [0])[0]) for sg in nn.get("signals") or []]
nosuch = C("simulate", {"schematic": rc, "analysis": {"type": "op"}, "sweep": {"parameter": "R9", "start": 1, "stop": 2, "step": 1}}, 300)
# 1.0.87 (#23): the RC's noise is its resistor's: sqrt(4kTR) = 4.07 nV/rtHz, sqrt(kT/C) = 0.2036 uV in all; Monte
# Carlo draws R1 and C1 per run, the same seed the same values; a transistor tolerance is refused
nz = C("simulate", {"schematic": rc, "analysis": {"type": "noise", "source": "V1", "output": "OUT", "sweep": "dec", "points": 20, "start": 1, "stop": 1e9}}, 300)
on = next((sg for sg in nz.get("signals") or [] if sg.get("signal") == "onoise"), {})
mcq = {"schematic": rc, "analysis": {"type": "ac", "sweep": "dec", "points": 1, "start": 1000, "stop": 1000}, "probes": [{"net": "OUT"}],
"monteCarlo": {"runs": 3, "distribution": "gaussian", "seed": 42, "tolerances": {"resistor": 5, "capacitor": 10}}}
mc1, mc2 = C("simulate", mcq, 300), C("simulate", mcq, 300)
steps = [sg.get("step") for sg in mc1.get("signals") or []]
mcbad = C("simulate", {"schematic": rc, "analysis": {"type": "op"}, "monteCarlo": {"runs": 2, "tolerances": {"transistor": 5}}}, 300)
own("simulate", {"ac": ac, "bad": bad, "partly": part, "none": none}, ok(ac) and sig.get("signal") == "V(OUT)" and sig.get("yUnit") == "dB" and str(ac.get("engine") or "").startswith("ngspice ") and f3 is not None and abs(f3 - 1591.5) / 1591.5 < 0.02 and ac.get("csvPath") and bad.get("errorCode") == "simulation_failed"
and ac.get("notSimulated") == [] and ok(part) and part.get("notSimulated") == ["J1"] and none.get("errorCode") == "no_model"
and ok(sw) and sw.get("runs") == 2 and cs.get("R1 = 1k") and cs.get("R1 = 2k") and abs(cs["R1 = 1k"] / cs["R1 = 2k"] - 2.0) < 0.05
and ok(tt) and [sg.get("step") for sg in tt.get("signals") or []] == ["T = -10", "T = 25", "T = 60"] and len(tv) == 3 and tv[0] > tv[1] > tv[2] > 0.4
and ok(nn) and len(nv) == 2 and 1.8 < nv[1] / max(nv[0], 1e-9) < 2.2 and nosuch.get("errorCode") == "bad_args"
and ok(nz) and on.get("yUnit") == "V/rtHz" and abs((on.get("y") or [0])[0] / 4.0714e-9 - 1) < 0.01
and abs(((nz.get("totals") or {}).get("onoise") or 0) / 2.036e-7 - 1) < 0.02
and ok(mc1) and len(steps) == 3 and all("R1 = " in (x or "") and "C1 = " in (x or "") for x in steps)
and steps == [sg.get("step") for sg in mc2.get("signals") or []] and mcbad.get("errorCode") == "not_supported",
f"the RC's -3 dB point was {f3} Hz, not about 1592, a failing analysis did not fail, the unmodelled connector was not named (notSimulated / no_model), or the sweep, noise or Monte Carlo went wrong (corners {cs}, diode {tv} over temperature, {nv} over N, noise totals {nz.get('totals')}, runs {steps})")
# 1.0.82: the app tier where KiCad answers: licence, KiCad's own Python (pcbnew), the dialog log, foreground (its refusal
# only: a real foreground would be a grab this battery counts against the bridge)
r = C("license_status", {}); own("license_status", r, ok(r) and r.get("license") == "GPL-3.0-or-later" and r.get("seatRequired") is False)
r = C("eval", {"filePath": CK, "code": "result = {'footprints': len(board.GetFootprints()), 'version': pcbnew.GetBuildVersion()}"}, 200)
bad = C("eval", {"code": "result = 1/0"}, 200)
own("eval", {"ok": r, "bad": bad}, ok(r) and ((r.get("result") or {}).get("footprints") or 0) > 100 and bad.get("errorCode") == "eval_failed" and "ZeroDivisionError" in (bad.get("stderr") or ""),
"pcbnew did not load the board, or a failing snippet did not fail")
scr = w["out"] + "/scripts/count_footprints.py"
import tempfile as _tfs
sf = Path(_tfs.gettempdir()) / "count_footprints.py"
sf.write_text("import sys, pcbnew\nprint(len(pcbnew.LoadBoard(sys.argv[1]).GetFootprints()))\n")
ab(a.target, "send_files", {"filePaths": [str(sf)], "dest": w["out"] + "/scripts"}, timeout=120)
r = C("run_script", {"script": scr, "args": [CK]}, 200)
own("run_script", r, ok(r) and r.get("exitCode") == 0 and (r.get("stdout") or "").strip().isdigit() and int(r["stdout"].strip()) > 100, "KiCad's Python did not run the script on the board")
r = C("dialog_log", {"limit": 5}); own("dialog_log", r, ok(r) and isinstance(r.get("entries"), list))
# sent straight (C adds the battery's own long reason, which would pass the check)
r = ab(a.target, "kicad_foreground", {"reason": "short"}, timeout=60)
own("foreground", r, r.get("errorCode") == "reason_required", "foreground was not refused without a real reason")
pr = C("plan_route", {"filePath": CK, "source": "file", "net": "/DRV_GHC", "from": "R12.1", "to": "U4.13"}, 200)
rn = pr.get("routeNet") or {}
own("plan_route", pr, ok(pr) and eng(pr) and pr.get("found") is True and (pr.get("planner") or {}).get("widthFrom") and isinstance(pr.get("crossesPours"), list) and rn.get("expectedRevision") == pr.get("revision") and rn.get("filePath") and rn.get("paths") and rn["paths"][0][0] == {"pad": "R12.1"},
"no route planned for /DRV_GHC, or routeNet is not ready for kicad_route_net")
r = C("panelize", {"pcbPath": w["board"], "panelPath": w["out"] + "/panel/panel-tabs.kicad_pcb", "rows": 2, "cols": 2, "separation": "tabs", "overwrite": True}, 300)
record("panelize_tabs", r, (ok(r) and (r.get("separation") or {}).get("kind") == "tabs" and (r.get("separation") or {}).get("holes", 0) > 0) or r.get("errorCode") == "not_rectangular",
"no mouse-bite panel")
RT = base + "/checks/route-fixture.kicad_pcb"
lens = C("net_lengths", {"filePath": RT, "source": "file"})
routed = next((n for n in (lens.get("nets") or []) if (n.get("lengthMm") or 0) > 5), None)
if routed:
r = C("length_tune", {"filePath": RT, "source": "file", "net": routed["net"], "targetMm": routed["lengthMm"] + 2.0}, 300)
after = C("net_lengths", {"filePath": RT, "source": "file", "nets": [routed["net"]]})
got = ((after.get("nets") or [{}])[0]).get("lengthMm") or 0
own("length_tune", {"tune": r, "after": after}, ok(r) and r.get("changed") is True and abs(got - routed["lengthMm"] - 2.0) < 0.05 and (r.get("drc") or {}).get("newErrors") == 0,
f"the net is {got} mm after tuning to {routed['lengthMm'] + 2.0}")
else:
own("length_tune", lens, False, "the route fixture has no routed net over 5 mm")
# 1.0.69: component classes with a room, and the silkscreen prep, both on the closed route fixture
r = C("component_classes", {"filePath": RT, "source": "file", "name": "Probe", "members": ["R1", "U1"], "room": True})
lst = C("component_classes", {"filePath": RT, "source": "file"})
drc = ab(a.target, "kicad_run_drc", {"filePath": RT}, timeout=200)
asserts = [v for v in ((drc.get("data") or {}).get("violations") or []) if v.get("type") == "assertion_failure"]
gone = C("component_classes", {"filePath": RT, "source": "file", "name": "Probe", "remove": True})
own("component_classes", {"make": r, "list": lst, "remove": gone}, ok(r) and r.get("missing") == [] and r.get("roomMm") and any(c.get("name") == "Probe" and len(c.get("members") or []) == 2 for c in lst.get("classes") or [])
and not asserts and ok(gone) and not gone.get("classes"), "the class and its room were not made, held in DRC, or removed")
r = C("silkscreen_prep", {"filePath": RT, "source": "file"}, 600)
own("silkscreen_prep", r, ok(r) and r.get("gerbersClipSilk") is True and (r.get("after") or {}).get("silkToSolderMask") is not None, "no prep, or no DRC counts after")
# 1.0.70: a schematic drawn from a netlist, checked against KiCad's own netlist of it
r = C("build_schematic", {"schPath": w["out"] + "/divider/divider.kicad_sch", "overwrite": True, "title": "Divider",
"parts": [{"ref": "R1", "symbol": "Device:R", "comment": "10k"}, {"ref": "R2", "symbol": "Device:R", "comment": "10k"}],
"nets": {"VIN": ["R1.1"], "MID": ["R1.2", "R2.1"], "GND": ["R2.2"]}}, 400)
own("build_schematic", r, ok(r) and r.get("placed") == 2 and r.get("mismatches") == [], "the drawn sheet's netlist does not match the nets given")
r = C("silk_designators", dict(fb, size=1.0), 400)
own("silk_designators", r, ok(r) and (r.get("placed") or 0) > 0 and r.get("onPadsAfter") == [] and (r.get("drc") or {}).get("newErrors") == 0,
"the labels were not written into the closed board after a clean DRC, or one is on copper")
live_phase()
k161_phase()
std_phase()
checks_phase()
# phase 7: clean up EVERYTHING the suite created on the box (John 2026-08-14)
if not only and not a.skip_gui:
cr = ab(a.target, "kicad_uninstall", {"testResidue": True}, timeout=120)
print(f"[7] cleanup: {cr.get('summary') or cr.get('error') or cr.get('status')}")
results["_cleanup"] = {"result": "PASS" if ok(cr) else "FAIL",
"response_head": json.dumps(cr)[:400]}
# skips recorded for non-phase entries
for verb, spec in M.items():
if spec.get("skip") and verb not in results:
results[verb] = {"result": "SKIP", "reason": spec["skip"]}
# Foreground GRABS, from the bridge's own focus log. fg_assert above only sees who is in front one
# second after a verb, so a KiCad window that took the foreground and was bounced within
# milliseconds passed as "zero steals" while it interrupted John's video 30 times in one run
# (AdomLapper, 2026-09-21). A bounce is still a grab: the user saw it. Count every one.
grabs = []
if not a.skip_gui:
fs = ab(a.target, "kicad_state", {"includeImages": False}, timeout=90)
for e in (fs.get("focusEvents") or []):
act = str(e.get("action") or "")
if "steal" in act and float(e.get("t") or 0) >= t0 - 5:
grabs.append({"action": act, "title": e.get("title"), "exe": e.get("exe"),
"secondsSinceUserInput": e.get("secondsSinceUserInput")})
if grabs and not guard_on:
grabs = [] # guard off: nothing is bounced, so nothing to count
if grabs:
by = {}
for g in grabs:
by[g["title"]] = by.get(g["title"], 0) + 1
print(f"FOREGROUND GRABS: {len(grabs)} KiCad window(s) took the foreground and were bounced: {by}")
fails = [v for v, r in results.items() if r["result"] == "FAIL"]
passes = [v for v, r in results.items() if r["result"] == "PASS"]
skips = [v for v, r in results.items() if r["result"] == "SKIP"]
report = {
"target": a.target, "caller": AI_THREAD, "started": datetime.now(timezone.utc).isoformat(), "tested": tested,
"elapsed_s": round(time.time() - t0, 1),
"totals": {"pass": len(passes), "fail": len(fails), "skip": len(skips)},
"coverage_gap": missing, "failures": fails, "results": results,
"foreground_violations": fg_violations,
"foreground_grabs": grabs,
"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]}",
f"Bridge {tested.get('bridge')} on KiCad {tested.get('kicad')}.",
f"**{len(passes)} pass / {len(fails)} fail / {len(skips)} skip** in {report['elapsed_s']}s", ""]
for v in sorted(results):
r = results[v]
md.append(f"- `kicad_{v}` — **{r['result']}**"
+ (f" ({r.get('reason')})" if r.get("reason") else ""))
if missing:
md.append(f"\n**COVERAGE GAP** (live verbs with no test/skip): {missing}")
if grabs:
md.append(f"\n**FOREGROUND GRABS** ({len(grabs)}): a KiCad window took the foreground and was bounced within "
f"milliseconds. The user sees every one. The run is RED until this is zero.")
if fg_violations:
md.append(f"\n**FOREGROUND VIOLATIONS** (KiCad window on top after a verb): {fg_violations}")
(outdir / "report.md").write_text("\n".join(md))
print(f"\n{len(passes)} pass / {len(fails)} fail / {len(skips)} skip"
f" -> {outdir}/report.md")
return 1 if (fails or missing or grabs) else 0
if __name__ == "__main__":
sys.exit(main())