kicad-mcp/tests/test_lib_resolver.py
Ryan Malloy b42992f64d
Some checks are pending
CI / Lint and Format (push) Waiting to run
CI / Test Python 3.11 on macos-latest (push) Waiting to run
CI / Test Python 3.12 on macos-latest (push) Waiting to run
CI / Test Python 3.13 on macos-latest (push) Waiting to run
CI / Test Python 3.10 on ubuntu-latest (push) Waiting to run
CI / Test Python 3.11 on ubuntu-latest (push) Waiting to run
CI / Test Python 3.12 on ubuntu-latest (push) Waiting to run
CI / Test Python 3.13 on ubuntu-latest (push) Waiting to run
CI / Security Scan (push) Waiting to run
CI / Build Package (push) Blocked by required conditions
Detect KiCad symbol dir cross-platform in test gating
requires_kicad keyed off a hardcoded /usr/share/kicad path, so symbol-dependent
tests silently skipped on macOS even with KiCad installed. Derive DEVICE_LIB
from get_kicad_symbol_dir() so those tests actually run on the macOS testbed.
2026-07-11 17:37:42 -06:00

477 lines
16 KiB
Python

"""Tests for unified symbol library resolution."""
import os
import time
import pytest
from mckicad.utils.lib_resolver import (
_lib_table_cache,
extract_pin_unit_map,
extract_symbol_pins,
extract_symbol_sexp_text,
get_kicad_symbol_dir,
load_sym_lib_table,
resolve_library_path,
resolve_symbol,
)
from mckicad.utils.sexp_tree import find_recursive, parse
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
# Locate the system symbol dir cross-platform (macOS/Linux/Windows) via the
# same resolver the code uses, rather than a hardcoded Linux path — otherwise
# `requires_kicad` tests silently skip on macOS even when KiCad is installed.
_SYMBOL_DIR = get_kicad_symbol_dir()
DEVICE_LIB = (
os.path.join(_SYMBOL_DIR, "Device.kicad_sym")
if _SYMBOL_DIR
else "/usr/share/kicad/symbols/Device.kicad_sym"
)
GLOBAL_SYM_LIB_TABLE_9 = os.path.expanduser("~/.config/kicad/9.0/sym-lib-table")
GLOBAL_SYM_LIB_TABLE_8 = os.path.expanduser("~/.config/kicad/8.0/sym-lib-table")
has_kicad = os.path.isfile(DEVICE_LIB)
has_global_table_9 = os.path.isfile(GLOBAL_SYM_LIB_TABLE_9)
has_global_table_8 = os.path.isfile(GLOBAL_SYM_LIB_TABLE_8)
has_global_table = has_global_table_9 or has_global_table_8
requires_kicad = pytest.mark.skipif(not has_kicad, reason="KiCad not installed")
requires_global_table = pytest.mark.skipif(
not has_global_table, reason="No global sym-lib-table",
)
@pytest.fixture
def project_with_lib(tmp_path):
"""Create a project directory with a custom symbol library."""
# Create a minimal .kicad_sym file
lib_content = """\
(kicad_symbol_lib
(version 20241209)
(symbol "MyResistor"
(symbol "MyResistor_1_1"
(pin passive line
(at 0 2.54 270)
(length 1.27)
(name "~" (effects (font (size 1.27 1.27))))
(number "1" (effects (font (size 1.27 1.27))))
)
(pin passive line
(at 0 -2.54 90)
(length 1.27)
(name "~" (effects (font (size 1.27 1.27))))
(number "2" (effects (font (size 1.27 1.27))))
)
)
)
)"""
lib_file = tmp_path / "MyLib.kicad_sym"
lib_file.write_text(lib_content)
# Create sym-lib-table pointing to it
table_content = (
"(sym_lib_table\n"
" (version 7)\n"
' (lib (name "MyLib")(type "KiCad")'
'(uri "${KIPRJMOD}/MyLib.kicad_sym")(options "")(descr ""))\n'
")\n"
)
table_file = tmp_path / "sym-lib-table"
table_file.write_text(table_content)
# Create .kicad_pro so it looks like a project
pro_file = tmp_path / "test.kicad_pro"
pro_file.write_text('{"meta": {}}')
return tmp_path
@pytest.fixture
def multi_unit_lib(tmp_path):
"""Create a project with a multi-unit symbol (like TL072)."""
lib_content = """\
(kicad_symbol_lib
(version 20241209)
(symbol "TL072"
(symbol "TL072_1_1"
(pin output line
(at 5.08 0 180)
(length 2.54)
(name "~" (effects (font (size 1.27 1.27))))
(number "1" (effects (font (size 1.27 1.27))))
)
(pin input line
(at -5.08 2.54 0)
(length 2.54)
(name "~" (effects (font (size 1.27 1.27))))
(number "3" (effects (font (size 1.27 1.27))))
)
(pin input line
(at -5.08 -2.54 0)
(length 2.54)
(name "~" (effects (font (size 1.27 1.27))))
(number "2" (effects (font (size 1.27 1.27))))
)
)
(symbol "TL072_2_1"
(pin output line
(at 5.08 0 180)
(length 2.54)
(name "~" (effects (font (size 1.27 1.27))))
(number "7" (effects (font (size 1.27 1.27))))
)
(pin input line
(at -5.08 2.54 0)
(length 2.54)
(name "~" (effects (font (size 1.27 1.27))))
(number "5" (effects (font (size 1.27 1.27))))
)
(pin input line
(at -5.08 -2.54 0)
(length 2.54)
(name "~" (effects (font (size 1.27 1.27))))
(number "6" (effects (font (size 1.27 1.27))))
)
)
)
)"""
lib_file = tmp_path / "OpAmps.kicad_sym"
lib_file.write_text(lib_content)
table_content = (
"(sym_lib_table\n"
" (version 7)\n"
' (lib (name "OpAmps")(type "KiCad")'
'(uri "${KIPRJMOD}/OpAmps.kicad_sym")(options "")(descr ""))\n'
")\n"
)
(tmp_path / "sym-lib-table").write_text(table_content)
(tmp_path / "test.kicad_pro").write_text('{"meta": {}}')
return tmp_path
@pytest.fixture(autouse=True)
def _clear_cache():
"""Clear the lib table cache between tests."""
_lib_table_cache.clear()
yield
_lib_table_cache.clear()
# ---------------------------------------------------------------------------
# resolve_library_path
# ---------------------------------------------------------------------------
@requires_global_table
class TestResolveLibraryPathGlobalTable:
def test_finds_device_lib(self):
path = resolve_library_path("Device")
assert path is not None
assert path.endswith("Device.kicad_sym")
assert os.path.isfile(path)
class TestResolveLibraryPathProjectTable:
def test_finds_project_lib(self, project_with_lib):
path = resolve_library_path("MyLib", project_dir=str(project_with_lib))
assert path is not None
assert path.endswith("MyLib.kicad_sym")
assert os.path.isfile(path)
class TestResolveLibraryPathPrecedence:
@requires_global_table
def test_project_overrides_global(self, tmp_path):
"""If both project and global define a library, project wins."""
# Create a project-level Device.kicad_sym
project_lib = tmp_path / "Device.kicad_sym"
project_lib.write_text("(kicad_symbol_lib (version 20241209))")
table_content = (
"(sym_lib_table\n"
" (version 7)\n"
' (lib (name "Device")(type "KiCad")'
'(uri "${KIPRJMOD}/Device.kicad_sym")(options "")(descr ""))\n'
")\n"
)
(tmp_path / "sym-lib-table").write_text(table_content)
path = resolve_library_path("Device", project_dir=str(tmp_path))
assert path is not None
# Should resolve to the project-local one, not /usr/share/kicad/...
assert str(tmp_path) in path
class TestResolveLibraryPathDirectFallback:
def test_finds_lib_in_same_dir(self, tmp_path):
"""Falls back to directory search when no sym-lib-table."""
lib_file = tmp_path / "CustomLib.kicad_sym"
lib_file.write_text("(kicad_symbol_lib)")
path = resolve_library_path("CustomLib", project_dir=str(tmp_path))
assert path is not None
assert path.endswith("CustomLib.kicad_sym")
def test_finds_lib_in_libs_subdir(self, tmp_path):
libs_dir = tmp_path / "libs"
libs_dir.mkdir()
lib_file = libs_dir / "CustomLib.kicad_sym"
lib_file.write_text("(kicad_symbol_lib)")
path = resolve_library_path("CustomLib", project_dir=str(tmp_path))
assert path is not None
def test_nonexistent_library(self, tmp_path):
path = resolve_library_path("NoSuchLib", project_dir=str(tmp_path))
assert path is None
# ---------------------------------------------------------------------------
# resolve_symbol
# ---------------------------------------------------------------------------
@requires_kicad
@requires_global_table
class TestResolveSymbolDeviceR:
def test_resolves_resistor(self):
sym = resolve_symbol("Device:R")
assert sym is not None
assert sym.tag == "symbol"
assert sym.values[0] == "R"
pins = find_recursive(sym, "pin")
assert len(pins) == 2
class TestResolveSymbolProjectLib:
def test_resolves_from_project(self, project_with_lib):
sym = resolve_symbol("MyLib:MyResistor", project_dir=str(project_with_lib))
assert sym is not None
assert sym.values[0] == "MyResistor"
class TestResolveSymbolEdgeCases:
def test_nonexistent_library(self):
assert resolve_symbol("NoSuchLib:NoSuchSymbol") is None
def test_nonexistent_symbol_in_library(self, project_with_lib):
assert resolve_symbol(
"MyLib:NoSuchSymbol", project_dir=str(project_with_lib),
) is None
def test_invalid_lib_id_no_colon(self):
assert resolve_symbol("JustAName") is None
def test_invalid_lib_id_empty_parts(self):
assert resolve_symbol(":Symbol") is None
assert resolve_symbol("Lib:") is None
# ---------------------------------------------------------------------------
# extract_symbol_pins
# ---------------------------------------------------------------------------
class TestExtractSymbolPinsFormat:
def test_pin_dict_format(self, project_with_lib):
sym = resolve_symbol("MyLib:MyResistor", project_dir=str(project_with_lib))
assert sym is not None
pins = extract_symbol_pins(sym)
assert len(pins) == 2
for pin in pins:
assert "number" in pin
assert "name" in pin
assert "type" in pin
assert "shape" in pin
assert "x" in pin
assert "y" in pin
assert "rotation" in pin
assert "length" in pin
# Check specific values
pin_numbers = {p["number"] for p in pins}
assert pin_numbers == {"1", "2"}
pin1 = next(p for p in pins if p["number"] == "1")
assert pin1["type"] == "passive"
assert pin1["shape"] == "line"
assert pin1["x"] == 0.0
assert pin1["y"] == 2.54
assert pin1["rotation"] == 270.0
assert pin1["length"] == 1.27
# ---------------------------------------------------------------------------
# extract_pin_unit_map
# ---------------------------------------------------------------------------
class TestExtractPinUnitMap:
def test_multi_unit_mapping(self, multi_unit_lib):
sym = resolve_symbol("OpAmps:TL072", project_dir=str(multi_unit_lib))
assert sym is not None
pin_map = extract_pin_unit_map(sym)
# Unit 1 pins: 1, 2, 3
assert pin_map["1"] == 1
assert pin_map["2"] == 1
assert pin_map["3"] == 1
# Unit 2 pins: 5, 6, 7
assert pin_map["5"] == 2
assert pin_map["6"] == 2
assert pin_map["7"] == 2
def test_single_unit_empty_map(self, project_with_lib):
"""Single-unit symbols don't have meaningful sub-symbol structure."""
sym = resolve_symbol("MyLib:MyResistor", project_dir=str(project_with_lib))
assert sym is not None
# MyResistor only has _1_1 sub-symbol, so all pins map to unit 1
pin_map = extract_pin_unit_map(sym)
# For a single-unit symbol, having a map is fine (unit 1)
if pin_map:
assert all(v == 1 for v in pin_map.values())
# ---------------------------------------------------------------------------
# extract_symbol_sexp_text
# ---------------------------------------------------------------------------
class TestExtractSymbolSexpText:
def test_returns_raw_text(self, project_with_lib):
text = extract_symbol_sexp_text(
"MyLib:MyResistor", project_dir=str(project_with_lib),
)
assert text is not None
assert text.startswith('(symbol "MyResistor"')
assert text.endswith(")")
# Should be parseable
node = parse(text)
assert node.tag == "symbol"
def test_nonexistent_returns_none(self):
assert extract_symbol_sexp_text("NoLib:NoSym") is None
# ---------------------------------------------------------------------------
# get_kicad_symbol_dir
# ---------------------------------------------------------------------------
class TestGetKicadSymbolDir:
@requires_kicad
def test_detected(self):
sym_dir = get_kicad_symbol_dir()
assert sym_dir is not None
assert os.path.isdir(sym_dir)
assert os.path.isfile(os.path.join(sym_dir, "Device.kicad_sym"))
def test_env_override(self, tmp_path, monkeypatch):
# Clear any ambient symbol-dir vars so the override under test is the
# only candidate (a real KICAD_SYMBOL_DIR or install path would
# otherwise take precedence).
for var in (
"KICAD_SYMBOL_DIR",
"KICAD10_SYMBOL_DIR",
"KICAD9_SYMBOL_DIR",
"KICAD8_SYMBOL_DIR",
"KICAD7_SYMBOL_DIR",
):
monkeypatch.delenv(var, raising=False)
monkeypatch.setenv("KICAD9_SYMBOL_DIR", str(tmp_path))
sym_dir = get_kicad_symbol_dir()
assert sym_dir == str(tmp_path)
# ---------------------------------------------------------------------------
# load_sym_lib_table
# ---------------------------------------------------------------------------
class TestLoadSymLibTable:
def test_parses_project_table(self, project_with_lib):
table_path = str(project_with_lib / "sym-lib-table")
result = load_sym_lib_table(table_path)
assert "MyLib" in result
assert "${KIPRJMOD}/MyLib.kicad_sym" in result["MyLib"]
@requires_global_table
def test_parses_global_table(self):
table_path = GLOBAL_SYM_LIB_TABLE_9 if has_global_table_9 else GLOBAL_SYM_LIB_TABLE_8
result = load_sym_lib_table(table_path)
assert "Device" in result
def test_nonexistent_file(self):
result = load_sym_lib_table("/no/such/file")
assert result == {}
# ---------------------------------------------------------------------------
# Variable substitution
# ---------------------------------------------------------------------------
class TestVariableSubstitution:
def test_kiprjmod(self, project_with_lib):
path = resolve_library_path("MyLib", project_dir=str(project_with_lib))
assert path is not None
# Should NOT contain ${KIPRJMOD} — it should be resolved
assert "${KIPRJMOD}" not in path
assert str(project_with_lib) in path
@requires_global_table
def test_kicad9_symbol_dir(self):
"""Global table uses ${KICAD9_SYMBOL_DIR} which should be resolved."""
path = resolve_library_path("Device")
assert path is not None
assert "${KICAD9_SYMBOL_DIR}" not in path
assert "${KICAD8_SYMBOL_DIR}" not in path
# ---------------------------------------------------------------------------
# Cache invalidation
# ---------------------------------------------------------------------------
class TestCacheInvalidation:
def test_cache_hit(self, project_with_lib):
table_path = str(project_with_lib / "sym-lib-table")
result1 = load_sym_lib_table(table_path)
result2 = load_sym_lib_table(table_path)
assert result1 == result2
# Should be the same dict object (cache hit)
assert result1 is result2
def test_cache_invalidation_on_mtime(self, project_with_lib):
table_path = project_with_lib / "sym-lib-table"
str_path = str(table_path)
result1 = load_sym_lib_table(str_path)
assert "MyLib" in result1
# Modify the file (add another library)
time.sleep(0.05) # ensure mtime changes
new_content = (
"(sym_lib_table\n"
" (version 7)\n"
' (lib (name "MyLib")(type "KiCad")'
'(uri "${KIPRJMOD}/MyLib.kicad_sym")(options "")(descr ""))\n'
' (lib (name "NewLib")(type "KiCad")'
'(uri "${KIPRJMOD}/NewLib.kicad_sym")(options "")(descr ""))\n'
")\n"
)
table_path.write_text(new_content)
result2 = load_sym_lib_table(str_path)
assert "NewLib" in result2
# Should NOT be the same object (cache was invalidated)
assert result1 is not result2