diff --git a/python/kicad_interface.py b/python/kicad_interface.py index 39ee958..b358445 100644 --- a/python/kicad_interface.py +++ b/python/kicad_interface.py @@ -623,8 +623,12 @@ class KiCADInterface: } sch_path = path if path and path != "." else None - schematic = SchematicManager.create_schematic(project_name, path=sch_path, metadata=metadata) - base_name = project_name if project_name.endswith(".kicad_sch") else f"{project_name}.kicad_sch" + schematic = SchematicManager.create_schematic( + project_name, path=sch_path, metadata=metadata + ) + base_name = ( + project_name if project_name.endswith(".kicad_sch") else f"{project_name}.kicad_sch" + ) normalized_path = path or "." file_path = os.path.join(normalized_path, base_name) success = SchematicManager.save_schematic(schematic, file_path) @@ -2075,6 +2079,13 @@ class KiCADInterface: if not schematic_path: return {"success": False, "message": "schematicPath is required"} + net_name = params.get("netName") + label_type = params.get("labelType") + + _valid_label_types = {"net", "global", "power"} + if label_type is not None and label_type not in _valid_label_types: + return {"success": False, "message": "labelType must be one of: net, global, power"} + schematic = SchematicManager.load_schematic(schematic_path) if not schematic: return {"success": False, "message": "Failed to load schematic"} @@ -2137,6 +2148,12 @@ class KiCADInterface: } ) + # Apply filters + if net_name is not None: + labels = [lbl for lbl in labels if lbl["name"] == net_name] + if label_type is not None: + labels = [lbl for lbl in labels if lbl["type"] == label_type] + return {"success": True, "labels": labels, "count": len(labels)} except Exception as e: diff --git a/src/tools/schematic.ts b/src/tools/schematic.ts index f234591..c7744e7 100644 --- a/src/tools/schematic.ts +++ b/src/tools/schematic.ts @@ -748,11 +748,24 @@ Note: operates on .kicad_sch files only. To modify a PCB footprint use edit_comp // List all labels in schematic server.tool( "list_schematic_labels", - "List all net labels, global labels, and power flags in the schematic.", + "List all net labels, global labels, and power flags in the schematic. " + + "Optionally filter by label name (netName) and/or label type (labelType).", { schematicPath: z.string().describe("Path to the .kicad_sch file"), + netName: z + .string() + .optional() + .describe( + "Filter to labels whose name exactly matches this string (case-sensitive). Omit to return all labels.", + ), + labelType: z + .enum(["net", "global", "power"]) + .optional() + .describe( + "Filter by label type. 'net' = local label, 'global' = global label, 'power' = power symbol. Omit to return all types.", + ), }, - async (args: { schematicPath: string }) => { + async (args: { schematicPath: string; netName?: string; labelType?: string }) => { const result = await callKicadScript("list_schematic_labels", args); if (result.success) { const labels = result.labels || []; @@ -1534,22 +1547,15 @@ Note: operates on .kicad_sch files only. To modify a PCB footprint use edit_comp "sheet pin name.", { schematicPath: z.string().describe("Path to the sub-sheet .kicad_sch file"), - text: z - .string() - .describe("Label text (e.g. 'SD_CLK') — must match the sheet pin name"), - position: z - .array(z.number()) - .length(2) - .describe("Position [x, y] in mm"), + text: z.string().describe("Label text (e.g. 'SD_CLK') — must match the sheet pin name"), + position: z.array(z.number()).length(2).describe("Position [x, y] in mm"), shape: z .enum(["input", "output", "bidirectional"]) .describe("Signal direction from the sub-sheet's perspective"), orientation: z .number() .optional() - .describe( - "Rotation in degrees: 0=label points right, 180=label points left (default: 0)", - ), + .describe("Rotation in degrees: 0=label points right, 180=label points left (default: 0)"), }, async (args: { schematicPath: string; @@ -1590,29 +1596,19 @@ Note: operates on .kicad_sch files only. To modify a PCB footprint use edit_comp schematicPath: z.string().describe("Path to the PARENT .kicad_sch file"), sheetName: z .string() - .describe( - "Sheet name as it appears in the Sheetname property (e.g. 'Storage')", - ), - pinName: z - .string() - .describe("Pin name — must match a hierarchical_label in the sub-sheet"), + .describe("Sheet name as it appears in the Sheetname property (e.g. 'Storage')"), + pinName: z.string().describe("Pin name — must match a hierarchical_label in the sub-sheet"), pinType: z .enum(["input", "output", "bidirectional"]) - .describe( - "Signal direction (should match the sub-sheet hierarchical label shape)", - ), + .describe("Signal direction (should match the sub-sheet hierarchical label shape)"), position: z .array(z.number()) .length(2) - .describe( - "Pin position [x, y] in mm — must be on the sheet block boundary", - ), + .describe("Pin position [x, y] in mm — must be on the sheet block boundary"), orientation: z .number() .optional() - .describe( - "Pin orientation: 0=right edge of sheet box, 180=left edge (default: 0)", - ), + .describe("Pin orientation: 0=right edge of sheet box, 180=left edge (default: 0)"), }, async (args: { schematicPath: string; @@ -1629,8 +1625,7 @@ Note: operates on .kicad_sch files only. To modify a PCB footprint use edit_comp { type: "text" as const, text: - result.message || - `Added sheet pin '${args.pinName}' to sheet '${args.sheetName}'`, + result.message || `Added sheet pin '${args.pinName}' to sheet '${args.sheetName}'`, }, ], }; diff --git a/tests/test_schematic_labels.py b/tests/test_schematic_labels.py new file mode 100644 index 0000000..1e166bd --- /dev/null +++ b/tests/test_schematic_labels.py @@ -0,0 +1,176 @@ +""" +Tests for schematic label filters on list_schematic_labels. +""" + +import shutil +import sys +import tempfile +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "python")) + +TEMPLATE_PATH = Path(__file__).resolve().parent.parent / "python" / "templates" / "empty.kicad_sch" + + +def _make_temp_schematic(extra_sexp: str = "") -> Path: + """Copy empty.kicad_sch to a temp file and optionally append S-expression content.""" + tmp = Path(tempfile.mkdtemp()) / "test.kicad_sch" + shutil.copy(TEMPLATE_PATH, tmp) + if extra_sexp: + content = tmp.read_text(encoding="utf-8") + idx = content.rfind(")") + content = content[:idx] + "\n" + extra_sexp + "\n)" + tmp.write_text(content, encoding="utf-8") + return tmp + + +def _label_sexp(name: str, x: float, y: float) -> str: + return f'(label "{name}" (at {x} {y} 0) (effects (font (size 1.27 1.27)) (justify left bottom)) (uuid "l-{name}-{x}-{y}"))' + + +def _global_label_sexp(name: str, x: float, y: float) -> str: + return f'(global_label "{name}" (at {x} {y} 0) (shape input) (effects (font (size 1.27 1.27))) (uuid "g-{name}-{x}-{y}"))' + + +# =========================================================================== +# TestListSchematicLabelsSchema (unit) +# =========================================================================== + + +@pytest.mark.unit +class TestListSchematicLabelsSchema: + """Validate parameter acceptance and rejection for list_schematic_labels.""" + + def test_list_schematic_labels_accepts_net_name_param(self) -> None: + from kicad_interface import KiCADInterface + + ki = KiCADInterface() + tmp = _make_temp_schematic() + result = ki._handle_list_schematic_labels({"schematicPath": str(tmp), "netName": "VCC"}) + assert result["success"] is True + + def test_list_schematic_labels_accepts_label_type_param(self) -> None: + from kicad_interface import KiCADInterface + + ki = KiCADInterface() + tmp = _make_temp_schematic() + result = ki._handle_list_schematic_labels({"schematicPath": str(tmp), "labelType": "net"}) + assert result["success"] is True + + def test_invalid_label_type_rejected(self) -> None: + from kicad_interface import KiCADInterface + + ki = KiCADInterface() + tmp = _make_temp_schematic() + result = ki._handle_list_schematic_labels({"schematicPath": str(tmp), "labelType": "label"}) + assert result["success"] is False + msg = result["message"] + assert "net" in msg + assert "global" in msg + assert "power" in msg + + +# =========================================================================== +# TestListSchematicLabelsFilters (unit) +# =========================================================================== + + +@pytest.mark.unit +class TestListSchematicLabelsFilters: + """Verify filter behaviour of _handle_list_schematic_labels.""" + + def _ki(self): + from kicad_interface import KiCADInterface + + return KiCADInterface() + + def test_no_filters_returns_all_labels(self) -> None: + extra = _label_sexp("VCC", 10, 10) + "\n" + _global_label_sexp("GND", 20, 20) + tmp = _make_temp_schematic(extra) + result = self._ki()._handle_list_schematic_labels({"schematicPath": str(tmp)}) + assert result["success"] is True + names = {lbl["name"] for lbl in result["labels"]} + assert "VCC" in names + assert "GND" in names + assert result["count"] == len(result["labels"]) + + def test_net_name_filter_returns_only_matching(self) -> None: + extra = _label_sexp("VCC", 10, 10) + "\n" + _label_sexp("GND", 20, 20) + tmp = _make_temp_schematic(extra) + result = self._ki()._handle_list_schematic_labels( + {"schematicPath": str(tmp), "netName": "VCC"} + ) + assert result["success"] is True + assert all(lbl["name"] == "VCC" for lbl in result["labels"]) + assert result["count"] == len(result["labels"]) + + def test_net_name_filter_case_sensitive(self) -> None: + extra = _label_sexp("VCC", 10, 10) + tmp = _make_temp_schematic(extra) + result = self._ki()._handle_list_schematic_labels( + {"schematicPath": str(tmp), "netName": "vcc"} + ) + assert result["success"] is True + assert result["count"] == 0 + + def test_net_name_filter_no_match_returns_empty(self) -> None: + extra = _label_sexp("VCC", 10, 10) + tmp = _make_temp_schematic(extra) + result = self._ki()._handle_list_schematic_labels( + {"schematicPath": str(tmp), "netName": "NONEXISTENT"} + ) + assert result["success"] is True + assert result["labels"] == [] + assert result["count"] == 0 + + def test_label_type_filter_net_only(self) -> None: + extra = _label_sexp("SIG", 10, 10) + "\n" + _global_label_sexp("SIG", 20, 20) + tmp = _make_temp_schematic(extra) + result = self._ki()._handle_list_schematic_labels( + {"schematicPath": str(tmp), "labelType": "net"} + ) + assert result["success"] is True + assert all(lbl["type"] == "net" for lbl in result["labels"]) + + def test_label_type_filter_global_only(self) -> None: + extra = _label_sexp("SIG", 10, 10) + "\n" + _global_label_sexp("SIG", 20, 20) + tmp = _make_temp_schematic(extra) + result = self._ki()._handle_list_schematic_labels( + {"schematicPath": str(tmp), "labelType": "global"} + ) + assert result["success"] is True + assert all(lbl["type"] == "global" for lbl in result["labels"]) + + def test_label_type_filter_power_only(self) -> None: + extra = _label_sexp("VCC", 10, 10) + tmp = _make_temp_schematic(extra) + result = self._ki()._handle_list_schematic_labels( + {"schematicPath": str(tmp), "labelType": "power"} + ) + assert result["success"] is True + assert all(lbl["type"] == "power" for lbl in result["labels"]) + + def test_combined_filters_and_semantics(self) -> None: + extra = ( + _label_sexp("VCC", 10, 10) + + "\n" + + _label_sexp("GND", 20, 20) + + "\n" + + _global_label_sexp("VCC", 30, 30) + ) + tmp = _make_temp_schematic(extra) + result = self._ki()._handle_list_schematic_labels( + {"schematicPath": str(tmp), "netName": "VCC", "labelType": "net"} + ) + assert result["success"] is True + assert all(lbl["name"] == "VCC" and lbl["type"] == "net" for lbl in result["labels"]) + + def test_absent_filters_backward_compatible(self) -> None: + extra = _label_sexp("NET1", 5, 5) + tmp = _make_temp_schematic(extra) + result = self._ki()._handle_list_schematic_labels({"schematicPath": str(tmp)}) + assert result["success"] is True + assert "labels" in result + assert "count" in result