From e5916005a0496b12adf3870ea18910fb68987128 Mon Sep 17 00:00:00 2001 From: Eugene Mikhantyev Date: Sun, 19 Apr 2026 21:26:39 +0100 Subject: [PATCH] feat: add add_schematic_text and list_schematic_texts tools Adds two new MCP tools for working with free-form text annotations (SCH_TEXT elements) in KiCad schematics: - add_schematic_text: place a text note with optional angle, font size, bold/italic, and justification - list_schematic_texts: list all text annotations with optional case-insensitive substring filter Includes WireManager.add_text / list_texts using _text_insert + sexpdata, handler dispatch in KiCADInterface, TypeScript tool definitions, registry entry, reference doc updates, and 30 unit tests. Co-Authored-By: Claude Sonnet 4.6 --- docs/SCHEMATIC_TOOLS_REFERENCE.md | 39 +++ docs/TOOL_INVENTORY.md | 7 + python/commands/wire_manager.py | 122 +++++++++ python/kicad_interface.py | 94 +++++++ src/tools/registry.ts | 2 + src/tools/schematic.ts | 108 ++++++++ tests/test_add_schematic_text.py | 406 ++++++++++++++++++++++++++++++ 7 files changed, 778 insertions(+) create mode 100644 tests/test_add_schematic_text.py diff --git a/docs/SCHEMATIC_TOOLS_REFERENCE.md b/docs/SCHEMATIC_TOOLS_REFERENCE.md index 72c63c8..48fa653 100644 --- a/docs/SCHEMATIC_TOOLS_REFERENCE.md +++ b/docs/SCHEMATIC_TOOLS_REFERENCE.md @@ -268,6 +268,45 @@ Checks net label / power symbol positions first (exact IU match), then wire endp | position | `{"x": float, "y": float}` — echoes the query coordinates | | source | `"net_label"` \| `"wire_endpoint"` \| `null` — how the net was resolved | +## Text Annotations (2 tools) + +### add_schematic_text + +Add a free-form text annotation (note, heading, documentation string) directly on the schematic canvas. Text annotations have no electrical significance — they are purely visual. For electrically meaningful labels, use `add_schematic_net_label` instead. + +| Parameter | Type | Required | Description | +| ------------- | ------------ | -------- | ------------------------------------------------ | +| schematicPath | string | Yes | Path to the .kicad_sch file | +| text | string | Yes | Text content to display | +| position | array [x, y] | Yes | Position in schematic mm coordinates | +| angle | number | No | Rotation angle in degrees (default: 0) | +| fontSize | number | No | Font size in mm (default: 1.27 — KiCad standard) | +| bold | boolean | No | Bold text (default: false) | +| italic | boolean | No | Italic text (default: false) | +| justify | string | No | `left` \| `center` \| `right` (default: `left`) | + +### list_schematic_texts + +List all free-form text annotations in the schematic. Optionally filter by a substring of the text content. + +| Parameter | Type | Required | Description | +| ------------- | ------ | -------- | -------------------------------------------------------------- | +| schematicPath | string | Yes | Path to the .kicad_sch file | +| text | string | No | Case-insensitive substring filter — only return matching texts | + +**Response fields (per text entry):** + +| Field | Description | +| --------- | ----------------------------------- | +| text | Text string content | +| position | `{"x": float, "y": float}` in mm | +| angle | Rotation angle in degrees | +| font_size | Font size in mm | +| bold | `true` / `false` | +| italic | `true` / `false` | +| justify | `"left"` \| `"center"` \| `"right"` | +| uuid | KiCad UUID of the element | + ## Schematic Creation and Export (6 tools) ### create_schematic diff --git a/docs/TOOL_INVENTORY.md b/docs/TOOL_INVENTORY.md index 5254698..de8fc59 100644 --- a/docs/TOOL_INVENTORY.md +++ b/docs/TOOL_INVENTORY.md @@ -169,6 +169,13 @@ _Source: `src/tools/schematic.ts`_ | `list_schematic_wires` | List all wires in schematic | Routed (schematic) | | `list_schematic_labels` | List all net labels | Routed (schematic) | +### Text Annotations + +| Tool | Description | Access | +| ---------------------- | ------------------------------------------------ | ------------------ | +| `add_schematic_text` | Add free-form text annotation to schematic | Routed (schematic) | +| `list_schematic_texts` | List all text annotations (with optional filter) | Routed (schematic) | + ### Schematic Creation and Export | Tool | Description | Access | diff --git a/python/commands/wire_manager.py b/python/commands/wire_manager.py index 8d56540..c30978b 100644 --- a/python/commands/wire_manager.py +++ b/python/commands/wire_manager.py @@ -754,6 +754,128 @@ class WireManager: return [start, corner, end] + @staticmethod + def list_texts(schematic_path: Path) -> Optional[List[Any]]: + """Return all free-form text annotations (SCH_TEXT) in a schematic. + + Each entry is a dict with keys: text, position (x/y), angle, + font_size, bold, italic, justify, uuid. + Returns None on parse error. + """ + try: + with open(schematic_path, "r", encoding="utf-8") as f: + sch_data = sexpdata.loads(f.read()) + + _SYM_TEXT = Symbol("text") + _SYM_EFFECTS = Symbol("effects") + _SYM_FONT = Symbol("font") + _SYM_SIZE = Symbol("size") + _SYM_JUSTIFY = Symbol("justify") + _SYM_BOLD = Symbol("bold") + _SYM_ITALIC = Symbol("italic") + + results = [] + for item in sch_data: + if not (isinstance(item, list) and len(item) >= 2 and item[0] == _SYM_TEXT): + continue + # item[1] is the text string + text_val = item[1] if len(item) > 1 else "" + + pos_x = pos_y = angle = 0.0 + font_size = 1.27 + bold = italic = False + justify = "left" + uid = "" + + for part in item[2:]: + if not isinstance(part, list) or not part: + continue + tag = part[0] + if tag == _SYM_AT and len(part) >= 3: + pos_x = float(part[1]) + pos_y = float(part[2]) + angle = float(part[3]) if len(part) >= 4 else 0.0 + elif tag == _SYM_UUID and len(part) >= 2: + uid = str(part[1]) + elif tag == _SYM_EFFECTS: + for eff in part[1:]: + if not isinstance(eff, list) or not eff: + continue + if eff[0] == _SYM_FONT: + for fp in eff[1:]: + if not isinstance(fp, list) or not fp: + continue + if fp[0] == _SYM_SIZE and len(fp) >= 2: + font_size = float(fp[1]) + elif fp[0] == _SYM_BOLD and len(fp) >= 2: + bold = str(fp[1]).lower() == "yes" + elif fp[0] == _SYM_ITALIC and len(fp) >= 2: + italic = str(fp[1]).lower() == "yes" + elif eff[0] == _SYM_JUSTIFY and len(eff) >= 2: + justify = str(eff[1]) + + results.append( + { + "text": text_val, + "position": {"x": pos_x, "y": pos_y}, + "angle": angle, + "font_size": font_size, + "bold": bold, + "italic": italic, + "justify": justify, + "uuid": uid, + } + ) + return results + except Exception as e: + logger.error(f"Error listing texts: {e}") + import traceback + + logger.error(traceback.format_exc()) + return None + + @staticmethod + def add_text( + schematic_path: Path, + text: str, + position: List[float], + angle: float = 0, + font_size: float = 1.27, + bold: bool = False, + italic: bool = False, + justify: str = "left", + ) -> bool: + """Add a free-form text annotation (SCH_TEXT) to a KiCad schematic.""" + try: + text_escaped = text.replace("\\", "\\\\").replace('"', '\\"') + uid = str(uuid.uuid4()) + font_attrs = f"\n\t\t\t\t(size {font_size} {font_size})" + if bold: + font_attrs += "\n\t\t\t\t(bold yes)" + if italic: + font_attrs += "\n\t\t\t\t(italic yes)" + text_sexp = ( + f'\t(text "{text_escaped}"\n' + f"\t\t(exclude_from_sim no)\n" + f"\t\t(at {position[0]} {position[1]} {angle})\n" + f"\t\t(effects\n" + f"\t\t\t(font{font_attrs}\n" + f"\t\t\t)\n" + f"\t\t\t(justify {justify} bottom)\n" + f"\t\t)\n" + f'\t\t(uuid "{uid}")\n' + f"\t)\n" + ) + _text_insert(schematic_path, text_sexp) + logger.info(f"Added text '{text}' at {position}") + return True + except Exception as e: + logger.error(f"Error adding text: {e}") + import traceback + + logger.error(traceback.format_exc()) + return False + @staticmethod def add_hierarchical_label( schematic_path: Path, diff --git a/python/kicad_interface.py b/python/kicad_interface.py index c2b016d..d837083 100644 --- a/python/kicad_interface.py +++ b/python/kicad_interface.py @@ -410,6 +410,8 @@ class KiCADInterface: "list_floating_labels": self._handle_list_floating_labels, "snap_to_grid": self._handle_snap_to_grid, "add_schematic_hierarchical_label": self._handle_add_schematic_hierarchical_label, + "add_schematic_text": self._handle_add_schematic_text, + "list_schematic_texts": self._handle_list_schematic_texts, "add_sheet_pin": self._handle_add_sheet_pin, "import_svg_logo": self._handle_import_svg_logo, # UI/Process management commands @@ -2731,6 +2733,98 @@ class KiCADInterface: logger.error(traceback.format_exc()) return {"success": False, "message": str(e)} + def _handle_list_schematic_texts(self, params: Dict[str, Any]) -> Dict[str, Any]: + """List all free-form text annotations (SCH_TEXT) in a schematic.""" + logger.info("Listing schematic text annotations") + try: + from commands.wire_manager import WireManager + + schematic_path = params.get("schematicPath") + if not schematic_path: + return {"success": False, "message": "schematicPath is required"} + + sch_file = Path(schematic_path) + if not sch_file.exists(): + return {"success": False, "message": f"Schematic not found: {schematic_path}"} + + texts = WireManager.list_texts(sch_file) + if texts is None: + return {"success": False, "message": "Failed to parse schematic"} + + # Optional text filter + filter_text = params.get("text") + if filter_text is not None: + texts = [t for t in texts if filter_text.lower() in t["text"].lower()] + + return {"success": True, "texts": texts, "count": len(texts)} + + except Exception as e: + logger.error(f"Error listing schematic texts: {e}") + import traceback + + logger.error(traceback.format_exc()) + return {"success": False, "message": str(e)} + + def _handle_add_schematic_text(self, params: Dict[str, Any]) -> Dict[str, Any]: + """Add a free-form text annotation (SCH_TEXT) to a schematic.""" + logger.info("Adding text annotation to schematic") + try: + from commands.wire_manager import WireManager + + schematic_path = params.get("schematicPath") + text = params.get("text") + position = params.get("position") + angle = params.get("angle", 0) + font_size = params.get("fontSize", 1.27) + bold = params.get("bold", False) + italic = params.get("italic", False) + justify = params.get("justify", "left") + + if not schematic_path: + return {"success": False, "message": "schematicPath is required"} + if not text: + return {"success": False, "message": "text is required"} + if not position or len(position) != 2: + return {"success": False, "message": "position [x, y] is required"} + if justify not in ("left", "center", "right"): + return {"success": False, "message": "justify must be left, center, or right"} + if font_size <= 0: + return {"success": False, "message": "fontSize must be positive"} + + sch_file = Path(schematic_path) + if not sch_file.exists(): + return { + "success": False, + "message": f"Schematic not found: {schematic_path}", + } + + success = WireManager.add_text( + sch_file, + text, + position, + angle=angle, + font_size=font_size, + bold=bold, + italic=italic, + justify=justify, + ) + + if success: + return { + "success": True, + "message": f"Added text '{text}' at ({position[0]}, {position[1]})", + "position": {"x": position[0], "y": position[1]}, + "angle": angle, + } + return {"success": False, "message": "Failed to add text annotation"} + + except Exception as e: + logger.error(f"Error adding schematic text: {e}") + import traceback + + logger.error(traceback.format_exc()) + return {"success": False, "message": str(e)} + def _handle_add_schematic_hierarchical_label(self, params: Dict[str, Any]) -> Dict[str, Any]: """Add a hierarchical label to a sub-sheet schematic.""" logger.info("Adding hierarchical label to schematic") diff --git a/src/tools/registry.ts b/src/tools/registry.ts index 1ef1103..770131e 100644 --- a/src/tools/registry.ts +++ b/src/tools/registry.ts @@ -109,6 +109,8 @@ export const toolCategories: ToolCategory[] = [ "get_schematic_view", "export_schematic_svg", "export_schematic_pdf", + "add_schematic_text", + "list_schematic_texts", ], }, { diff --git a/src/tools/schematic.ts b/src/tools/schematic.ts index b053d1a..32e6b3f 100644 --- a/src/tools/schematic.ts +++ b/src/tools/schematic.ts @@ -1633,6 +1633,114 @@ Note: operates on .kicad_sch files only. To modify a PCB footprint use edit_comp }, ); + // List free-form text annotations in schematic + server.tool( + "list_schematic_texts", + "List all free-form text annotations (notes, headings, documentation strings) in the schematic. " + + "Returns position, angle, font size, bold/italic flags, and justification for each text element. " + + "Optionally filter by a substring match on the text content.", + { + schematicPath: z.string().describe("Path to the .kicad_sch file"), + text: z + .string() + .optional() + .describe("Case-insensitive substring filter — only return texts containing this string"), + }, + async (args: { schematicPath: string; text?: string }) => { + const result = await callKicadScript("list_schematic_texts", args); + if (result.success) { + const texts = result.texts || []; + if (texts.length === 0) { + return { + content: [{ type: "text" as const, text: "No text annotations found in schematic." }], + }; + } + const lines = texts.map( + (t: any) => + ` "${t.text}" at (${t.position.x}, ${t.position.y})` + + (t.angle ? ` angle=${t.angle}` : "") + + ` size=${t.font_size}` + + (t.bold ? " bold" : "") + + (t.italic ? " italic" : "") + + ` justify=${t.justify}`, + ); + return { + content: [ + { + type: "text" as const, + text: `Text annotations (${texts.length}):\n${lines.join("\n")}`, + }, + ], + }; + } + return { + content: [ + { + type: "text" as const, + text: `Failed to list text annotations: ${result.message || "Unknown error"}`, + }, + ], + isError: true, + }; + }, + ); + + // Add free-form text annotation to schematic + server.tool( + "add_schematic_text", + "Add a free-form text annotation to the schematic. " + + "Use this to add notes, labels, section headings, or documentation strings " + + "directly on the schematic canvas. Unlike net labels, text annotations have " + + "no electrical significance.", + { + schematicPath: z.string().describe("Path to the .kicad_sch file"), + text: z.string().describe("Text content to display"), + position: z + .array(z.number()) + .length(2) + .describe("Position [x, y] in schematic mm coordinates"), + angle: z.number().optional().describe("Rotation angle in degrees (default: 0)"), + fontSize: z.number().optional().describe("Font size in mm (default: 1.27)"), + bold: z.boolean().optional().describe("Bold text (default: false)"), + italic: z.boolean().optional().describe("Italic text (default: false)"), + justify: z + .enum(["left", "center", "right"]) + .optional() + .describe("Horizontal text justification (default: left)"), + }, + async (args: { + schematicPath: string; + text: string; + position: number[]; + angle?: number; + fontSize?: number; + bold?: boolean; + italic?: boolean; + justify?: "left" | "center" | "right"; + }) => { + const result = await callKicadScript("add_schematic_text", args); + if (result.success) { + return { + content: [ + { + type: "text" as const, + text: result.message || "Text annotation added successfully", + }, + ], + }; + } else { + return { + content: [ + { + type: "text" as const, + text: `Failed to add text annotation: ${result.message || "Unknown error"}`, + }, + ], + }; + } + }, + ); + // Add sheet pin to a sheet block on the parent schematic server.tool( "add_sheet_pin", diff --git a/tests/test_add_schematic_text.py b/tests/test_add_schematic_text.py new file mode 100644 index 0000000..9370716 --- /dev/null +++ b/tests/test_add_schematic_text.py @@ -0,0 +1,406 @@ +""" +Tests for the add_schematic_text tool. + +Covers: + - WireManager.add_text S-expression insertion + - Correct position, angle, font options, justification + - String escaping (double quotes in text) + - Parameter validation in _handle_add_schematic_text +""" + +import sys +import textwrap +from pathlib import Path +from typing import Any +from unittest.mock import patch + +import pytest +import sexpdata + +sys.path.insert(0, str(Path(__file__).parent.parent / "python")) + + +# --------------------------------------------------------------------------- +# Shared helpers +# --------------------------------------------------------------------------- + + +def _make_iface() -> Any: + with patch("kicad_interface.USE_IPC_BACKEND", False): + from kicad_interface import KiCADInterface + + iface = KiCADInterface.__new__(KiCADInterface) + return iface + + +@pytest.fixture() +def iface(): + return _make_iface() + + +_MINIMAL_SCH = textwrap.dedent("""\ + (kicad_sch (version 20250114) (generator "test") + \t(uuid aaaaaaaa-0000-0000-0000-000000000001) + \t(paper "A4") + \t(sheet_instances (path "/" (page "1"))) + ) + """) + + +# --------------------------------------------------------------------------- +# WireManager.add_text unit tests +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestWireManagerAddText: + def test_inserts_text_element(self, tmp_path): + from commands.wire_manager import WireManager + + sch = tmp_path / "test.kicad_sch" + sch.write_text(_MINIMAL_SCH, encoding="utf-8") + + result = WireManager.add_text(sch, "Test Note", [50.0, 40.0]) + + assert result is True + content = sch.read_text(encoding="utf-8") + assert '(text "Test Note"' in content + assert "(at 50.0 40.0 0)" in content + + def test_inserts_before_sheet_instances(self, tmp_path): + from commands.wire_manager import WireManager + + sch = tmp_path / "test.kicad_sch" + sch.write_text(_MINIMAL_SCH, encoding="utf-8") + + WireManager.add_text(sch, "Section A", [10.0, 20.0]) + + content = sch.read_text(encoding="utf-8") + text_pos = content.find('(text "Section A"') + instances_pos = content.find("(sheet_instances") + assert text_pos < instances_pos + + def test_rotation_angle(self, tmp_path): + from commands.wire_manager import WireManager + + sch = tmp_path / "test.kicad_sch" + sch.write_text(_MINIMAL_SCH, encoding="utf-8") + + WireManager.add_text(sch, "Rotated", [20.0, 20.0], angle=90) + + content = sch.read_text(encoding="utf-8") + assert "(at 20.0 20.0 90)" in content + + def test_bold_and_italic_flags(self, tmp_path): + from commands.wire_manager import WireManager + + sch = tmp_path / "test.kicad_sch" + sch.write_text(_MINIMAL_SCH, encoding="utf-8") + + WireManager.add_text(sch, "Bold Italic", [30.0, 30.0], bold=True, italic=True) + + content = sch.read_text(encoding="utf-8") + assert "(bold yes)" in content + assert "(italic yes)" in content + + def test_center_justification(self, tmp_path): + from commands.wire_manager import WireManager + + sch = tmp_path / "test.kicad_sch" + sch.write_text(_MINIMAL_SCH, encoding="utf-8") + + WireManager.add_text(sch, "Centered", [50.0, 50.0], justify="center") + + content = sch.read_text(encoding="utf-8") + assert "(justify center bottom)" in content + + def test_custom_font_size(self, tmp_path): + from commands.wire_manager import WireManager + + sch = tmp_path / "test.kicad_sch" + sch.write_text(_MINIMAL_SCH, encoding="utf-8") + + WireManager.add_text(sch, "Big Text", [10.0, 10.0], font_size=2.54) + + content = sch.read_text(encoding="utf-8") + assert "(size 2.54 2.54)" in content + + def test_escapes_double_quotes_in_text(self, tmp_path): + from commands.wire_manager import WireManager + + sch = tmp_path / "test.kicad_sch" + sch.write_text(_MINIMAL_SCH, encoding="utf-8") + + WireManager.add_text(sch, 'He said "hello"', [10.0, 10.0]) + + content = sch.read_text(encoding="utf-8") + assert r"He said \"hello\"" in content + + def test_result_is_valid_sexp(self, tmp_path): + from commands.wire_manager import WireManager + + sch = tmp_path / "test.kicad_sch" + sch.write_text(_MINIMAL_SCH, encoding="utf-8") + + WireManager.add_text(sch, "Note", [10.0, 10.0]) + + # Must parse without error + sexpdata.loads(sch.read_text(encoding="utf-8")) + + def test_no_bold_italic_by_default(self, tmp_path): + from commands.wire_manager import WireManager + + sch = tmp_path / "test.kicad_sch" + sch.write_text(_MINIMAL_SCH, encoding="utf-8") + + WireManager.add_text(sch, "Plain", [10.0, 10.0]) + + content = sch.read_text(encoding="utf-8") + assert "(bold yes)" not in content + assert "(italic yes)" not in content + + +# --------------------------------------------------------------------------- +# _handle_add_schematic_text parameter-validation tests +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestHandleAddSchematicText: + def test_missing_schematic_path(self, iface): + result = iface._handle_add_schematic_text({"text": "Note", "position": [10.0, 20.0]}) + assert result["success"] is False + assert "schematicPath" in result["message"] + + def test_missing_text(self, iface, tmp_path): + sch = tmp_path / "test.kicad_sch" + sch.write_text(_MINIMAL_SCH, encoding="utf-8") + result = iface._handle_add_schematic_text( + {"schematicPath": str(sch), "position": [10.0, 20.0]} + ) + assert result["success"] is False + assert "text" in result["message"] + + def test_missing_position(self, iface, tmp_path): + sch = tmp_path / "test.kicad_sch" + sch.write_text(_MINIMAL_SCH, encoding="utf-8") + result = iface._handle_add_schematic_text({"schematicPath": str(sch), "text": "Note"}) + assert result["success"] is False + assert "position" in result["message"] + + def test_invalid_justify(self, iface, tmp_path): + sch = tmp_path / "test.kicad_sch" + sch.write_text(_MINIMAL_SCH, encoding="utf-8") + result = iface._handle_add_schematic_text( + { + "schematicPath": str(sch), + "text": "Note", + "position": [10.0, 20.0], + "justify": "top", + } + ) + assert result["success"] is False + assert "justify" in result["message"] + + def test_invalid_font_size(self, iface, tmp_path): + sch = tmp_path / "test.kicad_sch" + sch.write_text(_MINIMAL_SCH, encoding="utf-8") + result = iface._handle_add_schematic_text( + { + "schematicPath": str(sch), + "text": "Note", + "position": [10.0, 20.0], + "fontSize": 0, + } + ) + assert result["success"] is False + assert "fontSize" in result["message"] + + def test_nonexistent_file(self, iface, tmp_path): + result = iface._handle_add_schematic_text( + { + "schematicPath": str(tmp_path / "nope.kicad_sch"), + "text": "Note", + "position": [10, 20], + } + ) + assert result["success"] is False + assert "not found" in result["message"].lower() + + def test_success(self, iface, tmp_path): + sch = tmp_path / "test.kicad_sch" + sch.write_text(_MINIMAL_SCH, encoding="utf-8") + result = iface._handle_add_schematic_text( + { + "schematicPath": str(sch), + "text": "Power Section", + "position": [25.4, 50.8], + "angle": 0, + "fontSize": 1.5, + } + ) + assert result["success"] is True + assert "position" in result + content = sch.read_text(encoding="utf-8") + assert '(text "Power Section"' in content + + def test_success_returns_position(self, iface, tmp_path): + sch = tmp_path / "test.kicad_sch" + sch.write_text(_MINIMAL_SCH, encoding="utf-8") + result = iface._handle_add_schematic_text( + {"schematicPath": str(sch), "text": "Note", "position": [12.7, 25.4]} + ) + assert result["success"] is True + assert result["position"] == {"x": 12.7, "y": 25.4} + + +# --------------------------------------------------------------------------- +# WireManager.list_texts unit tests +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestWireManagerListTexts: + def test_empty_schematic_returns_empty_list(self, tmp_path): + from commands.wire_manager import WireManager + + sch = tmp_path / "test.kicad_sch" + sch.write_text(_MINIMAL_SCH, encoding="utf-8") + + result = WireManager.list_texts(sch) + + assert result == [] + + def test_lists_added_text(self, tmp_path): + from commands.wire_manager import WireManager + + sch = tmp_path / "test.kicad_sch" + sch.write_text(_MINIMAL_SCH, encoding="utf-8") + WireManager.add_text(sch, "Power Section", [10.0, 20.0]) + + result = WireManager.list_texts(sch) + + assert len(result) == 1 + assert result[0]["text"] == "Power Section" + assert result[0]["position"] == {"x": 10.0, "y": 20.0} + + def test_lists_multiple_texts(self, tmp_path): + from commands.wire_manager import WireManager + + sch = tmp_path / "test.kicad_sch" + sch.write_text(_MINIMAL_SCH, encoding="utf-8") + WireManager.add_text(sch, "Alpha", [0.0, 0.0]) + WireManager.add_text(sch, "Beta", [10.0, 10.0]) + + result = WireManager.list_texts(sch) + + texts = {r["text"] for r in result} + assert texts == {"Alpha", "Beta"} + + def test_angle_is_preserved(self, tmp_path): + from commands.wire_manager import WireManager + + sch = tmp_path / "test.kicad_sch" + sch.write_text(_MINIMAL_SCH, encoding="utf-8") + WireManager.add_text(sch, "Rotated", [5.0, 5.0], angle=90) + + result = WireManager.list_texts(sch) + + assert result[0]["angle"] == 90.0 + + def test_bold_italic_are_preserved(self, tmp_path): + from commands.wire_manager import WireManager + + sch = tmp_path / "test.kicad_sch" + sch.write_text(_MINIMAL_SCH, encoding="utf-8") + WireManager.add_text(sch, "Styled", [5.0, 5.0], bold=True, italic=True) + + result = WireManager.list_texts(sch) + + assert result[0]["bold"] is True + assert result[0]["italic"] is True + + def test_font_size_is_preserved(self, tmp_path): + from commands.wire_manager import WireManager + + sch = tmp_path / "test.kicad_sch" + sch.write_text(_MINIMAL_SCH, encoding="utf-8") + WireManager.add_text(sch, "Big", [5.0, 5.0], font_size=2.54) + + result = WireManager.list_texts(sch) + + assert result[0]["font_size"] == 2.54 + + def test_nonexistent_file_returns_none(self, tmp_path): + from commands.wire_manager import WireManager + + result = WireManager.list_texts(tmp_path / "nope.kicad_sch") + + assert result is None + + +# --------------------------------------------------------------------------- +# _handle_list_schematic_texts handler tests +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestHandleListSchematicTexts: + def test_missing_schematic_path(self, iface): + result = iface._handle_list_schematic_texts({}) + assert result["success"] is False + assert "schematicPath" in result["message"] + + def test_nonexistent_file(self, iface, tmp_path): + result = iface._handle_list_schematic_texts( + {"schematicPath": str(tmp_path / "nope.kicad_sch")} + ) + assert result["success"] is False + assert "not found" in result["message"].lower() + + def test_empty_schematic(self, iface, tmp_path): + sch = tmp_path / "test.kicad_sch" + sch.write_text(_MINIMAL_SCH, encoding="utf-8") + result = iface._handle_list_schematic_texts({"schematicPath": str(sch)}) + assert result["success"] is True + assert result["texts"] == [] + assert result["count"] == 0 + + def test_returns_all_texts(self, iface, tmp_path): + from commands.wire_manager import WireManager + + sch = tmp_path / "test.kicad_sch" + sch.write_text(_MINIMAL_SCH, encoding="utf-8") + WireManager.add_text(sch, "Note A", [0.0, 0.0]) + WireManager.add_text(sch, "Note B", [10.0, 10.0]) + + result = iface._handle_list_schematic_texts({"schematicPath": str(sch)}) + + assert result["success"] is True + assert result["count"] == 2 + + def test_text_filter_substring_match(self, iface, tmp_path): + from commands.wire_manager import WireManager + + sch = tmp_path / "test.kicad_sch" + sch.write_text(_MINIMAL_SCH, encoding="utf-8") + WireManager.add_text(sch, "Power Supply", [0.0, 0.0]) + WireManager.add_text(sch, "Ground Plane", [10.0, 10.0]) + + result = iface._handle_list_schematic_texts({"schematicPath": str(sch), "text": "power"}) + + assert result["success"] is True + assert result["count"] == 1 + assert result["texts"][0]["text"] == "Power Supply" + + def test_text_filter_case_insensitive(self, iface, tmp_path): + from commands.wire_manager import WireManager + + sch = tmp_path / "test.kicad_sch" + sch.write_text(_MINIMAL_SCH, encoding="utf-8") + WireManager.add_text(sch, "SECTION HEADER", [0.0, 0.0]) + + result = iface._handle_list_schematic_texts({"schematicPath": str(sch), "text": "section"}) + + assert result["success"] is True + assert result["count"] == 1