Files
autocad-mcp-pro/tests/test_xdata.py
T
Umut CANandClaude Opus 5 f4c2f27bee fix(task-4): refuse non-finite/non-numeric XDATA and budget 16 KB per entity
encode_values() let strings and nan/inf through float() into 1010 point
coordinates and never finiteness-checked bare 1040 reals, so
entity_set_xdata(handle, app, [["nan", 1]]) or [float("inf")] wrote a
literal "nan"/"inf" into the DXF (and every undo snapshot) and read back
as null. Numbers now go through the same rule block_specs._scalar applies:
int/float only, bool refused, math.isfinite, error naming values[i][j].

The advertised 16 KB limit was per call per app; AutoCAD's is per object
across every application (xdroom/xdsize), so three 15 KB apps on one
entity passed headless and would fail SetXData live. check_entity_budget()
now sums the entity's existing XDATA under every other app (ACAD's DSTYLE
overrides included) plus each 1001 marker with AutoCAD's resbuf sizes,
and both engines run it before registering the APPID or writing; removal
is never refused. The result's "bytes" is that same accounting.

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

357 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""XDATA round trips, typing by group code, limits, removal — both engines."""
from __future__ import annotations
import types
import pytest
from backends.xdata_specs import (
MAX_BYTES,
MAX_STRING,
app_size,
check_entity_budget,
decode_values,
encode_values,
split_by_app,
validate_app_name,
)
pytestmark = pytest.mark.asyncio
# ── pure encoding ────────────────────────────────────────────────────────────
def test_values_are_typed_by_group_code():
assert encode_values(["json", 7, 2.5, [1, 2], [1, 2, 3]]) == [
(1000, "json"),
(1071, 7),
(1040, 2.5),
(1010, (1.0, 2.0, 0.0)),
(1010, (1.0, 2.0, 3.0)),
]
def test_decode_is_the_inverse():
tags = encode_values(["a", 1, 1.5, [3, 4]])
assert decode_values(tags) == ["a", 1, 1.5, [3.0, 4.0, 0.0]]
@pytest.mark.parametrize("bad", [True, None, {"a": 1}, [1], [1, 2, 3, 4], ["x", "y"], 2**31])
def test_untypeable_values_are_refused(bad):
with pytest.raises((TypeError, ValueError)):
encode_values([bad])
@pytest.mark.parametrize(
"bad",
[
["1", "2"], # strings coerce with float() but are not numbers
["nan", 1], # ... and "nan" is the silent wrong number end to end
[1, "2", 3],
[float("nan"), 1],
[1, float("inf")],
[1, 2, float("-inf")],
],
)
def test_point_coordinates_must_be_finite_numbers(bad):
with pytest.raises((TypeError, ValueError), match=r"values\[0\]\[\d\]"):
encode_values([bad])
@pytest.mark.parametrize("bad", [float("nan"), float("inf"), float("-inf")])
def test_non_finite_floats_are_refused(bad):
# ezdxf writes a 1040 real verbatim, so nan/inf would land in the file
# (and every undo snapshot) as literal text that is not a DXF double.
with pytest.raises(ValueError, match=r"values\[1\] must be finite"):
encode_values([1.5, bad])
def test_integer_coordinates_still_encode_as_reals():
assert encode_values([[1, 2], [3, 4, 5]]) == [(1010, (1.0, 2.0, 0.0)), (1010, (3.0, 4.0, 5.0))]
def test_string_over_255_is_refused():
encode_values(["x" * MAX_STRING])
with pytest.raises(ValueError, match="255"):
encode_values(["x" * (MAX_STRING + 1)])
@pytest.mark.parametrize("sep", ["\n", "\r", "\r\n"])
def test_line_separators_in_strings_are_refused(sep):
# DXF is "code\nvalue\n" lines and ezdxf writes 1000 tags verbatim, so a
# separator inside the value splits the tag pair and corrupts the file.
with pytest.raises(ValueError, match=r"values\[1\].*line separators are not allowed"):
encode_values(["fine", f"Inspect weld{sep}before paint"])
def test_other_control_characters_are_still_allowed():
# Only LF/CR split a DXF tag; tabs and the exotic separators round-trip.
assert encode_values(["a\tb", "a\x0bb", "a
b"]) == [
(1000, "a\tb"),
(1000, "a\x0bb"),
(1000, "a
b"),
]
def test_total_size_over_16k_is_refused():
with pytest.raises(ValueError, match="16"):
encode_values(["x" * 255] * (MAX_BYTES // 255 + 1))
def test_entity_budget_counts_every_app_and_the_markers():
# AutoCAD's 16 KB is per object across all apps (xdroom/xdsize), so the
# gate must add what the entity already carries under other apps, ACAD's
# own DSTYLE overrides included, plus each app's 1001 marker.
big = encode_values(["x" * 255] * 40) # 40 * 258 = 10320 bytes + marker
assert app_size("APP_A", big) == 40 * 258 + 3 + len("APP_A")
assert check_entity_budget("APP_A", big, {}) == app_size("APP_A", big)
# Replacing the same app does not double-count its old payload.
assert check_entity_budget("APP_A", big, {"APP_A": big}) == app_size("APP_A", big)
# A second app of the same size no longer fits next to the first.
with pytest.raises(ValueError, match=r"other applications.*16 KB per entity"):
check_entity_budget("APP_B", big, {"APP_A": big})
# Removal is never refused, even when what is left is already over the
# limit (a file opened with too much XDATA must still be shrinkable).
assert check_entity_budget("APP_B", [], {"APP_A": big}) == app_size("APP_A", big)
assert check_entity_budget("APP_A", [], {"APP_A": big, "APP_B": big}) == app_size("APP_B", big)
# Existing tags may carry their own 1001 marker (ezdxf stores it); it is
# counted once either way.
with_marker = [(1001, "APP_A"), *big]
assert app_size("APP_A", with_marker) == app_size("APP_A", big)
assert check_entity_budget("APP_B", [], {"APP_A": with_marker}) == app_size("APP_A", big)
@pytest.mark.parametrize("bad", ["", "1abc", "a-b", "a" * 32, "ACAD"])
def test_bad_app_names_are_refused(bad):
with pytest.raises(ValueError):
validate_app_name(bad)
def test_split_by_app_groups_a_flat_activex_stream():
codes = [1001, 1000, 1071, 1001, 1040]
values = ["APP_A", "hello", 3, "APP_B", 1.5]
assert split_by_app(codes, values) == {"APP_A": ["hello", 3], "APP_B": [1.5]}
# ── headless engine ─────────────────────────────────────────────────────────
async def test_roundtrip_registers_the_appid(backend):
line = await backend.entity_create_line(0, 0, 1, 1)
result = await backend.entity_set_xdata(line.handle, "ACADMCP_PID", ["json", 42, 1.5, [1, 2]])
assert result["ok"] is True and result["value_count"] == 4 and result["removed"] is False
assert "ACADMCP_PID" in backend._doc.appids
got = await backend.entity_get_xdata(line.handle, "ACADMCP_PID")
assert got["xdata"] == {"ACADMCP_PID": ["json", 42, 1.5, [1.0, 2.0, 0.0]]}
async def test_get_without_app_returns_every_app(backend):
line = await backend.entity_create_line(0, 0, 1, 1)
await backend.entity_set_xdata(line.handle, "APP_A", ["a"])
await backend.entity_set_xdata(line.handle, "APP_B", ["b"])
got = await backend.entity_get_xdata(line.handle)
assert got["xdata"] == {"APP_A": ["a"], "APP_B": ["b"]}
async def test_unknown_app_is_empty_not_an_error(backend):
line = await backend.entity_create_line(0, 0, 1, 1)
got = await backend.entity_get_xdata(line.handle, "NOPE")
assert got["xdata"] == {}
async def test_set_replaces_and_empty_removes(backend):
line = await backend.entity_create_line(0, 0, 1, 1)
await backend.entity_set_xdata(line.handle, "APP_A", ["one", "two"])
await backend.entity_set_xdata(line.handle, "APP_A", ["three"])
assert (await backend.entity_get_xdata(line.handle, "APP_A"))["xdata"] == {"APP_A": ["three"]}
result = await backend.entity_set_xdata(line.handle, "APP_A", [])
assert result["removed"] is True
assert (await backend.entity_get_xdata(line.handle))["xdata"] == {}
async def test_limits_are_enforced_before_writing(backend):
line = await backend.entity_create_line(0, 0, 1, 1)
with pytest.raises(ValueError, match="255"):
await backend.entity_set_xdata(line.handle, "APP_A", ["x" * 256])
assert (await backend.entity_get_xdata(line.handle))["xdata"] == {}
async def test_non_finite_and_string_coordinates_are_refused_before_writing(backend):
line = await backend.entity_create_line(0, 0, 1, 1)
with pytest.raises(TypeError, match=r"values\[0\]\[0\] must be a number"):
await backend.entity_set_xdata(line.handle, "APP_A", [["nan", 1]])
with pytest.raises(ValueError, match=r"values\[0\] must be finite"):
await backend.entity_set_xdata(line.handle, "APP_A", [float("nan")])
with pytest.raises(ValueError, match=r"values\[1\] must be finite"):
await backend.entity_set_xdata(line.handle, "APP_A", [1.0, float("inf")])
assert (await backend.entity_get_xdata(line.handle))["xdata"] == {}
async def test_16k_is_per_entity_across_apps_not_per_call(backend, tmp_path):
# Three 15 KB payloads under three apps each fit on their own; the entity
# does not, and AutoCAD's SetXData would refuse the second one live.
line = await backend.entity_create_line(0, 0, 1, 1)
payload = ["x" * 255] * 60
first = await backend.entity_set_xdata(line.handle, "APP_A", payload)
assert first["ok"] is True and first["bytes"] == 60 * 258 + 3 + len("APP_A")
with pytest.raises(ValueError, match=r"other applications.*16 KB per entity"):
await backend.entity_set_xdata(line.handle, "APP_B", payload)
with pytest.raises(ValueError, match="16 KB per entity"):
await backend.entity_set_xdata(line.handle, "APP_C", ["y" * 255] * 5)
got = (await backend.entity_get_xdata(line.handle))["xdata"]
assert set(got) == {"APP_A"} and "APP_B" not in backend._doc.appids
# Replacing APP_A wholesale is judged against the entity *without* its old
# APP_A payload, so a same-size rewrite is fine ...
await backend.entity_set_xdata(line.handle, "APP_A", ["z" * 255] * 60)
# ... and once APP_A shrinks, APP_B fits.
await backend.entity_set_xdata(line.handle, "APP_A", ["small"])
await backend.entity_set_xdata(line.handle, "APP_B", payload)
got = (await backend.entity_get_xdata(line.handle))["xdata"]
assert got["APP_A"] == ["small"] and len(got["APP_B"]) == 60
path = str(tmp_path / "budget.dxf")
await backend.drawing_save(path)
await backend.drawing_open(path)
assert len((await backend.entity_get_xdata(line.handle, "APP_B"))["xdata"]["APP_B"]) == 60
async def test_budget_counts_acad_xdata_the_dimension_tools_write(backend):
# A toleranced dimension carries DSTYLE overrides under the ACAD app; the
# live engine counts them against the 16 KB and so must the gate.
dim = await backend.dimension_linear(
0, 0, 100, 0, 0, 20, tol_upper=0.1, tol_lower=-0.1, tol_mode="deviation"
)
acad = (await backend.entity_get_xdata(dim.handle))["xdata"]
assert "ACAD" in acad and acad["ACAD"], acad
ent = backend._get_entity(dim.handle)
acad_bytes = app_size("ACAD", [(t.code, t.value) for t in ent.xdata.data["ACAD"]])
assert acad_bytes > 0
# A payload that fits an empty entity but not one carrying the override.
base = ["x" * 255] * 63
slack = MAX_BYTES - app_size("APP_A", encode_values(base))
assert 3 < acad_bytes < slack
filler = [*base, "y" * (slack - 3 - acad_bytes // 2)]
assert app_size("APP_A", encode_values(filler)) <= MAX_BYTES
assert app_size("APP_A", encode_values(filler)) + acad_bytes > MAX_BYTES
with pytest.raises(ValueError, match=r"other applications.*16 KB per entity"):
await backend.entity_set_xdata(dim.handle, "APP_A", filler)
# Nothing was written and the override is untouched.
assert (await backend.entity_get_xdata(dim.handle))["xdata"] == acad
async def test_newline_is_refused_before_writing_and_snapshots_still_load(backend, tmp_path):
# Every undo/transaction snapshot is a DXF save, so an accepted newline
# would break rollback, not just the file on disk.
line = await backend.entity_create_line(0, 0, 1, 1)
with pytest.raises(ValueError, match="line separators are not allowed"):
await backend.entity_set_xdata(line.handle, "NOTES", ["Inspect weld\nbefore paint"])
assert (await backend.entity_get_xdata(line.handle))["xdata"] == {}
await backend.entity_set_xdata(line.handle, "NOTES", ["Inspect weld before paint"])
await backend.transaction_begin()
circle = await backend.entity_create_circle(5, 5, 1)
await backend.transaction_rollback()
assert (await backend.entity_get_xdata(line.handle, "NOTES"))["xdata"] == {
"NOTES": ["Inspect weld before paint"]
}
with pytest.raises(RuntimeError, match="not found"):
await backend.entity_get(circle.handle)
path = str(tmp_path / "notes.dxf")
await backend.drawing_save(path)
await backend.drawing_open(path)
assert (await backend.entity_get_xdata(line.handle, "NOTES"))["xdata"] == {
"NOTES": ["Inspect weld before paint"]
}
# ── live engine, fake ActiveX ────────────────────────────────────────────────
class _FakeEntity:
def __init__(self):
self.stored: tuple | None = None
def SetXData(self, types_, values):
self.stored = (list(types_.value), list(values.value))
def GetXData(self, app):
if self.stored is None:
return ([], [])
codes, values = self.stored
if app:
return (codes, values)
return (codes, values)
@pytest.fixture
def com_backend(monkeypatch):
pytest.importorskip("win32com.client", reason="pywin32 not installed")
from backends import com_backend as module
entity = _FakeEntity()
registered: list[str] = []
document = types.SimpleNamespace(
HandleToObject=lambda handle: entity,
RegisteredApplications=types.SimpleNamespace(Add=registered.append),
)
monkeypatch.setattr(module, "_acad_doc", lambda: document)
backend = module.ComBackend()
async def _run_inline(func, *args, **kwargs):
return func(*args, **kwargs)
monkeypatch.setattr(backend, "_run", _run_inline)
return backend, entity, registered
async def test_com_set_writes_the_1001_marker_first(com_backend):
backend, entity, registered = com_backend
await backend.entity_set_xdata("2F", "ACADMCP_PID", ["json", 3, [1, 2]])
assert registered == ["ACADMCP_PID"]
codes, values = entity.stored
assert codes == [1001, 1000, 1071, 1010]
assert values[0] == "ACADMCP_PID" and values[1] == "json" and values[2] == 3
assert tuple(values[3].value) == (1.0, 2.0, 0.0)
async def test_com_get_splits_by_app(com_backend):
backend, entity, _ = com_backend
await backend.entity_set_xdata("2F", "ACADMCP_PID", ["json"])
got = await backend.entity_get_xdata("2F", "ACADMCP_PID")
assert got["xdata"] == {"ACADMCP_PID": ["json"]}
async def test_com_empty_values_write_only_the_marker(com_backend):
backend, entity, _ = com_backend
result = await backend.entity_set_xdata("2F", "ACADMCP_PID", [])
assert result["removed"] is True
assert entity.stored == ([1001], ["ACADMCP_PID"])
async def test_com_budget_reads_the_whole_stream_before_writing(com_backend):
backend, entity, registered = com_backend
# Pretend AutoCAD already holds 15 KB under APP_A (and ACAD overrides).
entity.stored = (
[1001, 1070, 1001] + [1000] * 60,
["ACAD", 271, "APP_A"] + ["x" * 255] * 60,
)
with pytest.raises(ValueError, match=r"other applications.*16 KB per entity"):
await backend.entity_set_xdata("2F", "APP_B", ["y" * 255] * 5)
# Refused before RegisteredApplications.Add and SetXData.
assert registered == [] and entity.stored[1][2] == "APP_A" and len(entity.stored[0]) == 63
# Rewriting APP_A itself is judged without its old payload.
result = await backend.entity_set_xdata("2F", "APP_A", ["z" * 255] * 60)
assert result["ok"] is True and registered == ["APP_A"]
# Removal always goes through.
result = await backend.entity_set_xdata("2F", "APP_B", [])
assert result["removed"] is True and result["bytes"] == 0
async def test_com_newline_is_refused_before_any_activex_call(com_backend):
backend, entity, registered = com_backend
with pytest.raises(ValueError, match="line separators are not allowed"):
await backend.entity_set_xdata("2F", "NOTES", ["a\r\nb"])
assert entity.stored is None and registered == []