Files
kicad-mcp-server/tests/test_add_schematic_text.py
Matthew Runo 09ec6aaeb5 fix: escape newlines in WireManager.add_text
The KiCad s-expression parser rejects raw newline and carriage-return
characters inside quoted string literals — a multi-line text annotation
written through `add_text` produced a `.kicad_sch` file that eeschema
silently tolerated but `kicad-cli sch ...` refused with "Failed to load
schematic." The escape pass only handled backslashes and double quotes.

Add `\\n` → `\\\\n` and `\\r` → `\\\\r` to the same escape chain. Order
matters: backslashes are escaped first so we don't double-escape our
own escapes.

A new regression test (`test_escapes_newlines_in_multiline_text`)
checks both that the resulting quoted string literal contains no raw
newline characters and that the file round-trips cleanly through the
sexpdata parser.

End-to-end smoke: a 4-line annotation written through the patched
add_text now passes `kicad-cli sch erc` (exit 0) where the previous
behaviour failed parse.

Note: the same escape gap exists in `_make_hierarchical_label_text`
and `_make_sheet_pin_text` for unescaped quotes/newlines in the user-
supplied text. Not fixed here to keep this PR scoped to the documented
add_text bug; happy to fold it in if a reviewer prefers.
2026-05-01 10:03:49 -07:00

440 lines
15 KiB
Python

"""
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_escapes_newlines_in_multiline_text(self, tmp_path):
"""Raw newlines in quoted string literals break kicad-cli's parser
(silently in eeschema, but kicad-cli sch reports 'Failed to load
schematic'). They must be escaped to the two-character \\n sequence."""
from commands.wire_manager import WireManager
sch = tmp_path / "test.kicad_sch"
sch.write_text(_MINIMAL_SCH, encoding="utf-8")
WireManager.add_text(sch, "line one\nline two\r\nline three", [10.0, 10.0])
content = sch.read_text(encoding="utf-8")
# The text S-expression's quoted argument must not contain a literal
# newline. Find the (text "...") and check its first quoted arg.
text_start = content.index('(text "') + len('(text "')
# Find matching close-quote, respecting backslash escapes.
i = text_start
while i < len(content):
if content[i] == "\\":
i += 2
continue
if content[i] == '"':
break
i += 1
quoted = content[text_start:i]
assert (
"\n" not in quoted and "\r" not in quoted
), f"Quoted text still contains a raw newline: {quoted!r}"
assert "\\n" in quoted, "Newline should be escaped to \\n"
# And the file must round-trip through the s-expression parser cleanly.
sexpdata.loads(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