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.
323 lines
11 KiB
Python
323 lines
11 KiB
Python
"""
|
|
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("commands.schematic_handlers.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("commands.schematic_handlers.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("commands.schematic_handlers.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("commands.schematic_handlers.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("commands.schematic_handlers.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 == []
|