From 12523c7575a65d870cfdb1ecbc4c99a93cdcdd7e Mon Sep 17 00:00:00 2001 From: Gavin Colonese Date: Fri, 12 Jun 2026 07:47:34 -0400 Subject: [PATCH] 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) --- pyproject.toml | 1 + python/commands/component.py | 114 +++++++- python/kicad_interface.py | 98 ++++++- src/tools/component.ts | 53 ++++ tests/test_set_footprint_type.py | 439 +++++++++++++++++++++++++++++++ 5 files changed, 700 insertions(+), 5 deletions(-) create mode 100644 tests/test_set_footprint_type.py diff --git a/pyproject.toml b/pyproject.toml index a353a50..f8ed51b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,6 +33,7 @@ module = [ "cairosvg", "sexpdata", "skip", + "fitz", "kipy", "kipy.*", "schematic", diff --git a/python/commands/component.py b/python/commands/component.py index 1f5d22e..39e9fb0 100644 --- a/python/commands/component.py +++ b/python/commands/component.py @@ -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, diff --git a/python/kicad_interface.py b/python/kicad_interface.py index eb15e26..1e9c40e 100644 --- a/python/kicad_interface.py +++ b/python/kicad_interface.py @@ -325,9 +325,9 @@ try: from commands.project import ProjectCommands from commands.routing import RoutingCommands from commands.schematic import SchematicManager - from commands.schematic_hierarchy import SchematicHierarchyCommands - from commands.schematic_field_layout import SchematicFieldLayoutCommands from commands.schematic_batch import SchematicBatchCommands + from commands.schematic_field_layout import SchematicFieldLayoutCommands + from commands.schematic_hierarchy import SchematicHierarchyCommands from commands.symbol_creator import SymbolCreator from commands.symbol_pins import SymbolPinCommands @@ -504,6 +504,7 @@ class KiCADInterface: "align_components": self.component_commands.align_components, "check_courtyard_overlaps": self.component_commands.check_courtyard_overlaps, "duplicate_component": self.component_commands.duplicate_component, + "set_footprint_type": self.component_commands.set_footprint_type, # Routing commands "add_net": self.routing_commands.add_net, "route_trace": self.routing_commands.route_trace, @@ -678,6 +679,7 @@ class KiCADInterface: "delete_component": "_ipc_delete_component", "get_component_list": "_ipc_get_component_list", "get_component_properties": "_ipc_get_component_properties", + "set_footprint_type": "_ipc_set_footprint_type", # Save command "save_project": "_ipc_save_project", } @@ -930,6 +932,7 @@ class KiCADInterface: "sync_schematic_to_board", "connect_passthrough", "connect_to_net", + "set_footprint_type", } @staticmethod @@ -6267,6 +6270,97 @@ print("ok") logger.error(f"IPC get_component_properties error: {e}") return {"success": False, "message": str(e)} + def _ipc_set_footprint_type(self, params: Dict[str, Any]) -> Dict[str, Any]: + """IPC handler for set_footprint_type. + + Sets the placement type (through_hole / smd / unspecified) and optional + exclusion flags on a footprint via the kipy proto API, so the change is + visible in the KiCAD UI without a manual reload. + + Falls back to the SWIG path if the IPC footprint lookup fails, because + kipy's Footprint wrapper does not always expose a ``not_in_schematic`` + setter depending on the installed kipy version. + """ + try: + reference = params.get("reference", params.get("componentId", "")) + fp_type = params.get("type") + + 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", + } + + board = self.ipc_board_api._get_board() + footprints = board.get_footprints() + + target_fp = None + for fp in footprints: + if fp.reference_field and fp.reference_field.text.value == reference: + target_fp = fp + break + + if not target_fp: + return {"success": False, "message": f"Component {reference} not found"} + + try: + from kipy.proto.board.board_types_pb2 import FootprintMountingStyle + + style_map = { + "through_hole": FootprintMountingStyle.FMS_THROUGH_HOLE, + "smd": FootprintMountingStyle.FMS_SMD, + "unspecified": FootprintMountingStyle.FMS_UNSPECIFIED, + } + target_fp.proto.attributes.mounting_style = style_map[fp_type] + + if "exclude_from_pos_files" in params: + target_fp.proto.attributes.exclude_from_position_files = bool( + params["exclude_from_pos_files"] + ) + if "exclude_from_bom" in params: + target_fp.proto.attributes.exclude_from_bill_of_materials = bool( + params["exclude_from_bom"] + ) + if "not_in_schematic" in params: + target_fp.proto.attributes.not_in_schematic = bool(params["not_in_schematic"]) + + commit = board.begin_commit() + board.update_items([target_fp]) + board.push_commit(commit, f"Set footprint type for {reference}") + + return { + "success": True, + "message": f"Updated footprint type for {reference} (visible in KiCAD UI)", + "component": { + "reference": reference, + "type": fp_type, + "exclude_from_pos_files": target_fp.proto.attributes.exclude_from_position_files, # noqa: E501 + "exclude_from_bom": target_fp.proto.attributes.exclude_from_bill_of_materials, + "not_in_schematic": target_fp.proto.attributes.not_in_schematic, + }, + "_backend": "ipc", + "_realtime": True, + } + + except Exception as ipc_err: + # IPC proto manipulation failed; fall back to SWIG for the write. + logger.warning( + f"_ipc_set_footprint_type: IPC proto update failed ({ipc_err}), " + "falling back to SWIG" + ) + if self.board: + result = self.component_commands.set_footprint_type(params) + if isinstance(result, dict): + result["_backend"] = "swig" + result["_realtime"] = False + return result + raise + + except Exception as e: + logger.error(f"IPC set_footprint_type error: {e}") + return {"success": False, "message": str(e)} + # ========================================================================= # Legacy IPC command handlers (explicit ipc_* commands) diff --git a/src/tools/component.ts b/src/tools/component.ts index 9ea3d20..6b87857 100644 --- a/src/tools/component.ts +++ b/src/tools/component.ts @@ -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 // ------------------------------------------------------ diff --git a/tests/test_set_footprint_type.py b/tests/test_set_footprint_type.py new file mode 100644 index 0000000..6e7e64a --- /dev/null +++ b/tests/test_set_footprint_type.py @@ -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