Files
autocad-mcp-pro/tests/test_capability_contract.py
Umut CANandClaude Opus 5 70afd57831 feat(server): settings pack — 22 environment tools behind TOOL_PACKS, lean grows to 55, three groups, golden set +10
Task 24 (merge sanity) on main after the five track E groups merged.

- TOOL_PACK_NAMES = ("core", "pid", "settings"); PACK_TOOL_NAMES["settings"]
  is exactly the 22 SECTION 20 tools. Styles and page setup stay in core.
- LEAN_TOOL_NAMES += page_setup_apply, batch_plot (53 -> 55).
- _GROUP_TAG_PRIORITY / _GROUP_TAG_LABELS: "style" moved up next to "pid",
  "pagesetup" -> page_setup and "environment" -> environment added; the 28
  SECTION 19/20 decorators gain their section tag, so _tool_groups() reports
  styles 10 / page_setup 6 / environment 22 (23 groups, 204 tools) and
  pack == group == section for the settings pack.
- SECTION headers, backends/base.py composition, contracts/__init__.py
  exports and the layer_states feature entry were already correct after the
  merge (read and confirmed, nothing edited).
- Pack text: config.py, .env.example, README config row; README <sub> line
  204 tools in 23 groups; release snapshot 3259 collected tests (measured).
- docs/tool-inventory.json regenerated; registry snapshot re-derived.
- tests: test_tool_packs.py replaced (11 tests), test_capability_contract.py
  +3 (composition, the agreed capability flags, layer_states honesty),
  golden set +10 tuning cases (81 = 66 tuning + 15 holdout); all ten route
  in the top 3 without alias edits.

Deviation from the plan text: TRACK_E_KEYS carries five keys, not six. The
plan's "mleaderstyle" (ezdxf True / com False) contradicts spec §8.1, which
retracted that key because the ActiveX route exists (§4.2); measured on the
tree: capability_keys() == {boundary_trace, chspace, interactive_prompt,
live_application, preferences, revcloud, wipeout}, and
ComBackend.mleaderstyle_create is a native implementation
(backends/com_backend.py:6752, is_capability_default -> False). The test now
also asserts the key is absent.

No new ActiveX call was written in this task, so no live probe was needed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-22 11:35:00 +03:00

169 lines
7.3 KiB
Python

