KiCad serialises rescued or locally-customised library entries with an
extra (lib_name "...") child before (lib_id "..."):
(symbol
(lib_name "RESISTOR_0603_4")
(lib_id "MF_Passives:RESISTOR_0603")
(at 132.08 44.45 90)
...)
The block-matching regex in _handle_get_schematic_component,
_handle_edit_schematic_component, and _handle_delete_schematic_component
required (lib_id IMMEDIATELY after (symbol, so any placed component
using this form was silently invisible to lookup. The user-visible
symptom is "Component '<ref>' not found in schematic" even though the
component is plainly present (and reachable through list / IPC paths).
This bug also affected set/remove_schematic_component_property and the
existing footprint/value/reference rewriting paths in edit, since they
all share the same lookup code.
The parent-position lookup used a similarly-strict regex
((symbol (lib_id "...") (at ...))), which silently fell back to (0,0)
on (lib_name)-first symbols and caused new properties added through
the custom-properties path to anchor at the schematic origin instead
of the parent symbol.
Fix: relax the symbol-block opening pattern to (symbol\s+\( — matching
any opening paren after (symbol — and read the symbol's origin from
the first (at ...) inside the block. Library-definition entries inside
(lib_symbols ...) are still excluded by the existing range check
(they use the (symbol "name" ...) form with a quoted string, not a
paren).
Adds 7 regression tests in TestLibNameBeforeLibIdOrdering using a
real-world (lib_name)-first resistor block, covering get / edit /
set-property / remove-property / delete and verifying that newly
added properties anchor to the symbol origin instead of (0, 0).
866 lines
31 KiB
Python
866 lines
31 KiB
Python
"""
|
|
Tests for custom property support on edit_schematic_component,
|
|
set_schematic_component_property, and remove_schematic_component_property.
|
|
|
|
Custom properties are arbitrary key/value fields attached to a placed schematic
|
|
symbol — used for BOM / sourcing metadata such as MPN, Manufacturer,
|
|
DigiKey_PN, LCSC, JLCPCB_PN, Voltage, Tolerance, Dielectric, etc.
|
|
"""
|
|
|
|
import re
|
|
import sys
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import pytest
|
|
|
|
sys.path.insert(0, str(Path(__file__).parent.parent / "python"))
|
|
|
|
|
|
TEMPLATE_SCH = Path(__file__).parent.parent / "python" / "templates" / "empty.kicad_sch"
|
|
|
|
# Minimal placed-symbol block embedded into the test schematic
|
|
PLACED_RESISTOR_BLOCK = """\
|
|
(symbol (lib_id "Device:R") (at 50 50 0) (unit 1)
|
|
(in_bom yes) (on_board yes) (dnp no)
|
|
(uuid "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee")
|
|
(property "Reference" "R1" (at 51.27 47.46 0)
|
|
(effects (font (size 1.27 1.27)))
|
|
)
|
|
(property "Value" "10k" (at 51.27 52.54 0)
|
|
(effects (font (size 1.27 1.27)))
|
|
)
|
|
(property "Footprint" "Resistor_SMD:R_0603_1608Metric" (at 50 50 0)
|
|
(effects (font (size 1.27 1.27)) hide)
|
|
)
|
|
(property "Datasheet" "~" (at 50 50 0)
|
|
(effects (font (size 1.27 1.27)) hide)
|
|
)
|
|
)
|
|
"""
|
|
|
|
# Multi-line placed-symbol block in the format KiCad emits for symbols whose
|
|
# library entry has been rescued / customised — these carry an extra
|
|
# (lib_name "...") child BEFORE (lib_id "..."). Reproduced from a real user
|
|
# schematic that exposed a regex bug where (symbol (lib_id "...")) was the
|
|
# only matched form and `(symbol (lib_name "...") (lib_id "..."))` placed
|
|
# components were invisible to get/edit/delete.
|
|
PLACED_RESISTOR_BLOCK_LIBNAME_FIRST = """\
|
|
(symbol
|
|
(lib_name "RESISTOR_0603_4")
|
|
(lib_id "MF_Passives:RESISTOR_0603")
|
|
(at 132.08 44.45 90)
|
|
(unit 1)
|
|
(in_bom yes)
|
|
(on_board yes)
|
|
(dnp no)
|
|
(uuid "bbbbbbbb-cccc-dddd-eeee-ffffffffffff")
|
|
(property "Reference" "R7"
|
|
(at 132.08 40.894 90)
|
|
(effects (font (size 1.143 1.143)))
|
|
)
|
|
(property "Value" "499k"
|
|
(at 132.08 42.418 90)
|
|
(effects (font (size 1.143 1.143)))
|
|
)
|
|
(property "Footprint" "MF_Passives_R0603"
|
|
(at 134.366 38.1 0)
|
|
(effects (font (size 1.27 1.27)) (hide yes))
|
|
)
|
|
(property "Datasheet" "~"
|
|
(at 132.08 44.45 0)
|
|
(effects (font (size 1.27 1.27)) (hide yes))
|
|
)
|
|
)
|
|
"""
|
|
|
|
|
|
def _make_test_schematic(tmp_dir: Path, extra_block: str = "") -> Path:
|
|
"""Copy empty.kicad_sch into tmp_dir, optionally appending a placed symbol block."""
|
|
dest = tmp_dir / "test.kicad_sch"
|
|
src_content = TEMPLATE_SCH.read_text(encoding="utf-8")
|
|
if extra_block:
|
|
src_content = src_content.rstrip()
|
|
if src_content.endswith(")"):
|
|
src_content = src_content[:-1] + "\n" + extra_block + ")\n"
|
|
dest.write_text(src_content, encoding="utf-8")
|
|
return dest
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Pure unit tests — exercise the static helpers in isolation.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestStaticHelpers:
|
|
"""Tests for _escape_sexpr_string and _find_matching_paren."""
|
|
|
|
def _iface(self) -> Any:
|
|
from kicad_interface import KiCADInterface
|
|
|
|
return KiCADInterface
|
|
|
|
def test_escape_handles_quotes(self) -> None:
|
|
cls = self._iface()
|
|
assert cls._escape_sexpr_string('a"b') == 'a\\"b'
|
|
|
|
def test_escape_handles_backslashes(self) -> None:
|
|
cls = self._iface()
|
|
assert cls._escape_sexpr_string("a\\b") == "a\\\\b"
|
|
|
|
def test_escape_handles_both(self) -> None:
|
|
cls = self._iface()
|
|
# Order matters: backslashes are doubled first, then quotes
|
|
assert cls._escape_sexpr_string('a"b\\c') == 'a\\"b\\\\c'
|
|
|
|
def test_escape_passes_normal_text(self) -> None:
|
|
cls = self._iface()
|
|
assert cls._escape_sexpr_string("RC0603FR-0710KL") == "RC0603FR-0710KL"
|
|
|
|
def test_find_matching_paren_simple(self) -> None:
|
|
cls = self._iface()
|
|
s = "(abc)"
|
|
assert cls._find_matching_paren(s, 0) == 4
|
|
|
|
def test_find_matching_paren_nested(self) -> None:
|
|
cls = self._iface()
|
|
s = "(a (b (c) d) e)"
|
|
assert cls._find_matching_paren(s, 0) == 14
|
|
assert cls._find_matching_paren(s, 3) == 11
|
|
assert cls._find_matching_paren(s, 6) == 8
|
|
|
|
def test_find_matching_paren_no_match(self) -> None:
|
|
cls = self._iface()
|
|
s = "(abc"
|
|
assert cls._find_matching_paren(s, 0) == -1
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Integration tests — full file I/O through the public command interface.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.integration
|
|
class TestEditSchematicComponentProperties:
|
|
"""Tests for the new `properties` and `removeProperties` parameters."""
|
|
|
|
@pytest.fixture
|
|
def sch_with_r1(self, tmp_path: Any) -> Any:
|
|
return _make_test_schematic(tmp_path, PLACED_RESISTOR_BLOCK)
|
|
|
|
def _iface(self) -> Any:
|
|
from kicad_interface import KiCADInterface
|
|
|
|
return KiCADInterface()
|
|
|
|
def test_add_single_custom_property_string(self, sch_with_r1: Any) -> None:
|
|
iface = self._iface()
|
|
result = iface.handle_command(
|
|
"edit_schematic_component",
|
|
{
|
|
"schematicPath": str(sch_with_r1),
|
|
"reference": "R1",
|
|
"properties": {"MPN": "RC0603FR-0710KL"},
|
|
},
|
|
)
|
|
assert result["success"] is True
|
|
assert "MPN" in result["updated"]["propertiesAdded"]
|
|
|
|
get_result = iface.handle_command(
|
|
"get_schematic_component",
|
|
{"schematicPath": str(sch_with_r1), "reference": "R1"},
|
|
)
|
|
assert "MPN" in get_result["fields"]
|
|
assert get_result["fields"]["MPN"]["value"] == "RC0603FR-0710KL"
|
|
|
|
def test_add_multiple_custom_properties(self, sch_with_r1: Any) -> None:
|
|
iface = self._iface()
|
|
result = iface.handle_command(
|
|
"edit_schematic_component",
|
|
{
|
|
"schematicPath": str(sch_with_r1),
|
|
"reference": "R1",
|
|
"properties": {
|
|
"MPN": "RC0603FR-0710KL",
|
|
"Manufacturer": "Yageo",
|
|
"Tolerance": "1%",
|
|
"Power": "0.1W",
|
|
},
|
|
},
|
|
)
|
|
assert result["success"] is True
|
|
assert set(result["updated"]["propertiesAdded"].keys()) == {
|
|
"MPN",
|
|
"Manufacturer",
|
|
"Tolerance",
|
|
"Power",
|
|
}
|
|
|
|
get_result = iface.handle_command(
|
|
"get_schematic_component",
|
|
{"schematicPath": str(sch_with_r1), "reference": "R1"},
|
|
)
|
|
for name, expected_value in [
|
|
("MPN", "RC0603FR-0710KL"),
|
|
("Manufacturer", "Yageo"),
|
|
("Tolerance", "1%"),
|
|
("Power", "0.1W"),
|
|
]:
|
|
assert name in get_result["fields"], f"Missing property {name}"
|
|
assert get_result["fields"][name]["value"] == expected_value
|
|
|
|
def test_update_existing_custom_property(self, sch_with_r1: Any) -> None:
|
|
iface = self._iface()
|
|
# First add
|
|
iface.handle_command(
|
|
"edit_schematic_component",
|
|
{
|
|
"schematicPath": str(sch_with_r1),
|
|
"reference": "R1",
|
|
"properties": {"MPN": "OLD-PN"},
|
|
},
|
|
)
|
|
# Then update
|
|
result = iface.handle_command(
|
|
"edit_schematic_component",
|
|
{
|
|
"schematicPath": str(sch_with_r1),
|
|
"reference": "R1",
|
|
"properties": {"MPN": "RC0603FR-0710KL"},
|
|
},
|
|
)
|
|
assert result["success"] is True
|
|
assert "MPN" in result["updated"]["propertiesUpdated"]
|
|
|
|
get_result = iface.handle_command(
|
|
"get_schematic_component",
|
|
{"schematicPath": str(sch_with_r1), "reference": "R1"},
|
|
)
|
|
assert get_result["fields"]["MPN"]["value"] == "RC0603FR-0710KL"
|
|
|
|
def test_add_property_with_full_spec_dict(self, sch_with_r1: Any) -> None:
|
|
iface = self._iface()
|
|
result = iface.handle_command(
|
|
"edit_schematic_component",
|
|
{
|
|
"schematicPath": str(sch_with_r1),
|
|
"reference": "R1",
|
|
"properties": {
|
|
"MPN": {
|
|
"value": "RC0603FR-0710KL",
|
|
"x": 60.0,
|
|
"y": 60.0,
|
|
"angle": 90,
|
|
"hide": False,
|
|
}
|
|
},
|
|
},
|
|
)
|
|
assert result["success"] is True
|
|
|
|
get_result = iface.handle_command(
|
|
"get_schematic_component",
|
|
{"schematicPath": str(sch_with_r1), "reference": "R1"},
|
|
)
|
|
mpn = get_result["fields"]["MPN"]
|
|
assert mpn["value"] == "RC0603FR-0710KL"
|
|
assert mpn["x"] == pytest.approx(60.0)
|
|
assert mpn["y"] == pytest.approx(60.0)
|
|
assert mpn["angle"] == pytest.approx(90.0)
|
|
|
|
# Verify the (hide no) flag actually made it into the file
|
|
content = sch_with_r1.read_text(encoding="utf-8")
|
|
m = re.search(
|
|
r'\(property\s+"MPN"\s+"[^"]*"\s+\(at[^)]+\)\s+\(effects.*?\(hide no\)',
|
|
content,
|
|
re.DOTALL,
|
|
)
|
|
assert m is not None, "Expected (hide no) on the MPN property"
|
|
|
|
def test_new_property_defaults_to_hidden(self, sch_with_r1: Any) -> None:
|
|
iface = self._iface()
|
|
iface.handle_command(
|
|
"edit_schematic_component",
|
|
{
|
|
"schematicPath": str(sch_with_r1),
|
|
"reference": "R1",
|
|
"properties": {"DigiKey_PN": "311-10.0KHRCT-ND"},
|
|
},
|
|
)
|
|
content = sch_with_r1.read_text(encoding="utf-8")
|
|
# Match (hide yes) inside the DigiKey_PN property block
|
|
m = re.search(
|
|
r'\(property\s+"DigiKey_PN"\s+"[^"]*"\s+\(at[^)]+\)\s+\(effects.*?\(hide yes\)',
|
|
content,
|
|
re.DOTALL,
|
|
)
|
|
assert m is not None, "New custom properties should default to (hide yes)"
|
|
|
|
def test_property_added_at_component_origin_by_default(self, sch_with_r1: Any) -> None:
|
|
iface = self._iface()
|
|
iface.handle_command(
|
|
"edit_schematic_component",
|
|
{
|
|
"schematicPath": str(sch_with_r1),
|
|
"reference": "R1",
|
|
"properties": {"MPN": "RC0603FR-0710KL"},
|
|
},
|
|
)
|
|
get_result = iface.handle_command(
|
|
"get_schematic_component",
|
|
{"schematicPath": str(sch_with_r1), "reference": "R1"},
|
|
)
|
|
mpn = get_result["fields"]["MPN"]
|
|
# Default position should equal the parent symbol's (50, 50)
|
|
assert mpn["x"] == pytest.approx(50.0)
|
|
assert mpn["y"] == pytest.approx(50.0)
|
|
|
|
def test_remove_custom_property(self, sch_with_r1: Any) -> None:
|
|
iface = self._iface()
|
|
iface.handle_command(
|
|
"edit_schematic_component",
|
|
{
|
|
"schematicPath": str(sch_with_r1),
|
|
"reference": "R1",
|
|
"properties": {"MPN": "RC0603FR-0710KL"},
|
|
},
|
|
)
|
|
result = iface.handle_command(
|
|
"edit_schematic_component",
|
|
{
|
|
"schematicPath": str(sch_with_r1),
|
|
"reference": "R1",
|
|
"removeProperties": ["MPN"],
|
|
},
|
|
)
|
|
assert result["success"] is True
|
|
assert "MPN" in result["updated"]["propertiesRemoved"]
|
|
|
|
get_result = iface.handle_command(
|
|
"get_schematic_component",
|
|
{"schematicPath": str(sch_with_r1), "reference": "R1"},
|
|
)
|
|
assert "MPN" not in get_result["fields"]
|
|
|
|
def test_remove_protected_field_rejected(self, sch_with_r1: Any) -> None:
|
|
iface = self._iface()
|
|
for name in ("Reference", "Value", "Footprint", "Datasheet"):
|
|
result = iface.handle_command(
|
|
"edit_schematic_component",
|
|
{
|
|
"schematicPath": str(sch_with_r1),
|
|
"reference": "R1",
|
|
"removeProperties": [name],
|
|
},
|
|
)
|
|
assert result["success"] is False, f"Removal of {name} should be rejected"
|
|
assert name in result["message"]
|
|
|
|
def test_remove_non_existent_property_is_noop(self, sch_with_r1: Any) -> None:
|
|
iface = self._iface()
|
|
result = iface.handle_command(
|
|
"edit_schematic_component",
|
|
{
|
|
"schematicPath": str(sch_with_r1),
|
|
"reference": "R1",
|
|
"removeProperties": ["DoesNotExist"],
|
|
},
|
|
)
|
|
assert result["success"] is True
|
|
# No-op: the field was not present, so it should not appear in propertiesRemoved
|
|
assert "propertiesRemoved" not in result["updated"]
|
|
|
|
def test_batch_update_adds_and_removes_atomically(self, sch_with_r1: Any) -> None:
|
|
iface = self._iface()
|
|
iface.handle_command(
|
|
"edit_schematic_component",
|
|
{
|
|
"schematicPath": str(sch_with_r1),
|
|
"reference": "R1",
|
|
"properties": {"OldField": "drop_me"},
|
|
},
|
|
)
|
|
result = iface.handle_command(
|
|
"edit_schematic_component",
|
|
{
|
|
"schematicPath": str(sch_with_r1),
|
|
"reference": "R1",
|
|
"properties": {
|
|
"MPN": "RC0603FR-0710KL",
|
|
"Manufacturer": "Yageo",
|
|
},
|
|
"removeProperties": ["OldField"],
|
|
},
|
|
)
|
|
assert result["success"] is True
|
|
assert set(result["updated"]["propertiesAdded"].keys()) == {"MPN", "Manufacturer"}
|
|
assert "OldField" in result["updated"]["propertiesRemoved"]
|
|
|
|
get_result = iface.handle_command(
|
|
"get_schematic_component",
|
|
{"schematicPath": str(sch_with_r1), "reference": "R1"},
|
|
)
|
|
assert "OldField" not in get_result["fields"]
|
|
assert "MPN" in get_result["fields"]
|
|
assert "Manufacturer" in get_result["fields"]
|
|
|
|
def test_property_with_special_chars_is_escaped(self, sch_with_r1: Any) -> None:
|
|
"""Values containing " and \\ must be backslash-escaped in the .kicad_sch file
|
|
so the resulting S-expression is still well-formed and can be re-opened by KiCad.
|
|
"""
|
|
iface = self._iface()
|
|
tricky = 'Has "quotes" and \\backslash'
|
|
result = iface.handle_command(
|
|
"edit_schematic_component",
|
|
{
|
|
"schematicPath": str(sch_with_r1),
|
|
"reference": "R1",
|
|
"properties": {"Description": tricky},
|
|
},
|
|
)
|
|
assert result["success"] is True
|
|
|
|
# Inspect the file directly: the on-disk form must contain the escaped
|
|
# representation, NOT the raw quotes (which would corrupt the S-expression).
|
|
content = sch_with_r1.read_text(encoding="utf-8")
|
|
assert (
|
|
r'(property "Description" "Has \"quotes\" and \\backslash"' in content
|
|
), f"Expected escaped property value in file. Got:\n{content[-1000:]}"
|
|
|
|
def test_existing_value_field_is_unchanged_when_adding_property(self, sch_with_r1: Any) -> None:
|
|
iface = self._iface()
|
|
iface.handle_command(
|
|
"edit_schematic_component",
|
|
{
|
|
"schematicPath": str(sch_with_r1),
|
|
"reference": "R1",
|
|
"properties": {"MPN": "RC0603FR-0710KL"},
|
|
},
|
|
)
|
|
get_result = iface.handle_command(
|
|
"get_schematic_component",
|
|
{"schematicPath": str(sch_with_r1), "reference": "R1"},
|
|
)
|
|
# Built-in fields must be untouched
|
|
assert get_result["fields"]["Value"]["value"] == "10k"
|
|
assert get_result["fields"]["Reference"]["value"] == "R1"
|
|
assert get_result["fields"]["Footprint"]["value"] == "Resistor_SMD:R_0603_1608Metric"
|
|
|
|
def test_uuid_preserved_after_property_changes(self, sch_with_r1: Any) -> None:
|
|
iface = self._iface()
|
|
before = sch_with_r1.read_text(encoding="utf-8")
|
|
iface.handle_command(
|
|
"edit_schematic_component",
|
|
{
|
|
"schematicPath": str(sch_with_r1),
|
|
"reference": "R1",
|
|
"properties": {"MPN": "RC0603FR-0710KL", "Manufacturer": "Yageo"},
|
|
"removeProperties": ["Datasheet"] if False else None,
|
|
},
|
|
)
|
|
after = sch_with_r1.read_text(encoding="utf-8")
|
|
assert "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" in after
|
|
# And the uuid must still be the only one (we did not duplicate the symbol)
|
|
assert before.count("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee") == after.count(
|
|
"aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
|
|
)
|
|
|
|
def test_unknown_reference_returns_failure(self, sch_with_r1: Any) -> None:
|
|
iface = self._iface()
|
|
result = iface.handle_command(
|
|
"edit_schematic_component",
|
|
{
|
|
"schematicPath": str(sch_with_r1),
|
|
"reference": "R99",
|
|
"properties": {"MPN": "RC0603FR-0710KL"},
|
|
},
|
|
)
|
|
assert result["success"] is False
|
|
|
|
def test_invalid_properties_type_returns_failure(self, sch_with_r1: Any) -> None:
|
|
iface = self._iface()
|
|
result = iface.handle_command(
|
|
"edit_schematic_component",
|
|
{
|
|
"schematicPath": str(sch_with_r1),
|
|
"reference": "R1",
|
|
"properties": ["not", "a", "dict"],
|
|
},
|
|
)
|
|
assert result["success"] is False
|
|
|
|
def test_invalid_remove_properties_type_returns_failure(self, sch_with_r1: Any) -> None:
|
|
iface = self._iface()
|
|
result = iface.handle_command(
|
|
"edit_schematic_component",
|
|
{
|
|
"schematicPath": str(sch_with_r1),
|
|
"reference": "R1",
|
|
"removeProperties": "MPN", # should be a list
|
|
},
|
|
)
|
|
assert result["success"] is False
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tests for the dedicated set_/remove_ tools
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.integration
|
|
class TestSetSchematicComponentProperty:
|
|
"""Tests for the convenience `set_schematic_component_property` tool."""
|
|
|
|
@pytest.fixture
|
|
def sch_with_r1(self, tmp_path: Any) -> Any:
|
|
return _make_test_schematic(tmp_path, PLACED_RESISTOR_BLOCK)
|
|
|
|
def _iface(self) -> Any:
|
|
from kicad_interface import KiCADInterface
|
|
|
|
return KiCADInterface()
|
|
|
|
def test_set_creates_property(self, sch_with_r1: Any) -> None:
|
|
iface = self._iface()
|
|
result = iface.handle_command(
|
|
"set_schematic_component_property",
|
|
{
|
|
"schematicPath": str(sch_with_r1),
|
|
"reference": "R1",
|
|
"name": "MPN",
|
|
"value": "RC0603FR-0710KL",
|
|
},
|
|
)
|
|
assert result["success"] is True
|
|
assert result["updated"]["propertiesAdded"]["MPN"] == "RC0603FR-0710KL"
|
|
|
|
def test_set_updates_existing_property(self, sch_with_r1: Any) -> None:
|
|
iface = self._iface()
|
|
iface.handle_command(
|
|
"set_schematic_component_property",
|
|
{
|
|
"schematicPath": str(sch_with_r1),
|
|
"reference": "R1",
|
|
"name": "MPN",
|
|
"value": "OLD",
|
|
},
|
|
)
|
|
result = iface.handle_command(
|
|
"set_schematic_component_property",
|
|
{
|
|
"schematicPath": str(sch_with_r1),
|
|
"reference": "R1",
|
|
"name": "MPN",
|
|
"value": "NEW",
|
|
},
|
|
)
|
|
assert result["success"] is True
|
|
assert result["updated"]["propertiesUpdated"]["MPN"] == "NEW"
|
|
|
|
def test_set_with_visible_position(self, sch_with_r1: Any) -> None:
|
|
iface = self._iface()
|
|
result = iface.handle_command(
|
|
"set_schematic_component_property",
|
|
{
|
|
"schematicPath": str(sch_with_r1),
|
|
"reference": "R1",
|
|
"name": "MPN",
|
|
"value": "RC0603FR-0710KL",
|
|
"x": 70.0,
|
|
"y": 65.0,
|
|
"hide": False,
|
|
"fontSize": 0.85,
|
|
},
|
|
)
|
|
assert result["success"] is True
|
|
|
|
get_result = iface.handle_command(
|
|
"get_schematic_component",
|
|
{"schematicPath": str(sch_with_r1), "reference": "R1"},
|
|
)
|
|
mpn = get_result["fields"]["MPN"]
|
|
assert mpn["x"] == pytest.approx(70.0)
|
|
assert mpn["y"] == pytest.approx(65.0)
|
|
|
|
def test_set_missing_name_fails(self, sch_with_r1: Any) -> None:
|
|
iface = self._iface()
|
|
result = iface.handle_command(
|
|
"set_schematic_component_property",
|
|
{
|
|
"schematicPath": str(sch_with_r1),
|
|
"reference": "R1",
|
|
"value": "RC0603FR-0710KL",
|
|
},
|
|
)
|
|
assert result["success"] is False
|
|
|
|
def test_set_missing_value_fails(self, sch_with_r1: Any) -> None:
|
|
iface = self._iface()
|
|
result = iface.handle_command(
|
|
"set_schematic_component_property",
|
|
{
|
|
"schematicPath": str(sch_with_r1),
|
|
"reference": "R1",
|
|
"name": "MPN",
|
|
},
|
|
)
|
|
assert result["success"] is False
|
|
|
|
def test_set_can_modify_built_in_value_field(self, sch_with_r1: Any) -> None:
|
|
"""Built-in fields can be re-targeted via set_..._property too."""
|
|
iface = self._iface()
|
|
result = iface.handle_command(
|
|
"set_schematic_component_property",
|
|
{
|
|
"schematicPath": str(sch_with_r1),
|
|
"reference": "R1",
|
|
"name": "Value",
|
|
"value": "22k",
|
|
},
|
|
)
|
|
assert result["success"] is True
|
|
get_result = iface.handle_command(
|
|
"get_schematic_component",
|
|
{"schematicPath": str(sch_with_r1), "reference": "R1"},
|
|
)
|
|
assert get_result["fields"]["Value"]["value"] == "22k"
|
|
|
|
|
|
@pytest.mark.integration
|
|
class TestRemoveSchematicComponentProperty:
|
|
"""Tests for the convenience `remove_schematic_component_property` tool."""
|
|
|
|
@pytest.fixture
|
|
def sch_with_r1(self, tmp_path: Any) -> Any:
|
|
return _make_test_schematic(tmp_path, PLACED_RESISTOR_BLOCK)
|
|
|
|
def _iface(self) -> Any:
|
|
from kicad_interface import KiCADInterface
|
|
|
|
return KiCADInterface()
|
|
|
|
def test_remove_existing_custom_property(self, sch_with_r1: Any) -> None:
|
|
iface = self._iface()
|
|
iface.handle_command(
|
|
"set_schematic_component_property",
|
|
{
|
|
"schematicPath": str(sch_with_r1),
|
|
"reference": "R1",
|
|
"name": "MPN",
|
|
"value": "RC0603FR-0710KL",
|
|
},
|
|
)
|
|
result = iface.handle_command(
|
|
"remove_schematic_component_property",
|
|
{
|
|
"schematicPath": str(sch_with_r1),
|
|
"reference": "R1",
|
|
"name": "MPN",
|
|
},
|
|
)
|
|
assert result["success"] is True
|
|
assert "MPN" in result["updated"]["propertiesRemoved"]
|
|
|
|
get_result = iface.handle_command(
|
|
"get_schematic_component",
|
|
{"schematicPath": str(sch_with_r1), "reference": "R1"},
|
|
)
|
|
assert "MPN" not in get_result["fields"]
|
|
|
|
def test_remove_built_in_field_rejected(self, sch_with_r1: Any) -> None:
|
|
iface = self._iface()
|
|
result = iface.handle_command(
|
|
"remove_schematic_component_property",
|
|
{
|
|
"schematicPath": str(sch_with_r1),
|
|
"reference": "R1",
|
|
"name": "Reference",
|
|
},
|
|
)
|
|
assert result["success"] is False
|
|
|
|
def test_remove_missing_property_succeeds_with_no_change(self, sch_with_r1: Any) -> None:
|
|
iface = self._iface()
|
|
result = iface.handle_command(
|
|
"remove_schematic_component_property",
|
|
{
|
|
"schematicPath": str(sch_with_r1),
|
|
"reference": "R1",
|
|
"name": "NeverExisted",
|
|
},
|
|
)
|
|
assert result["success"] is True
|
|
assert "propertiesRemoved" not in result["updated"]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Regression: KiCad emits (symbol (lib_name "...") (lib_id "...") ...) for
|
|
# rescued / customised symbols. The original lookup regex required (lib_id
|
|
# IMMEDIATELY after (symbol — silently failing on these blocks. All three
|
|
# affected handlers (get / edit / set / remove) must find them.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.integration
|
|
class TestLibNameBeforeLibIdOrdering:
|
|
"""Reproduces the real-world failure where R1 in a 90k-line user
|
|
schematic was reported as 'not found' because the placed (symbol ...)
|
|
block carried a (lib_name "...") child before (lib_id "...")."""
|
|
|
|
@pytest.fixture
|
|
def sch_with_libname(self, tmp_path: Any) -> Any:
|
|
return _make_test_schematic(tmp_path, PLACED_RESISTOR_BLOCK_LIBNAME_FIRST)
|
|
|
|
def _iface(self) -> Any:
|
|
from kicad_interface import KiCADInterface
|
|
|
|
return KiCADInterface()
|
|
|
|
def test_get_finds_symbol_with_libname_before_libid(self, sch_with_libname: Any) -> None:
|
|
iface = self._iface()
|
|
result = iface.handle_command(
|
|
"get_schematic_component",
|
|
{"schematicPath": str(sch_with_libname), "reference": "R7"},
|
|
)
|
|
assert result["success"] is True, result.get("message")
|
|
assert result["fields"]["Value"]["value"] == "499k"
|
|
assert result["fields"]["Footprint"]["value"] == "MF_Passives_R0603"
|
|
# Symbol position must come from the symbol's own (at), not a property
|
|
assert result["position"]["x"] == pytest.approx(132.08)
|
|
assert result["position"]["y"] == pytest.approx(44.45)
|
|
|
|
def test_set_property_works_on_symbol_with_libname_before_libid(
|
|
self, sch_with_libname: Any
|
|
) -> None:
|
|
iface = self._iface()
|
|
result = iface.handle_command(
|
|
"set_schematic_component_property",
|
|
{
|
|
"schematicPath": str(sch_with_libname),
|
|
"reference": "R7",
|
|
"name": "Mfr",
|
|
"value": "Yageo",
|
|
},
|
|
)
|
|
assert result["success"] is True, result.get("message")
|
|
assert result["updated"]["propertiesAdded"]["Mfr"] == "Yageo"
|
|
|
|
verify = iface.handle_command(
|
|
"get_schematic_component",
|
|
{"schematicPath": str(sch_with_libname), "reference": "R7"},
|
|
)
|
|
assert verify["fields"]["Mfr"]["value"] == "Yageo"
|
|
|
|
def test_edit_with_properties_dict_works_on_libname_first_form(
|
|
self, sch_with_libname: Any
|
|
) -> None:
|
|
iface = self._iface()
|
|
result = iface.handle_command(
|
|
"edit_schematic_component",
|
|
{
|
|
"schematicPath": str(sch_with_libname),
|
|
"reference": "R7",
|
|
"properties": {
|
|
"MPN": "RC0603FR-07499KL",
|
|
"Manufacturer": "Yageo",
|
|
"Tolerance": "1%",
|
|
},
|
|
},
|
|
)
|
|
assert result["success"] is True, result.get("message")
|
|
assert set(result["updated"]["propertiesAdded"].keys()) == {
|
|
"MPN",
|
|
"Manufacturer",
|
|
"Tolerance",
|
|
}
|
|
|
|
def test_remove_property_works_on_libname_first_form(self, sch_with_libname: Any) -> None:
|
|
iface = self._iface()
|
|
# First add a property so we have something to remove
|
|
iface.handle_command(
|
|
"set_schematic_component_property",
|
|
{
|
|
"schematicPath": str(sch_with_libname),
|
|
"reference": "R7",
|
|
"name": "MPN",
|
|
"value": "RC0603FR-07499KL",
|
|
},
|
|
)
|
|
result = iface.handle_command(
|
|
"remove_schematic_component_property",
|
|
{
|
|
"schematicPath": str(sch_with_libname),
|
|
"reference": "R7",
|
|
"name": "MPN",
|
|
},
|
|
)
|
|
assert result["success"] is True, result.get("message")
|
|
assert "MPN" in result["updated"]["propertiesRemoved"]
|
|
|
|
def test_default_property_position_matches_symbol_origin_on_libname_first_form(
|
|
self, sch_with_libname: Any
|
|
) -> None:
|
|
"""Newly-added properties default to the parent symbol's (at) position.
|
|
This used to silently fall back to (0,0) on (lib_name)-first symbols
|
|
because the position-extraction regex required (symbol (lib_id ...) (at ...)).
|
|
"""
|
|
iface = self._iface()
|
|
iface.handle_command(
|
|
"set_schematic_component_property",
|
|
{
|
|
"schematicPath": str(sch_with_libname),
|
|
"reference": "R7",
|
|
"name": "Mfr",
|
|
"value": "Yageo",
|
|
},
|
|
)
|
|
verify = iface.handle_command(
|
|
"get_schematic_component",
|
|
{"schematicPath": str(sch_with_libname), "reference": "R7"},
|
|
)
|
|
mfr = verify["fields"]["Mfr"]
|
|
assert mfr["x"] == pytest.approx(132.08)
|
|
assert mfr["y"] == pytest.approx(44.45)
|
|
|
|
def test_edit_builtin_fields_works_on_libname_first_form(self, sch_with_libname: Any) -> None:
|
|
"""The pre-existing edit path (footprint / value / reference rewriting,
|
|
unrelated to custom properties) also relied on the buggy symbol-lookup
|
|
regex. Verify it now works on (lib_name)-first symbols too."""
|
|
iface = self._iface()
|
|
result = iface.handle_command(
|
|
"edit_schematic_component",
|
|
{
|
|
"schematicPath": str(sch_with_libname),
|
|
"reference": "R7",
|
|
"value": "1k",
|
|
"footprint": "Resistor_SMD:R_0603_1608Metric",
|
|
},
|
|
)
|
|
assert result["success"] is True, result.get("message")
|
|
|
|
verify = iface.handle_command(
|
|
"get_schematic_component",
|
|
{"schematicPath": str(sch_with_libname), "reference": "R7"},
|
|
)
|
|
assert verify["fields"]["Value"]["value"] == "1k"
|
|
assert verify["fields"]["Footprint"]["value"] == "Resistor_SMD:R_0603_1608Metric"
|
|
|
|
def test_delete_works_on_libname_first_form(self, sch_with_libname: Any) -> None:
|
|
"""The pre-existing delete path used the same buggy symbol-lookup
|
|
regex and would silently report 'not found' for (lib_name)-first
|
|
symbols. Verify it can now locate and remove them."""
|
|
iface = self._iface()
|
|
result = iface.handle_command(
|
|
"delete_schematic_component",
|
|
{"schematicPath": str(sch_with_libname), "reference": "R7"},
|
|
)
|
|
assert result["success"] is True, result.get("message")
|
|
|
|
verify = iface.handle_command(
|
|
"get_schematic_component",
|
|
{"schematicPath": str(sch_with_libname), "reference": "R7"},
|
|
)
|
|
assert verify["success"] is False
|
|
assert "not found" in verify.get("message", "").lower()
|