Compare commits

...

36 Commits

Author SHA1 Message Date
mixelpixx
99b1d8a168 fix: include KiCad 10 Windows install paths in library auto-discovery
Some checks failed
CI/CD Pipeline / TypeScript Build & Test (20.x, macos-latest) (push) Has been cancelled
CI/CD Pipeline / TypeScript Build & Test (20.x, ubuntu-22.04) (push) Has been cancelled
CI/CD Pipeline / TypeScript Build & Test (20.x, ubuntu-24.04) (push) Has been cancelled
CI/CD Pipeline / TypeScript Build & Test (20.x, windows-latest) (push) Has been cancelled
CI/CD Pipeline / TypeScript Build & Test (22.x, macos-latest) (push) Has been cancelled
CI/CD Pipeline / TypeScript Build & Test (22.x, ubuntu-22.04) (push) Has been cancelled
CI/CD Pipeline / TypeScript Build & Test (22.x, ubuntu-24.04) (push) Has been cancelled
CI/CD Pipeline / TypeScript Build & Test (22.x, windows-latest) (push) Has been cancelled
CI/CD Pipeline / Python Tests (ubuntu-22.04, 3.10) (push) Has been cancelled
CI/CD Pipeline / Python Tests (ubuntu-22.04, 3.11) (push) Has been cancelled
CI/CD Pipeline / Python Tests (ubuntu-22.04, 3.12) (push) Has been cancelled
CI/CD Pipeline / Python Tests (ubuntu-22.04, 3.9) (push) Has been cancelled
CI/CD Pipeline / Python Tests (ubuntu-24.04, 3.10) (push) Has been cancelled
CI/CD Pipeline / Python Tests (ubuntu-24.04, 3.11) (push) Has been cancelled
CI/CD Pipeline / Python Tests (ubuntu-24.04, 3.12) (push) Has been cancelled
CI/CD Pipeline / Python Tests (ubuntu-24.04, 3.9) (push) Has been cancelled
CI/CD Pipeline / Integration Tests (Linux + KiCAD) (10.0) (push) Has been cancelled
CI/CD Pipeline / Integration Tests (Linux + KiCAD) (8.0) (push) Has been cancelled
CI/CD Pipeline / Integration Tests (Linux + KiCAD) (9.0) (push) Has been cancelled
CI/CD Pipeline / Docker Build Test (push) Has been cancelled
CI/CD Pipeline / Code Quality (push) Has been cancelled
CI/CD Pipeline / Documentation Check (push) Has been cancelled
Fixes #245 — _find_kicad_footprint_dir() only listed the 9.0/8.0 Windows
install paths, so a Windows host with only KiCad 10 installed got None and
footprint auto-discovery found nothing. The same gap existed for symbols in
library_symbol.py and dynamic_symbol_loader.py.

- Add C:/Program Files/KiCad/10.0/share/kicad/{footprints,symbols} to the
  candidate lists (10.0 first, matching the existing newest-first order)
- Honor KICAD10_FOOTPRINT_DIR and KICAD10_SYMBOL_DIR env overrides alongside
  the existing 9/8 ones
- Regression tests in tests/test_kicad10_paths.py
2026-06-12 16:06:11 -04:00
mixelpixx
48f2d09fdd Merge pull request #214 from mixelpixx/refactor/extract-schematic-handlers-pr
refactor: extract schematic handlers into SchematicHandlersMixin (kicad_interface.py 6.7k→3.7k)
2026-06-12 12:50:36 -04:00
mixelpixx
263195a07d Merge pull request #240 from ThomasBenjaminCook/issue-105-upstream-clean
Closes #105 — Linux KiCad 8/9/10 real-pcbnew smoke matrix. Thanks @ThomasBenjaminCook.
2026-06-12 12:37:06 -04:00
Thomas Cook
fcf23f1965 ci: add Linux KiCad real-pcbnew matrix
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-12 12:35:36 -04:00
mixelpixx
a094d1b937 Merge pull request #236 from boovaragan/chore/ts-test-scaffolding-and-version-sync
chore: add Vitest scaffolding and sync package.json to v2.2.3
2026-06-12 12:33:50 -04:00
mixelpixx
602d76af23 refactor: extract schematic handlers into SchematicHandlersMixin
kicad_interface.py had grown to 8,596 lines. Most domains already delegate to
classes in commands/, but the schematic domain (43 _handle_* methods + the
schematic-only _svg_to_png helper, ~3,100 lines) was still inline. Move it into
commands/schematic_handlers.py as SchematicHandlersMixin and have KiCADInterface
inherit from it.

Pure mechanical relocation — no logic change:
- Methods stay bound to the same instance via the mixin, so every self.board,
  self._auto_save_board(), cross-handler call, and the command_routes table
  resolve exactly as before.
- Verified all 159 functions/methods (116 kept + 43 moved + _svg_to_png) are
  AST-identical between the original and the split.
- tests/test_sync_schematic_to_board_footprints.py patch targets updated to
  follow the moved module (commands.schematic_handlers.pcbnew).
- Full suite at the exact current main baseline (8 failed / 1044 passed; the 8
  are the pre-existing Java-not-installed freerouting/autoroute set).

