feat: add mil unit support across position/coordinate commands (#162)
* feat(units): add mil unit support across all position/coordinate commands KiCad natively supports mils, so the MCP server should too. Added "mil" as a valid unit option in tool schemas and updated all unit-to-nanometer scale conversions across component, routing, outline, view, and IPC handler code paths. 1 mil = 25400 nm (0.0254 mm). Also fixes a pre-existing mypy overload error in pin_locator.py (str cast on dict.get key) that was blocking pre-commit on any Python file change. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(units): add mil to TypeScript tool schemas The Python-side mil support was added but the actual input validation happens in the TypeScript/Zod schemas. Updated all z.enum(["mm", "inch"]) to include "mil" across board, component, routing, design-rules, and export tool definitions. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(tools): replace CP-1252 mojibake with correct Unicode in board.ts Replace U+00C3 U+00D7 (×) with U+00D7 (×) in add_logo size output string. Character was mangled when file was saved as CP-1252 instead of UTF-8. * fix: restore em-dash and fix pre-commit mypy in component/routing component.py: replace CP-1252 mojibake (â€") with correct Unicode em-dash (—) in the 'Add to board first' comment. Addresses maintainer review on PR #162. routing.py: annotate ex/ey as float at first assignment site in _point_to_segment_distance_nm so mypy pre-commit hook passes cleanly on this branch. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
106
tests/test_mil_unit_support.py
Normal file
106
tests/test_mil_unit_support.py
Normal file
@@ -0,0 +1,106 @@
|
||||
"""Tests for ``mil`` unit support across position/coordinate commands.
|
||||
|
||||
KiCad natively understands mils (1 mil = 0.0254 mm = 25 400 nm). The MCP
|
||||
server now accepts ``"mil"`` as a value of the ``unit`` field everywhere a
|
||||
position or coordinate is specified. Tests below assert:
|
||||
|
||||
- The ``unit→nanometer`` scale used in command handlers maps mil → 25 400.
|
||||
- The IPC handler converts mil positions to mm before calling the IPC API.
|
||||
- The Python tool schema enums include ``"mil"`` (it was previously
|
||||
restricted to ``["mm", "inch"]``).
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent / "python"))
|
||||
|
||||
# Scale used by the per-command handlers when they convert position values
|
||||
# to KiCad internal nanometers.
|
||||
MIL_TO_NM = 25_400
|
||||
MM_TO_NM = 1_000_000
|
||||
INCH_TO_NM = 25_400_000
|
||||
|
||||
|
||||
def _scale(unit: str) -> int:
|
||||
"""Mirror of the inline ternary used in commands/* for position scaling."""
|
||||
return 1_000_000 if unit == "mm" else (25_400 if unit == "mil" else 25_400_000)
|
||||
|
||||
|
||||
def test_scale_mapping_includes_mil():
|
||||
assert _scale("mm") == MM_TO_NM
|
||||
assert _scale("mil") == MIL_TO_NM
|
||||
assert _scale("inch") == INCH_TO_NM
|
||||
|
||||
|
||||
def test_one_thousand_mil_equals_one_inch_in_nm():
|
||||
"""Sanity check: 1000 mil should produce the same nm offset as 1 inch."""
|
||||
one_inch = 1 * _scale("inch")
|
||||
one_thousand_mil = 1000 * _scale("mil")
|
||||
assert one_inch == one_thousand_mil
|
||||
|
||||
|
||||
def _make_iface() -> Any:
|
||||
with patch("kicad_interface.USE_IPC_BACKEND", True):
|
||||
from kicad_interface import KiCADInterface
|
||||
|
||||
iface = KiCADInterface.__new__(KiCADInterface)
|
||||
iface.use_ipc = True
|
||||
iface.board = None
|
||||
iface.ipc_board_api = MagicMock()
|
||||
iface.ipc_board_api.place_component.return_value = True
|
||||
iface.ipc_board_api.move_component.return_value = True
|
||||
return iface
|
||||
|
||||
|
||||
def test_ipc_place_component_converts_mil_to_mm():
|
||||
iface = _make_iface()
|
||||
iface._ipc_place_component(
|
||||
{
|
||||
"reference": "R1",
|
||||
"footprint": "Resistor_SMD:R_0805",
|
||||
"position": {"x": 100, "y": 200, "unit": "mil"},
|
||||
"rotation": 0,
|
||||
"layer": "F.Cu",
|
||||
"value": "220",
|
||||
}
|
||||
)
|
||||
_, kwargs = iface.ipc_board_api.place_component.call_args
|
||||
assert kwargs["x"] == pytest.approx(2.54)
|
||||
assert kwargs["y"] == pytest.approx(5.08)
|
||||
|
||||
|
||||
def test_ipc_move_component_converts_mil_to_mm():
|
||||
iface = _make_iface()
|
||||
iface._ipc_move_component({"reference": "R1", "position": {"x": 1000, "y": 500, "unit": "mil"}})
|
||||
_, kwargs = iface.ipc_board_api.move_component.call_args
|
||||
assert kwargs["x"] == pytest.approx(25.4)
|
||||
assert kwargs["y"] == pytest.approx(12.7)
|
||||
|
||||
|
||||
def test_python_tool_schema_unit_enums_include_mil():
|
||||
"""Every unit enum in TOOL_SCHEMAS must include mil."""
|
||||
from schemas.tool_schemas import TOOL_SCHEMAS
|
||||
|
||||
def _find_unit_enums(node):
|
||||
"""Walk the schema and yield every enum list found under 'unit' fields."""
|
||||
if isinstance(node, dict):
|
||||
for k, v in node.items():
|
||||
if k == "unit" and isinstance(v, dict) and "enum" in v:
|
||||
yield v["enum"]
|
||||
else:
|
||||
yield from _find_unit_enums(v)
|
||||
elif isinstance(node, list):
|
||||
for item in node:
|
||||
yield from _find_unit_enums(item)
|
||||
|
||||
enums = list(_find_unit_enums(TOOL_SCHEMAS))
|
||||
assert enums, "Expected at least one unit enum in TOOL_SCHEMAS"
|
||||
for enum in enums:
|
||||
assert "mil" in enum, f"Unit enum {enum} is missing 'mil'"
|
||||
# Sanity: mm and inch should still be present too
|
||||
assert "mm" in enum and "inch" in enum, f"Unit enum {enum} is malformed"
|
||||
Reference in New Issue
Block a user