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
169 lines
7.3 KiB
Python
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
|