Regenerated against main after the June merge wave (#226-229, #231-234, #238,
#252, #253); deliberately excludes the 19 _handle_export_* kicad-cli handlers
from #253 and the datasheet delegators.
2026-06-12 12:29:46 -04:00
mixelpixx
07bad447c1 Merge pull request #253 from gcolonese/feat/add-remaining-export-options
feat: add kicad-cli export tools (19 fabrication/3D/schematic formats)
2026-06-12 12:22:21 -04:00
mixelpixx
7fa6c350f4 Merge pull request #252 from gcolonese/feat-set-footprint-type
Note: get_component_properties 'attributes' response changed from raw bitmask ints to {type: smd|through_hole|unspecified, exclude_from_pos_files, exclude_from_bom, not_in_schematic}. Cleaner shape for AI consumers; callers parsing the old integer keys must update.
2026-06-12 12:18:25 -04:00
mixelpixx
10b9736604 Merge pull request #232 from Jud/fix/issue-185-netclass-kicad-pro-persistence
Closes #185 — net classes created via create_netclass now persist to .kicad_pro (atomic write, preserves unrelated project settings). Thanks @Jud.
2026-06-12 12:13:18 -04:00
mixelpixx
4db886e15a Merge pull request #233 from Jud/fix/board-view-frame-to-board
fix(board-view): frame get_board_2d_view to the board, not the A4 sheet
2026-06-12 12:12:14 -04:00
Gavin Colonese
59aed24d05 refactor: use pathlib for file paths in new export handlers
CONTRIBUTING.md mandates pathlib.Path over os.path (added for
cross-platform/Linux support); the rest of the codebase's newer code
already follows it. Converts the 19 new _handle_export_* handlers to
pathlib (exists/mkdir/iterdir/is_file, expanduser().resolve()) so this
feature's new code is compliant. Tests updated to patch pathlib.Path.

Scope is limited to the new export-handler block; the pre-existing
os.path usage elsewhere in kicad_interface.py/export.py is untouched
here and will be migrated in a dedicated refactor PR to keep that
sweep reviewable in isolation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 11:17:57 -04:00
Gavin Colonese
12523c7575 feat: add set_footprint_type tool for placement attribute control
Adds a `set_footprint_type` MCP tool to set a placed footprint's
placement type (through_hole / smd / unspecified) plus the optional
exclude_from_pos_files / exclude_from_bom / not_in_schematic flags.
These attributes gate inclusion in pick-and-place (.pos) exports and
were previously unreachable: edit_component only handled
reference/value/footprint and get_component_properties did not report
the placement type at all.

- python/commands/component.py: SWIG (pcbnew) handler via
  Set/GetAttributes + the dedicated exclusion setters; extends
  get_component_properties to report a human-readable `type` plus the
  three exclusion flags.
- python/kicad_interface.py: IPC (kipy) handler setting
  attributes.mounting_style and the exclusion fields over the proto
  API with begin_commit/update_items/push_commit so changes are live in
  the KiCAD UI; registered in the command map, the IPC handler map, and
  the realtime/IPC whitelist; SWIG fallback on proto error.
- src/tools/component.ts: tool registration with zod schema.
- tests/test_set_footprint_type.py: 26 unit tests over both backends
  plus the extended get_component_properties response.
- pyproject.toml: add `fitz` (PyMuPDF) to mypy ignore_missing_imports
  overrides; pre-existing gap that fails mypy on any edit to files
  importing it (board/view.py, kicad_interface.py).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 11:07:45 -04:00
Gavin Colonese
605dac7fd3 test: add unit coverage for kicad-cli export handlers
Covers all 19 new _handle_export_* handlers: parametrized success /
kicad-cli-not-found / subprocess-failure paths, plus validation
(missing output, unresolvable board, missing schematicPath) and
detailed flag-construction assertions for gerbers/drill/ipc2581.
Subprocess + filesystem are mocked; no real kicad-cli or board touched.
64 tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 11:06:24 -04:00
Gavin Colonese
d48f4835b0 feat: add export_sch_python_bom tool (kicad-cli, legacy Python-BOM XML)
New MCP tool `export_sch_python_bom` wrapping `kicad-cli sch export
python-bom`: emits the legacy intermediate XML netlist consumed by the
schematic editor's Python BOM scripts. Minimal option set (outputPath +
schematicPath only, matching the subcommand's --help). schematicPath
required and validated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 11:00:57 -04:00
Gavin Colonese
91477e8d02 feat: add export_sch_ps tool (kicad-cli, schematic PostScript)
New MCP tool `export_sch_ps` wrapping `kicad-cli sch export ps`: outputs
one PS per page into a directory. Exposes drawing-sheet override, theme,
B&W, exclude-drawing-sheet, default-font, no-background-color, and page
selection. schematicPath required; directory output uses outputDir.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 11:00:31 -04:00
Gavin Colonese
c893509993 feat: add export_sch_hpgl tool (kicad-cli, schematic HPGL)
New MCP tool `export_sch_hpgl` wrapping `kicad-cli sch export hpgl`: outputs
one plot per page into a directory. Exposes drawing-sheet override,
exclude-drawing-sheet, default-font, page selection, pen size, and the
origin/scale mode. schematicPath required; directory output uses outputDir.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 11:00:05 -04:00
Gavin Colonese
cff3addc95 feat: add export_sch_dxf tool (kicad-cli, schematic DXF)
New MCP tool `export_sch_dxf` wrapping `kicad-cli sch export dxf`: outputs
one DXF per page into a directory. Exposes drawing-sheet override, theme,
B&W, exclude-drawing-sheet, default-font, and page selection.
schematicPath required; directory output uses outputDir.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 10:59:40 -04:00
Gavin Colonese
95f803900d feat: add export_sch_svg tool (kicad-cli, schematic SVG)
New MCP tool `export_sch_svg` wrapping `kicad-cli sch export svg`: outputs
one SVG per page into a directory. Exposes drawing-sheet override, theme,
B&W, exclude-drawing-sheet, default-font, no-background-color, and page
selection. schematicPath required; directory output uses outputDir.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 10:59:16 -04:00
Gavin Colonese
b257b7276d feat: add export_sch_pdf tool (kicad-cli, schematic PDF)
New MCP tool `export_sch_pdf` wrapping `kicad-cli sch export pdf`: exposes
the full option set (drawing-sheet override, theme, B&W,
exclude-drawing-sheet, default-font, the PDF property-popup /
hierarchical-link / metadata excludes, no-background-color, and page
selection). schematicPath is required and validated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 10:58:50 -04:00
Gavin Colonese
7210e0b59c feat: add export_sch_bom tool (kicad-cli, schematic BOM)
New MCP tool `export_sch_bom` wrapping `kicad-cli sch export bom`: exposes
the full BOM option set (presets, field/label lists, group-by, sort
field/direction, filter, exclude-DNP, include-excluded-from-BOM, and the
field/string/ref/ref-range delimiters, plus keep-tabs/keep-line-breaks).
schematicPath is required and validated. Self-contained interface handler.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 10:58:25 -04:00
Gavin Colonese
f23f1f658a feat: add export_3d_cli tool (kicad-cli, format-dispatched 3D export)
New MCP tool `export_3d_cli` wrapping the kicad-cli 3D subcommands. A
`format` enum {step,glb,stl,ply,brep,xao,vrml} selects the subcommand and
the handler forwards only flags valid for that subcommand: the shared
geometry/include set for step/glb/stl/ply/brep/xao, no-optimize-step for
STEP only, and units + models-dir/models-relative for VRML. Rich CLI
sibling of the existing export_3d / export_vrml (left untouched). Reads
the saved .kicad_pcb.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 10:58:01 -04:00
Gavin Colonese
0faef07650 feat: add export_gerber_single tool (kicad-cli, single-file Gerber)
New MCP tool `export_gerber_single` wrapping `kicad-cli pcb export gerber`:
plots the selected layers to a SINGLE Gerber file (singular sibling of
export_gerbers). Exposes the full single-file Plot option set (X2, netlist
attributes, the four DNP fab-layer modes, soldermask subtraction, aperture
macros, drill-file origin, precision, Protel extension). Reads the saved
.kicad_pcb. Note: this subcommand is deprecated in KiCad 9.0 (still exits 0).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 10:57:35 -04:00
Gavin Colonese
ce6cff1b39 feat: add export_pcb_dxf tool (kicad-cli, full layer-plot DXF)
New MCP tool `export_pcb_dxf` wrapping `kicad-cli pcb export dxf`: exposes
the full layer-plot surface (layer/common-layer lists, refdes/value
exclusion, soldermask subtraction, use-contours, use-drill-origin,
border+title, output-units, the four DNP fab-layer modes, drill-shape, and
the single/multi output modes). Reads the saved .kicad_pcb.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 10:57:09 -04:00
Gavin Colonese
494c2e1ffb feat: add export_pcb_svg tool (kicad-cli, full layer-plot SVG)
New MCP tool `export_pcb_svg` wrapping `kicad-cli pcb export svg`: exposes
the full layer-plot surface (layer/common-layer lists, mirror, soldermask
subtraction, negative, B&W, theme, the four DNP fab-layer modes,
page-size-mode, fit-page-to-board, exclude-drawing-sheet, drill-shape, and
the single/multi output modes). Rich CLI sibling of the existing SWIG
export_svg (left untouched). Reads the saved .kicad_pcb.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 10:56:41 -04:00
Gavin Colonese
f491df4eea feat: add export_pcb_pdf tool (kicad-cli, full layer-plot PDF)
New MCP tool `export_pcb_pdf` wrapping `kicad-cli pcb export pdf`: exposes
the full layer-plot surface (layer/common-layer lists, mirror,
refdes/value exclusion, border+title, soldermask subtraction, the four
DNP fab-layer modes, negative, B&W, theme, drill-shape, and the
single/separate/multipage output modes). Rich CLI sibling of the existing
SWIG export_pdf (left untouched). Reads the saved .kicad_pcb.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 10:56:17 -04:00
Gavin Colonese
f6d38f1942 feat: add export_pos tool (kicad-cli, full placement-file option set)
New MCP tool `export_pos` wrapping `kicad-cli pcb export pos`: exposes
side, format, units, bottom-negate-X, drill-file origin, SMD-only,
exclude-through-hole, exclude-DNP and gerber-board-edge. Rich CLI sibling
of the existing SWIG-limited export_position_file (left untouched).
Self-contained interface handler reading the saved .kicad_pcb.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 10:55:50 -04:00
Gavin Colonese
ab7a2101af feat: add export_gencad tool (kicad-cli, GenCAD assembly/test format)
New MCP tool `export_gencad` wrapping `kicad-cli pcb export gencad`:
exposes define-var plus the flip-bottom-pads, unique-pins,
unique-footprints, use-drill-origin and store-origin-coord flags.
Self-contained interface handler reading the saved .kicad_pcb.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 10:55:26 -04:00
Gavin Colonese
cdaa1eb635 feat: add export_ipcd356 tool (kicad-cli, IPC-D-356 netlist)
New MCP tool `export_ipcd356` wrapping `kicad-cli pcb export ipcd356`:
generates an IPC-D-356 bare-board electrical-test netlist for flying-probe
and bed-of-nails testers. Minimal option set (outputPath + boardPath only,
matching the subcommand's --help). Self-contained interface handler reading
the saved .kicad_pcb.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 10:55:02 -04:00
Gavin Colonese
0f07ccd593 feat: add export_odb tool (kicad-cli, ODB++ archive)
New MCP tool `export_odb` wrapping `kicad-cli pcb export odb`: precision,
compression mode (zip/tgz/none), and units. Single ODB++ job archive for
CAM/MES/assembly. Self-contained interface handler reading the saved
.kicad_pcb.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 10:54:37 -04:00
Gavin Colonese
88226d567b feat: add export_ipc2581 tool (kicad-cli, with BOM column mapping)
New MCP tool `export_ipc2581` wrapping `kicad-cli pcb export ipc2581`:
precision, compression, standard version, units, and the five
--bom-col-* field mappings (internal id, mfg P/N, mfg, distributor P/N,
distributor) that control the BOM part data embedded in the IPC-2581
file — directly useful for MES/assembly imports that need a real
internal P/N rather than the description. Self-contained interface
handler reading the saved .kicad_pcb.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 10:54:11 -04:00
Gavin Colonese
b37ca6a225 feat: add export_drill tool (kicad-cli, full drill option set)
New MCP tool `export_drill` wrapping `kicad-cli pcb export drill`:
format (excellon/gerber), drill origin, Excellon zero-suppression and
oval formats, units, mirror-Y, minimal header, separate PTH/NPTH files,
drill map generation + map format, and Gerber precision. Self-contained
interface handler reading the saved .kicad_pcb.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 10:53:45 -04:00
Gavin Colonese
82e5de7aa9 feat: add export_gerbers tool (kicad-cli, full Plot option set)
New MCP tool `export_gerbers` wrapping `kicad-cli pcb export gerbers`,
exposing the complete Plot-dialog option surface that the existing
SWIG-based export_gerber lacks: layer + common-layer lists, X2 on/off,
netlist attributes, soldermask subtraction, aperture macros, the three
DNP fab-layer modes, refdes/value exclusion, border+title, drill-file
origin, coordinate precision (5/6), Protel vs KiCad extensions, and
--board-plot-params to reuse the board's stored plot settings.

Implemented as a self-contained interface handler (_handle_export_gerbers)
that resolves the board path (param or current board) and shells out to
kicad-cli, mirroring the _handle_export_netlist pattern — no dependence
on a live SWIG board, since the KiCad IPC API exposes no plot/export
endpoints. Reads the saved .kicad_pcb on disk.

Wiring: dispatch map + src/tools/export.ts registration + export
category in registry.ts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 10:53:19 -04:00
Gavin Colonese
2fb54802b6 chore: add fitz to mypy ignore_missing_imports overrides
PyMuPDF (`fitz`) has no type stubs, so mypy's import-not-found fails on
any edit to files importing it (commands/board/view.py,
kicad_interface.py). Same treatment as the existing pcbnew/kipy/PIL
entries. Needed so the export-tool commits on this branch pass the mypy
pre-commit hook independently.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 10:51:29 -04:00
Boovaragan
59b5eb0bd2 chore: add Vitest scaffolding and sync package.json to v2.2.3 2026-06-08 17:58:58 +05:30
Judson Stephenson
409b18c83d fix(board-view): frame get_board_2d_view to the board, not the A4 sheet
kicad-cli's `pcb export svg` defaults to `--page-size-mode 0` (the full drawing sheet), so only the area inside the sheet is plotted -- board geometry past the page edge is left out. Pass `--exclude-drawing-sheet` and `--page-size-mode 2` (board area only) so the output is sized to the board's bounding box and the full board is rendered. Both flags exist since KiCad 7, so KiCad 8/9 are unaffected.
2026-06-04 15:26:05 -05:00
Judson Stephenson
3f55a09661 fix(routing): persist create_netclass net classes to .kicad_pro (#185)
Net class definitions live in <project>.kicad_pro (net_settings) on KiCad 7+, not in the .kicad_pcb that the SWIG board save writes -- so create_netclass mutated the in-memory board and reported success, but nothing survived a reload.

Write the class definition and its net memberships (netclass_patterns) into the project JSON, which is what KiCad reads on open. The transform is a pure, unit-tested function that needs no live KiCad/SWIG round-trip; the in-memory NETCLASS path is kept for live-session consistency.
2026-06-04 13:04:47 -05:00
29 changed files with 9065 additions and 3214 deletions

View File

@@ -14,7 +14,7 @@ jobs:
strategy:
matrix:
os: [ubuntu-24.04, ubuntu-22.04, windows-latest, macos-latest]
node-version: [18.x, 20.x, 22.x]
node-version: [20.x, 22.x]
steps:
- name: Checkout code
@@ -33,10 +33,11 @@ jobs:
run: npm run build
- name: Run linter
shell: bash
run: npm run lint || echo "Linter not configured yet"
- name: Run tests
run: npm test || echo "Tests not configured yet"
- name: Run TypeScript tests
run: npm run test:ts
# Python tests
python-tests:
@@ -88,46 +89,225 @@ jobs:
integration-tests:
name: Integration Tests (Linux + KiCAD)
runs-on: ubuntu-24.04
strategy:
fail-fast: false
matrix:
kicad: ["8.0", "9.0", "10.0"]
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "20.x"
cache: "npm"
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: "pip"
- name: Add KiCAD PPA and Install KiCAD 9.0
- name: Add KiCAD PPA and Install KiCAD ${{ matrix.kicad }}
run: |
sudo add-apt-repository --yes ppa:kicad/kicad-9.0-releases
sudo add-apt-repository --yes ppa:kicad/kicad-${{ matrix.kicad }}-releases
sudo apt-get update
sudo apt-get install -y kicad kicad-libraries
mapfile -t KICAD_PYTHON_PACKAGES < <(
{
apt-cache search kicad
apt-cache search pcbnew
} | awk '
BEGIN { IGNORECASE = 1 }
{
name = $1
line = tolower($0)
if (name !~ /nightly/ && (line ~ /kicad/ || line ~ /pcbnew/) && line ~ /python/) print name
}
' | sort -u
)
if [ "${#KICAD_PYTHON_PACKAGES[@]}" -gt 0 ]; then
echo "Installing KiCad Python packages: ${KICAD_PYTHON_PACKAGES[*]}"
sudo apt-get install -y "${KICAD_PYTHON_PACKAGES[@]}"
fi
KICAD_PYTHONPATH="$(python3 - <<'PY'
import glob
import os
import subprocess
import sys
from pathlib import Path
candidates = []
seen = set()
def add_candidate(root: Path) -> None:
if not root.exists():
return
root_str = str(root)
if root_str not in seen:
seen.add(root_str)
candidates.append(root_str)
def add_candidate_for_file(file_path: Path) -> None:
if file_path.name == "__init__.py" and file_path.parent.name == "pcbnew":
add_candidate(file_path.parent.parent)
return
if file_path.parent.name == "pcbnew":
add_candidate(file_path.parent.parent)
return
add_candidate(file_path.parent)
def looks_like_pcbnew(file_path: Path) -> bool:
name = file_path.name
if name == "pcbnew.py":
return True
if name == "__init__.py" and file_path.parent.name == "pcbnew":
return True
return name.endswith(".so") and (
name.startswith("pcbnew") or name.startswith("_pcbnew")
)
def collect_from_package(package: str) -> None:
try:
result = subprocess.run(
["dpkg", "-L", package],
check=True,
capture_output=True,
text=True,
)
except subprocess.CalledProcessError:
return
for raw_path in result.stdout.splitlines():
path = Path(raw_path)
if path.is_file() and looks_like_pcbnew(path):
add_candidate_for_file(path)
package_result = subprocess.run(
["dpkg-query", "-W", "-f=${Package}\n"],
check=True,
capture_output=True,
text=True,
)
installed_packages = [
package
for package in package_result.stdout.splitlines()
if "kicad" in package or "pcbnew" in package
]
for package in installed_packages:
collect_from_package(package)
file_patterns = [
"/usr/lib/python3/dist-packages/pcbnew.py",
"/usr/lib/python3/dist-packages/pcbnew*.so",
"/usr/lib/python3/dist-packages/_pcbnew*.so",
"/usr/lib/python3/dist-packages/pcbnew/__init__.py",
"/usr/lib/python3/dist-packages/pcbnew/_pcbnew*.so",
"/usr/lib/kicad*/lib/python3/dist-packages/pcbnew.py",
"/usr/lib/kicad*/lib/python3/dist-packages/pcbnew*.so",
"/usr/lib/kicad*/lib/python3/dist-packages/_pcbnew*.so",
"/usr/lib/kicad*/lib/python3/dist-packages/pcbnew/__init__.py",
"/usr/lib/kicad*/lib/python3/dist-packages/pcbnew/_pcbnew*.so",
"/usr/lib/*/dist-packages/pcbnew.py",
"/usr/lib/*/dist-packages/pcbnew*.so",
"/usr/lib/*/dist-packages/_pcbnew*.so",
"/usr/lib/*/dist-packages/pcbnew/__init__.py",
"/usr/lib/*/dist-packages/pcbnew/_pcbnew*.so",
"/usr/local/lib/python*/dist-packages/pcbnew.py",
"/usr/local/lib/python*/dist-packages/pcbnew*.so",
"/usr/local/lib/python*/dist-packages/_pcbnew*.so",
"/usr/local/lib/python*/dist-packages/pcbnew/__init__.py",
"/usr/local/lib/python*/dist-packages/pcbnew/_pcbnew*.so",
"/usr/share/kicad*/scripting/pcbnew.py",
"/usr/share/kicad*/scripting/pcbnew*.so",
"/usr/share/kicad*/scripting/_pcbnew*.so",
"/usr/share/kicad*/scripting/pcbnew/__init__.py",
"/usr/share/kicad*/scripting/pcbnew/_pcbnew*.so",
]
for pattern in file_patterns:
for raw_path in glob.glob(pattern):
path = Path(raw_path)
if path.is_file() and looks_like_pcbnew(path):
add_candidate_for_file(path)
if not candidates:
for file_path in Path("/usr").rglob("*"):
if file_path.is_file() and looks_like_pcbnew(file_path):
add_candidate_for_file(file_path)
def import_succeeds(candidate: str) -> bool:
env = os.environ.copy()
existing = env.get("PYTHONPATH", "")
env["PYTHONPATH"] = (
f"{candidate}:{existing}" if existing else candidate
)
probe = subprocess.run(
[
sys.executable,
"-c",
(
"import pcbnew; "
"print(getattr(pcbnew, '__file__', '')); "
"print(pcbnew.GetBuildVersion())"
),
],
capture_output=True,
text=True,
env=env,
)
if probe.returncode == 0:
print(
f"Using pcbnew from {probe.stdout.splitlines()[0]}",
file=sys.stderr,
)
return True
return False
working_candidates = [candidate for candidate in candidates if import_succeeds(candidate)]
print(":".join(working_candidates))
PY
)"
if [ -z "$KICAD_PYTHONPATH" ]; then
echo "Unable to locate pcbnew Python bindings after installing KiCad ${{ matrix.kicad }}" >&2
{
apt-cache search kicad
apt-cache search pcbnew
} | awk 'BEGIN { IGNORECASE = 1 } tolower($0) ~ /python/ { print }' || true
dpkg-query -W -f='${Package}\n' | grep -Ei 'kicad|pcbnew' || true
find /usr -path '*dist-packages*' \( -name 'pcbnew.py' -o -name 'pcbnew*.so' -o -name '_pcbnew*.so' \) -print || true
exit 1
fi
echo "KICAD_PYTHONPATH=$KICAD_PYTHONPATH" >> $GITHUB_ENV
- name: Verify KiCAD installation
run: |
kicad-cli version || echo "kicad-cli not found"
python3 -c "import pcbnew; print(f'pcbnew version: {pcbnew.GetBuildVersion()}')" || echo "pcbnew module not found"
export KICAD_CLI="kicad-cli"
export PYTHONPATH="$KICAD_PYTHONPATH:$PYTHONPATH"
"$KICAD_CLI" version
python3 -c "import pcbnew; print(f'pcbnew version: {pcbnew.GetBuildVersion()}')"
- name: Install dependencies
run: |
npm ci
python -m pip install --upgrade pip
pip install -r requirements.txt
if [ -f requirements-dev.txt ]; then pip install -r requirements-dev.txt; fi
- name: Build TypeScript
run: npm run build
- name: Run integration tests
- name: Run real pcbnew smoke tests
env:
KICAD_USE_REAL_PCBNEW: "1"
run: |
echo "Integration tests not yet configured"
# pytest tests/integration/
export PYTHONPATH="$KICAD_PYTHONPATH:$PYTHONPATH"
pytest tests/test_real_pcbnew_matrix.py -m "integration and real_pcbnew"
# Docker build test
docker-build:

View File

@@ -4,6 +4,20 @@ All notable changes to the KiCAD MCP Server project are documented here.
## [Unreleased]
### Tooling
- **TypeScript test scaffolding (Vitest)**: `npm run test:ts` now runs a real
Vitest suite instead of the placeholder echo. Starter coverage lives in
`tests-ts/` and exercises the pure-function modules `src/tools/registry.ts`
(categories, direct vs. routed classification, search, and stats invariants)
and `src/tools/tool-response.ts` (`formatKicadResult` success/error shaping).
Use `npm run test:ts:watch` for the watch-mode REPL. The CI `typescript-tests`
job now invokes the suite directly instead of swallowing failures.
- **Version sync**: `package.json` now reports `2.2.3`, matching the released
version documented in this changelog and in `docs/ROADMAP.md`. Previously it
was stuck at `2.1.0-alpha`.
### Bug Fixes
- **`rotate_component` now treats `angle` as an absolute target rotation**,
@@ -65,7 +79,6 @@ All notable changes to the KiCAD MCP Server project are documented here.
copper — this implementation explicitly walks all layers.
Combines three placement strategies, freely composable:
- `grid` — regular grid across the board interior.
- `around_refs` — densify around named footprints (good for tucking
extra ground under MCUs, switching regulators, or RF parts).
@@ -77,12 +90,12 @@ All notable changes to the KiCAD MCP Server project are documented here.
`clearance`, `edgeMargin`), an `maxVias` cap for incremental work,
auto-detection of the GND net (tries `GND` / `GROUND` / `VSS` /
`/GND`), and a `dryRun` mode that returns the placements that
*would* be made without modifying the board — useful for previewing
_would_ be made without modifying the board — useful for previewing
before committing.
Returns `{ placed: [{x, y, unit}, ...], summary: {placed_count,
candidates_evaluated, skipped_by_zone_membership,
skipped_by_collision, ...} }`.
candidates_evaluated, skipped_by_zone_membership,
skipped_by_collision, ...} }`.
Approach ported from
[morningfire-pcb-automation](https://github.com/NiNjA-CodE/morningfire-pcb-automation)

View File

@@ -250,8 +250,20 @@ Then create a Pull Request on GitHub.
### Running Tests
The project has two test suites:
- **TypeScript** (Vitest) — lives in `tests-ts/`, covers the MCP protocol/router layer in `src/`.
- **Python** (pytest) — lives in `tests/`, covers the KiCAD interface in `python/`.
```bash
# All tests
# TypeScript tests (Vitest)
npm run test:ts # one-shot run
npm run test:ts:watch # watch mode
# Run both TS and Python suites
npm test
# Python tests only
pytest
# Unit tests only

