"""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