"""``@capability``: the escape hatch that is not allowed to become a hole.
M7 split a 1854-line ABC with 112 abstract methods into per-domain contracts.
The split makes contract changes navigable; it does not fix the reason contract
changes hurt, which is that every method is an `@abstractmethod`, so adding one
makes *both* backends fail at instantiation until both are written — and two
test modules construct `ComBackend` at import time, so a half-finished edit
takes the whole suite red.
`@capability` gives such a method a default. The default is the dangerous part,
so it is pinned here:
* It **raises** `UnsupportedCapabilityError`, never returns a quiet
`{"ok": False}`. A method nobody implemented must be indistinguishable from a
deliberate refusal, not from a soft failure — the v1.5.0 honesty pass exists
because things that quietly did nothing looked like things that worked.
* Its capability key is **declared in both backends' capability maps**. Without
this test the decorator would be a way to ship refusals that
`system_capabilities` never mentions, which is worse than the build break it
replaces: a client that pre-checks the feature map would be told the call is
fine and then be refused.
* Overriding it is ordinary subclassing, so an implementing backend pays
nothing.
"""
from __future__ import annotations
import inspect
import pytest
from backends.base import AutoCADBackend, UnsupportedCapabilityError
from backends.capability import capability_keys, is_capability_default
from backends.com_backend import ComBackend
from backends.contracts.environment import EnvironmentContract
from backends.contracts.pagesetup import PageSetupContract
from backends.contracts.styles import StylesContract
from backends.ezdxf_backend import EzdxfBackend
# Marked per-test rather than module-wide: half of these are plain registry
# checks, and pytest-asyncio warns on a sync test carrying the mark.
asyncio_test = pytest.mark.asyncio
def _feature_keys(backend) -> set[str]:
return set(backend.capabilities().to_dict()["features"])
# ── the registry is real ────────────────────────────────────────────────────
def test_at_least_one_method_uses_the_decorator():
"""An unused abstraction is dead weight; this keeps the gate meaningful."""
assert capability_keys(), "no @capability methods — delete the decorator or use it"
def test_every_declared_key_exists_in_both_capability_maps():
"""The gate. A refusal the feature map never mentions is a lie by omission."""
ezdxf_keys = _feature_keys(EzdxfBackend())
com_keys = _feature_keys(ComBackend())
for key in capability_keys():
assert key in ezdxf_keys, f"@capability({key!r}) is undeclared on the ezdxf backend"
assert key in com_keys, f"@capability({key!r}) is undeclared on the COM backend"
def test_the_contract_still_exposes_every_method_it_used_to():
"""The mixin split moved code; it must not have dropped any."""
for names in capability_keys().values():
for name in names:
assert callable(getattr(AutoCADBackend, name, None)), f"{name} vanished in the split"
# ── the default behaves like a deliberate refusal ───────────────────────────
@asyncio_test
async def test_a_backend_that_does_not_implement_it_refuses_with_the_key():
backend = ComBackend()
assert is_capability_default(ComBackend, "entity_change_space"), (
"fixture assumption: the COM backend inherits the default here"
)
with pytest.raises(UnsupportedCapabilityError) as excinfo:
await backend.entity_change_space(["1A"], "2B")
assert excinfo.value.capability == "chspace"
payload = excinfo.value.to_dict()
assert payload["ok"] is False
assert payload["capability"] == "chspace"
@asyncio_test
async def test_the_refusal_names_the_backend_and_says_what_to_do_instead():
with pytest.raises(UnsupportedCapabilityError) as excinfo:
await ComBackend().entity_change_space(["1A"], "2B")
message = str(excinfo.value)
assert "com" in message
assert "ezdxf" in message, "a refusal that does not name the way out is half a refusal"
@asyncio_test
async def test_the_default_never_returns_a_soft_failure():
"""The design decision this file exists to defend.
Returning ``{"ok": False, "capability": ...}`` would satisfy every caller's
type expectations and silently mean "nothing happened".
"""
try:
result = await ComBackend().entity_change_space(["1A"], "2B")
except UnsupportedCapabilityError:
return
pytest.fail(f"the default returned {result!r} instead of raising")
@asyncio_test
async def test_an_implementing_backend_is_unaffected(backend):
assert not is_capability_default(EzdxfBackend, "entity_change_space")
assert inspect.iscoroutinefunction(EzdxfBackend.entity_change_space)
result = await backend.entity_change_space([], "nope")
assert result["ok"] is False
assert "error" in result, "the real implementation still does its own validation"
# ── track E: composition and the five agreed keys ───────────────────────────
#: spec §8.1 — the flag each engine must report. `mleaderstyle` is deliberately
#: absent: the spec retracted it (the ActiveX route through the
#: ``ACAD_MLEADERSTYLE`` dictionary exists, §4.2), and both engines implement
#: ``mleaderstyle_create`` natively — `capability_keys()` never registers it.
TRACK_E_KEYS = {
"dwgprops": {"ezdxf": False, "com": True},
"dwt_write": {"ezdxf": False, "com": True},
"live_application": {"ezdxf": False, "com": True},
"preferences": {"ezdxf": False, "com": True},
"interactive_prompt": {"ezdxf": False, "com": True},
}
def _feature_keys_full(backend) -> dict:
return backend.capabilities().to_dict()["features"]
def test_track_e_contracts_are_composed_into_the_backend():
for contract in (StylesContract, PageSetupContract, EnvironmentContract):
assert issubclass(AutoCADBackend, contract), f"{contract.__name__} not in AutoCADBackend"
assert issubclass(EzdxfBackend, contract) and issubclass(ComBackend, contract)
def test_track_e_capability_keys_carry_the_agreed_flags():
assert "mleaderstyle" not in capability_keys(), "spec §8.1: no such engine boundary"
ezdxf_features = _feature_keys_full(EzdxfBackend())
com_features = _feature_keys_full(ComBackend())
for key, flags in TRACK_E_KEYS.items():
assert key in ezdxf_features, f"{key} undeclared on ezdxf"
assert key in com_features, f"{key} undeclared on com"
assert ezdxf_features[key]["supported"] is flags["ezdxf"], key
assert com_features[key]["supported"] is flags["com"], key
def test_layer_states_are_declared_as_ours_not_autocads():
"""Spec §7.2 / §8.3: the states live in the file and travel with it, and do
not appear in AutoCAD's Layer States Manager. Said in the map, not only in
a docstring."""
for backend in (EzdxfBackend(), ComBackend()):
feature = _feature_keys_full(backend)["layer_states"]
assert feature["supported"] is True
assert feature["mode"] == "xrecord"
assert "layer_states_manager" in (feature["reason"] or ""), backend.name