1420
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "kicad-mcp",
"version": "2.1.0-alpha",
"version": "2.2.3",
"description": "AI-assisted PCB design with KiCAD via Model Context Protocol",
"type": "module",
"main": "dist/index.js",
@@ -12,7 +12,8 @@
"clean": "rm -rf dist",
"rebuild": "npm run clean && npm run build",
"test": "npm run test:ts && npm run test:py",
"test:ts": "echo 'TypeScript tests not yet configured'",
"test:ts": "vitest run",
"test:ts:watch": "vitest",
"test:py": "pytest tests/ -v",
"test:coverage": "pytest tests/ --cov=python --cov-report=html --cov-report=term",
"lint": "npm run lint:ts && npm run lint:py",
@@ -48,6 +49,7 @@
"nodemon": "^3.0.1",
"prettier": "^3.8.1",
"typescript": "^5.9.3",
"typescript-eslint": "^8.57.2"
"typescript-eslint": "^8.57.2",
"vitest": "^2.1.9"
}
}

View File

@@ -33,6 +33,7 @@ module = [
"cairosvg",
"sexpdata",
"skip",
"fitz",
"kipy",
"kipy.*",
"schematic",

View File

@@ -29,6 +29,7 @@ addopts =
markers =
unit: Unit tests (fast, no external dependencies)
integration: Integration tests (requires KiCAD)
real_pcbnew: Headless tests that exercise real KiCad SWIG bindings
slow: Slow-running tests
linux: Linux-specific tests
windows: Windows-specific tests

View File

@@ -198,6 +198,14 @@ class BoardViewCommands:
output_svg,
"--mode-single",
"--black-and-white",
# Render the board, not the drawing sheet. kicad-cli's
# default (page-size-mode 0) plots only the area inside the
# sheet, so board geometry past the page edge is left out.
# Mode 2 sizes the output to the board's bounding box;
# --exclude-drawing-sheet drops the title block.
"--exclude-drawing-sheet",
"--page-size-mode",
"2",
]
if layers:
cmd += ["--layers", ",".join(layers)]

View File

