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
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.
477 lines
16 KiB
Python
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
|