feat(server): tool profiles, paper-space layouts + viewports, ISO 286 fits, opt-in COM 3D solids
- TOOL_PROFILE=lean|core|full applied in the lifespan via server-level enable/disable; system_about reports the active profile; opt-in solid_* tools are hidden while ENABLE_3D=false - layouts: layout_list/create/set_current + viewport_create (scaled model viewports) on both backends; drawing_export_pdf(layout=...); ezdxf uses TILEMODE + active-layout binding, COM switches ActiveLayout and restores it - ISO 286: engineering/fits.py authored IT4-11 + fundamental-deviation tables (1-500 mm; shafts d..p/h/js, holes D..H/JS) with clear out-of-scope errors; dimension_linear/radius/diameter gained fit= resolved from the measured nominal - tolerances: DIMTM now signed in deviation/limit modes so double-positive fits (p6 +0.035/+0.022) render correctly; legacy positive-magnitude contract preserved - solids: solid_box/cylinder/extrude/revolve/boolean native on COM (AddBox/AddCylinder/AddRegion+AddExtrudedSolid/AddRevolvedSolid/Boolean); ezdxf reports the honest solid_3d capability boundary - capability maps: paper_space native, new viewport_render feature, ENABLE_3D-gated solid_3d - 60 new tests (profiles, layouts, viewports, layout PDF, fits, gating); version pins in tests now read version.__version__ Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
6d7c39e012
commit
3cbe30df54
@@ -5,6 +5,7 @@ from unittest.mock import patch
|
||||
import pytest
|
||||
|
||||
from backends.base import CapabilityMap, FeatureCapability
|
||||
from version import __version__
|
||||
|
||||
|
||||
def test_capability_types_serialize_stably():
|
||||
@@ -32,7 +33,11 @@ async def test_ezdxf_capabilities_are_machine_readable(backend):
|
||||
"reason": None,
|
||||
}
|
||||
assert caps["features"]["mleader"]["mode"] == "composite"
|
||||
assert caps["features"]["paper_space"]["supported"] is False
|
||||
# v1.4: paper-space layouts/viewports are native on both backends;
|
||||
# projecting model content through viewports stays COM-only, and 3D
|
||||
# ACIS solids remain impossible headlessly.
|
||||
assert caps["features"]["paper_space"]["supported"] is True
|
||||
assert caps["features"]["viewport_render"]["supported"] is False
|
||||
assert caps["features"]["solid_3d"]["supported"] is False
|
||||
|
||||
|
||||
@@ -70,5 +75,5 @@ async def test_system_about_uses_package_version_and_capability_summary(backend)
|
||||
|
||||
result = await server.system_about(ctx=Ctx())
|
||||
|
||||
assert result["version"] == "1.3.0"
|
||||
assert result["version"] == __version__
|
||||
assert result["capabilities"]["table"]["mode"] == "composite"
|
||||
|
||||
@@ -10,6 +10,7 @@ import pytest
|
||||
import server
|
||||
from engineering.delivery import compare_drawing_snapshots, deliver_drawing, drawing_snapshot
|
||||
from engineering.layers import ensure_engineering_layers, ensure_standard_linetypes
|
||||
from version import __version__
|
||||
|
||||
|
||||
async def _clean_drawing(backend) -> None:
|
||||
@@ -42,7 +43,7 @@ async def test_delivery_writes_hashed_manifest_and_reopen_parity(backend, tmp_pa
|
||||
assert dxf["status"] == "created"
|
||||
assert dxf["sha256"] == hashlib.sha256(artifact_path.read_bytes()).hexdigest()
|
||||
assert manifest["parity"]["ok"] is True
|
||||
assert manifest["version"] == "1.3.0"
|
||||
assert manifest["version"] == __version__
|
||||
assert manifest["capabilities"]["backend"] == "ezdxf"
|
||||
|
||||
|
||||
@@ -107,9 +108,7 @@ async def test_snapshot_parity_rejects_incomplete_entity_inventory(backend, monk
|
||||
assert source["listed_entity_count"] == 1
|
||||
assert source["inventory_complete"] is False
|
||||
assert result["ok"] is False
|
||||
assert "source_inventory_complete" in {
|
||||
item["field"] for item in result["differences"]
|
||||
}
|
||||
assert "source_inventory_complete" in {item["field"] for item in result["differences"]}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
"""ISO 286 fit-table lookups pinned against published table values."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from fastmcp.exceptions import ToolError
|
||||
|
||||
from engineering.fits import FitDeviation, fit_lookup, parse_fit_code
|
||||
from engineering.tolerances import build_dim_override
|
||||
from server import _fit_to_tolerances
|
||||
|
||||
|
||||
def _um(value_mm: float) -> float:
|
||||
return round(value_mm * 1000.0, 3)
|
||||
|
||||
|
||||
# (code, nominal, upper µm, lower µm) — published ISO 286-2 values.
|
||||
CANONICAL_FITS = [
|
||||
("H7", 20.0, 21, 0),
|
||||
("H7", 40.0, 25, 0),
|
||||
("H8", 30.0, 33, 0),
|
||||
("H9", 6.0, 30, 0),
|
||||
("H7", 200.0, 46, 0),
|
||||
("g6", 20.0, -7, -20),
|
||||
("g6", 6.0, -4, -12),
|
||||
("f7", 40.0, -25, -50),
|
||||
("e8", 25.0, -40, -73),
|
||||
("d9", 100.0, -120, -207),
|
||||
("h6", 30.0, 0, -13), # 30 mm is inside the >18-30 step (inclusive upper bound)
|
||||
("h7", 50.0, 0, -25),
|
||||
("k6", 20.0, 15, 2),
|
||||
("m6", 25.0, 21, 8),
|
||||
("n6", 20.0, 28, 15),
|
||||
("p6", 20.0, 35, 22),
|
||||
("js9", 25.0, 26, -26),
|
||||
("G7", 6.0, 16, 4),
|
||||
("F8", 40.0, 64, 25),
|
||||
("D10", 30.0, 149, 65),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("code,nominal,upper_um,lower_um", CANONICAL_FITS)
|
||||
def test_canonical_fit_values(code, nominal, upper_um, lower_um):
|
||||
deviation = fit_lookup(code, nominal)
|
||||
assert isinstance(deviation, FitDeviation)
|
||||
assert _um(deviation.upper_mm) == pytest.approx(upper_um), f"{code}@{nominal} upper"
|
||||
assert _um(deviation.lower_mm) == pytest.approx(lower_um), f"{code}@{nominal} lower"
|
||||
|
||||
|
||||
def test_k_outside_it4_to_7_has_zero_lower_deviation():
|
||||
deviation = fit_lookup("k9", 20.0)
|
||||
assert deviation.lower_mm == 0.0
|
||||
assert _um(deviation.upper_mm) == 52 # IT9 @ 18-30
|
||||
|
||||
|
||||
def test_parse_fit_code():
|
||||
assert parse_fit_code("H7") == ("H", 7)
|
||||
assert parse_fit_code("js10") == ("js", 10)
|
||||
with pytest.raises(ValueError):
|
||||
parse_fit_code("77")
|
||||
with pytest.raises(ValueError):
|
||||
parse_fit_code("")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"code,nominal",
|
||||
[
|
||||
("K7", 20.0), # transition hole letters need the delta-rule — excluded
|
||||
("P7", 20.0),
|
||||
("x6", 20.0), # letter outside the authored subset (regex rejects)
|
||||
("H3", 20.0), # grade below authored range
|
||||
("H12", 20.0), # grade above authored range
|
||||
("H7", 0.5), # below 1 mm
|
||||
("H7", 600.0), # above 500 mm
|
||||
],
|
||||
)
|
||||
def test_out_of_scope_lookups_raise(code, nominal):
|
||||
with pytest.raises(ValueError):
|
||||
fit_lookup(code, nominal)
|
||||
|
||||
|
||||
def test_double_positive_fit_renders_correct_dimvars():
|
||||
"""p6-style fits (+upper/+lower) must produce a negative DIMTM so AutoCAD
|
||||
displays the plus lower deviation."""
|
||||
deviation = fit_lookup("p6", 20.0)
|
||||
override, _ = build_dim_override(
|
||||
tol_upper=deviation.upper_mm,
|
||||
tol_lower=-deviation.lower_mm, # server convention: positive = minus
|
||||
tol_mode="deviation",
|
||||
)
|
||||
assert override["dimtp"] == pytest.approx(0.035)
|
||||
assert override["dimtm"] == pytest.approx(-0.022) # displayed as +0.022
|
||||
|
||||
|
||||
def test_fit_to_tolerances_contract():
|
||||
tol_upper, tol_lower, tol_mode, text = _fit_to_tolerances("H7", 20.0, None, None, "none", None)
|
||||
assert tol_upper == pytest.approx(0.021)
|
||||
assert tol_lower == pytest.approx(0.0)
|
||||
assert tol_mode == "deviation"
|
||||
assert text == "<> H7"
|
||||
|
||||
|
||||
def test_fit_to_tolerances_rejects_mixed_usage():
|
||||
with pytest.raises(ToolError):
|
||||
_fit_to_tolerances("H7", 20.0, 0.1, None, "none", None)
|
||||
with pytest.raises(ToolError):
|
||||
_fit_to_tolerances("H7", 20.0, None, None, "symmetric", None)
|
||||
with pytest.raises(ToolError):
|
||||
_fit_to_tolerances("Q9", 20.0, None, None, "none", None)
|
||||
|
||||
|
||||
def test_fit_passthrough_without_code():
|
||||
assert _fit_to_tolerances(None, 20.0, 0.1, 0.05, "deviation", "x") == (
|
||||
0.1,
|
||||
0.05,
|
||||
"deviation",
|
||||
"x",
|
||||
)
|
||||
@@ -0,0 +1,134 @@
|
||||
"""Paper-space layouts, viewports, layout PDF export and opt-in 3D solids."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from importlib.util import find_spec
|
||||
|
||||
import ezdxf
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
|
||||
import config
|
||||
import server
|
||||
from backends.ezdxf_backend import EzdxfBackend
|
||||
|
||||
pytestmark = pytest.mark.asyncio
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def backend():
|
||||
b = EzdxfBackend()
|
||||
await b.connect()
|
||||
await b.drawing_new()
|
||||
yield b
|
||||
await b.disconnect()
|
||||
|
||||
|
||||
async def test_layout_list_reports_model_and_current(backend):
|
||||
result = await backend.layout_list()
|
||||
assert result["ok"] is True
|
||||
assert "Model" in result["layouts"]
|
||||
assert isinstance(result["current"], str)
|
||||
|
||||
|
||||
async def test_layout_create_and_duplicate_rejected(backend):
|
||||
created = await backend.layout_create("A3-Sheet")
|
||||
assert created["ok"] is True
|
||||
listing = await backend.layout_list()
|
||||
assert "A3-Sheet" in listing["layouts"]
|
||||
duplicate = await backend.layout_create("A3-Sheet")
|
||||
assert duplicate["ok"] is False
|
||||
|
||||
|
||||
async def test_layout_set_current_roundtrip(backend):
|
||||
await backend.layout_create("A3-Sheet")
|
||||
switched = await backend.layout_set_current("A3-Sheet")
|
||||
assert switched["ok"] is True
|
||||
assert (await backend.layout_list())["current"] == "A3-Sheet"
|
||||
back = await backend.layout_set_current("Model")
|
||||
assert back["ok"] is True
|
||||
missing = await backend.layout_set_current("NOPE")
|
||||
assert missing["ok"] is False
|
||||
|
||||
|
||||
async def test_viewport_create_scaled(backend, tmp_path):
|
||||
await backend.layout_create("A3-Sheet")
|
||||
result = await backend.viewport_create(
|
||||
"A3-Sheet",
|
||||
center_x=200,
|
||||
center_y=140,
|
||||
width=180,
|
||||
height=120,
|
||||
view_center_x=50,
|
||||
view_center_y=25,
|
||||
scale=0.5,
|
||||
)
|
||||
assert result["ok"] is True
|
||||
assert result["view_height"] == pytest.approx(240.0) # height / scale
|
||||
|
||||
exported = tmp_path / "layout_roundtrip.dxf"
|
||||
save = await backend.drawing_export_dxf(str(exported))
|
||||
assert save["ok"] is True
|
||||
doc = ezdxf.readfile(str(exported))
|
||||
viewports = doc.layouts.get("A3-Sheet").query("VIEWPORT")
|
||||
assert len(viewports) >= 1
|
||||
created = [vp for vp in viewports if vp.dxf.handle == result["handle"]]
|
||||
assert created, "created viewport handle must survive the DXF roundtrip"
|
||||
assert created[0].dxf.view_height == pytest.approx(240.0)
|
||||
|
||||
|
||||
async def test_viewport_rejects_model_and_bad_scale(backend):
|
||||
bad_layout = await backend.viewport_create("Model", 0, 0, 10, 10, 0, 0, 1.0)
|
||||
assert bad_layout["ok"] is False
|
||||
await backend.layout_create("A3-Sheet")
|
||||
bad_scale = await backend.viewport_create("A3-Sheet", 0, 0, 10, 10, 0, 0, 0.0)
|
||||
assert bad_scale["ok"] is False
|
||||
|
||||
|
||||
@pytest.mark.skipif(find_spec("matplotlib") is None, reason="matplotlib not installed")
|
||||
async def test_export_pdf_layout(backend, tmp_path):
|
||||
await backend.layout_create("A3-Sheet")
|
||||
await backend.entity_create_line(0, 0, 100, 0)
|
||||
target = tmp_path / "layout.pdf"
|
||||
result = await backend.drawing_export_pdf(str(target), layout="A3-Sheet")
|
||||
assert result["ok"] is True
|
||||
assert result["layout"] == "A3-Sheet"
|
||||
assert "note" in result # honest viewport-projection limitation
|
||||
assert target.is_file() and target.stat().st_size > 0
|
||||
missing = await backend.drawing_export_pdf(str(tmp_path / "x.pdf"), layout="NOPE")
|
||||
assert missing["ok"] is False
|
||||
|
||||
|
||||
async def test_solids_unsupported_headlessly(backend):
|
||||
box = await backend.solid_box(0, 0, 0, 10, 10, 10)
|
||||
cylinder = await backend.solid_cylinder(0, 0, 0, 5, 10)
|
||||
extrude = await backend.solid_extrude("FF", 10)
|
||||
revolve = await backend.solid_revolve("FF", 0, 0, 0, 1)
|
||||
boolean = await backend.solid_boolean("FF", "FE", "union")
|
||||
for result in (box, cylinder, extrude, revolve, boolean):
|
||||
assert result["ok"] is False
|
||||
assert result["capability"] == "solid_3d"
|
||||
|
||||
|
||||
async def test_capability_map_reports_paper_space_and_solids(backend):
|
||||
features = backend.capabilities().to_dict()["features"]
|
||||
assert features["paper_space"]["supported"] is True
|
||||
assert features["viewport_render"]["supported"] is False
|
||||
assert features["solid_3d"]["supported"] is False
|
||||
|
||||
|
||||
class TestSolidToolGating:
|
||||
@pytest.mark.asyncio
|
||||
async def test_solid_tools_hidden_without_enable_3d(self, monkeypatch):
|
||||
monkeypatch.setattr(config.settings, "enable_3d", False)
|
||||
info = await server._apply_tool_profile("full")
|
||||
assert server.SOLID_TOOL_NAMES <= set(info["disabled_tools"])
|
||||
await server._apply_tool_profile("full")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_solid_tools_visible_with_enable_3d(self, monkeypatch):
|
||||
monkeypatch.setattr(config.settings, "enable_3d", True)
|
||||
info = await server._apply_tool_profile("full")
|
||||
assert not (server.SOLID_TOOL_NAMES & set(info["disabled_tools"]))
|
||||
monkeypatch.setattr(config.settings, "enable_3d", False)
|
||||
await server._apply_tool_profile("full")
|
||||
@@ -0,0 +1,78 @@
|
||||
"""Tool-profile (TOOL_PROFILE=lean/core/full) behavior."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from fastmcp import Client
|
||||
|
||||
import config
|
||||
import server
|
||||
|
||||
pytestmark = pytest.mark.asyncio
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(autouse=True)
|
||||
async def _restore_full_profile():
|
||||
"""Profiles mutate global server enablement; always restore full."""
|
||||
yield
|
||||
await server._apply_tool_profile("full")
|
||||
|
||||
|
||||
async def _registered_names() -> set[str]:
|
||||
return {tool.name for tool in await server._registered_tools() if getattr(tool, "name", None)}
|
||||
|
||||
|
||||
async def test_lean_names_all_exist_in_registry():
|
||||
registered = await _registered_names()
|
||||
missing = server.LEAN_TOOL_NAMES - registered
|
||||
assert not missing, f"LEAN_TOOL_NAMES references unknown tools: {sorted(missing)}"
|
||||
|
||||
|
||||
async def test_core_exclusions_all_exist_in_registry():
|
||||
registered = await _registered_names()
|
||||
missing = server.CORE_EXCLUDED_TOOL_NAMES - registered
|
||||
assert not missing, f"CORE_EXCLUDED_TOOL_NAMES references unknown tools: {sorted(missing)}"
|
||||
|
||||
|
||||
async def test_full_profile_hides_only_gated_solids():
|
||||
"""Full hides nothing except the opt-in 3D tools while ENABLE_3D=false."""
|
||||
info = await server._apply_tool_profile("full")
|
||||
assert info["profile"] == "full"
|
||||
assert set(info["disabled_tools"]) == set(server.SOLID_TOOL_NAMES)
|
||||
assert info["enabled_count"] == info["registered_count"] - len(server.SOLID_TOOL_NAMES)
|
||||
|
||||
|
||||
async def test_invalid_profile_falls_back_to_full():
|
||||
info = await server._apply_tool_profile("does-not-exist")
|
||||
assert info["profile"] == "full"
|
||||
assert set(info["disabled_tools"]) == set(server.SOLID_TOOL_NAMES)
|
||||
|
||||
|
||||
async def test_lean_profile_filters_client_view(monkeypatch):
|
||||
monkeypatch.setattr(config.settings, "backend", "ezdxf")
|
||||
monkeypatch.setattr(config.settings, "tool_profile", "lean")
|
||||
async with Client(server.mcp) as client:
|
||||
visible = {tool.name for tool in await client.list_tools()}
|
||||
assert visible == set(server.LEAN_TOOL_NAMES)
|
||||
|
||||
|
||||
async def test_core_profile_hides_escape_hatches_keeps_engineering(monkeypatch):
|
||||
monkeypatch.setattr(config.settings, "backend", "ezdxf")
|
||||
monkeypatch.setattr(config.settings, "tool_profile", "core")
|
||||
async with Client(server.mcp) as client:
|
||||
visible = {tool.name for tool in await client.list_tools()}
|
||||
assert "system_run_command" not in visible
|
||||
assert "system_run_lisp" not in visible
|
||||
assert "gear_draw_spur_front_view" in visible
|
||||
assert "drawing_refine" in visible
|
||||
registered = await _registered_names()
|
||||
assert visible == registered - server.CORE_EXCLUDED_TOOL_NAMES - server.SOLID_TOOL_NAMES
|
||||
|
||||
|
||||
async def test_profile_switch_is_idempotent():
|
||||
await server._apply_tool_profile("lean")
|
||||
info = await server._apply_tool_profile("full")
|
||||
assert set(info["disabled_tools"]) == set(server.SOLID_TOOL_NAMES)
|
||||
lean_again = await server._apply_tool_profile("lean")
|
||||
assert lean_again["enabled_count"] == len(server.LEAN_TOOL_NAMES)
|
||||
Reference in New Issue
Block a user