fix(sync_schematic_to_board): add missing footprints, not just nets (#171)
The handler iterated `board.GetFootprints()` and assigned nets to existing
pads, but had no path to *add* footprints for schematic symbols whose
Reference was not yet on the board. New parts placed on the schematic
landed in the net list with no PCB representation — the rats nest had
nowhere to terminate, and place_component / route_pad_to_pad would fail
because the target footprint did not exist.
KiCad's "Update PCB from Schematic" (F8) implicitly adds the missing
footprints; bring the MCP's behaviour in line.
Implementation:
* `_extract_components_from_schematic` runs `kicad-cli sch export netlist
--format kicadxml` (the same path `_handle_generate_netlist` already
uses) and returns a flat `[{reference, value, footprint}]` list. Walks
hierarchical sub-sheets transparently because kicad-cli does.
* `_add_missing_footprints_from_schematic` resolves each missing component
against the project's fp-lib-table via `LibraryManager`, calls
`pcbnew.FootprintLoad`, sets reference / value / FPID, and places the
footprint at the board origin (the user / autoplacer can position it).
Power and flag references (`#PWR…`, `#FLG…`) are excluded — they have
no PCB footprint.
* The pad-net assignment loop now runs *after* the add path, so newly
placed footprints get their nets assigned in the same call.
* Response payload gains `footprints_added` and `footprints_skipped`
diagnostic lists. The textual `message` field reports both the new
footprint count and the existing net / pad counts.
Adds tests/test_sync_schematic_to_board_footprints.py — 9 unit tests
covering the add path (missing ref, already-present ref, power refs,
empty footprint, unknown library) and the kicad-cli helper (XML parse,
missing kicad-cli, non-zero exit).
This commit is contained in:
@@ -4181,6 +4181,15 @@ class KiCADInterface:
|
|||||||
# Build hierarchical pad→net map (walks all sub-sheets)
|
# Build hierarchical pad→net map (walks all sub-sheets)
|
||||||
pad_net_map, net_names = self._build_hierarchical_pad_net_map(schematic_path)
|
pad_net_map, net_names = self._build_hierarchical_pad_net_map(schematic_path)
|
||||||
|
|
||||||
|
# Add missing footprints from the schematic to the board *before*
|
||||||
|
# we add nets and assign pads — F8 in KiCad does this implicitly
|
||||||
|
# ("Update PCB from Schematic"), but our previous implementation
|
||||||
|
# only mutated nets, leaving newly-added schematic symbols with no
|
||||||
|
# PCB footprint at all.
|
||||||
|
added_footprints, skipped_footprints = self._add_missing_footprints_from_schematic(
|
||||||
|
board, schematic_path
|
||||||
|
)
|
||||||
|
|
||||||
# Add all nets to board
|
# Add all nets to board
|
||||||
netinfo = board.GetNetInfo()
|
netinfo = board.GetNetInfo()
|
||||||
nets_by_name = netinfo.NetsByName()
|
nets_by_name = netinfo.NetsByName()
|
||||||
@@ -4195,7 +4204,7 @@ class KiCADInterface:
|
|||||||
netinfo = board.GetNetInfo()
|
netinfo = board.GetNetInfo()
|
||||||
nets_by_name = netinfo.NetsByName()
|
nets_by_name = netinfo.NetsByName()
|
||||||
|
|
||||||
# Assign nets to pads
|
# Assign nets to pads (now also covers any footprints we just added)
|
||||||
assigned_pads = 0
|
assigned_pads = 0
|
||||||
unmatched = []
|
unmatched = []
|
||||||
for fp in board.GetFootprints():
|
for fp in board.GetFootprints():
|
||||||
@@ -4219,15 +4228,21 @@ class KiCADInterface:
|
|||||||
self._update_command_handlers()
|
self._update_command_handlers()
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"sync_schematic_to_board: {len(added_nets)} nets added, {assigned_pads} pads assigned"
|
f"sync_schematic_to_board: {len(added_nets)} nets added, "
|
||||||
|
f"{len(added_footprints)} footprints added, {assigned_pads} pads assigned"
|
||||||
)
|
)
|
||||||
return {
|
return {
|
||||||
"success": True,
|
"success": True,
|
||||||
"message": f"PCB nets synced from schematic: {len(added_nets)} nets added, {assigned_pads} pads assigned",
|
"message": (
|
||||||
|
f"PCB updated from schematic: {len(added_footprints)} footprints added, "
|
||||||
|
f"{len(added_nets)} nets added, {assigned_pads} pads assigned"
|
||||||
|
),
|
||||||
"nets_added": added_nets,
|
"nets_added": added_nets,
|
||||||
"nets_total": len(net_names),
|
"nets_total": len(net_names),
|
||||||
"pads_assigned": assigned_pads,
|
"pads_assigned": assigned_pads,
|
||||||
"unmatched_pads_sample": unmatched[:10],
|
"unmatched_pads_sample": unmatched[:10],
|
||||||
|
"footprints_added": added_footprints,
|
||||||
|
"footprints_skipped": skipped_footprints,
|
||||||
}
|
}
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -4237,6 +4252,155 @@ class KiCADInterface:
|
|||||||
logger.error(traceback.format_exc())
|
logger.error(traceback.format_exc())
|
||||||
return {"success": False, "message": str(e)}
|
return {"success": False, "message": str(e)}
|
||||||
|
|
||||||
|
def _extract_components_from_schematic(self, schematic_path: str) -> List[Dict[str, str]]:
|
||||||
|
"""Run kicad-cli netlist export and return the flat list of components.
|
||||||
|
|
||||||
|
Each entry: {"reference": str, "value": str, "footprint": str}
|
||||||
|
Empty list on any failure (kicad-cli missing, parse error, etc.) — the
|
||||||
|
caller treats that as "no missing footprints to add".
|
||||||
|
"""
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
import xml.etree.ElementTree as ET
|
||||||
|
|
||||||
|
kicad_cli = self._find_kicad_cli_static()
|
||||||
|
if not kicad_cli:
|
||||||
|
logger.warning("kicad-cli not found — sync will not add new footprints")
|
||||||
|
return []
|
||||||
|
|
||||||
|
with tempfile.NamedTemporaryFile(suffix=".xml", delete=False) as tmp:
|
||||||
|
tmp_path = tmp.name
|
||||||
|
try:
|
||||||
|
cmd = [
|
||||||
|
kicad_cli,
|
||||||
|
"sch",
|
||||||
|
"export",
|
||||||
|
"netlist",
|
||||||
|
"--format",
|
||||||
|
"kicadxml",
|
||||||
|
"--output",
|
||||||
|
tmp_path,
|
||||||
|
schematic_path,
|
||||||
|
]
|
||||||
|
result = subprocess.run(cmd, capture_output=True, text=True, timeout=60)
|
||||||
|
if result.returncode != 0:
|
||||||
|
logger.warning(
|
||||||
|
f"kicad-cli netlist export failed (exit {result.returncode}): "
|
||||||
|
f"{result.stderr.strip()}"
|
||||||
|
)
|
||||||
|
return []
|
||||||
|
|
||||||
|
tree = ET.parse(tmp_path)
|
||||||
|
root = tree.getroot()
|
||||||
|
components = []
|
||||||
|
for comp in root.findall("./components/comp"):
|
||||||
|
components.append(
|
||||||
|
{
|
||||||
|
"reference": comp.get("ref", ""),
|
||||||
|
"value": comp.findtext("value", ""),
|
||||||
|
"footprint": comp.findtext("footprint", ""),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return components
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to extract components from schematic: {e}")
|
||||||
|
return []
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
os.unlink(tmp_path)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _add_missing_footprints_from_schematic(
|
||||||
|
self, board: Any, schematic_path: str
|
||||||
|
) -> Tuple[List[Dict[str, str]], List[Dict[str, str]]]:
|
||||||
|
"""Add footprints to ``board`` for any schematic component not yet present.
|
||||||
|
|
||||||
|
New footprints are placed at the board origin so the user can move them
|
||||||
|
into position. Power/flag references (``#PWR``, ``#FLG``) are skipped —
|
||||||
|
they have no PCB representation.
|
||||||
|
|
||||||
|
Returns ``(added, skipped)``: each entry is
|
||||||
|
``{"reference": str, "footprint": str, "reason": str?}``.
|
||||||
|
"""
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from commands.library import LibraryManager
|
||||||
|
|
||||||
|
added: List[Dict[str, str]] = []
|
||||||
|
skipped: List[Dict[str, str]] = []
|
||||||
|
|
||||||
|
components = self._extract_components_from_schematic(schematic_path)
|
||||||
|
if not components:
|
||||||
|
return added, skipped
|
||||||
|
|
||||||
|
existing_refs = {fp.GetReference() for fp in board.GetFootprints()}
|
||||||
|
project_dir = Path(schematic_path).parent
|
||||||
|
library_manager = LibraryManager(project_path=project_dir)
|
||||||
|
|
||||||
|
for comp in components:
|
||||||
|
ref = comp["reference"]
|
||||||
|
fp_str = comp["footprint"]
|
||||||
|
if not ref or ref.startswith("#"):
|
||||||
|
# Power flags / global indicators — no PCB footprint expected.
|
||||||
|
continue
|
||||||
|
if ref in existing_refs:
|
||||||
|
continue
|
||||||
|
if not fp_str or ":" not in fp_str:
|
||||||
|
skipped.append(
|
||||||
|
{
|
||||||
|
"reference": ref,
|
||||||
|
"footprint": fp_str,
|
||||||
|
"reason": "no Library:Name footprint set on schematic symbol",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
lib_name, fp_name = fp_str.split(":", 1)
|
||||||
|
library_path = library_manager.libraries.get(lib_name)
|
||||||
|
if not library_path:
|
||||||
|
skipped.append(
|
||||||
|
{
|
||||||
|
"reference": ref,
|
||||||
|
"footprint": fp_str,
|
||||||
|
"reason": f"library '{lib_name}' not in fp-lib-table",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
module = pcbnew.FootprintLoad(library_path, fp_name)
|
||||||
|
except Exception as e:
|
||||||
|
skipped.append(
|
||||||
|
{"reference": ref, "footprint": fp_str, "reason": f"FootprintLoad failed: {e}"}
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not module:
|
||||||
|
skipped.append(
|
||||||
|
{
|
||||||
|
"reference": ref,
|
||||||
|
"footprint": fp_str,
|
||||||
|
"reason": f"footprint '{fp_name}' not in '{lib_name}'",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
module.SetReference(ref)
|
||||||
|
if comp["value"]:
|
||||||
|
module.SetValue(comp["value"])
|
||||||
|
module.SetFPID(pcbnew.LIB_ID(lib_name, fp_name))
|
||||||
|
# Place at board origin; user / autoplacer can position from there.
|
||||||
|
module.SetPosition(pcbnew.VECTOR2I(0, 0))
|
||||||
|
|
||||||
|
board.Add(module)
|
||||||
|
existing_refs.add(ref)
|
||||||
|
added.append({"reference": ref, "footprint": fp_str})
|
||||||
|
|
||||||
|
if added:
|
||||||
|
logger.info(f"_add_missing_footprints_from_schematic: added {len(added)} footprints")
|
||||||
|
return added, skipped
|
||||||
|
|
||||||
# ===================================================================
|
# ===================================================================
|
||||||
# Schematic analysis tools (read-only)
|
# Schematic analysis tools (read-only)
|
||||||
# ===================================================================
|
# ===================================================================
|
||||||
|
|||||||
322
tests/test_sync_schematic_to_board_footprints.py
Normal file
322
tests/test_sync_schematic_to_board_footprints.py
Normal file
@@ -0,0 +1,322 @@
|
|||||||
|
"""
|
||||||
|
Regression tests for sync_schematic_to_board's footprint-add path.
|
||||||
|
|
||||||
|
Before the fix, _handle_sync_schematic_to_board only mutated nets and pad
|
||||||
|
assignments — it iterated board.GetFootprints() and never added new ones.
|
||||||
|
A schematic symbol whose Reference was not yet on the PCB was therefore
|
||||||
|
silently dropped on the floor: no footprint added, no rats nest reaching
|
||||||
|
the missing component.
|
||||||
|
|
||||||
|
These tests cover _add_missing_footprints_from_schematic and its kicad-cli
|
||||||
|
helper _extract_components_from_schematic.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, List
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent.parent / "python"))
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _make_existing_fp(reference: str) -> MagicMock:
|
||||||
|
fp = MagicMock(name=f"existing_fp_{reference}")
|
||||||
|
fp.GetReference.return_value = reference
|
||||||
|
return fp
|
||||||
|
|
||||||
|
|
||||||
|
def _interface() -> Any:
|
||||||
|
from kicad_interface import KiCADInterface
|
||||||
|
|
||||||
|
return KiCADInterface()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# _add_missing_footprints_from_schematic
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestAddMissingFootprintsFromSchematic:
|
||||||
|
"""The fix path: walk netlist, add footprints for refs not yet on the board."""
|
||||||
|
|
||||||
|
def _patch_extract(self, components: List[dict]) -> Any:
|
||||||
|
return patch.object(
|
||||||
|
_interface().__class__,
|
||||||
|
"_extract_components_from_schematic",
|
||||||
|
return_value=components,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_adds_footprint_for_missing_reference(self, tmp_path: Any) -> None:
|
||||||
|
sch = tmp_path / "test.kicad_sch"
|
||||||
|
sch.write_text("(kicad_sch)\n")
|
||||||
|
|
||||||
|
board = MagicMock(name="board")
|
||||||
|
board.GetFootprints.return_value = [] # nothing on the board yet
|
||||||
|
|
||||||
|
loaded_module = MagicMock(name="loaded_R0603")
|
||||||
|
with (
|
||||||
|
patch.object(
|
||||||
|
_interface().__class__,
|
||||||
|
"_extract_components_from_schematic",
|
||||||
|
return_value=[
|
||||||
|
{
|
||||||
|
"reference": "R99",
|
||||||
|
"value": "10k",
|
||||||
|
"footprint": "Resistor_SMD:R_0603_1608Metric",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
),
|
||||||
|
patch("kicad_interface.pcbnew") as mock_pcbnew,
|
||||||
|
patch("commands.library.LibraryManager") as mock_lm_cls,
|
||||||
|
):
|
||||||
|
mock_pcbnew.FootprintLoad.return_value = loaded_module
|
||||||
|
lm = MagicMock()
|
||||||
|
lm.libraries = {"Resistor_SMD": "/fake/Resistor_SMD.pretty"}
|
||||||
|
mock_lm_cls.return_value = lm
|
||||||
|
|
||||||
|
iface = _interface()
|
||||||
|
added, skipped = iface._add_missing_footprints_from_schematic(board, str(sch))
|
||||||
|
|
||||||
|
assert len(added) == 1
|
||||||
|
assert added[0]["reference"] == "R99"
|
||||||
|
assert added[0]["footprint"] == "Resistor_SMD:R_0603_1608Metric"
|
||||||
|
assert skipped == []
|
||||||
|
# Footprint was added to the board.
|
||||||
|
board.Add.assert_called_once_with(loaded_module)
|
||||||
|
loaded_module.SetReference.assert_called_with("R99")
|
||||||
|
loaded_module.SetValue.assert_called_with("10k")
|
||||||
|
|
||||||
|
def test_skips_reference_already_on_board(self, tmp_path: Any) -> None:
|
||||||
|
sch = tmp_path / "test.kicad_sch"
|
||||||
|
sch.write_text("(kicad_sch)\n")
|
||||||
|
|
||||||
|
board = MagicMock(name="board")
|
||||||
|
board.GetFootprints.return_value = [_make_existing_fp("R1")]
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(
|
||||||
|
_interface().__class__,
|
||||||
|
"_extract_components_from_schematic",
|
||||||
|
return_value=[
|
||||||
|
{
|
||||||
|
"reference": "R1",
|
||||||
|
"value": "10k",
|
||||||
|
"footprint": "Resistor_SMD:R_0603_1608Metric",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
),
|
||||||
|
patch("kicad_interface.pcbnew"),
|
||||||
|
patch("commands.library.LibraryManager") as mock_lm_cls,
|
||||||
|
):
|
||||||
|
lm = MagicMock()
|
||||||
|
lm.libraries = {"Resistor_SMD": "/fake/Resistor_SMD.pretty"}
|
||||||
|
mock_lm_cls.return_value = lm
|
||||||
|
|
||||||
|
iface = _interface()
|
||||||
|
added, skipped = iface._add_missing_footprints_from_schematic(board, str(sch))
|
||||||
|
|
||||||
|
assert added == []
|
||||||
|
assert skipped == []
|
||||||
|
board.Add.assert_not_called()
|
||||||
|
|
||||||
|
def test_skips_power_symbols(self, tmp_path: Any) -> None:
|
||||||
|
"""References starting with # (e.g. #PWR, #FLG) have no PCB footprint."""
|
||||||
|
sch = tmp_path / "test.kicad_sch"
|
||||||
|
sch.write_text("(kicad_sch)\n")
|
||||||
|
|
||||||
|
board = MagicMock(name="board")
|
||||||
|
board.GetFootprints.return_value = []
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(
|
||||||
|
_interface().__class__,
|
||||||
|
"_extract_components_from_schematic",
|
||||||
|
return_value=[
|
||||||
|
{"reference": "#PWR0001", "value": "GND", "footprint": ""},
|
||||||
|
{"reference": "#FLG0001", "value": "PWR_FLAG", "footprint": ""},
|
||||||
|
],
|
||||||
|
),
|
||||||
|
patch("kicad_interface.pcbnew"),
|
||||||
|
patch("commands.library.LibraryManager") as mock_lm_cls,
|
||||||
|
):
|
||||||
|
mock_lm_cls.return_value = MagicMock(libraries={})
|
||||||
|
|
||||||
|
iface = _interface()
|
||||||
|
added, skipped = iface._add_missing_footprints_from_schematic(board, str(sch))
|
||||||
|
|
||||||
|
assert added == []
|
||||||
|
# Power refs are excluded entirely — they don't show up in the skipped
|
||||||
|
# diagnostic list either, since "no PCB footprint" is the right answer.
|
||||||
|
assert skipped == []
|
||||||
|
board.Add.assert_not_called()
|
||||||
|
|
||||||
|
def test_records_skip_reason_for_missing_footprint_property(self, tmp_path: Any) -> None:
|
||||||
|
"""A schematic symbol with no Footprint property is reported as skipped."""
|
||||||
|
sch = tmp_path / "test.kicad_sch"
|
||||||
|
sch.write_text("(kicad_sch)\n")
|
||||||
|
|
||||||
|
board = MagicMock(name="board")
|
||||||
|
board.GetFootprints.return_value = []
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(
|
||||||
|
_interface().__class__,
|
||||||
|
"_extract_components_from_schematic",
|
||||||
|
return_value=[{"reference": "R1", "value": "10k", "footprint": ""}],
|
||||||
|
),
|
||||||
|
patch("kicad_interface.pcbnew"),
|
||||||
|
patch("commands.library.LibraryManager") as mock_lm_cls,
|
||||||
|
):
|
||||||
|
mock_lm_cls.return_value = MagicMock(libraries={})
|
||||||
|
|
||||||
|
iface = _interface()
|
||||||
|
added, skipped = iface._add_missing_footprints_from_schematic(board, str(sch))
|
||||||
|
|
||||||
|
assert added == []
|
||||||
|
assert len(skipped) == 1
|
||||||
|
assert skipped[0]["reference"] == "R1"
|
||||||
|
assert "no Library:Name" in skipped[0]["reason"]
|
||||||
|
|
||||||
|
def test_records_skip_reason_for_unknown_library(self, tmp_path: Any) -> None:
|
||||||
|
"""If the footprint's library nickname isn't in fp-lib-table, skip with reason."""
|
||||||
|
sch = tmp_path / "test.kicad_sch"
|
||||||
|
sch.write_text("(kicad_sch)\n")
|
||||||
|
|
||||||
|
board = MagicMock(name="board")
|
||||||
|
board.GetFootprints.return_value = []
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(
|
||||||
|
_interface().__class__,
|
||||||
|
"_extract_components_from_schematic",
|
||||||
|
return_value=[
|
||||||
|
{
|
||||||
|
"reference": "U1",
|
||||||
|
"value": "MyChip",
|
||||||
|
"footprint": "MyVendor:MyChip_QFN24",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
),
|
||||||
|
patch("kicad_interface.pcbnew"),
|
||||||
|
patch("commands.library.LibraryManager") as mock_lm_cls,
|
||||||
|
):
|
||||||
|
mock_lm_cls.return_value = MagicMock(libraries={}) # MyVendor not present
|
||||||
|
|
||||||
|
iface = _interface()
|
||||||
|
added, skipped = iface._add_missing_footprints_from_schematic(board, str(sch))
|
||||||
|
|
||||||
|
assert added == []
|
||||||
|
assert len(skipped) == 1
|
||||||
|
assert skipped[0]["reference"] == "U1"
|
||||||
|
assert "MyVendor" in skipped[0]["reason"]
|
||||||
|
|
||||||
|
def test_no_op_when_kicad_cli_returns_empty(self, tmp_path: Any) -> None:
|
||||||
|
"""If the netlist extractor returns nothing, the helper is a no-op."""
|
||||||
|
sch = tmp_path / "test.kicad_sch"
|
||||||
|
sch.write_text("(kicad_sch)\n")
|
||||||
|
|
||||||
|
board = MagicMock(name="board")
|
||||||
|
board.GetFootprints.return_value = []
|
||||||
|
|
||||||
|
with patch.object(
|
||||||
|
_interface().__class__,
|
||||||
|
"_extract_components_from_schematic",
|
||||||
|
return_value=[],
|
||||||
|
):
|
||||||
|
iface = _interface()
|
||||||
|
added, skipped = iface._add_missing_footprints_from_schematic(board, str(sch))
|
||||||
|
|
||||||
|
assert added == []
|
||||||
|
assert skipped == []
|
||||||
|
board.Add.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# _extract_components_from_schematic
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestExtractComponentsFromSchematic:
|
||||||
|
"""The kicad-cli helper that produces (reference, value, footprint) records."""
|
||||||
|
|
||||||
|
def test_parses_kicad_xml_netlist(self, tmp_path: Any) -> None:
|
||||||
|
netlist_xml = """<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<export version="E">
|
||||||
|
<design />
|
||||||
|
<components>
|
||||||
|
<comp ref="R1">
|
||||||
|
<value>10k</value>
|
||||||
|
<footprint>Resistor_SMD:R_0603_1608Metric</footprint>
|
||||||
|
</comp>
|
||||||
|
<comp ref="C1">
|
||||||
|
<value>0.1uF</value>
|
||||||
|
<footprint>Capacitor_SMD:C_0603_1608Metric</footprint>
|
||||||
|
</comp>
|
||||||
|
<comp ref="U1">
|
||||||
|
<value>MyChip</value>
|
||||||
|
<footprint />
|
||||||
|
</comp>
|
||||||
|
</components>
|
||||||
|
<nets />
|
||||||
|
</export>
|
||||||
|
"""
|
||||||
|
sch = tmp_path / "test.kicad_sch"
|
||||||
|
sch.write_text("(kicad_sch)\n")
|
||||||
|
|
||||||
|
def fake_run(cmd: Any, **kwargs: Any) -> Any:
|
||||||
|
output_idx = cmd.index("--output") + 1
|
||||||
|
Path(cmd[output_idx]).write_text(netlist_xml)
|
||||||
|
return MagicMock(returncode=0, stderr="", stdout="")
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(
|
||||||
|
_interface().__class__, "_find_kicad_cli_static", return_value="/fake/kicad-cli"
|
||||||
|
),
|
||||||
|
patch("subprocess.run", side_effect=fake_run),
|
||||||
|
):
|
||||||
|
iface = _interface()
|
||||||
|
comps = iface._extract_components_from_schematic(str(sch))
|
||||||
|
|
||||||
|
assert len(comps) == 3
|
||||||
|
refs = [c["reference"] for c in comps]
|
||||||
|
assert refs == ["R1", "C1", "U1"]
|
||||||
|
# Empty <footprint /> resolves to ""
|
||||||
|
u1 = next(c for c in comps if c["reference"] == "U1")
|
||||||
|
assert u1["footprint"] == ""
|
||||||
|
|
||||||
|
def test_returns_empty_when_kicad_cli_missing(self, tmp_path: Any) -> None:
|
||||||
|
sch = tmp_path / "test.kicad_sch"
|
||||||
|
sch.write_text("(kicad_sch)\n")
|
||||||
|
|
||||||
|
with patch.object(_interface().__class__, "_find_kicad_cli_static", return_value=None):
|
||||||
|
iface = _interface()
|
||||||
|
comps = iface._extract_components_from_schematic(str(sch))
|
||||||
|
|
||||||
|
assert comps == []
|
||||||
|
|
||||||
|
def test_returns_empty_when_kicad_cli_fails(self, tmp_path: Any) -> None:
|
||||||
|
sch = tmp_path / "test.kicad_sch"
|
||||||
|
sch.write_text("(kicad_sch)\n")
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(
|
||||||
|
_interface().__class__, "_find_kicad_cli_static", return_value="/fake/kicad-cli"
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"subprocess.run",
|
||||||
|
return_value=MagicMock(returncode=1, stderr="boom", stdout=""),
|
||||||
|
),
|
||||||
|
):
|
||||||
|
iface = _interface()
|
||||||
|
comps = iface._extract_components_from_schematic(str(sch))
|
||||||
|
|
||||||
|
assert comps == []
|
||||||
Reference in New Issue
Block a user