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.
440 lines
15 KiB
Python
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
|