feat(settings): SYSVAR_CATALOG + system_variable_describe; system_set_variable refuses out-of-range values
90 authored rows (type, range/enum, default, meaning, saved-in, per-engine support, friendly key); every ezdxf claim is checked against the real backend and the registry-saved set is pinned to what the backend refuses. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
This commit is contained in:
co-authored by
Claude Opus 5
parent
f3ff703144
commit
125a8ed3b8
@@ -0,0 +1,315 @@
|
||||
"""SYSVAR_CATALOG: authored facts about AutoCAD system variables, pinned to the engines.
|
||||
|
||||
Every `engines["ezdxf"]` claim is checked against the real headless backend
|
||||
(read, and write of the default where the variable is writable); every
|
||||
`saved_in` that is not "drawing" is checked to be exactly the set the backend
|
||||
refuses with `capability: registry_sysvar`. The catalogue cannot say a thing
|
||||
the backend does not do.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from backends.base import UnsupportedCapabilityError
|
||||
from backends.contracts.settings import _SETTING_MAP
|
||||
from backends.ezdxf_backend import _UNSAVED_SYSVARS
|
||||
from engineering.standards.sysvars import (
|
||||
SAVED_IN,
|
||||
SYSVAR_CATALOG,
|
||||
SYSVAR_TYPES,
|
||||
SysVar,
|
||||
check_sysvar_value,
|
||||
describe_sysvar,
|
||||
search_sysvars,
|
||||
)
|
||||
|
||||
asyncio_test = pytest.mark.asyncio
|
||||
|
||||
DIM_WHITELIST = (
|
||||
"DIMTXT",
|
||||
"DIMASZ",
|
||||
"DIMEXE",
|
||||
"DIMEXO",
|
||||
"DIMGAP",
|
||||
"DIMTAD",
|
||||
"DIMTIH",
|
||||
"DIMTOH",
|
||||
"DIMDEC",
|
||||
"DIMDSEP",
|
||||
"DIMLUNIT",
|
||||
"DIMZIN",
|
||||
"DIMBLK",
|
||||
"DIMTXSTY",
|
||||
"DIMLWD",
|
||||
"DIMLWE",
|
||||
"DIMSCALE",
|
||||
"DIMTOL",
|
||||
"DIMTP",
|
||||
"DIMTM",
|
||||
"DIMTOLJ",
|
||||
"DIMTFAC",
|
||||
"DIMLFAC",
|
||||
"DIMRND",
|
||||
"DIMATFIT",
|
||||
"DIMTMOVE",
|
||||
"DIMCLRD",
|
||||
"DIMCLRE",
|
||||
"DIMCLRT",
|
||||
"DIMSAH",
|
||||
"DIMBLK1",
|
||||
"DIMBLK2",
|
||||
"DIMCEN",
|
||||
)
|
||||
|
||||
SPEC_NAMES = (
|
||||
"LTSCALE PSLTSCALE INSUNITS LUNITS LUPREC AUNITS AUPREC LIMMIN LIMMAX GRIDMODE GRIDUNIT "
|
||||
"SNAPMODE SNAPUNIT ORTHOMODE POLARMODE POLARANG OSMODE CANNOSCALE TEXTSIZE TEXTSTYLE "
|
||||
"DIMSTYLE CLAYER CELTYPE CECOLOR CELWEIGHT PDMODE PDSIZE FILLETRAD MIRRTEXT ATTREQ ATTDIA "
|
||||
"TILEMODE CTAB DWGNAME DWGPREFIX SAVETIME FILEDIA CMDECHO HIGHLIGHT REGENMODE UCSNAME "
|
||||
"VIEWCTR VIEWSIZE ANGBASE ANGDIR PLINEWID HPNAME HPSCALE HPANG LWDISPLAY XREFCTL PROXYSHOW "
|
||||
"ISOLINES FACETRES DISPSILH"
|
||||
).split()
|
||||
|
||||
#: The two engines legitimately represent these differently (a character vs.
|
||||
#: its code; a colour name vs. its ACI number), so their default cannot be
|
||||
#: written verbatim headlessly. Everything else round-trips its default.
|
||||
_REPRESENTATION_DIFFERS = {"DIMDSEP", "CECOLOR"}
|
||||
|
||||
|
||||
# ── the data is complete ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_catalogue_is_authored_not_sparse():
|
||||
assert len(SYSVAR_CATALOG) >= 60
|
||||
|
||||
|
||||
def test_every_dim_whitelist_variable_and_every_spec_name_is_present():
|
||||
missing = [n for n in (*DIM_WHITELIST, *SPEC_NAMES) if n not in SYSVAR_CATALOG]
|
||||
assert missing == []
|
||||
|
||||
|
||||
def test_every_entry_is_complete_and_well_typed():
|
||||
for name, var in SYSVAR_CATALOG.items():
|
||||
assert isinstance(var, SysVar) and var.name == name
|
||||
assert var.type in SYSVAR_TYPES, name
|
||||
assert var.meaning.strip(), name
|
||||
assert var.saved_in in SAVED_IN, name
|
||||
assert set(var.engines) == {"ezdxf", "com"}, name
|
||||
assert var.friendly_key is None or var.friendly_key in _SETTING_MAP, name
|
||||
if var.type == "enum":
|
||||
assert var.enum and all(isinstance(k, int) for k in var.enum), name
|
||||
assert var.default in var.enum, name
|
||||
else:
|
||||
assert var.enum is None, name
|
||||
if var.range is not None:
|
||||
low, high = var.range
|
||||
assert low < high, name
|
||||
|
||||
|
||||
def test_friendly_keys_cover_the_whole_facade():
|
||||
covered = {var.friendly_key for var in SYSVAR_CATALOG.values() if var.friendly_key}
|
||||
assert covered == set(_SETTING_MAP)
|
||||
for key, (variable, _kind) in _SETTING_MAP.items():
|
||||
assert SYSVAR_CATALOG[variable].friendly_key == key, (key, variable)
|
||||
|
||||
|
||||
def test_non_drawing_variables_are_exactly_the_ones_the_headless_engine_refuses():
|
||||
unsaved = {n for n, v in SYSVAR_CATALOG.items() if v.saved_in != "drawing"}
|
||||
assert unsaved == set(_UNSAVED_SYSVARS)
|
||||
for name in unsaved:
|
||||
assert SYSVAR_CATALOG[name].engines["ezdxf"] is False, name
|
||||
|
||||
|
||||
# ── describe / search / check ───────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_describe_is_case_insensitive_and_complete():
|
||||
row = describe_sysvar("ltscale")
|
||||
assert row["known"] is True and row["name"] == "LTSCALE"
|
||||
assert row["type"] == "float" and row["range"][0] > 0
|
||||
assert row["saved_in"] == "drawing"
|
||||
assert row["engines"] == {"ezdxf": True, "com": True}
|
||||
assert row["friendly_key"] == "ltscale"
|
||||
assert describe_sysvar("$LIMMAX")["default"] == [420.0, 297.0]
|
||||
|
||||
|
||||
def test_describe_unknown_names_the_nearest_without_raising():
|
||||
row = describe_sysvar("LTSCAL")
|
||||
assert row["known"] is False and row["name"] == "LTSCAL"
|
||||
assert "LTSCALE" in row["nearest"]
|
||||
assert describe_sysvar("")["known"] is False
|
||||
|
||||
|
||||
def test_search_matches_names_and_meanings():
|
||||
names = [row["name"] for row in search_sysvars("scale")]
|
||||
for expected in ("LTSCALE", "DIMSCALE", "CANNOSCALE", "PSLTSCALE", "HPSCALE"):
|
||||
assert expected in names
|
||||
assert [row["name"] for row in search_sysvars("decimal separator")] == ["DIMDSEP"]
|
||||
assert search_sysvars(" ") == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"name, value, fragment",
|
||||
[
|
||||
("DIMDEC", 9, "0..8"),
|
||||
("dimdec", -1, "0..8"),
|
||||
("DIMDEC", 2.5, "integer"),
|
||||
("DIMTXT", "big", "number"),
|
||||
("DIMTXT", 0, "out of range"),
|
||||
("LUNITS", 7, "architectural"),
|
||||
("ORTHOMODE", 2, "0 or 1"),
|
||||
("LIMMAX", 5, "[x, y]"),
|
||||
("DIMSTYLE", "ANSI", "read-only"),
|
||||
("CANNOSCALEVALUE", 0.5, "read-only"),
|
||||
("POLARANG", 0, "out of range"),
|
||||
],
|
||||
)
|
||||
def test_check_refuses_with_a_message_naming_the_variable(name, value, fragment):
|
||||
message = check_sysvar_value(name, value)
|
||||
assert message is not None
|
||||
assert name.upper() in message and fragment in message
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"name, value",
|
||||
[
|
||||
("DIMDEC", 3),
|
||||
("DIMTXT", "3.5"),
|
||||
("LUNITS", 4),
|
||||
("ORTHOMODE", "on"),
|
||||
("LIMMAX", [297, 210]),
|
||||
("DIMDSEP", ","),
|
||||
("NOSUCHVARIABLE", 12345),
|
||||
],
|
||||
)
|
||||
def test_check_passes_good_values_and_unknown_names(name, value):
|
||||
assert check_sysvar_value(name, value) is None
|
||||
|
||||
|
||||
# ── the engine claims are true ──────────────────────────────────────────────
|
||||
|
||||
|
||||
@asyncio_test
|
||||
async def test_every_ezdxf_true_entry_reads_on_a_fresh_document(backend):
|
||||
unreadable = []
|
||||
for name, var in SYSVAR_CATALOG.items():
|
||||
if var.engines["ezdxf"] and await backend.system_get_variable(name) is None:
|
||||
unreadable.append(name)
|
||||
assert unreadable == []
|
||||
|
||||
|
||||
@asyncio_test
|
||||
async def test_every_writable_ezdxf_true_entry_round_trips_its_default(backend):
|
||||
for name, var in SYSVAR_CATALOG.items():
|
||||
if not var.engines["ezdxf"] or var.read_only or name in _REPRESENTATION_DIFFERS:
|
||||
continue
|
||||
await backend.system_set_variable(name, var.default)
|
||||
back = await backend.system_get_variable(name)
|
||||
if var.type == "point":
|
||||
assert tuple(float(v) for v in back[:2]) == tuple(float(v) for v in var.default), name
|
||||
elif var.type in ("bool", "int", "enum"):
|
||||
assert int(back) == int(var.default), name
|
||||
elif var.type == "float":
|
||||
assert float(back) == pytest.approx(float(var.default)), name
|
||||
else:
|
||||
assert back == var.default, name
|
||||
|
||||
|
||||
@asyncio_test
|
||||
async def test_every_ezdxf_false_entry_is_refused_before_any_write(backend):
|
||||
for name, var in SYSVAR_CATALOG.items():
|
||||
if var.engines["ezdxf"]:
|
||||
continue
|
||||
if var.saved_in == "drawing":
|
||||
with pytest.raises(ValueError, match=name): # no header slot in ezdxf
|
||||
await backend.system_set_variable(name, var.default)
|
||||
else:
|
||||
with pytest.raises(UnsupportedCapabilityError) as excinfo:
|
||||
await backend.system_set_variable(name, var.default)
|
||||
assert excinfo.value.capability == "registry_sysvar", name
|
||||
|
||||
|
||||
# ── server tools ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class _FakeCtx:
|
||||
def __init__(self, backend):
|
||||
self.lifespan_context = {"backend": backend}
|
||||
|
||||
async def info(self, *a, **k):
|
||||
pass
|
||||
|
||||
|
||||
@asyncio_test
|
||||
async def test_set_variable_tool_refuses_out_of_range_before_the_backend_sees_it(backend):
|
||||
from fastmcp.exceptions import ToolError
|
||||
|
||||
import server
|
||||
|
||||
ctx = _FakeCtx(backend)
|
||||
with pytest.raises(ToolError, match="DIMDEC"):
|
||||
await server.system_set_variable(name="DIMDEC", value=99, ctx=ctx)
|
||||
assert await backend.system_get_variable("DIMDEC") == 2
|
||||
with pytest.raises(ToolError, match="read-only"):
|
||||
await server.system_set_variable(name="DIMSTYLE", value="ANSI", ctx=ctx)
|
||||
# a name the catalogue does not know still passes straight through
|
||||
out = await server.system_set_variable(name="MEASUREMENT", value=0, ctx=ctx)
|
||||
assert out["ok"] is True
|
||||
assert await backend.system_get_variable("MEASUREMENT") == 0
|
||||
|
||||
|
||||
@asyncio_test
|
||||
async def test_set_variable_tool_refuses_before_a_live_backend_is_called(monkeypatch):
|
||||
pytest.importorskip("win32com.client", reason="pywin32 not installed")
|
||||
from fastmcp.exceptions import ToolError
|
||||
|
||||
import server
|
||||
from backends import com_backend as module
|
||||
|
||||
calls: list[tuple] = []
|
||||
|
||||
class _App:
|
||||
def GetVariable(self, name):
|
||||
calls.append(("GetVariable", name))
|
||||
return 2
|
||||
|
||||
def SetVariable(self, name, value):
|
||||
calls.append(("SetVariable", name, value))
|
||||
|
||||
monkeypatch.setattr(module, "_acad_app", lambda: _App())
|
||||
backend = module.ComBackend()
|
||||
|
||||
async def _run_inline(func, *args, **kwargs):
|
||||
return func(*args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(backend, "_run", _run_inline)
|
||||
with pytest.raises(ToolError, match="DIMDEC"):
|
||||
await server.system_set_variable(name="DIMDEC", value=40, ctx=_FakeCtx(backend))
|
||||
assert calls == []
|
||||
|
||||
|
||||
@asyncio_test
|
||||
async def test_describe_tool_returns_the_row_with_the_current_value(backend):
|
||||
import server
|
||||
|
||||
ctx = _FakeCtx(backend)
|
||||
row = await server.system_variable_describe(name="dimdec", ctx=ctx)
|
||||
assert row["known"] is True and row["name"] == "DIMDEC" and row["current"] == 2
|
||||
unknown = await server.system_variable_describe(name="DIMDEK", ctx=ctx)
|
||||
assert unknown["known"] is False and "DIMDEC" in unknown["nearest"]
|
||||
found = await server.system_variable_describe(search="decimal separator", ctx=ctx)
|
||||
assert [row["name"] for row in found["matches"]] == ["DIMDSEP"]
|
||||
index = await server.system_variable_describe(ctx=ctx)
|
||||
assert index["count"] == len(SYSVAR_CATALOG) and "LTSCALE" in index["names"]
|
||||
|
||||
|
||||
@asyncio_test
|
||||
async def test_describe_tool_works_without_a_backend():
|
||||
import server
|
||||
|
||||
class _NoBackendCtx:
|
||||
lifespan_context = {"backend": None}
|
||||
|
||||
row = await server.system_variable_describe(name="LTSCALE", ctx=_NoBackendCtx())
|
||||
assert row["known"] is True and "current" not in row
|
||||
@@ -517,8 +517,10 @@ async def test_tool_group_sizes_are_unchanged():
|
||||
when `pid_tag_parse` (the ISA-5.1 tag grammar as a read-only tool)
|
||||
joined; the `pid`
|
||||
tag is ranked first in `_GROUP_TAG_PRIORITY` so those tools file under
|
||||
`pid` rather than under their secondary `query` / `create` tags. Every
|
||||
other number here has been unchanged since the snapshot was taken.
|
||||
`pid` rather than under their secondary `query` / `create` tags. `system`
|
||||
moved 7 -> 8 when v1.6's `system_variable_describe` (the sysvar catalogue
|
||||
as a read-only tool) opened SECTION 20. Every other number here has been
|
||||
unchanged since the snapshot was taken.
|
||||
"""
|
||||
sizes = {label: len(names) for label, names in (await server._tool_groups()).items()}
|
||||
assert sizes == {
|
||||
@@ -537,10 +539,10 @@ async def test_tool_group_sizes_are_unchanged():
|
||||
"pid": 9,
|
||||
"premium": 12,
|
||||
"solids": 5,
|
||||
"system": 7,
|
||||
"system": 8,
|
||||
"templates": 2,
|
||||
"transactions": 3,
|
||||
"validation": 1,
|
||||
"view": 4,
|
||||
}
|
||||
assert sum(sizes.values()) == 166
|
||||
assert sum(sizes.values()) == 167
|
||||
|
||||
Reference in New Issue
Block a user