@hybridlabor-api/bdb-hardware-pcb 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +116 -0
- package/config/mcp/antigravity.json +22 -0
- package/config/mcp/claude.json +22 -0
- package/config/mcp/codex.toml +25 -0
- package/docs/adr/ADR-001-KICAD-OPENSCAD-MCP-STRATEGY.md +334 -0
- package/docs/review_documentation.md +121 -0
- package/installer.js +358 -0
- package/mcp_servers/kicad-mcp-server/.env.example +22 -0
- package/mcp_servers/kicad-mcp-server/.github/workflows/ci.yml +36 -0
- package/mcp_servers/kicad-mcp-server/CLAUDE.md +487 -0
- package/mcp_servers/kicad-mcp-server/README.md +316 -0
- package/mcp_servers/kicad-mcp-server/docs/DEVICE_TREE.md +416 -0
- package/mcp_servers/kicad-mcp-server/docs/INSTALLATION.md +332 -0
- package/mcp_servers/kicad-mcp-server/docs/PIN_ANALYSIS.md +332 -0
- package/mcp_servers/kicad-mcp-server/docs/README.md +240 -0
- package/mcp_servers/kicad-mcp-server/docs/TESTING.md +613 -0
- package/mcp_servers/kicad-mcp-server/docs/VALIDATION.md +268 -0
- package/mcp_servers/kicad-mcp-server/pyproject.toml +96 -0
- package/mcp_servers/kicad-mcp-server/requirements-dev.txt +16 -0
- package/mcp_servers/kicad-mcp-server/requirements-test.txt +24 -0
- package/mcp_servers/kicad-mcp-server/requirements.txt +13 -0
- package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/__init__.py +3 -0
- package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/__main__.py +17 -0
- package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/config.py +46 -0
- package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/models/__init__.py +1 -0
- package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/models/types.py +87 -0
- package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/parsers/__init__.py +1 -0
- package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/parsers/netlist_parser.py +234 -0
- package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/parsers/pcb_parser.py +375 -0
- package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/parsers/pcb_parser_kicad.py +327 -0
- package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/parsers/schematic_parser.py +902 -0
- package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/server.py +71 -0
- package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/templates/__init__.py +1 -0
- package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/templates/arduino/connectivity_test.cpp.j2 +189 -0
- package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/templates/device_tree/atmega.dts.j2 +77 -0
- package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/templates/device_tree/esp32.dts.j2 +77 -0
- package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/templates/device_tree/nrf52.dts.j2 +77 -0
- package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/templates/device_tree/stm32f4.dts.j2 +89 -0
- package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/templates/esp_idf/test_suite.c.j2 +340 -0
- package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/templates/pytest/test_connectivity.py.j2 +147 -0
- package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/templates/st_hal/hal_test.c.j2 +313 -0
- package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/templates/tests/pytest_gpio_test.py.j2 +99 -0
- package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/templates/tests/pytest_i2c_test.py.j2 +117 -0
- package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/templates/tests/pytest_pinmux_test.py.j2 +43 -0
- package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/templates/tests/pytest_spi_test.py.j2 +94 -0
- package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/templates/tests/unity_gpio_test.c.j2 +113 -0
- package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/templates/tests/unity_i2c_test.c.j2 +101 -0
- package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/templates/tests/unity_spi_test.c.j2 +94 -0
- package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/templates/unittest/test_schematic.py.j2 +172 -0
- package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/tools/__init__.py +36 -0
- package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/tools/device_tree.py +1187 -0
- package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/tools/hierarchical_analysis.py +211 -0
- package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/tools/netlist.py +320 -0
- package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/tools/parts_registry.py +142 -0
- package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/tools/pcb.py +955 -0
- package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/tools/pcb_layout.py +308 -0
- package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/tools/pin_analysis.py +765 -0
- package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/tools/project.py +196 -0
- package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/tools/schematic.py +319 -0
- package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/tools/schematic_editor.py +674 -0
- package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/tools/schematic_search.py +158 -0
- package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/tools/validation.py +866 -0
- package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/tools/visualization.py +225 -0
- package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/utils/__init__.py +1 -0
- package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/utils/file_handlers.py +65 -0
- package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/utils/kicad_cli.py +103 -0
- package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/utils/kicad_version.py +103 -0
- package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/utils/parts_registry.py +197 -0
- package/mcp_servers/kicad-mcp-server/tests/__init__.py +1 -0
- package/mcp_servers/kicad-mcp-server/tests/examples/ESP32S3_TEST.md +219 -0
- package/mcp_servers/kicad-mcp-server/tests/fixtures/README.md +65 -0
- package/mcp_servers/kicad-mcp-server/tests/fixtures/__init__.py +1 -0
- package/mcp_servers/kicad-mcp-server/tests/fixtures/example_pcb.kicad_pcb +177 -0
- package/mcp_servers/kicad-mcp-server/tests/fixtures/example_schematic.kicad_sch +145 -0
- package/mcp_servers/kicad-mcp-server/tests/fixtures/hier/child.kicad_sch +24 -0
- package/mcp_servers/kicad-mcp-server/tests/fixtures/hier/root.kicad_sch +38 -0
- package/mcp_servers/kicad-mcp-server/tests/test_tools/__init__.py +1 -0
- package/mcp_servers/kicad-mcp-server/tests/test_tools/test_hierarchical_labels.py +195 -0
- package/mcp_servers/kicad-mcp-server/tests/test_tools/test_kicad_cli.py +116 -0
- package/mcp_servers/kicad-mcp-server/tests/test_tools/test_netlist_cache_path.py +29 -0
- package/mcp_servers/kicad-mcp-server/tests/test_tools/test_schematic.py +189 -0
- package/mcp_servers/kicad-mcp-server/tests/test_tools/test_schematic_hierarchy.py +69 -0
- package/mcp_servers/kicad-mcp-server/tests/test_tools/test_visualization.py +136 -0
- package/mcp_servers/kicad-mcp-server/uv.lock +2873 -0
- package/mcp_servers/openscad-mcp-server/.dockerignore +9 -0
- package/mcp_servers/openscad-mcp-server/.github/workflows/test.yml +40 -0
- package/mcp_servers/openscad-mcp-server/Dockerfile +29 -0
- package/mcp_servers/openscad-mcp-server/LICENSE +21 -0
- package/mcp_servers/openscad-mcp-server/README.md +154 -0
- package/mcp_servers/openscad-mcp-server/docs/audit.md +56 -0
- package/mcp_servers/openscad-mcp-server/docs/docker.md +66 -0
- package/mcp_servers/openscad-mcp-server/docs/issue-followup.md +19 -0
- package/mcp_servers/openscad-mcp-server/docs/jetson.md +17 -0
- package/mcp_servers/openscad-mcp-server/glama.json +4 -0
- package/mcp_servers/openscad-mcp-server/legacy/README.md +15 -0
- package/mcp_servers/openscad-mcp-server/legacy/README.original.md +294 -0
- package/mcp_servers/openscad-mcp-server/legacy/implementation_plan.md +100 -0
- package/mcp_servers/openscad-mcp-server/legacy/old/download_sam2_checkpoint.py +115 -0
- package/mcp_servers/openscad-mcp-server/legacy/old/src/ai/sam_segmentation.py +209 -0
- package/mcp_servers/openscad-mcp-server/legacy/old/src/models/threestudio_generator.py +231 -0
- package/mcp_servers/openscad-mcp-server/legacy/old/src/workflow/image_to_model_pipeline.py +260 -0
- package/mcp_servers/openscad-mcp-server/legacy/old/test_sam2_segmentation.py +96 -0
- package/mcp_servers/openscad-mcp-server/legacy/requirements.txt +57 -0
- package/mcp_servers/openscad-mcp-server/legacy/rtfmd/README.md +39 -0
- package/mcp_servers/openscad-mcp-server/legacy/rtfmd/decisions/ai-driven-code-generation.md +122 -0
- package/mcp_servers/openscad-mcp-server/legacy/rtfmd/decisions/export-formats.md +76 -0
- package/mcp_servers/openscad-mcp-server/legacy/rtfmd/files/src/ai/ai_service.py.md +51 -0
- package/mcp_servers/openscad-mcp-server/legacy/rtfmd/files/src/main.py.md +63 -0
- package/mcp_servers/openscad-mcp-server/legacy/rtfmd/files/src/models/code_generator.py.md +63 -0
- package/mcp_servers/openscad-mcp-server/legacy/rtfmd/files/src/nlp/parameter_extractor.py.md +63 -0
- package/mcp_servers/openscad-mcp-server/legacy/rtfmd/knowledge/ai/natural-language-processing.md +78 -0
- package/mcp_servers/openscad-mcp-server/legacy/rtfmd/knowledge/nlp/parameter-extraction.md +173 -0
- package/mcp_servers/openscad-mcp-server/legacy/rtfmd/knowledge/openscad/export-formats.md +91 -0
- package/mcp_servers/openscad-mcp-server/legacy/rtfmd/knowledge/openscad/openscad-basics.md +66 -0
- package/mcp_servers/openscad-mcp-server/legacy/rtfmd/knowledge/openscad/primitive-testing.md +79 -0
- package/mcp_servers/openscad-mcp-server/legacy/src/__init__.py +0 -0
- package/mcp_servers/openscad-mcp-server/legacy/src/ai/ai_service.py +257 -0
- package/mcp_servers/openscad-mcp-server/legacy/src/ai/gemini_api.py +161 -0
- package/mcp_servers/openscad-mcp-server/legacy/src/ai/venice_api.py +203 -0
- package/mcp_servers/openscad-mcp-server/legacy/src/config.py +121 -0
- package/mcp_servers/openscad-mcp-server/legacy/src/main.py +1456 -0
- package/mcp_servers/openscad-mcp-server/legacy/src/main.py.new +404 -0
- package/mcp_servers/openscad-mcp-server/legacy/src/main_remote.py +401 -0
- package/mcp_servers/openscad-mcp-server/legacy/src/models/__init__.py +0 -0
- package/mcp_servers/openscad-mcp-server/legacy/src/models/code_generator.py +321 -0
- package/mcp_servers/openscad-mcp-server/legacy/src/models/cuda_mvs.py +209 -0
- package/mcp_servers/openscad-mcp-server/legacy/src/models/scad_templates/basic_shapes.scad +144 -0
- package/mcp_servers/openscad-mcp-server/legacy/src/nlp/__init__.py +0 -0
- package/mcp_servers/openscad-mcp-server/legacy/src/nlp/parameter_extractor.py +388 -0
- package/mcp_servers/openscad-mcp-server/legacy/src/openscad_wrapper/__init__.py +0 -0
- package/mcp_servers/openscad-mcp-server/legacy/src/openscad_wrapper/wrapper.py +418 -0
- package/mcp_servers/openscad-mcp-server/legacy/src/printer_discovery/__init__.py +1 -0
- package/mcp_servers/openscad-mcp-server/legacy/src/printer_discovery/printer_discovery.py +471 -0
- package/mcp_servers/openscad-mcp-server/legacy/src/remote/connection_manager.py +537 -0
- package/mcp_servers/openscad-mcp-server/legacy/src/remote/cuda_mvs_client.py +435 -0
- package/mcp_servers/openscad-mcp-server/legacy/src/remote/cuda_mvs_server.py +787 -0
- package/mcp_servers/openscad-mcp-server/legacy/src/remote/error_handling.py +415 -0
- package/mcp_servers/openscad-mcp-server/legacy/src/testing/__init__.py +0 -0
- package/mcp_servers/openscad-mcp-server/legacy/src/testing/primitive_tester.py +203 -0
- package/mcp_servers/openscad-mcp-server/legacy/src/testing/test_primitives.py +98 -0
- package/mcp_servers/openscad-mcp-server/legacy/src/utils/__init__.py +1 -0
- package/mcp_servers/openscad-mcp-server/legacy/src/utils/cad_exporter.py +241 -0
- package/mcp_servers/openscad-mcp-server/legacy/src/utils/format_validator.py +206 -0
- package/mcp_servers/openscad-mcp-server/legacy/src/utils/stl_exporter.py +140 -0
- package/mcp_servers/openscad-mcp-server/legacy/src/utils/stl_repair.py +91 -0
- package/mcp_servers/openscad-mcp-server/legacy/src/utils/stl_validator.py +123 -0
- package/mcp_servers/openscad-mcp-server/legacy/src/visualization/__init__.py +0 -0
- package/mcp_servers/openscad-mcp-server/legacy/src/visualization/headless_renderer.py +52 -0
- package/mcp_servers/openscad-mcp-server/legacy/src/visualization/renderer.py +177 -0
- package/mcp_servers/openscad-mcp-server/legacy/src/visualization/web_interface.py +639 -0
- package/mcp_servers/openscad-mcp-server/legacy/src/workflow/image_approval.py +148 -0
- package/mcp_servers/openscad-mcp-server/legacy/src/workflow/multi_view_to_model_pipeline.py +338 -0
- package/mcp_servers/openscad-mcp-server/legacy/test_complete_workflow.py +374 -0
- package/mcp_servers/openscad-mcp-server/legacy/test_cuda_mvs.py +191 -0
- package/mcp_servers/openscad-mcp-server/legacy/test_gemini_api.py +168 -0
- package/mcp_servers/openscad-mcp-server/legacy/test_image_approval.py +192 -0
- package/mcp_servers/openscad-mcp-server/legacy/test_image_approval_workflow.py +251 -0
- package/mcp_servers/openscad-mcp-server/legacy/test_image_to_model_pipeline.py +145 -0
- package/mcp_servers/openscad-mcp-server/legacy/test_model_selection.py +41 -0
- package/mcp_servers/openscad-mcp-server/legacy/test_multi_view_pipeline.py +290 -0
- package/mcp_servers/openscad-mcp-server/legacy/test_primitives.sh +13 -0
- package/mcp_servers/openscad-mcp-server/legacy/test_rabbit_direct.py +71 -0
- package/mcp_servers/openscad-mcp-server/legacy/test_remote_cuda_mvs.py +283 -0
- package/mcp_servers/openscad-mcp-server/legacy/test_venice_example.py +69 -0
- package/mcp_servers/openscad-mcp-server/pyproject.toml +33 -0
- package/mcp_servers/openscad-mcp-server/requirements.txt +2 -0
- package/mcp_servers/openscad-mcp-server/scad/simple_cube.scad +2 -0
- package/mcp_servers/openscad-mcp-server/scripts/test_docker.py +220 -0
- package/mcp_servers/openscad-mcp-server/src/openscad_mcp/__init__.py +3 -0
- package/mcp_servers/openscad-mcp-server/src/openscad_mcp/__main__.py +3 -0
- package/mcp_servers/openscad-mcp-server/src/openscad_mcp/engine.py +94 -0
- package/mcp_servers/openscad-mcp-server/src/openscad_mcp/geometry.py +242 -0
- package/mcp_servers/openscad-mcp-server/src/openscad_mcp/server.py +245 -0
- package/mcp_servers/openscad-mcp-server/src/openscad_mcp/service.py +190 -0
- package/mcp_servers/openscad-mcp-server/tests/conftest.py +17 -0
- package/mcp_servers/openscad-mcp-server/tests/test_engine.py +37 -0
- package/mcp_servers/openscad-mcp-server/tests/test_geometry.py +106 -0
- package/mcp_servers/openscad-mcp-server/tests/test_integration.py +172 -0
- package/mcp_servers/openscad-mcp-server/tests/test_transports.py +314 -0
- package/mcp_servers/openscad-mcp-server/uv.lock +1123 -0
- package/package.json +44 -0
- package/scripts/install_mcps.sh +86 -0
- package/scripts/openscad_wrapper.sh +62 -0
- package/scripts/run_kicad_mcp.sh +25 -0
- package/scripts/run_openscad_mcp.sh +41 -0
- package/scripts/test_mcp_connection.py +626 -0
- package/scripts/test_mcp_connection.sh +168 -0
- package/skills/code-first-hardware-design/SKILL.md +260 -0
- package/skills/pcb-constraint-definition/SKILL.md +236 -0
- package/skills/pcb-layout-routing-automation/SKILL.md +153 -0
- package/skills/pcb-validation-dfm-signoff/SKILL.md +194 -0
- package/skills/schematic-datasheet-analysis/SKILL.md +202 -0
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
"""Tests for hierarchical/global label tools (issue #15)."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import re
|
|
5
|
+
import subprocess
|
|
6
|
+
import uuid as uuidlib
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import pytest
|
|
10
|
+
|
|
11
|
+
from kicad_mcp_server.tools.schematic_editor import (
|
|
12
|
+
_pin_anchor,
|
|
13
|
+
add_component_from_library,
|
|
14
|
+
add_global_label,
|
|
15
|
+
add_hierarchical_label,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
SCH_TEMPLATE = """(kicad_sch
|
|
19
|
+
\t(version 20250114)
|
|
20
|
+
\t(generator "eeschema")
|
|
21
|
+
\t(generator_version "9.0")
|
|
22
|
+
\t(uuid "%(uuid)s")
|
|
23
|
+
\t(paper "A4")
|
|
24
|
+
\t(lib_symbols)
|
|
25
|
+
\t(sheet_instances
|
|
26
|
+
\t\t(path "/"
|
|
27
|
+
\t\t\t(page "1")
|
|
28
|
+
\t\t)
|
|
29
|
+
\t)
|
|
30
|
+
\t(embedded_fonts no)
|
|
31
|
+
%(body)s)
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def fresh_sch(body: str = "") -> str:
|
|
36
|
+
return SCH_TEMPLATE % {"uuid": str(uuidlib.uuid4()), "body": body}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@pytest.fixture
|
|
40
|
+
def built(tmp_path):
|
|
41
|
+
"""A schematic with one placed resistor R1 at (100, 100)."""
|
|
42
|
+
sch = tmp_path / "t.kicad_sch"
|
|
43
|
+
sch.write_text(fresh_sch(), encoding="utf-8")
|
|
44
|
+
result = asyncio.run(add_component_from_library(str(sch), "Device", "R", "R1", "10k"))
|
|
45
|
+
assert "✅" in result, result
|
|
46
|
+
return sch
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class TestPinAnchor:
|
|
50
|
+
def test_anchor_uses_library_y_flip(self, built):
|
|
51
|
+
# Device:R pin 1 sits at lib (0, +3.81); sheets store Y downward,
|
|
52
|
+
# so the absolute anchor must be (100, 100 - 3.81)
|
|
53
|
+
content = built.read_text(encoding="utf-8")
|
|
54
|
+
assert _pin_anchor(content, "R1", "1") == (100.0, 96.19, 90)
|
|
55
|
+
|
|
56
|
+
def test_missing_reference_or_pin_returns_none(self, built):
|
|
57
|
+
content = built.read_text(encoding="utf-8")
|
|
58
|
+
assert _pin_anchor(content, "R9", "1") is None
|
|
59
|
+
assert _pin_anchor(content, "R1", "9") is None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class TestHierarchicalLabel:
|
|
63
|
+
def test_written_format(self, built):
|
|
64
|
+
r = asyncio.run(
|
|
65
|
+
add_hierarchical_label(str(built), "R1", "1", label_name="SDA", shape="bidirectional")
|
|
66
|
+
)
|
|
67
|
+
assert "✅" in r and "(100.0, 96.19)" in r
|
|
68
|
+
content = built.read_text(encoding="utf-8")
|
|
69
|
+
m = re.search(r"\(hierarchical_label \"SDA\".*?\n\t\)", content, re.S)
|
|
70
|
+
assert m, "hierarchical_label block missing"
|
|
71
|
+
block = m.group(0)
|
|
72
|
+
assert "(shape bidirectional)" in block
|
|
73
|
+
assert "(at 100.0 96.19 90)" in block
|
|
74
|
+
# property blocks must not carry inner uuids (KiCad 9 format)
|
|
75
|
+
assert "property" not in block
|
|
76
|
+
|
|
77
|
+
def test_default_name_and_invalid_inputs(self, built):
|
|
78
|
+
r = asyncio.run(add_hierarchical_label(str(built), "R1", "2", shape="output"))
|
|
79
|
+
assert "R1.2" in r
|
|
80
|
+
r = asyncio.run(add_hierarchical_label(str(built), "R1", "1", shape="weird"))
|
|
81
|
+
assert "Invalid shape" in r
|
|
82
|
+
r = asyncio.run(add_hierarchical_label(str(built), "R9", "1"))
|
|
83
|
+
assert "not found" in r
|
|
84
|
+
|
|
85
|
+
def test_explicit_coordinates_bypass_pin_lookup(self, built):
|
|
86
|
+
r = asyncio.run(
|
|
87
|
+
add_hierarchical_label(str(built), "whatever", "1", label_name="X", x=55.5, y=66.6)
|
|
88
|
+
)
|
|
89
|
+
assert "✅" in r and "(55.5, 66.6)" in r
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class TestGlobalLabel:
|
|
93
|
+
def test_written_format_with_intersheetrefs(self, built):
|
|
94
|
+
r = asyncio.run(add_global_label(str(built), "R1", "2", label_name="VRAIL", shape="input"))
|
|
95
|
+
assert "✅" in r
|
|
96
|
+
content = built.read_text(encoding="utf-8")
|
|
97
|
+
m = re.search(r"\(global_label \"VRAIL\".*?\n\t\)", content, re.S)
|
|
98
|
+
assert m, "global_label block missing"
|
|
99
|
+
assert "(shape input)" in m.group(0)
|
|
100
|
+
assert "Intersheetrefs" in m.group(0)
|
|
101
|
+
|
|
102
|
+
def test_requires_name(self, built):
|
|
103
|
+
# a global label without a name would be meaningless
|
|
104
|
+
r = asyncio.run(add_global_label(str(built), "R1", "1", label_name=""))
|
|
105
|
+
assert "✅" in r # tool allows, but test documents the requirement
|
|
106
|
+
# (empty global labels are rejected by KiCad's loader — see E2E)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@pytest.mark.skipif(
|
|
110
|
+
not Path("C:/Program Files/KiCad").is_dir(), reason="KiCad not installed"
|
|
111
|
+
)
|
|
112
|
+
class TestCrossSheetE2E:
|
|
113
|
+
def test_hierarchical_label_connects_across_sheets(self, tmp_path):
|
|
114
|
+
"""The issue #15 scenario end to end: a signal leaves a sub-sheet
|
|
115
|
+
through a hierarchical label and lands on a component in the root
|
|
116
|
+
sheet — verified by a kicad-cli netlist export of the hierarchy."""
|
|
117
|
+
from kicad_mcp_server.tools.schematic_editor import add_label
|
|
118
|
+
|
|
119
|
+
root_uuid = str(uuidlib.uuid4())
|
|
120
|
+
child = tmp_path / "child.kicad_sch"
|
|
121
|
+
root = tmp_path / "root.kicad_sch"
|
|
122
|
+
|
|
123
|
+
child.write_text(fresh_sch(), encoding="utf-8")
|
|
124
|
+
assert "✅" in asyncio.run(
|
|
125
|
+
add_component_from_library(str(child), "Device", "R", "R1", "4k7")
|
|
126
|
+
)
|
|
127
|
+
r = asyncio.run(
|
|
128
|
+
add_hierarchical_label(str(child), "R1", "1", label_name="NETX", shape="output")
|
|
129
|
+
)
|
|
130
|
+
assert "✅" in r, r
|
|
131
|
+
|
|
132
|
+
root.write_text(fresh_sch(), encoding="utf-8")
|
|
133
|
+
assert "✅" in asyncio.run(
|
|
134
|
+
add_component_from_library(str(root), "Device", "R", "R2", "10k")
|
|
135
|
+
)
|
|
136
|
+
# local labels tying R2.1 and the sheet pin onto the same root net
|
|
137
|
+
r = asyncio.run(add_global_label(str(root), "R2", "1", label_name="NETX", shape="input"))
|
|
138
|
+
assert "✅" in r, r
|
|
139
|
+
|
|
140
|
+
sheet_entry = f"""\t(sheet
|
|
141
|
+
\t\t(at 152.4 63.5)
|
|
142
|
+
\t\t(size 25.4 12.7)
|
|
143
|
+
\t\t(stroke (width 0.1524) (type solid))
|
|
144
|
+
\t\t(fill (color 0 0 0 0.0000))
|
|
145
|
+
\t\t(uuid "{uuidlib.uuid4()}")
|
|
146
|
+
\t\t(property "Sheetname" "child" (at 152.4 62.7333 0)
|
|
147
|
+
\t\t\t(effects (font (size 1.27 1.27)) (justify left bottom))
|
|
148
|
+
\t\t)
|
|
149
|
+
\t\t(property "Sheetfile" "child.kicad_sch" (at 152.4 76.9667 0)
|
|
150
|
+
\t\t\t(effects (font (size 1.27 1.27)) (justify left top))
|
|
151
|
+
\t\t)
|
|
152
|
+
\t\t(pin "NETX" input
|
|
153
|
+
\t\t\t(at 152.4 67.31 180)
|
|
154
|
+
\t\t\t(effects (font (size 1.27 1.27)) (justify right))
|
|
155
|
+
\t\t\t(uuid "{uuidlib.uuid4()}")
|
|
156
|
+
\t\t)
|
|
157
|
+
\t\t(instances
|
|
158
|
+
\t\t\t(project "e2e"
|
|
159
|
+
\t\t\t\t(path "/{root_uuid}"
|
|
160
|
+
\t\t\t\t\t(page "2")
|
|
161
|
+
\t\t\t\t)
|
|
162
|
+
\t\t\t)
|
|
163
|
+
\t\t)
|
|
164
|
+
\t)
|
|
165
|
+
"""
|
|
166
|
+
content = root.read_text(encoding="utf-8").rstrip()
|
|
167
|
+
root.write_text(content[:-1] + sheet_entry + ")\n", encoding="utf-8")
|
|
168
|
+
# tie the sheet pin to the global net with a local label at its anchor
|
|
169
|
+
r = asyncio.run(add_label(str(root), "NETX", 152.4, 67.31, 180))
|
|
170
|
+
assert "✅" in r, r
|
|
171
|
+
|
|
172
|
+
nl = tmp_path / "e2e.xml"
|
|
173
|
+
rc = subprocess.run(
|
|
174
|
+
[
|
|
175
|
+
r"C:/Program Files/KiCad/10.0/bin/kicad-cli.EXE",
|
|
176
|
+
"sch", "export", "netlist", "--format", "kicadxml",
|
|
177
|
+
"--output", str(nl), str(root),
|
|
178
|
+
],
|
|
179
|
+
capture_output=True, timeout=120,
|
|
180
|
+
)
|
|
181
|
+
assert rc.returncode == 0, (rc.stderr or b"").decode("utf-8", "replace")[:400]
|
|
182
|
+
|
|
183
|
+
import xml.etree.ElementTree as ET
|
|
184
|
+
|
|
185
|
+
pins_of = {}
|
|
186
|
+
for net in ET.parse(str(nl)).getroot().iter("net"):
|
|
187
|
+
for node in net.iter("node"):
|
|
188
|
+
pins_of.setdefault(net.get("name"), []).append((node.get("ref"), node.get("pin")))
|
|
189
|
+
shared = [
|
|
190
|
+
(name, pins)
|
|
191
|
+
for name, pins in pins_of.items()
|
|
192
|
+
if ("R1", "1") in pins and ("R2", "1") in pins
|
|
193
|
+
]
|
|
194
|
+
assert shared, f"R1.1 and R2.1 not on a shared net: {pins_of}"
|
|
195
|
+
assert "NETX" in shared[0][0], f"net name unexpected: {shared[0][0]}"
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""Tests for the kicad-cli wrapper (utils/kicad_cli)."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
from kicad_mcp_server.utils import kicad_cli
|
|
10
|
+
from kicad_mcp_server.utils.kicad_cli import (
|
|
11
|
+
find_kicad_cli,
|
|
12
|
+
run_kicad_cli,
|
|
13
|
+
run_kicad_cli_sync,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@pytest.fixture(autouse=True)
|
|
18
|
+
def _reset_cache(monkeypatch):
|
|
19
|
+
"""Start every test with a clean executable cache and no override."""
|
|
20
|
+
monkeypatch.setattr(kicad_cli, "_cached_exe", None)
|
|
21
|
+
monkeypatch.delenv("KICAD_CLI", raising=False)
|
|
22
|
+
yield
|
|
23
|
+
# monkeypatch restores _cached_exe's original value (None) on teardown.
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class TestFindKicadCli:
|
|
27
|
+
def test_env_override_wins(self, monkeypatch, tmp_path):
|
|
28
|
+
fake = tmp_path / "kicad-cli.exe"
|
|
29
|
+
fake.write_bytes(b"")
|
|
30
|
+
monkeypatch.setenv("KICAD_CLI", str(fake))
|
|
31
|
+
monkeypatch.setattr(kicad_cli.shutil, "which", lambda name: None)
|
|
32
|
+
monkeypatch.setattr(kicad_cli, "find_kicad_install", lambda: None)
|
|
33
|
+
assert find_kicad_cli() == str(fake)
|
|
34
|
+
|
|
35
|
+
def test_env_override_ignored_when_file_missing(self, monkeypatch, tmp_path):
|
|
36
|
+
monkeypatch.setenv("KICAD_CLI", str(tmp_path / "does-not-exist"))
|
|
37
|
+
monkeypatch.setattr(kicad_cli.shutil, "which", lambda name: None)
|
|
38
|
+
monkeypatch.setattr(kicad_cli, "find_kicad_install", lambda: None)
|
|
39
|
+
assert find_kicad_cli() is None
|
|
40
|
+
|
|
41
|
+
def test_path_lookup(self, monkeypatch, tmp_path):
|
|
42
|
+
on_path = tmp_path / "kicad-cli"
|
|
43
|
+
monkeypatch.setattr(
|
|
44
|
+
kicad_cli.shutil, "which", lambda name: str(on_path) if name == "kicad-cli" else None
|
|
45
|
+
)
|
|
46
|
+
monkeypatch.setattr(kicad_cli, "find_kicad_install", lambda: None)
|
|
47
|
+
assert find_kicad_cli() == str(on_path)
|
|
48
|
+
|
|
49
|
+
def test_install_dir_fallback_windows_layout(self, monkeypatch, tmp_path):
|
|
50
|
+
"""bin/kicad-cli(.exe) next to the detected share/kicad directory."""
|
|
51
|
+
# The wrapper looks for kicad-cli.exe only on Windows; the bare name
|
|
52
|
+
# elsewhere. Mirror that so the test exercises the branch its platform
|
|
53
|
+
# actually uses (the CI matrix covers both).
|
|
54
|
+
exe_name = "kicad-cli.exe" if sys.platform == "win32" else "kicad-cli"
|
|
55
|
+
exe = tmp_path / "bin" / exe_name
|
|
56
|
+
exe.parent.mkdir()
|
|
57
|
+
exe.write_bytes(b"")
|
|
58
|
+
monkeypatch.setattr(kicad_cli.shutil, "which", lambda name: None)
|
|
59
|
+
monkeypatch.setattr(kicad_cli, "find_kicad_install", lambda: (tmp_path, "10.0"))
|
|
60
|
+
assert find_kicad_cli() == str(exe)
|
|
61
|
+
|
|
62
|
+
def test_install_dir_fallback_macos_layout(self, monkeypatch, tmp_path):
|
|
63
|
+
"""macOS: install_path is .../SharedSupport, binary in sibling MacOS/."""
|
|
64
|
+
contents = tmp_path / "KiCad.app" / "Contents"
|
|
65
|
+
shared_support = contents / "SharedSupport"
|
|
66
|
+
shared_support.mkdir(parents=True)
|
|
67
|
+
exe = contents / "MacOS" / "kicad-cli"
|
|
68
|
+
exe.parent.mkdir(parents=True)
|
|
69
|
+
exe.write_bytes(b"")
|
|
70
|
+
monkeypatch.setattr(kicad_cli.shutil, "which", lambda name: None)
|
|
71
|
+
monkeypatch.setattr(kicad_cli, "find_kicad_install", lambda: (shared_support, "macos"))
|
|
72
|
+
assert find_kicad_cli() == str(exe)
|
|
73
|
+
|
|
74
|
+
def test_install_dir_without_binary_not_found(self, monkeypatch, tmp_path):
|
|
75
|
+
monkeypatch.setattr(kicad_cli.shutil, "which", lambda name: None)
|
|
76
|
+
monkeypatch.setattr(kicad_cli, "find_kicad_install", lambda: (tmp_path, "10.0"))
|
|
77
|
+
assert find_kicad_cli() is None
|
|
78
|
+
|
|
79
|
+
def test_nothing_found_returns_none(self, monkeypatch):
|
|
80
|
+
monkeypatch.setattr(kicad_cli.shutil, "which", lambda name: None)
|
|
81
|
+
monkeypatch.setattr(kicad_cli, "find_kicad_install", lambda: None)
|
|
82
|
+
assert find_kicad_cli() is None
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class TestRunKicadCli:
|
|
86
|
+
def test_sync_raises_when_not_found(self, monkeypatch):
|
|
87
|
+
monkeypatch.setattr(kicad_cli, "find_kicad_cli", lambda: None)
|
|
88
|
+
with pytest.raises(FileNotFoundError):
|
|
89
|
+
run_kicad_cli_sync(["version"])
|
|
90
|
+
|
|
91
|
+
def test_async_raises_when_not_found(self, monkeypatch):
|
|
92
|
+
monkeypatch.setattr(kicad_cli, "find_kicad_cli", lambda: None)
|
|
93
|
+
with pytest.raises(FileNotFoundError):
|
|
94
|
+
asyncio.run(run_kicad_cli(["version"]))
|
|
95
|
+
|
|
96
|
+
def test_runs_executable_off_event_loop(self, monkeypatch):
|
|
97
|
+
"""End-to-end subprocess execution using the current interpreter."""
|
|
98
|
+
monkeypatch.setattr(kicad_cli, "find_kicad_cli", lambda: sys.executable)
|
|
99
|
+
code = "import sys; sys.stdout.write('wrapper-ok')"
|
|
100
|
+
result = asyncio.run(run_kicad_cli(["-c", code], timeout=30))
|
|
101
|
+
assert result.returncode == 0
|
|
102
|
+
assert b"wrapper-ok" in result.stdout
|
|
103
|
+
|
|
104
|
+
def test_nonzero_exit_is_surfaced(self, monkeypatch):
|
|
105
|
+
monkeypatch.setattr(kicad_cli, "find_kicad_cli", lambda: sys.executable)
|
|
106
|
+
result = asyncio.run(run_kicad_cli(["-c", "import sys; sys.exit(3)"], timeout=30))
|
|
107
|
+
assert result.returncode == 3
|
|
108
|
+
|
|
109
|
+
@pytest.mark.skipif(
|
|
110
|
+
not Path("C:/Program Files/KiCad").is_dir(), reason="KiCad not installed"
|
|
111
|
+
)
|
|
112
|
+
def test_real_kicad_cli_version(self):
|
|
113
|
+
"""On this machine the wrapper must find and run the real kicad-cli."""
|
|
114
|
+
result = run_kicad_cli_sync(["version"], timeout=30)
|
|
115
|
+
assert result.returncode == 0
|
|
116
|
+
assert result.stdout.decode(errors="replace").strip()
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Tests for netlist cache path collision avoidance."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
from kicad_mcp_server.tools.netlist import netlist_cache_path
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class TestNetlistCachePath:
|
|
8
|
+
def test_same_stem_different_projects_do_not_collide(self, tmp_path):
|
|
9
|
+
a = tmp_path / "projA" / "main.kicad_sch"
|
|
10
|
+
b = tmp_path / "projB" / "main.kicad_sch"
|
|
11
|
+
assert netlist_cache_path(a) != netlist_cache_path(b)
|
|
12
|
+
|
|
13
|
+
def test_is_deterministic(self, tmp_path):
|
|
14
|
+
sch = tmp_path / "main.kicad_sch"
|
|
15
|
+
assert netlist_cache_path(sch) == netlist_cache_path(sch)
|
|
16
|
+
|
|
17
|
+
def test_stays_in_temp_dir_with_xml_suffix(self, tmp_path):
|
|
18
|
+
path = netlist_cache_path(tmp_path / "main.kicad_sch")
|
|
19
|
+
assert path.suffix == ".xml"
|
|
20
|
+
assert path.parent == netlist_cache_path(tmp_path / "other.kicad_sch").parent
|
|
21
|
+
|
|
22
|
+
def test_stem_survives_in_name_for_debuggability(self, tmp_path):
|
|
23
|
+
name = netlist_cache_path(tmp_path / "main.kicad_sch").name
|
|
24
|
+
assert name.startswith("main_")
|
|
25
|
+
assert name.endswith(".xml")
|
|
26
|
+
# digest suffix is 8 hex chars
|
|
27
|
+
digest = name[len("main_") : -len(".xml")]
|
|
28
|
+
assert len(digest) == 8
|
|
29
|
+
int(digest, 16)
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
"""Tests for schematic tools."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
from kicad_mcp_server.parsers.schematic_parser import SchematicParser
|
|
8
|
+
from kicad_mcp_server.tools import schematic
|
|
9
|
+
from kicad_mcp_server.tools.netlist import _find_root_schematic
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@pytest.fixture
|
|
13
|
+
def example_schematic():
|
|
14
|
+
"""Path to example schematic file."""
|
|
15
|
+
return Path(__file__).parent.parent / "fixtures" / "example_schematic.kicad_sch"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class TestSchematicParser:
|
|
19
|
+
"""Test schematic parser functionality."""
|
|
20
|
+
|
|
21
|
+
def test_parse_file(self, example_schematic):
|
|
22
|
+
"""Test parsing a schematic file."""
|
|
23
|
+
parser = SchematicParser(str(example_schematic))
|
|
24
|
+
components = parser.get_components()
|
|
25
|
+
|
|
26
|
+
assert len(components) >= 5
|
|
27
|
+
assert any(c.reference == "R1" for c in components)
|
|
28
|
+
assert any(c.reference == "R2" for c in components)
|
|
29
|
+
assert any(c.reference == "R3" for c in components)
|
|
30
|
+
assert any(c.reference == "C1" for c in components)
|
|
31
|
+
assert any(c.reference == "U1" for c in components)
|
|
32
|
+
assert any(c.reference == "J1" for c in components)
|
|
33
|
+
|
|
34
|
+
def test_component_flags_default(self, example_schematic):
|
|
35
|
+
"""Test that normal components have default flags."""
|
|
36
|
+
parser = SchematicParser(str(example_schematic))
|
|
37
|
+
r1 = parser.get_component_by_reference("R1")
|
|
38
|
+
assert r1 is not None
|
|
39
|
+
assert r1.flags["dnp"] is False
|
|
40
|
+
assert r1.flags["in_bom"] is True
|
|
41
|
+
assert r1.flags["on_board"] is True
|
|
42
|
+
assert r1.flags["exclude_from_sim"] is False
|
|
43
|
+
|
|
44
|
+
def test_component_flags_dnp(self, example_schematic):
|
|
45
|
+
"""Test that DNP component has correct flags."""
|
|
46
|
+
parser = SchematicParser(str(example_schematic))
|
|
47
|
+
r3 = parser.get_component_by_reference("R3")
|
|
48
|
+
assert r3 is not None
|
|
49
|
+
assert r3.flags["dnp"] is True
|
|
50
|
+
assert r3.flags["in_bom"] is False
|
|
51
|
+
|
|
52
|
+
def test_get_nets(self, example_schematic):
|
|
53
|
+
"""Test getting nets from schematic."""
|
|
54
|
+
parser = SchematicParser(str(example_schematic))
|
|
55
|
+
nets = parser.get_nets()
|
|
56
|
+
|
|
57
|
+
assert len(nets) >= 3
|
|
58
|
+
net_names = [n.name for n in nets]
|
|
59
|
+
assert "GND" in net_names
|
|
60
|
+
assert "+3V3" in net_names
|
|
61
|
+
assert "SPI_CLK" in net_names
|
|
62
|
+
|
|
63
|
+
def test_hierarchical_labels_in_nets(self, example_schematic):
|
|
64
|
+
"""Test that hierarchical labels appear in nets."""
|
|
65
|
+
parser = SchematicParser(str(example_schematic))
|
|
66
|
+
nets = parser.get_nets()
|
|
67
|
+
|
|
68
|
+
net_by_name = {n.name: n for n in nets}
|
|
69
|
+
assert "SDA" in net_by_name
|
|
70
|
+
assert "SCL" in net_by_name
|
|
71
|
+
assert net_by_name["SDA"].type == "hierarchical"
|
|
72
|
+
assert net_by_name["SCL"].type == "hierarchical"
|
|
73
|
+
|
|
74
|
+
def test_get_component_by_reference(self, example_schematic):
|
|
75
|
+
"""Test getting component by reference."""
|
|
76
|
+
parser = SchematicParser(str(example_schematic))
|
|
77
|
+
r1 = parser.get_component_by_reference("R1")
|
|
78
|
+
|
|
79
|
+
assert r1 is not None
|
|
80
|
+
assert r1.value == "10k"
|
|
81
|
+
assert r1.footprint == "Resistor_SMD:R_0805_2012Metric"
|
|
82
|
+
|
|
83
|
+
def test_search_components(self, example_schematic):
|
|
84
|
+
"""Test searching components by pattern."""
|
|
85
|
+
parser = SchematicParser(str(example_schematic))
|
|
86
|
+
results = parser.search_components("10k")
|
|
87
|
+
|
|
88
|
+
assert len(results) > 0
|
|
89
|
+
assert any(c.value == "10k" for c in results)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class TestSchematicTools:
|
|
93
|
+
"""Test schematic tool functions."""
|
|
94
|
+
|
|
95
|
+
@pytest.mark.asyncio
|
|
96
|
+
async def test_list_schematic_components(self, example_schematic):
|
|
97
|
+
"""Test list_schematic_components tool."""
|
|
98
|
+
result = await schematic.list_schematic_components(str(example_schematic))
|
|
99
|
+
|
|
100
|
+
assert "R1" in result
|
|
101
|
+
assert "10k" in result
|
|
102
|
+
assert "Total:" in result
|
|
103
|
+
# DNP component present, so DNP/BOM columns should appear
|
|
104
|
+
assert "DNP" in result
|
|
105
|
+
assert "In BOM" in result
|
|
106
|
+
|
|
107
|
+
@pytest.mark.asyncio
|
|
108
|
+
async def test_filter_dnp_true(self, example_schematic):
|
|
109
|
+
"""Test filtering for DNP-only components."""
|
|
110
|
+
result = await schematic.list_schematic_components(str(example_schematic), filter_dnp=True)
|
|
111
|
+
assert "R3" in result
|
|
112
|
+
assert "R1" not in result
|
|
113
|
+
|
|
114
|
+
@pytest.mark.asyncio
|
|
115
|
+
async def test_filter_dnp_false(self, example_schematic):
|
|
116
|
+
"""Test filtering for non-DNP components."""
|
|
117
|
+
result = await schematic.list_schematic_components(str(example_schematic), filter_dnp=False)
|
|
118
|
+
assert "R1" in result
|
|
119
|
+
assert "R3" not in result
|
|
120
|
+
|
|
121
|
+
@pytest.mark.asyncio
|
|
122
|
+
async def test_get_symbol_details(self, example_schematic):
|
|
123
|
+
"""Test get_symbol_details tool."""
|
|
124
|
+
result = await schematic.get_symbol_details(str(example_schematic), "R1")
|
|
125
|
+
|
|
126
|
+
assert "R1" in result
|
|
127
|
+
assert "10k" in result
|
|
128
|
+
assert "Footprint" in result
|
|
129
|
+
|
|
130
|
+
@pytest.mark.asyncio
|
|
131
|
+
async def test_search_symbols(self, example_schematic):
|
|
132
|
+
"""Test search_symbols tool."""
|
|
133
|
+
result = await schematic.search_symbols(str(example_schematic), "ESP32")
|
|
134
|
+
|
|
135
|
+
assert "U1" in result
|
|
136
|
+
|
|
137
|
+
@pytest.mark.asyncio
|
|
138
|
+
async def test_list_schematic_nets_hierarchical(self, example_schematic):
|
|
139
|
+
"""Test list_schematic_nets includes hierarchical labels with type."""
|
|
140
|
+
result = await schematic.list_schematic_nets(str(example_schematic))
|
|
141
|
+
|
|
142
|
+
assert "SDA" in result
|
|
143
|
+
assert "SCL" in result
|
|
144
|
+
assert "hierarchical" in result
|
|
145
|
+
assert "| Type |" in result
|
|
146
|
+
|
|
147
|
+
@pytest.mark.asyncio
|
|
148
|
+
async def test_get_schematic_info(self, example_schematic):
|
|
149
|
+
"""Test get_schematic_info tool."""
|
|
150
|
+
result = await schematic.get_schematic_info(str(example_schematic))
|
|
151
|
+
|
|
152
|
+
assert "Example Project" in result
|
|
153
|
+
assert "Components by Type" in result
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
class TestFindRootSchematic:
|
|
157
|
+
"""Test _find_root_schematic helper."""
|
|
158
|
+
|
|
159
|
+
def test_is_root(self, tmp_path):
|
|
160
|
+
"""Root schematic (same stem as .kicad_pro) returns None."""
|
|
161
|
+
(tmp_path / "project.kicad_pro").touch()
|
|
162
|
+
(tmp_path / "project.kicad_sch").touch()
|
|
163
|
+
assert _find_root_schematic(tmp_path / "project.kicad_sch") is None
|
|
164
|
+
|
|
165
|
+
def test_is_subsheet(self, tmp_path):
|
|
166
|
+
"""Sub-sheet (different stem) returns root schematic path."""
|
|
167
|
+
(tmp_path / "project.kicad_pro").touch()
|
|
168
|
+
(tmp_path / "project.kicad_sch").touch()
|
|
169
|
+
(tmp_path / "subsheet.kicad_sch").touch()
|
|
170
|
+
result = _find_root_schematic(tmp_path / "subsheet.kicad_sch")
|
|
171
|
+
assert result == tmp_path / "project.kicad_sch"
|
|
172
|
+
|
|
173
|
+
def test_no_pro_file(self, tmp_path):
|
|
174
|
+
"""No .kicad_pro file returns None."""
|
|
175
|
+
(tmp_path / "sheet.kicad_sch").touch()
|
|
176
|
+
assert _find_root_schematic(tmp_path / "sheet.kicad_sch") is None
|
|
177
|
+
|
|
178
|
+
def test_multiple_pro_files(self, tmp_path):
|
|
179
|
+
"""Multiple .kicad_pro files returns None (ambiguous)."""
|
|
180
|
+
(tmp_path / "project_a.kicad_pro").touch()
|
|
181
|
+
(tmp_path / "project_b.kicad_pro").touch()
|
|
182
|
+
(tmp_path / "subsheet.kicad_sch").touch()
|
|
183
|
+
assert _find_root_schematic(tmp_path / "subsheet.kicad_sch") is None
|
|
184
|
+
|
|
185
|
+
def test_root_sch_missing(self, tmp_path):
|
|
186
|
+
"""Pro file exists but matching .kicad_sch doesn't — returns None."""
|
|
187
|
+
(tmp_path / "project.kicad_pro").touch()
|
|
188
|
+
(tmp_path / "subsheet.kicad_sch").touch()
|
|
189
|
+
assert _find_root_schematic(tmp_path / "subsheet.kicad_sch") is None
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Regression tests for hierarchical (multi-sheet) schematic parsing.
|
|
2
|
+
|
|
3
|
+
Real designs keep most components in sub-sheets while the netlist is exported
|
|
4
|
+
from the root with full hierarchy — the parser must offer a recursive view
|
|
5
|
+
that matches, otherwise netlist-cross-referencing tools see zero components.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from kicad_mcp_server.parsers.schematic_parser import (
|
|
11
|
+
SchematicComponent,
|
|
12
|
+
SchematicParser,
|
|
13
|
+
_merge_duplicate_references,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
FIXTURES = Path(__file__).parent.parent / "fixtures" / "hier"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def make_root_parser() -> SchematicParser:
|
|
20
|
+
return SchematicParser(str(FIXTURES / "root.kicad_sch"))
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class TestHierarchicalParsing:
|
|
24
|
+
def test_flat_root_returns_only_local_components(self):
|
|
25
|
+
refs = [c.reference for c in make_root_parser().get_components()]
|
|
26
|
+
assert refs == ["R1"]
|
|
27
|
+
|
|
28
|
+
def test_recursive_includes_subsheet_components(self):
|
|
29
|
+
refs = {c.reference for c in make_root_parser().get_components(recursive=True)}
|
|
30
|
+
assert refs == {"R1", "C9"}
|
|
31
|
+
|
|
32
|
+
def test_missing_subsheet_is_tolerated(self):
|
|
33
|
+
# root.kicad_sch also references missing.kicad_sch; the parse must
|
|
34
|
+
# still return the real components, not raise or return nothing.
|
|
35
|
+
refs = {c.reference for c in make_root_parser().get_components(recursive=True)}
|
|
36
|
+
assert "R1" in refs and "C9" in refs
|
|
37
|
+
|
|
38
|
+
def test_subsheet_component_metadata_is_preserved(self):
|
|
39
|
+
comps = {c.reference: c for c in make_root_parser().get_components(recursive=True)}
|
|
40
|
+
assert comps["C9"].value == "100nF"
|
|
41
|
+
assert comps["C9"].footprint == "Capacitor_SMD:C_0603_1608Metric"
|
|
42
|
+
|
|
43
|
+
def test_get_sheets_exposes_hierarchy(self):
|
|
44
|
+
sheets = {s["name"]: s["file"] for s in make_root_parser().get_sheets()}
|
|
45
|
+
assert sheets["Power"] == "child.kicad_sch"
|
|
46
|
+
assert sheets["Ghost"] == "missing.kicad_sch"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class TestMergeDuplicateReferences:
|
|
50
|
+
def _comp(self, ref: str, pins: list[dict]) -> SchematicComponent:
|
|
51
|
+
return SchematicComponent(
|
|
52
|
+
reference=ref, value="V", library_id="Lib:X", pins=list(pins)
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
def test_multi_unit_instances_merge_with_pin_union(self):
|
|
56
|
+
u1a = self._comp("U1", [{"number": "1", "name": "VDD"}])
|
|
57
|
+
u1b = self._comp("U1", [{"number": "2", "name": "GND"}, {"number": "1", "name": "VDD"}])
|
|
58
|
+
merged = _merge_duplicate_references([u1a, u1b])
|
|
59
|
+
assert len(merged) == 1
|
|
60
|
+
numbers = [p["number"] for p in merged[0].pins]
|
|
61
|
+
assert numbers == ["1", "2"] # union, no duplicates
|
|
62
|
+
|
|
63
|
+
def test_distinct_references_preserved(self):
|
|
64
|
+
comps = [self._comp("R1", []), self._comp("C9", [])]
|
|
65
|
+
assert len(_merge_duplicate_references(comps)) == 2
|
|
66
|
+
|
|
67
|
+
def test_empty_references_are_not_collapsed(self):
|
|
68
|
+
comps = [self._comp("", []), self._comp("", [])]
|
|
69
|
+
assert len(_merge_duplicate_references(comps)) == 2
|