@@ -390,6 +390,104 @@ class ComponentCommands:
logger.error(f"Error editing component: {str(e)}")
return {"success": False, "message": "Failed to edit component", "errorDetails": str(e)}
def set_footprint_type(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Set the placement type and exclusion flags of an existing PCB footprint.
The placement type controls whether a footprint appears in pick-and-place
(.pos) output files. KiCAD recognises three types:
- ``through_hole`` — PTH component (through-hole)
- ``smd`` — SMD component (surface-mount)
- ``unspecified`` — neither bit set (default for manually-placed items)
Optional exclusion flags (omit to leave unchanged):
- ``exclude_from_pos_files`` — suppress this ref from .pos exports
- ``exclude_from_bom`` — suppress this ref from BoM exports
- ``not_in_schematic`` — marks footprint as board-only (no schematic
symbol); KiCAD stores this as FP_BOARD_ONLY
"""
try:
if not self.board:
return {
"success": False,
"message": "No board is loaded",
"errorDetails": "Load or create a board first",
}
reference = params.get("reference")
fp_type = params.get("type")
if not reference:
return {
"success": False,
"message": "Missing reference",
"errorDetails": "reference parameter is required",
}
if fp_type not in ("smd", "through_hole", "unspecified"):
return {
"success": False,
"message": "Invalid type",
"errorDetails": "type must be one of: smd, through_hole, unspecified",
}
# Find the footprint
module = self.board.FindFootprintByReference(reference)
if not module:
return {
"success": False,
"message": "Component not found",
"errorDetails": f"Could not find component: {reference}",
}
# --- Placement type (mutually exclusive bits) ---
# Read current attributes, clear both type bits, then set the new one.
attrs = module.GetAttributes()
attrs &= ~(pcbnew.FP_THROUGH_HOLE | pcbnew.FP_SMD)
if fp_type == "through_hole":
attrs |= pcbnew.FP_THROUGH_HOLE
elif fp_type == "smd":
attrs |= pcbnew.FP_SMD
# "unspecified" leaves both bits clear
module.SetAttributes(attrs)
# --- Optional exclusion flags ---
# Use the dedicated setters so we don't have to manage bit masks manually.
if "exclude_from_pos_files" in params:
module.SetExcludedFromPosFiles(bool(params["exclude_from_pos_files"]))
if "exclude_from_bom" in params:
module.SetExcludedFromBOM(bool(params["exclude_from_bom"]))
if "not_in_schematic" in params:
# FP_BOARD_ONLY is the underlying bit for the "not in schematic" flag.
module.SetBoardOnly(bool(params["not_in_schematic"]))
# Read back the final state so the caller can verify.
final_attrs = module.GetAttributes()
if final_attrs & pcbnew.FP_THROUGH_HOLE:
resolved_type = "through_hole"
elif final_attrs & pcbnew.FP_SMD:
resolved_type = "smd"
else:
resolved_type = "unspecified"
return {
"success": True,
"message": f"Updated footprint type for {reference}",
"component": {
"reference": reference,
"type": resolved_type,
"exclude_from_pos_files": module.IsExcludedFromPosFiles(),
"exclude_from_bom": module.IsExcludedFromBOM(),
"not_in_schematic": module.IsBoardOnly(),
},
}
except Exception as e:
logger.error(f"Error setting footprint type: {str(e)}")
return {
"success": False,
"message": "Failed to set footprint type",
"errorDetails": str(e),
}
def get_component_properties(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Get detailed properties of a component"""
try:
@@ -454,6 +552,15 @@ class ComponentCommands:
except Exception:
pass # Courtyard may not exist or API may differ
# Resolve placement type as a human-readable string
raw_attrs = module.GetAttributes()
if raw_attrs & pcbnew.FP_THROUGH_HOLE:
placement_type = "through_hole"
elif raw_attrs & pcbnew.FP_SMD:
placement_type = "smd"
else:
placement_type = "unspecified"
return {
"success": True,
"component": {
@@ -464,9 +571,10 @@ class ComponentCommands:
"rotation": module.GetOrientation().AsDegrees(),
"layer": self.board.GetLayerName(module.GetLayer()),
"attributes": {
"smd": module.GetAttributes() & pcbnew.FP_SMD,
"through_hole": module.GetAttributes() & pcbnew.FP_THROUGH_HOLE,
"board_only": module.GetAttributes() & pcbnew.FP_BOARD_ONLY,
"type": placement_type,
"exclude_from_pos_files": module.IsExcludedFromPosFiles(),
"exclude_from_bom": module.IsExcludedFromBOM(),
"not_in_schematic": module.IsBoardOnly(),
},
"boundingBox": bbox_data,
"courtyard": courtyard_data,

View File

@@ -38,6 +38,7 @@ class DynamicSymbolLoader:
possible_paths = [
Path("/usr/share/kicad/symbols"),
Path("/usr/local/share/kicad/symbols"),
Path("C:/Program Files/KiCad/10.0/share/kicad/symbols"),
Path("C:/Program Files/KiCad/9.0/share/kicad/symbols"),
Path("C:/Program Files/KiCad/8.0/share/kicad/symbols"),
Path("/Applications/KiCad/KiCad.app/Contents/SharedSupport/symbols"),
@@ -477,16 +478,18 @@ class DynamicSymbolLoader:
# Iterate over every top-level (property ...) block in the symbol
search_pos = 0
while True:
m = re.search(r'\(property\s+"([^"]+)"\s+"[^"]*"\s+\(at\s+([-\d.]+)\s+([-\d.]+)\s+([-\d.]+)\)',
sym_block[search_pos:])
m = re.search(
r'\(property\s+"([^"]+)"\s+"[^"]*"\s+\(at\s+([-\d.]+)\s+([-\d.]+)\s+([-\d.]+)\)',
sym_block[search_pos:],
)
if not m:
break
abs_start = search_pos + m.start()
prop_block = self._extract_paren_block(sym_block, abs_start)
name = m.group(1)
dx = float(m.group(2))
dy = float(m.group(3))
name = m.group(1)
dx = float(m.group(2))
dy = float(m.group(3))
angle = float(m.group(4))
# Extract (effects ...) block from within this property
@@ -495,7 +498,7 @@ class DynamicSymbolLoader:
effects_str = self._extract_paren_block(prop_block, eff_pos)
# Strip (hide ...) sub-expressions — visibility will be set
# separately by the caller
effects_str = re.sub(r'\s*\(hide\s+[^)]+\)', '', effects_str)
effects_str = re.sub(r"\s*\(hide\s+[^)]+\)", "", effects_str)
effects_str = effects_str.strip()
else:
effects_str = "(effects (font (size 1.27 1.27)))"
@@ -556,14 +559,13 @@ class DynamicSymbolLoader:
new_uuid = str(uuid.uuid4())
# --- read property offsets from the already-injected lib_symbols block -----
lib_props = self._extract_lib_property_positions(
schematic_path, library_name, symbol_name
)
lib_props = self._extract_lib_property_positions(schematic_path, library_name, symbol_name)
_DEFAULT_EFFECTS = "(effects (font (size 1.27 1.27)))"
def _prop_at(name: str, fallback_dx: float, fallback_dy: float,
fallback_angle: float = 0) -> tuple:
def _prop_at(
name: str, fallback_dx: float, fallback_dy: float, fallback_angle: float = 0
) -> tuple:
"""Return (abs_x, abs_y, text_angle, effects_str) for a property."""
if name in lib_props:
dx, dy, text_ang, eff = lib_props[name]
@@ -572,10 +574,10 @@ class DynamicSymbolLoader:
rdx, rdy = self._rotate_offset(dx, dy, angle)
return round(x + rdx, 3), round(y + rdy, 3), text_ang, eff
ref_x, ref_y, ref_a, ref_eff = _prop_at("Reference", 2.032, 0, 0)
val_x, val_y, val_a, val_eff = _prop_at("Value", 0, 2.54, 0)
fp_x, fp_y, _, _ = _prop_at("Footprint", 0, 0, 0)
ds_x, ds_y, _, _ = _prop_at("Datasheet", 0, 0, 0)
ref_x, ref_y, ref_a, ref_eff = _prop_at("Reference", 2.032, 0, 0)
val_x, val_y, val_a, val_eff = _prop_at("Value", 0, 2.54, 0)
fp_x, fp_y, _, _ = _prop_at("Footprint", 0, 0, 0)
ds_x, ds_y, _, _ = _prop_at("Datasheet", 0, 0, 0)
mirror_str = " (mirror y)" if mirror_y else ""
instance_block = f""" (symbol (lib_id "{full_lib_id}") (at {x} {y} {angle}){mirror_str} (unit {unit})

View File

@@ -182,12 +182,15 @@ class LibraryManager:
possible_paths = [
"/usr/share/kicad/footprints",
"/usr/local/share/kicad/footprints",
"C:/Program Files/KiCad/10.0/share/kicad/footprints",
"C:/Program Files/KiCad/9.0/share/kicad/footprints",
"C:/Program Files/KiCad/8.0/share/kicad/footprints",
"/Applications/KiCad/KiCad.app/Contents/SharedSupport/footprints",
]
# Also check environment variable
if "KICAD10_FOOTPRINT_DIR" in os.environ:
possible_paths.insert(0, os.environ["KICAD10_FOOTPRINT_DIR"])
if "KICAD9_FOOTPRINT_DIR" in os.environ:
possible_paths.insert(0, os.environ["KICAD9_FOOTPRINT_DIR"])
if "KICAD8_FOOTPRINT_DIR" in os.environ:

View File

@@ -83,9 +83,7 @@ class SymbolLibraryManager:
logger.debug("Skipping unparseable library: %s", nickname)
logger.info("Symbol cache warm-up complete (%d libraries)", len(self.libraries))
threading.Thread(
target=_warm, name="symbol-cache-warmup", daemon=True
).start()
threading.Thread(target=_warm, name="symbol-cache-warmup", daemon=True).start()
def _load_libraries(self) -> None:
"""Load libraries from sym-lib-table files"""
@@ -232,12 +230,15 @@ class SymbolLibraryManager:
possible_paths = [
"/usr/share/kicad/symbols",
"/usr/local/share/kicad/symbols",
"C:/Program Files/KiCad/10.0/share/kicad/symbols",
"C:/Program Files/KiCad/9.0/share/kicad/symbols",
"C:/Program Files/KiCad/8.0/share/kicad/symbols",
"/Applications/KiCad/KiCad.app/Contents/SharedSupport/symbols",
]
# Check environment variable
if "KICAD10_SYMBOL_DIR" in os.environ:
possible_paths.insert(0, os.environ["KICAD10_SYMBOL_DIR"])
if "KICAD9_SYMBOL_DIR" in os.environ:
possible_paths.insert(0, os.environ["KICAD9_SYMBOL_DIR"])
if "KICAD8_SYMBOL_DIR" in os.environ:
@@ -610,8 +611,10 @@ class SymbolLibraryCommands:
# Patch (SER2RJ45): keep cache when project_path matches AND libraries were
# actually loaded. Original early-return skipped rebuild even when the cache
# was empty (e.g. first call after create_project, before sym-lib-table existed).
if (self.library_manager.project_path == project_path
and len(self.library_manager.libraries) > 0):
if (
self.library_manager.project_path == project_path
and len(self.library_manager.libraries) > 0
):
return
logger.info(f"Rebuilding SymbolLibraryManager for project: {project_path}")
self.library_manager = SymbolLibraryManager(project_path=project_path)

View File

@@ -2,9 +2,12 @@
Routing-related command implementations for KiCAD interface
"""
import json
import logging
import math
import os
import tempfile
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
import pcbnew
@@ -12,6 +15,128 @@ import pcbnew
logger = logging.getLogger("kicad_interface")
# --- Net class project-file persistence (KiCad 7+) ------------------------
#
# Net class *definitions* live in the project file (``<project>.kicad_pro`` ->
# ``net_settings``), not in the ``.kicad_pcb`` board file, since KiCad 7. The
# SWIG board save only writes ``.kicad_pcb``, so a NETCLASS created on the
# in-memory board never survives a reload on its own (issue #185). These
# helpers write the class definition straight into the project JSON, which is
# what KiCad reads on open. Values are millimetres (the
# ``.kicad_pro`` unit). They are pure module functions so they can be unit
# tested without a live KiCad / SWIG round-trip.
# net_settings.classes numeric fields this tool can set (millimetres). The
# caller passes values already keyed by these names, so the persisted class
# cannot drift from differing request-key spellings (e.g. traceWidth).
_NETCLASS_NUMERIC_FIELDS = (
"clearance",
"track_width",
"via_diameter",
"via_drill",
"microvia_diameter",
"microvia_drill",
"diff_pair_width",
"diff_pair_gap",
)
# Fallback field set (KiCad 10 defaults) used only when the project has no
# "Default" class to clone the shape from.
_DEFAULT_NETCLASS_TEMPLATE = {
"bus_width": 12,
"clearance": 0.2,
"diff_pair_gap": 0.25,
"diff_pair_via_gap": 0.25,
"diff_pair_width": 0.2,
"line_style": 0,
"microvia_diameter": 0.3,
"microvia_drill": 0.1,
"pcb_color": "rgba(0, 0, 0, 0.000)",
"priority": 0,
"schematic_color": "rgba(0, 0, 0, 0.000)",
"track_width": 0.2,
"tuning_profile": "",
"via_diameter": 0.6,
"via_drill": 0.3,
"wire_width": 6,
}
def apply_netclass_to_project_settings(
data: Dict[str, Any], name: str, props: Dict[str, Any]
) -> Dict[str, Any]:
"""Insert or update a net class *definition* in a parsed ``.kicad_pro`` dict.
Pure: mutates and returns ``data``; performs no I/O.
Net class definitions live in the project file's ``net_settings.classes`` on
KiCad 7+. ``props`` is keyed by ``net_settings.classes`` field names
(``clearance``/``track_width``/...) in millimetres; the caller normalizes the
request keys so the persisted class never diverges from the live board. A
new class is cloned from the project's ``Default`` class (falling back to a
built-in template) so it carries KiCad's full field set.
"""
net_settings = data.setdefault("net_settings", {})
classes = net_settings.setdefault("classes", [])
cls = next((c for c in classes if c.get("name") == name), None)
if cls is None:
template = next((c for c in classes if c.get("name") == "Default"), None)
cls = dict(template) if template else dict(_DEFAULT_NETCLASS_TEMPLATE)
cls["name"] = name
cls["priority"] = 0 # custom classes; the Default class keeps its own priority
classes.append(cls)
for key in _NETCLASS_NUMERIC_FIELDS:
value = props.get(key)
if value is not None:
cls[key] = float(value)
return data
def persist_netclass_to_project(
pro_path: Optional[str], name: str, props: Dict[str, Any]
) -> Dict[str, Any]:
"""Read/modify/write ``pro_path`` so the net class definition survives a
reload (KiCad 7+ keeps it in the project file, not the board).
Returns ``{"persisted": bool, "projectFile"?: str, "warning"?: str}``. Never
raises: a persistence failure is reported, not fatal, so the in-memory net
class still stands. The write is atomic (temp file + ``os.replace``) so a
crash mid-write cannot corrupt the project file.
"""
if not pro_path or not os.path.exists(pro_path):
return {
"persisted": False,
"warning": "no .kicad_pro project file found; net class set in memory "
"only and will not persist across a reload",
}
try:
with open(pro_path, "r", encoding="utf-8") as handle:
data = json.load(handle)
apply_netclass_to_project_settings(data, name, props)
directory = os.path.dirname(pro_path) or "."
fd, tmp_path = tempfile.mkstemp(dir=directory, prefix=".netclass-", suffix=".kicad_pro")
try:
with os.fdopen(fd, "w", encoding="utf-8") as handle:
json.dump(data, handle, indent=2, sort_keys=True)
handle.write("\n")
os.replace(tmp_path, pro_path)
except Exception:
try:
os.remove(tmp_path)
except OSError:
pass
raise
return {"persisted": True, "projectFile": pro_path}
except Exception as exc: # report, never fail the command on a persistence error
return {
"persisted": False,
"warning": f"could not persist net class to {pro_path}: {exc}",
}
class RoutingCommands:
"""Handles routing-related KiCAD operations"""
@@ -1262,76 +1387,88 @@ class RoutingCommands:
"errorDetails": "name parameter is required",
}
# Get net classes — KiCad 6/7 returns NETCLASSES with .Find/.Add;
# KiCad 9/10 returns a netclasses_map (SWIG-wrapped std::map) that is dict-like.
net_classes = self.board.GetNetClasses()
# Net class DEFINITIONS live in <project>.kicad_pro (net_settings) on
# KiCad 7+, not in the .kicad_pcb the SWIG board save writes. Resolve
# the project file up front so the durable write below runs even if a
# SWIG API (which shifted across KiCad 6->10) throws (issue #185).
pro_path = None
try:
board_path = self.board.GetFileName()
if board_path and board_path.endswith(".kicad_pcb"):
pro_path = str(Path(board_path).with_suffix(".kicad_pro"))
except Exception:
pro_path = None
existing = None
if hasattr(net_classes, "Find"):
existing = net_classes.Find(name)
else:
try:
if name in net_classes:
existing = net_classes[name]
except Exception:
existing = None
if existing is None:
netclass = pcbnew.NETCLASS(name)
if hasattr(net_classes, "Add"):
net_classes.Add(netclass)
else:
net_classes[name] = netclass
else:
netclass = existing
# Set properties
scale = 1000000 # mm to nm
net_class_values: Dict[str, Any] = {}
in_memory_warning = None
# Defensive setters — KiCad 10's NETCLASS dropped some legacy mutators.
def _safe_set(method_name, value):
if value is None:
return
method = getattr(netclass, method_name, None)
if method is None:
return
try:
method(int(value * scale))
except Exception:
pass
# Best-effort in-memory NETCLASS for the live session. A SWIG failure
# here is logged but must NOT skip the .kicad_pro persistence below,
# which is the deterministic, SWIG-independent part (issue #185).
try:
# KiCad 6/7 returns NETCLASSES with .Find/.Add; KiCad 9/10 returns
# a netclasses_map (SWIG-wrapped std::map) that is dict-like.
net_classes = self.board.GetNetClasses()
_safe_set("SetClearance", clearance)
_safe_set("SetTrackWidth", track_width)
_safe_set("SetViaDiameter", via_diameter)
_safe_set("SetViaDrill", via_drill)
_safe_set("SetMicroViaDiameter", uvia_diameter)
_safe_set("SetMicroViaDrill", uvia_drill)
_safe_set("SetDiffPairWidth", diff_pair_width)
_safe_set("SetDiffPairGap", diff_pair_gap)
existing = None
if hasattr(net_classes, "Find"):
existing = net_classes.Find(name)
else:
try:
if name in net_classes:
existing = net_classes[name]
except Exception:
existing = None
# Add nets to net class
netinfo = self.board.GetNetInfo()
nets_map = netinfo.NetsByName()
for net_name in nets:
if nets_map.has_key(net_name):
net = nets_map[net_name]
net.SetClass(netclass)
if existing is None:
netclass = pcbnew.NETCLASS(name)
if hasattr(net_classes, "Add"):
net_classes.Add(netclass)
else:
net_classes[name] = netclass
else:
netclass = existing
# Defensive accessors — KiCad 10's NETCLASS dropped some legacy getters.
def _safe_get(method_name):
method = getattr(netclass, method_name, None)
if method is None:
return None
try:
return method() / scale
except Exception:
return None
# Defensive setters — KiCad 10's NETCLASS dropped some legacy mutators.
def _safe_set(method_name, value):
if value is None:
return
method = getattr(netclass, method_name, None)
if method is None:
return
try:
method(int(value * scale))
except Exception:
pass
return {
"success": True,
"message": f"Created net class: {name}",
"netClass": {
"name": name,
_safe_set("SetClearance", clearance)
_safe_set("SetTrackWidth", track_width)
_safe_set("SetViaDiameter", via_diameter)
_safe_set("SetViaDrill", via_drill)
_safe_set("SetMicroViaDiameter", uvia_diameter)
_safe_set("SetMicroViaDrill", uvia_drill)
_safe_set("SetDiffPairWidth", diff_pair_width)
_safe_set("SetDiffPairGap", diff_pair_gap)
netinfo = self.board.GetNetInfo()
nets_map = netinfo.NetsByName()
for net_name in nets:
if nets_map.has_key(net_name):
net = nets_map[net_name]
net.SetClass(netclass)
# Defensive accessors — KiCad 10's NETCLASS dropped some legacy getters.
def _safe_get(method_name):
method = getattr(netclass, method_name, None)
if method is None:
return None
try:
return method() / scale
except Exception:
return None
net_class_values = {
"clearance": _safe_get("GetClearance"),
"trackWidth": _safe_get("GetTrackWidth"),
"viaDiameter": _safe_get("GetViaDiameter"),
@@ -1340,9 +1477,47 @@ class RoutingCommands:
"uviaDrill": _safe_get("GetMicroViaDrill"),
"diffPairWidth": _safe_get("GetDiffPairWidth"),
"diffPairGap": _safe_get("GetDiffPairGap"),
"nets": nets,
},
}
except Exception as exc:
in_memory_warning = "in-memory NETCLASS update failed: %s" % exc
logger.warning("create_netclass: %s", in_memory_warning)
# Persist the class DEFINITION (net_settings.classes) using the same
# normalized values as the live path, so the persisted class can never
# diverge from the request. Membership lives in netclass_patterns and
# is out of scope here (create_netclass exposes no `nets` field).
project_props = {
"clearance": clearance,
"track_width": track_width,
"via_diameter": via_diameter,
"via_drill": via_drill,
"microvia_diameter": uvia_diameter,
"microvia_drill": uvia_drill,
"diff_pair_width": diff_pair_width,
"diff_pair_gap": diff_pair_gap,
}
persist = persist_netclass_to_project(pro_path, name, project_props)
warnings = [w for w in (in_memory_warning, persist.get("warning")) if w]
if in_memory_warning and not persist.get("persisted"):
# Neither the live board nor the project file received the class.
return {
"success": False,
"message": "Failed to create net class",
"errorDetails": "; ".join(warnings),
}
result = {
"success": True,
"message": f"Created net class: {name}",
"netClass": {"name": name, "nets": nets, **net_class_values},
"persisted": persist.get("persisted", False),
}
if persist.get("projectFile"):
result["projectFile"] = persist["projectFile"]
if warnings:
result["warning"] = "; ".join(warnings)
return result
except Exception as e:
logger.error(f"Error creating net class: {str(e)}")

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -202,6 +202,59 @@ export function registerComponentTools(server: McpServer, callKicadScript: Comma
},
);
// ------------------------------------------------------
// Set Footprint Type Tool
// ------------------------------------------------------
server.tool(
"set_footprint_type",
"Set the placement type (through_hole / smd / unspecified) and optional exclusion flags on a placed PCB footprint. The placement type controls whether the footprint is included in pick-and-place (.pos) output files. Use exclude_from_pos_files to suppress a footprint from .pos exports without changing its type.",
{
reference: z.string().describe("Reference designator of the footprint (e.g. 'R1', 'U3')"),
type: z
.enum(["smd", "through_hole", "unspecified"])
.describe(
"Placement type: 'smd' for surface-mount, 'through_hole' for PTH components, 'unspecified' to clear both bits (e.g. for board-only or mechanically-placed items)",
),
exclude_from_pos_files: z
.boolean()
.optional()
.describe(
"When true, suppress this footprint from pick-and-place (.pos) exports. Omit to leave the current setting unchanged.",
),
exclude_from_bom: z
.boolean()
.optional()
.describe(
"When true, suppress this footprint from BoM exports. Omit to leave the current setting unchanged.",
),
not_in_schematic: z
.boolean()
.optional()
.describe(
"When true, marks the footprint as board-only (no corresponding schematic symbol). Omit to leave the current setting unchanged.",
),
},
async ({ reference, type, exclude_from_pos_files, exclude_from_bom, not_in_schematic }) => {
logger.debug(`Setting footprint type for: ${reference} -> ${type}`);
const result = await callKicadScript("set_footprint_type", {
reference,
type,
exclude_from_pos_files,
exclude_from_bom,
not_in_schematic,
});
return {
content: [
{
type: "text",
text: JSON.stringify(result),
},
],
};
},
);
// ------------------------------------------------------
// Find Component Tool
// ------------------------------------------------------

File diff suppressed because it is too large Load Diff

View File

@@ -58,6 +58,25 @@ export const toolCategories: ToolCategory[] = [
description: "File export for fabrication and documentation: Gerber, PDF, BOM, 3D models",
tools: [
"export_gerber",
"export_gerbers",
"export_drill",
"export_ipc2581",
"export_odb",
"export_ipcd356",
"export_gencad",
"export_pos",
"export_pcb_pdf",
"export_pcb_svg",
"export_pcb_dxf",
"export_gerber_single",
"export_3d_cli",
"export_sch_bom",
"export_sch_pdf",
"export_sch_svg",
"export_sch_dxf",
"export_sch_hpgl",
"export_sch_ps",
"export_sch_python_bom",
"export_pdf",
"export_svg",
"export_3d",

149
tests-ts/registry.test.ts Normal file
View File

@@ -0,0 +1,149 @@
import { describe, it, expect } from "vitest";
import {
directToolNames,
getAllCategories,
getCategory,
getRegistryStats,
getRoutedToolNames,
getToolCategory,
isDirectTool,
isRoutedTool,
searchTools,
toolCategories,
} from "../src/tools/registry.js";
describe("tool registry — categories", () => {
it("getAllCategories returns the exported list", () => {
const categories = getAllCategories();
expect(categories).toBe(toolCategories);
expect(categories.length).toBeGreaterThan(0);
});
it("every category has a name, description, and at least one tool", () => {
for (const category of getAllCategories()) {
expect(category.name).toBeTruthy();
expect(category.description.length).toBeGreaterThan(0);
expect(category.tools.length).toBeGreaterThan(0);
}
});
it("getCategory returns a known category", () => {
const schematic = getCategory("schematic");
expect(schematic).toBeDefined();
expect(schematic?.name).toBe("schematic");
expect(schematic?.tools).toContain("add_schematic_wire");
});
it("getCategory returns undefined for unknown names", () => {
expect(getCategory("nonexistent_category")).toBeUndefined();
});
});
describe("tool registry — direct vs routed classification", () => {
it("isDirectTool identifies direct tools", () => {
expect(isDirectTool("create_project")).toBe(true);
expect(isDirectTool("route_trace")).toBe(true);
});
it("isDirectTool rejects routed tools", () => {
expect(isDirectTool("add_schematic_wire")).toBe(false);
});
it("isRoutedTool identifies routed tools", () => {
expect(isRoutedTool("add_schematic_wire")).toBe(true);
expect(isRoutedTool("export_gerber")).toBe(true);
});
it("isRoutedTool rejects direct tools and unknowns", () => {
expect(isRoutedTool("create_project")).toBe(false);
expect(isRoutedTool("totally_made_up_tool")).toBe(false);
});
it("getToolCategory maps a tool to its category", () => {
expect(getToolCategory("add_schematic_wire")).toBe("schematic");
expect(getToolCategory("export_gerber")).toBe("export");
expect(getToolCategory("nonexistent_tool")).toBeUndefined();
});
});
describe("tool registry — invariants", () => {
// Schematic essentials are intentionally exposed both as direct tools (so the
// AI sees them without first calling list_tool_categories) and via the
// "schematic" / "schematic_batch" categories (so they surface during routed
// discovery). See the directToolNames comment in src/tools/registry.ts.
// Any direct/routed overlap outside this allowlist is unintentional.
const INTENTIONAL_OVERLAP = new Set([
"add_schematic_component",
"list_schematic_components",
"annotate_schematic",
"connect_passthrough",
"connect_to_net",
"add_schematic_net_label",
"sync_schematic_to_board",
]);
it("direct/routed overlap is limited to the documented schematic essentials", () => {
const routed = new Set(getRoutedToolNames());
const unexpectedOverlap = directToolNames.filter(
(name) => routed.has(name) && !INTENTIONAL_OVERLAP.has(name),
);
expect(unexpectedOverlap).toEqual([]);
});
it("no tool name is duplicated across categories", () => {
const seen = new Map<string, string>();
const duplicates: string[] = [];
for (const category of getAllCategories()) {
for (const tool of category.tools) {
const previous = seen.get(tool);
if (previous) {
duplicates.push(`${tool} (${previous} + ${category.name})`);
} else {
seen.set(tool, category.name);
}
}
}
expect(duplicates).toEqual([]);
});
});
describe("tool registry — stats", () => {
it("getRegistryStats totals match the underlying sources", () => {
const stats = getRegistryStats();
expect(stats.total_categories).toBe(getAllCategories().length);
expect(stats.total_routed_tools).toBe(getRoutedToolNames().length);
expect(stats.total_direct_tools).toBe(directToolNames.length);
expect(stats.total_tools).toBe(stats.total_routed_tools + stats.total_direct_tools);
});
it("getRegistryStats per-category counts match category.tools.length", () => {
const stats = getRegistryStats();
for (const entry of stats.categories) {
const category = getCategory(entry.name);
expect(category).toBeDefined();
expect(entry.tool_count).toBe(category!.tools.length);
}
});
});
describe("tool registry — search", () => {
it("searchTools finds routed tools by substring", () => {
const results = searchTools("schematic");
expect(results.length).toBeGreaterThan(0);
expect(results.some((r) => r.tool === "add_schematic_wire")).toBe(true);
});
it("searchTools finds direct tools by substring", () => {
const results = searchTools("create_project");
expect(results.some((r) => r.category === "direct" && r.tool === "create_project")).toBe(true);
});
it("searchTools returns an empty array for no matches", () => {
expect(searchTools("xyzzy_nonexistent_query")).toEqual([]);
});
it("searchTools caps results at 20", () => {
const results = searchTools("a");
expect(results.length).toBeLessThanOrEqual(20);
});
});

View File

@@ -0,0 +1,45 @@
import { describe, it, expect } from "vitest";
import { formatKicadResult } from "../src/tools/tool-response.js";
describe("formatKicadResult", () => {
it("wraps a successful object result as JSON text content", () => {
const response = formatKicadResult({ success: true, value: 42 });
expect(response.content).toEqual([
{ type: "text", text: JSON.stringify({ success: true, value: 42 }) },
]);
expect(response.isError).toBeUndefined();
});
it("flags isError when the KiCAD payload reports success=false", () => {
const response = formatKicadResult({ success: false, error: "boom" });
expect(response.isError).toBe(true);
expect(response.content[0].text).toBe(JSON.stringify({ success: false, error: "boom" }));
});
it("does not flag isError for payloads without a success field", () => {
const response = formatKicadResult({ data: [1, 2, 3] });
expect(response.isError).toBeUndefined();
});
it("does not flag isError for success=true", () => {
const response = formatKicadResult({ success: true });
expect(response.isError).toBeUndefined();
});
it("does not flag isError when success is a truthy non-false value", () => {
const response = formatKicadResult({ success: "ok" });
expect(response.isError).toBeUndefined();
});
it("handles string results", () => {
const response = formatKicadResult("hello");
expect(response.content[0].text).toBe(JSON.stringify("hello"));
expect(response.isError).toBeUndefined();
});
it("handles null without throwing", () => {
const response = formatKicadResult(null);
expect(response.content[0].type).toBe("text");
expect(response.isError).toBeUndefined();
});
});

View File

@@ -6,6 +6,7 @@ test module can trigger their import, preventing crashes on systems where the
real KiCAD environment is not fully initialised for testing.
"""
import os
import sys
import types
from unittest.mock import MagicMock
@@ -16,12 +17,13 @@ from unittest.mock import MagicMock
# attribute access (pcbnew.BOARD, pcbnew.PCB_TRACK, …) returns a mock
# rather than raising AttributeError.
# ---------------------------------------------------------------------------
_pcbnew = MagicMock(name="pcbnew")
_pcbnew.__file__ = "/fake/pcbnew.cpython-313-x86_64-linux-gnu.so"
_pcbnew.__name__ = "pcbnew"
_pcbnew.__spec__ = None
_pcbnew.GetBuildVersion.return_value = "9.0.0-stub"
sys.modules["pcbnew"] = _pcbnew
if os.environ.get("KICAD_USE_REAL_PCBNEW") != "1":
_pcbnew = MagicMock(name="pcbnew")
_pcbnew.__file__ = "/fake/pcbnew.cpython-313-x86_64-linux-gnu.so"
_pcbnew.__name__ = "pcbnew"
_pcbnew.__spec__ = None
_pcbnew.GetBuildVersion.return_value = "9.0.0-stub"
sys.modules["pcbnew"] = _pcbnew
# ---------------------------------------------------------------------------
# Stub: skip (kicad-skip — use real module if available, stub otherwise)

176
tests/test_export_cli.py Normal file
View File

@@ -0,0 +1,176 @@
"""Unit tests for the kicad-cli-backed export handlers on ``KiCADInterface``.
These handlers (``_handle_export_*``) shell out to ``kicad-cli``. The tests mock
the subprocess call and the filesystem, then assert command construction,
validation, and error handling. No real ``kicad-cli`` binary, board, or
schematic is touched.
conftest.py pre-installs a MagicMock for ``pcbnew`` in sys.modules so
kicad_interface imports without a real KiCAD install.
"""
import sys
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
import pytest
sys.path.insert(0, str(Path(__file__).parent.parent / "python"))
pytestmark = pytest.mark.unit
from kicad_interface import KiCADInterface # noqa: E402
# (handler suffix, minimal params) — correct output key per handler, schematicPath for sch tools
PCB_HANDLERS = [
("gerbers", {"outputDir": "/out"}),
("drill", {"outputDir": "/out"}),
("ipc2581", {"outputPath": "/out/x.xml"}),
("odb", {"outputPath": "/out/x.zip"}),
("ipcd356", {"outputPath": "/out/x.d356"}),
("gencad", {"outputPath": "/out/x.cad"}),
("pos", {"outputPath": "/out/x.pos"}),
("pcb_pdf", {"outputPath": "/out/x.pdf"}),
("pcb_svg", {"outputPath": "/out/x.svg"}),
("pcb_dxf", {"outputPath": "/out/x.dxf"}),
("gerber_single", {"outputPath": "/out/x.gbr", "layers": ["F.Cu"]}),
("3d_cli", {"outputPath": "/out/x.step", "format": "step"}),
]
SCH_HANDLERS = [
("sch_bom", {"outputPath": "/out/b.csv", "schematicPath": "/p/x.kicad_sch"}),
("sch_pdf", {"outputPath": "/out/b.pdf", "schematicPath": "/p/x.kicad_sch"}),
("sch_python_bom", {"outputPath": "/out/b.xml", "schematicPath": "/p/x.kicad_sch"}),
("sch_svg", {"outputDir": "/out", "schematicPath": "/p/x.kicad_sch"}),
("sch_dxf", {"outputDir": "/out", "schematicPath": "/p/x.kicad_sch"}),
("sch_hpgl", {"outputDir": "/out", "schematicPath": "/p/x.kicad_sch"}),
("sch_ps", {"outputDir": "/out", "schematicPath": "/p/x.kicad_sch"}),
]
ALL_HANDLERS = PCB_HANDLERS + SCH_HANDLERS
def _make_iface():
"""KiCADInterface instance with the cli/board-path helpers mocked."""
iface = KiCADInterface.__new__(KiCADInterface)
iface.board = None
iface._find_kicad_cli_static = MagicMock(return_value="kicad-cli")
iface._current_board_path = MagicMock(return_value="/proj/board.kicad_pcb")
return iface
def _call(iface, suffix, params, rc=0, stderr=""):
"""Invoke a handler with subprocess + filesystem mocked. Returns (result, run_mock)."""
method = getattr(iface, f"_handle_export_{suffix}")
fake = SimpleNamespace(returncode=rc, stdout="", stderr=stderr)
with (
patch("subprocess.run", return_value=fake) as run,
patch("pathlib.Path.exists", return_value=True),
patch("pathlib.Path.mkdir"),
patch("pathlib.Path.iterdir", return_value=[]),
patch("pathlib.Path.is_file", return_value=True),
):
result = method(dict(params))
return result, run
class TestAllHandlers:
@pytest.mark.parametrize("suffix,params", ALL_HANDLERS)
def test_success_invokes_kicad_cli(self, suffix, params):
iface = _make_iface()
result, run = _call(iface, suffix, params, rc=0)
assert result["success"] is True, result
run.assert_called_once()
cmd = run.call_args.args[0]
assert cmd[0] == "kicad-cli"
assert cmd[1] in ("pcb", "sch")
assert cmd[2] == "export"
@pytest.mark.parametrize("suffix,params", ALL_HANDLERS)
def test_cli_not_found(self, suffix, params):
iface = _make_iface()
iface._find_kicad_cli_static = MagicMock(return_value=None)
result, _ = _call(iface, suffix, params)
assert result["success"] is False
assert "kicad-cli" in result["message"].lower()
@pytest.mark.parametrize("suffix,params", ALL_HANDLERS)
def test_subprocess_failure_propagates(self, suffix, params):
iface = _make_iface()
result, _ = _call(iface, suffix, params, rc=1, stderr="boom")
assert result["success"] is False
class TestValidation:
def test_missing_output_errors(self):
iface = _make_iface()
result, _ = _call(iface, "gerbers", {})
assert result["success"] is False
def test_pcb_no_board_resolvable_errors(self):
iface = _make_iface()
iface._current_board_path = MagicMock(return_value=None)
result, _ = _call(iface, "ipc2581", {"outputPath": "/o/x.xml"})
assert result["success"] is False
def test_sch_missing_schematic_errors(self):
iface = _make_iface()
result, _ = _call(iface, "sch_bom", {"outputPath": "/o/b.csv"})
assert result["success"] is False
class TestFlagConstruction:
"""Detailed flag mapping for the originally-authored handlers."""
def test_gerbers_flags(self):
iface = _make_iface()
_, run = _call(
iface,
"gerbers",
{
"outputDir": "/out",
"layers": ["F.Cu", "B.Cu"],
"subtractSoldermask": True,
"noX2": True,
"precision": 5,
},
)
cmd = run.call_args.args[0]
assert "--subtract-soldermask" in cmd
assert "--no-x2" in cmd
assert "--layers" in cmd
assert "F.Cu,B.Cu" in cmd
assert cmd[cmd.index("--precision") + 1] == "5"
def test_gerbers_omitted_flags_absent(self):
iface = _make_iface()
_, run = _call(iface, "gerbers", {"outputDir": "/out"})
cmd = run.call_args.args[0]
assert "--no-x2" not in cmd
assert "--subtract-soldermask" not in cmd
def test_drill_flags(self):
iface = _make_iface()
_, run = _call(
iface,
"drill",
{
"outputDir": "/out",
"format": "gerber",
"excellonSeparateTh": True,
"generateMap": True,
},
)
cmd = run.call_args.args[0]
assert "--excellon-separate-th" in cmd
assert "--generate-map" in cmd
assert cmd[cmd.index("--format") + 1] == "gerber"
def test_ipc2581_bom_column_mapping(self):
iface = _make_iface()
_, run = _call(
iface,
"ipc2581",
{"outputPath": "/o/x.xml", "bomColIntId": "FAST P/N"},
)
cmd = run.call_args.args[0]
assert cmd[cmd.index("--bom-col-int-id") + 1] == "FAST P/N"

View File

@@ -79,6 +79,19 @@ def test_inline_png_returns_base64_image_data(tmp_path):
assert "filePath" not in result
@pytest.mark.unit
def test_export_frames_to_board_area(tmp_path):
"""The export drops the drawing sheet and crops to the board area, so a small
board isn't rendered on a mostly-empty A4 page (kicad-cli's default)."""
cmd, root, board_path = _make_view_cmd(tmp_path)
w1, w2, w3 = _patch_kicad_cli(root)
with w1, w2 as run, w3, patch("commands.board.view._svg_to_png", return_value=_FAKE_PNG):
cmd.get_board_2d_view({"pcbPath": str(board_path), "format": "png"})
argv = run.call_args[0][0]
assert "--exclude-drawing-sheet" in argv
assert argv[argv.index("--page-size-mode") + 1] == "2"
@pytest.mark.unit
def test_inline_svg_returns_base64_image_data(tmp_path):
"""responseMode='inline' with format='svg' returns base64-encoded SVG in imageData."""

View File

@@ -0,0 +1,46 @@
"""Regression tests for issue #245: KiCad 10 Windows install paths must be in
the auto-discovery candidate lists (footprints and symbols), with env-var
overrides for KiCad 10.
"""
import sys
from pathlib import Path
from unittest.mock import patch
import pytest
sys.path.insert(0, str(Path(__file__).parent.parent / "python"))
from commands.library import LibraryManager # noqa: E402
from commands.library_symbol import SymbolLibraryManager # noqa: E402
K10_FOOTPRINTS = "C:/Program Files/KiCad/10.0/share/kicad/footprints"
K10_SYMBOLS = "C:/Program Files/KiCad/10.0/share/kicad/symbols"
@pytest.mark.unit
class TestKicad10FootprintDir:
def test_k10_windows_path_is_discovered(self):
lm = LibraryManager.__new__(LibraryManager)
with patch("commands.library.os.path.isdir", side_effect=lambda p: p == K10_FOOTPRINTS):
assert lm._find_kicad_footprint_dir() == K10_FOOTPRINTS
def test_k10_env_override_wins(self, monkeypatch):
lm = LibraryManager.__new__(LibraryManager)
monkeypatch.setenv("KICAD10_FOOTPRINT_DIR", "/custom/k10/footprints")
with patch("commands.library.os.path.isdir", return_value=True):
assert lm._find_kicad_footprint_dir() == "/custom/k10/footprints"
@pytest.mark.unit
class TestKicad10SymbolDir:
def test_k10_windows_path_is_discovered(self):
sm = SymbolLibraryManager.__new__(SymbolLibraryManager)
with patch("commands.library_symbol.os.path.isdir", side_effect=lambda p: p == K10_SYMBOLS):
assert sm._find_kicad_symbol_dir() == K10_SYMBOLS
def test_k10_env_override_wins(self, monkeypatch):
sm = SymbolLibraryManager.__new__(SymbolLibraryManager)
monkeypatch.setenv("KICAD10_SYMBOL_DIR", "/custom/k10/symbols")
with patch("commands.library_symbol.os.path.isdir", return_value=True):
assert sm._find_kicad_symbol_dir() == "/custom/k10/symbols"

View File

@@ -0,0 +1,201 @@
"""Tests for create_netclass persisting net class definitions to ``.kicad_pro`` (#185).
Net class *definitions* live in ``<project>.kicad_pro`` (``net_settings.classes``),
not in the ``.kicad_pcb`` board file, on KiCad 7+. These tests exercise the pure
JSON transform, the atomic file round-trip, and the create_netclass wiring without
needing a live KiCad / SWIG board.
"""
import json
import sys
from pathlib import Path
from unittest.mock import MagicMock
sys.path.insert(0, str(Path(__file__).parent.parent / "python"))
from commands.routing import ( # noqa: E402
RoutingCommands,
apply_netclass_to_project_settings,
persist_netclass_to_project,
)
def _project_with_default():
return {
"net_settings": {
"classes": [
{
"name": "Default",
"clearance": 0.2,
"track_width": 0.2,
"via_diameter": 0.6,
"via_drill": 0.3,
"priority": 2147483647,
}
],
"netclass_patterns": [],
}
}
# --- pure transform -------------------------------------------------------
def test_apply_adds_class_with_mm_fields():
data = _project_with_default()
apply_netclass_to_project_settings(
data, "HV", {"clearance": 0.5, "track_width": 0.4, "via_diameter": 1.0, "via_drill": 0.5}
)
hv = next(c for c in data["net_settings"]["classes"] if c["name"] == "HV")
assert hv["clearance"] == 0.5
assert hv["track_width"] == 0.4
assert hv["via_diameter"] == 1.0
assert hv["via_drill"] == 0.5
# custom classes get priority 0; the Default class keeps its own
assert hv["priority"] == 0
def test_apply_clones_default_field_shape():
data = _project_with_default()
apply_netclass_to_project_settings(data, "HV", {"clearance": 0.5})
hv = next(c for c in data["net_settings"]["classes"] if c["name"] == "HV")
assert "track_width" in hv and "via_diameter" in hv
def test_apply_creates_class_template_when_no_default():
data = {"net_settings": {"classes": []}}
apply_netclass_to_project_settings(data, "HV", {"clearance": 0.5})
hv = next(c for c in data["net_settings"]["classes"] if c["name"] == "HV")
# full KiCad-10 field set from the fallback template
for key in ("bus_width", "track_width", "via_diameter", "via_drill", "line_style"):
assert key in hv
def test_apply_updates_existing_class_without_duplicating():
data = _project_with_default()
apply_netclass_to_project_settings(data, "HV", {"clearance": 0.5})
apply_netclass_to_project_settings(data, "HV", {"clearance": 0.8})
hv = [c for c in data["net_settings"]["classes"] if c["name"] == "HV"]
assert len(hv) == 1
assert hv[0]["clearance"] == 0.8
def test_apply_creates_net_settings_when_absent():
data = {}
apply_netclass_to_project_settings(data, "HV", {"clearance": 0.5})
assert data["net_settings"]["classes"][0]["name"] == "HV"
# --- file persistence -----------------------------------------------------
def test_persist_round_trips_through_a_real_file(tmp_path):
pro = tmp_path / "proj.kicad_pro"
pro.write_text(json.dumps(_project_with_default()))
result = persist_netclass_to_project(str(pro), "HV", {"clearance": 0.5, "track_width": 0.4})
assert result["persisted"] is True
assert result["projectFile"] == str(pro)
hv = next(
c for c in json.loads(pro.read_text())["net_settings"]["classes"] if c["name"] == "HV"
)
assert hv["clearance"] == 0.5 and hv["track_width"] == 0.4
def test_persist_preserves_unrelated_project_and_net_settings_content(tmp_path):
pro = tmp_path / "proj.kicad_pro"
project = _project_with_default()
project["board"] = {"design_settings": {"rules": {"min_clearance": 0.1}}}
project["net_settings"]["meta"] = {"version": 4}
project["net_settings"]["net_colors"] = {"GND": "rgba(1, 2, 3, 0.5)"}
project["net_settings"]["netclass_assignments"] = {"GND": "Default"}
pro.write_text(json.dumps(project))
persist_netclass_to_project(str(pro), "HV", {"clearance": 0.5})
reloaded = json.loads(pro.read_text())
assert reloaded["board"]["design_settings"]["rules"]["min_clearance"] == 0.1
assert reloaded["net_settings"]["meta"] == {"version": 4}
assert reloaded["net_settings"]["net_colors"] == {"GND": "rgba(1, 2, 3, 0.5)"}
assert reloaded["net_settings"]["netclass_assignments"] == {"GND": "Default"}
default = next(c for c in reloaded["net_settings"]["classes"] if c["name"] == "Default")
assert default["priority"] == 2147483647
def test_persist_writes_atomically_leaving_no_temp_file(tmp_path):
pro = tmp_path / "proj.kicad_pro"
pro.write_text(json.dumps(_project_with_default()))
persist_netclass_to_project(str(pro), "HV", {"clearance": 0.5})
assert [p.name for p in tmp_path.iterdir()] == ["proj.kicad_pro"]
def test_persist_warns_when_no_project_file():
result = persist_netclass_to_project(None, "HV", {"clearance": 0.5})
assert result["persisted"] is False
assert "warning" in result
def test_persist_warns_on_malformed_json_and_leaves_file_intact(tmp_path):
pro = tmp_path / "proj.kicad_pro"
pro.write_text("{not valid json")
result = persist_netclass_to_project(str(pro), "HV", {"clearance": 0.5})
assert result["persisted"] is False
assert str(pro) in result["warning"]
assert pro.read_text() == "{not valid json" # never half-written
def test_persist_warns_when_path_is_a_directory(tmp_path):
# a directory passes os.path.exists() but open()-for-read fails -> hits the except
result = persist_netclass_to_project(str(tmp_path), "HV", {"clearance": 0.5})
assert result["persisted"] is False
assert str(tmp_path) in result["warning"]
# --- create_netclass wiring ----------------------------------------------
def test_create_netclass_persists_canonical_trace_width(tmp_path):
# Regression: a schema-conformant call (canonical "traceWidth") must reach
# .kicad_pro as track_width, not the cloned Default's value.
pro = tmp_path / "p.kicad_pro"
pro.write_text(json.dumps(_project_with_default()))
board = MagicMock()
board.GetFileName.return_value = str(tmp_path / "p.kicad_pcb")
result = RoutingCommands(board).create_netclass(
{"name": "HV", "traceWidth": 5.0, "clearance": 0.5}
)
assert result["success"] is True
assert result["persisted"] is True
hv = next(
c for c in json.loads(pro.read_text())["net_settings"]["classes"] if c["name"] == "HV"
)
assert hv["track_width"] == 5.0
assert hv["clearance"] == 0.5
def test_create_netclass_persists_even_when_swig_path_fails(tmp_path):
# The .kicad_pro write is SWIG-independent: a SWIG throw must not skip it.
pro = tmp_path / "p.kicad_pro"
pro.write_text(json.dumps(_project_with_default()))
board = MagicMock()
board.GetNetClasses.side_effect = RuntimeError("swig boom")
board.GetFileName.return_value = str(tmp_path / "p.kicad_pcb")
result = RoutingCommands(board).create_netclass(
{"name": "HV", "traceWidth": 0.4, "clearance": 0.5}
)
assert result["success"] is True
assert result["persisted"] is True
assert "swig boom" in result.get("warning", "")
hv = next(
c for c in json.loads(pro.read_text())["net_settings"]["classes"] if c["name"] == "HV"
)
assert hv["track_width"] == 0.4
def test_create_netclass_fails_when_neither_memory_nor_disk_succeeds(tmp_path):
board = MagicMock()
board.GetNetClasses.side_effect = RuntimeError("swig boom")
# sibling .kicad_pro does not exist -> persistence can't happen either
board.GetFileName.return_value = str(tmp_path / "missing.kicad_pcb")
result = RoutingCommands(board).create_netclass(
{"name": "HV", "traceWidth": 0.4, "clearance": 0.5}
)
assert result["success"] is False
assert "swig boom" in result["errorDetails"]

View File

@@ -0,0 +1,43 @@
import os
import sys
from pathlib import Path
import pytest
PYTHON_DIR = Path(__file__).parent.parent / "python"
sys.path.insert(0, str(PYTHON_DIR))
pytestmark = [
pytest.mark.integration,
pytest.mark.real_pcbnew,
pytest.mark.linux,
]
@pytest.fixture(autouse=True)
def require_real_pcbnew() -> None:
if os.environ.get("KICAD_USE_REAL_PCBNEW") != "1":
pytest.skip("real pcbnew smoke tests require KICAD_USE_REAL_PCBNEW=1")
def test_project_commands_create_and_load_board_with_real_pcbnew(tmp_path: Path) -> None:
import pcbnew
from commands.project import ProjectCommands
version = pcbnew.GetBuildVersion()
assert version
assert not str(version).endswith("-stub")
commands = ProjectCommands()
result = commands.create_project({"name": "matrix_smoke", "path": str(tmp_path)})
assert result["success"] is True, result
board_path = Path(result["project"]["boardPath"])
assert board_path.exists()
board = pcbnew.LoadBoard(str(board_path))
assert board is not None
assert hasattr(board, "GetFileName")
assert board.GetFileName().endswith(".kicad_pcb")

View File

@@ -0,0 +1,439 @@
"""Unit tests for the ``set_footprint_type`` command.
Tests exercise both the SWIG-path handler (``ComponentCommands.set_footprint_type``)
and the IPC-path handler (``KiCADInterface._ipc_set_footprint_type``).
conftest.py pre-installs a MagicMock for ``pcbnew`` in sys.modules; these tests
configure the relevant attribute constants on that mock so that component.py can
run without a real KiCAD install.
"""
import sys
import types
from pathlib import Path
from unittest.mock import MagicMock, call, patch
import pytest
sys.path.insert(0, str(Path(__file__).parent.parent / "python"))
pytestmark = pytest.mark.unit
# The conftest.py-installed pcbnew MagicMock is already in sys.modules.
# We set the FP_* integer constants we need once at module level so they are
# stable for the life of the test session (conftest doesn't reset them).
import pcbnew as _pcbnew_stub # noqa: E402 — must come after sys.path insert
_pcbnew_stub.FP_THROUGH_HOLE = 1
_pcbnew_stub.FP_SMD = 2
_pcbnew_stub.FP_EXCLUDE_FROM_POS_FILES = 4
_pcbnew_stub.FP_EXCLUDE_FROM_BOM = 8
_pcbnew_stub.FP_BOARD_ONLY = 16
_pcbnew_stub.FP_DNP = 32
_pcbnew_stub.F_CrtYd = 0
_pcbnew_stub.B_CrtYd = 1
# ---------------------------------------------------------------------------
# Shared helpers
# ---------------------------------------------------------------------------
def _make_footprint_mock(attrs=0, excluded_pos=False, excluded_bom=False, board_only=False):
"""Return a MagicMock mimicking a pcbnew.FOOTPRINT for attribute editing."""
fp = MagicMock()
fp.GetAttributes.return_value = attrs
fp.IsExcludedFromPosFiles.return_value = excluded_pos
fp.IsExcludedFromBOM.return_value = excluded_bom
fp.IsBoardOnly.return_value = board_only
return fp
def _make_component_commands(fp_mock):
"""Return a ComponentCommands wired to a board holding *fp_mock*."""
from commands.component import ComponentCommands
board = MagicMock()
board.FindFootprintByReference.return_value = fp_mock
cmd = ComponentCommands.__new__(ComponentCommands)
cmd.board = board
cmd.library_manager = MagicMock()
return cmd
# ---------------------------------------------------------------------------
# SWIG path ComponentCommands.set_footprint_type
# ---------------------------------------------------------------------------
class TestSetFootprintTypeSwig:
def test_set_through_hole_clears_smd_bit(self):
"""Setting through_hole must set FP_THROUGH_HOLE and clear FP_SMD."""
fp = _make_footprint_mock(attrs=_pcbnew_stub.FP_SMD)
cmd = _make_component_commands(fp)
result = cmd.set_footprint_type({"reference": "R1", "type": "through_hole"})
assert result["success"] is True
fp.SetAttributes.assert_called_once()
written = fp.SetAttributes.call_args[0][0]
assert written & _pcbnew_stub.FP_THROUGH_HOLE
assert not (written & _pcbnew_stub.FP_SMD)
def test_set_smd_clears_through_hole_bit(self):
fp = _make_footprint_mock(attrs=_pcbnew_stub.FP_THROUGH_HOLE)
cmd = _make_component_commands(fp)
result = cmd.set_footprint_type({"reference": "U1", "type": "smd"})
assert result["success"] is True
written = fp.SetAttributes.call_args[0][0]
assert written & _pcbnew_stub.FP_SMD
assert not (written & _pcbnew_stub.FP_THROUGH_HOLE)
def test_set_unspecified_clears_both_bits(self):
fp = _make_footprint_mock(attrs=_pcbnew_stub.FP_THROUGH_HOLE | _pcbnew_stub.FP_SMD)
cmd = _make_component_commands(fp)
result = cmd.set_footprint_type({"reference": "TP1", "type": "unspecified"})
assert result["success"] is True
written = fp.SetAttributes.call_args[0][0]
assert not (written & _pcbnew_stub.FP_SMD)
assert not (written & _pcbnew_stub.FP_THROUGH_HOLE)
def test_exclude_from_pos_files_set(self):
fp = _make_footprint_mock()
cmd = _make_component_commands(fp)
cmd.set_footprint_type({"reference": "R2", "type": "smd", "exclude_from_pos_files": True})
fp.SetExcludedFromPosFiles.assert_called_once_with(True)
def test_exclude_from_bom_set(self):
fp = _make_footprint_mock()
cmd = _make_component_commands(fp)
cmd.set_footprint_type({"reference": "R3", "type": "smd", "exclude_from_bom": True})
fp.SetExcludedFromBOM.assert_called_once_with(True)
def test_not_in_schematic_set(self):
fp = _make_footprint_mock()
cmd = _make_component_commands(fp)
cmd.set_footprint_type(
{"reference": "MH1", "type": "unspecified", "not_in_schematic": True}
)
fp.SetBoardOnly.assert_called_once_with(True)
def test_omitted_optional_flags_not_called(self):
"""When optional flags are omitted, their setters must NOT be called."""
fp = _make_footprint_mock()
cmd = _make_component_commands(fp)
cmd.set_footprint_type({"reference": "C1", "type": "smd"})
fp.SetExcludedFromPosFiles.assert_not_called()
fp.SetExcludedFromBOM.assert_not_called()
fp.SetBoardOnly.assert_not_called()
def test_missing_reference_returns_error(self):
fp = _make_footprint_mock()
cmd = _make_component_commands(fp)
result = cmd.set_footprint_type({"type": "smd"})
assert result["success"] is False
assert "reference" in result["errorDetails"].lower()
def test_invalid_type_returns_error(self):
fp = _make_footprint_mock()
cmd = _make_component_commands(fp)
result = cmd.set_footprint_type({"reference": "R1", "type": "bad_type"})
assert result["success"] is False
def test_component_not_found_returns_error(self):
cmd = _make_component_commands(None)
cmd.board.FindFootprintByReference.return_value = None
result = cmd.set_footprint_type({"reference": "ZZZ", "type": "smd"})
assert result["success"] is False
assert "not found" in result["message"].lower()
def test_no_board_returns_error(self):
fp = _make_footprint_mock()
cmd = _make_component_commands(fp)
cmd.board = None
result = cmd.set_footprint_type({"reference": "R1", "type": "smd"})
assert result["success"] is False
assert "no board" in result["message"].lower()
def test_response_includes_readback_type(self):
"""Response component.type must reflect the resolved type after the write."""
# First call (before SetAttributes): original value; second call (readback): FP_SMD
fp = _make_footprint_mock(attrs=0)
fp.GetAttributes.side_effect = [0, _pcbnew_stub.FP_SMD]
cmd = _make_component_commands(fp)
result = cmd.set_footprint_type({"reference": "R1", "type": "smd"})
assert result["success"] is True
assert result["component"]["type"] == "smd"
def test_response_includes_exclusion_flags(self):
"""Response must include exclude_from_pos_files, exclude_from_bom, not_in_schematic."""
fp = _make_footprint_mock()
fp.GetAttributes.side_effect = [0, 0]
fp.IsExcludedFromPosFiles.return_value = True
fp.IsExcludedFromBOM.return_value = False
fp.IsBoardOnly.return_value = False
cmd = _make_component_commands(fp)
result = cmd.set_footprint_type(
{"reference": "R1", "type": "smd", "exclude_from_pos_files": True}
)
assert result["success"] is True
comp = result["component"]
assert comp["exclude_from_pos_files"] is True
assert comp["exclude_from_bom"] is False
assert comp["not_in_schematic"] is False
# ---------------------------------------------------------------------------
# get_component_properties verify extended attributes in response
# ---------------------------------------------------------------------------
class TestGetComponentPropertiesAttributes:
"""Verify that get_component_properties now returns human-readable type + flags."""
def _setup(self, attrs, excluded_pos=False, excluded_bom=False, board_only=False):
fp = _make_footprint_mock(
attrs=attrs,
excluded_pos=excluded_pos,
excluded_bom=excluded_bom,
board_only=board_only,
)
# get_component_properties needs GetPosition, GetBoundingBox etc.
fp.GetPosition.return_value = MagicMock(x=0, y=0)
fp.GetOrientation.return_value = MagicMock()
fp.GetOrientation.return_value.AsDegrees.return_value = 0.0
fp.GetCourtyard.return_value = MagicMock()
fp.GetCourtyard.return_value.OutlineCount.return_value = 0
fp.GetBoundingBox.return_value = MagicMock(
GetLeft=lambda: 0,
GetTop=lambda: 0,
GetRight=lambda: 0,
GetBottom=lambda: 0,
)
cmd = _make_component_commands(fp)
# board.GetLayerName must return a string
cmd.board.GetLayerName.return_value = "F.Cu"
return cmd
def test_smd_type_string(self):
cmd = self._setup(_pcbnew_stub.FP_SMD)
result = cmd.get_component_properties({"reference": "R1"})
assert result["success"] is True
assert result["component"]["attributes"]["type"] == "smd"
def test_through_hole_type_string(self):
cmd = self._setup(_pcbnew_stub.FP_THROUGH_HOLE)
result = cmd.get_component_properties({"reference": "R1"})
assert result["component"]["attributes"]["type"] == "through_hole"
def test_unspecified_type_string(self):
cmd = self._setup(0)
result = cmd.get_component_properties({"reference": "R1"})
assert result["component"]["attributes"]["type"] == "unspecified"
def test_exclusion_flags_reported(self):
cmd = self._setup(
_pcbnew_stub.FP_SMD,
excluded_pos=True,
excluded_bom=True,
board_only=False,
)
result = cmd.get_component_properties({"reference": "R1"})
attrs = result["component"]["attributes"]
assert attrs["exclude_from_pos_files"] is True
assert attrs["exclude_from_bom"] is True
assert attrs["not_in_schematic"] is False
# ---------------------------------------------------------------------------
# IPC path KiCADInterface._ipc_set_footprint_type
# ---------------------------------------------------------------------------
def _make_ipc_iface():
"""Construct a minimal KiCADInterface with a mocked IPC board API."""
with patch("kicad_interface.USE_IPC_BACKEND", True):
from kicad_interface import KiCADInterface
iface = KiCADInterface.__new__(KiCADInterface)
iface.use_ipc = True
iface.board = None
iface.ipc_board_api = MagicMock()
iface.component_commands = MagicMock()
return iface
def _make_kipy_fp_mock(reference: str):
"""Return a MagicMock that looks enough like a kipy Footprint proto wrapper."""
fp = MagicMock()
fp.reference_field.text.value = reference
fp.proto.attributes.mounting_style = 0
fp.proto.attributes.exclude_from_position_files = False
fp.proto.attributes.exclude_from_bill_of_materials = False
fp.proto.attributes.not_in_schematic = False
return fp
def _fms_stub():
return types.SimpleNamespace(FMS_THROUGH_HOLE=1, FMS_SMD=2, FMS_UNSPECIFIED=3)
class TestSetFootprintTypeIpc:
def test_ipc_sets_mounting_style_smd(self):
iface = _make_ipc_iface()
target_fp = _make_kipy_fp_mock("U1")
board_mock = iface.ipc_board_api._get_board.return_value
board_mock.get_footprints.return_value = [target_fp]
fms = _fms_stub()
proto_mod = types.ModuleType("kipy.proto.board.board_types_pb2")
proto_mod.FootprintMountingStyle = fms
with patch.dict(sys.modules, {"kipy.proto.board.board_types_pb2": proto_mod}):
result = iface._ipc_set_footprint_type({"reference": "U1", "type": "smd"})
assert result["success"] is True
assert target_fp.proto.attributes.mounting_style == fms.FMS_SMD
board_mock.update_items.assert_called_once_with([target_fp])
def test_ipc_sets_mounting_style_through_hole(self):
iface = _make_ipc_iface()
target_fp = _make_kipy_fp_mock("J1")
board_mock = iface.ipc_board_api._get_board.return_value
board_mock.get_footprints.return_value = [target_fp]
fms = _fms_stub()
proto_mod = types.ModuleType("kipy.proto.board.board_types_pb2")
proto_mod.FootprintMountingStyle = fms
with patch.dict(sys.modules, {"kipy.proto.board.board_types_pb2": proto_mod}):
result = iface._ipc_set_footprint_type({"reference": "J1", "type": "through_hole"})
assert result["success"] is True
assert target_fp.proto.attributes.mounting_style == fms.FMS_THROUGH_HOLE
def test_ipc_sets_exclude_from_pos_when_provided(self):
iface = _make_ipc_iface()
target_fp = _make_kipy_fp_mock("R1")
board_mock = iface.ipc_board_api._get_board.return_value
board_mock.get_footprints.return_value = [target_fp]
fms = _fms_stub()
proto_mod = types.ModuleType("kipy.proto.board.board_types_pb2")
proto_mod.FootprintMountingStyle = fms
with patch.dict(sys.modules, {"kipy.proto.board.board_types_pb2": proto_mod}):
result = iface._ipc_set_footprint_type(
{"reference": "R1", "type": "through_hole", "exclude_from_pos_files": True}
)
assert result["success"] is True
assert target_fp.proto.attributes.exclude_from_position_files is True
def test_ipc_sets_exclude_from_bom(self):
iface = _make_ipc_iface()
target_fp = _make_kipy_fp_mock("R1")
board_mock = iface.ipc_board_api._get_board.return_value
board_mock.get_footprints.return_value = [target_fp]
fms = _fms_stub()
proto_mod = types.ModuleType("kipy.proto.board.board_types_pb2")
proto_mod.FootprintMountingStyle = fms
with patch.dict(sys.modules, {"kipy.proto.board.board_types_pb2": proto_mod}):
result = iface._ipc_set_footprint_type(
{"reference": "R1", "type": "smd", "exclude_from_bom": True}
)
assert result["success"] is True
assert target_fp.proto.attributes.exclude_from_bill_of_materials is True
def test_ipc_not_in_schematic_written(self):
iface = _make_ipc_iface()
target_fp = _make_kipy_fp_mock("MH1")
board_mock = iface.ipc_board_api._get_board.return_value
board_mock.get_footprints.return_value = [target_fp]
fms = _fms_stub()
proto_mod = types.ModuleType("kipy.proto.board.board_types_pb2")
proto_mod.FootprintMountingStyle = fms
with patch.dict(sys.modules, {"kipy.proto.board.board_types_pb2": proto_mod}):
result = iface._ipc_set_footprint_type(
{"reference": "MH1", "type": "unspecified", "not_in_schematic": True}
)
assert result["success"] is True
assert target_fp.proto.attributes.not_in_schematic is True
def test_ipc_component_not_found_returns_error(self):
iface = _make_ipc_iface()
board_mock = iface.ipc_board_api._get_board.return_value
board_mock.get_footprints.return_value = []
fms = _fms_stub()
proto_mod = types.ModuleType("kipy.proto.board.board_types_pb2")
proto_mod.FootprintMountingStyle = fms
with patch.dict(sys.modules, {"kipy.proto.board.board_types_pb2": proto_mod}):
result = iface._ipc_set_footprint_type({"reference": "ZZZ", "type": "smd"})
assert result["success"] is False
assert "not found" in result["message"].lower()
def test_ipc_invalid_type_returns_error(self):
iface = _make_ipc_iface()
result = iface._ipc_set_footprint_type({"reference": "R1", "type": "bad_type"})
assert result["success"] is False
def test_ipc_fallback_to_swig_on_proto_error(self):
"""When kipy proto import fails, the handler must fall back to the SWIG path."""
iface = _make_ipc_iface()
target_fp = _make_kipy_fp_mock("R1")
board_mock = iface.ipc_board_api._get_board.return_value
board_mock.get_footprints.return_value = [target_fp]
iface.board = MagicMock() # SWIG board available
swig_result = {"success": True, "component": {"reference": "R1", "type": "smd"}}
iface.component_commands.set_footprint_type.return_value = swig_result
with patch.dict(sys.modules, {"kipy.proto.board.board_types_pb2": None}):
result = iface._ipc_set_footprint_type({"reference": "R1", "type": "smd"})
iface.component_commands.set_footprint_type.assert_called_once()
assert result["success"] is True
assert result.get("_backend") == "swig"
def test_ipc_response_includes_backend_marker(self):
"""Successful IPC response must carry _backend: 'ipc'."""
iface = _make_ipc_iface()
target_fp = _make_kipy_fp_mock("C1")
board_mock = iface.ipc_board_api._get_board.return_value
board_mock.get_footprints.return_value = [target_fp]
fms = _fms_stub()
proto_mod = types.ModuleType("kipy.proto.board.board_types_pb2")
proto_mod.FootprintMountingStyle = fms
with patch.dict(sys.modules, {"kipy.proto.board.board_types_pb2": proto_mod}):
result = iface._ipc_set_footprint_type({"reference": "C1", "type": "smd"})
assert result.get("_backend") == "ipc"
assert result.get("_realtime") is True

View File

@@ -74,7 +74,7 @@ class TestAddMissingFootprintsFromSchematic:
}
],
),
patch("kicad_interface.pcbnew") as mock_pcbnew,
patch("commands.schematic_handlers.pcbnew") as mock_pcbnew,
patch("commands.library.LibraryManager") as mock_lm_cls,
):
mock_pcbnew.FootprintLoad.return_value = loaded_module
@@ -113,7 +113,7 @@ class TestAddMissingFootprintsFromSchematic:
}
],
),
patch("kicad_interface.pcbnew"),
patch("commands.schematic_handlers.pcbnew"),
patch("commands.library.LibraryManager") as mock_lm_cls,
):
lm = MagicMock()
@@ -144,7 +144,7 @@ class TestAddMissingFootprintsFromSchematic:
{"reference": "#FLG0001", "value": "PWR_FLAG", "footprint": ""},
],
),
patch("kicad_interface.pcbnew"),
patch("commands.schematic_handlers.pcbnew"),
patch("commands.library.LibraryManager") as mock_lm_cls,
):
mock_lm_cls.return_value = MagicMock(libraries={})
@@ -172,7 +172,7 @@ class TestAddMissingFootprintsFromSchematic:
"_extract_components_from_schematic",
return_value=[{"reference": "R1", "value": "10k", "footprint": ""}],
),
patch("kicad_interface.pcbnew"),
patch("commands.schematic_handlers.pcbnew"),
patch("commands.library.LibraryManager") as mock_lm_cls,
):
mock_lm_cls.return_value = MagicMock(libraries={})
@@ -205,7 +205,7 @@ class TestAddMissingFootprintsFromSchematic:
}
],
),
patch("kicad_interface.pcbnew"),
patch("commands.schematic_handlers.pcbnew"),
patch("commands.library.LibraryManager") as mock_lm_cls,
):
mock_lm_cls.return_value = MagicMock(libraries={}) # MyVendor not present

9
vitest.config.ts Normal file
View File

@@ -0,0 +1,9 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
include: ["tests-ts/**/*.test.ts"],
environment: "node",
reporters: ["verbose"],
},
});