Files
autocad-mcp-pro/tests/test_dimension_style.py
T
Umut CANandClaude Opus 5 ca1c79fd01
Release / test (full, windows-latest, 3.11) (push) Canceled after 0s
Release / test (pdf, ubuntu-latest, 3.11) (push) Canceled after 0s
Release / test (pdf, ubuntu-latest, 3.12) (push) Canceled after 0s
Release / build (push) Canceled after 0s
Release / publish-pypi (push) Canceled after 0s
Release / github-release (push) Canceled after 0s
fix: seven defects an adversarial audit found, and the gates that missed them
Every one of these was reported ok/clean to the caller while being wrong.

Silently wrong numbers
- selection_filter(min_area) read `entity.dxf.get("area", 0.0)` for SOLID,
  TRACE and HATCH, none of which carry that attribute, so every hatch
  measured 0.0. A 1600 mm^2 hatch was excluded by min_area=1.0 while
  `filtered_by` asserted the filter had run, and COM (which reads ActiveX
  .Area) returned a different selection for the same call. It now answers
  from the measurement `entity_measure` uses, so the two cannot disagree.
- Every headless dimension rendered through ezdxf's bare `Standard` style:
  1.0 mm text with a comma decimal marker, against ISO 3098's 2.5 mm minimum
  and ISO 129's point -- while the document header itself said $DIMTXT 2.5.
  `ezdxf.new(setup=True)` is not the fix; measured, it renders a 100 mm
  dimension as "10000". The attributes are now stored explicitly.
- Saving writes `dimrnd = 0.0` onto the Standard style, and ezdxf applies
  `xround(value, dimrnd)` whenever the attribute is present at all --
  `xround(33.333, 0.0)` is 33. So on any reopened file, including one this
  server saved, every dimension was rounded to a whole unit before DIMDEC
  formatted it: a 12.75 mm feature dimensioned 13. AutoCAD means DIMRND 0 as
  *no* rounding; the sentinel is now discarded at every load site, and a real
  0.5 mm rounding is left alone.
- The DIM* header fold gave out-of-range values teeth they did not have when
  they died in the header: dim_decimals=-1 returned ok and then killed the
  next dimension with `ValueError: Format specifier missing precision` from
  inside ezdxf, and 40 printed forty digits of float noise onto the sheet.
  Ranges are now AutoCAD's own (DIMDEC 0-8, DIMZIN 0-15, lengths positive).

Security
- security.py recorded the policy as "sanitize_command and sanitize_lisp each
  have exactly ONE caller ... the policy is scoped to the CHANNEL". The
  premise was false. `_ensure_linetype_loaded` and `ComBackend.linetype_load`
  build `_-LINETYPE _LOAD {name} {file}` and hand it to SendCommand, which
  treats a newline and ';' as segment separators -- so a `linetype=` argument
  carrying either injected further AutoCAD commands, and the 36-verb denylist
  never saw that channel. Guarded with the DXF symbol-name rule (the
  injection characters fall out of it rather than being enumerated) plus
  validate_path for the file argument, which was also the one path-taking
  tool reaching the filesystem unchecked. The policy comment is corrected.

Gates that could not see
- drawing_critique returned zero issues on a quarantined document: all seven
  checks wrapped their backend call in a bare `except Exception`. CLAUDE.md
  rule 7 makes that call the gate an agent is told to trust. Only the
  quarantine now escapes; an ordinary broken check still degrades.
  `_check_dimension_legibility` also read `_doc` directly from the event loop
  thread, missing the gate and performing exactly the unsynchronised access
  the quarantine exists to prevent. `construction_clear` answered
  "deleted: 0" -- there was nothing to clear -- for the same reason.
- The iso128 focus checked layer lineweights only, so a sheet lettered at
  1.0 mm critiqued clean. It now reads the effective dimension height and
  separator, header-then-style, in the order the renderer resolves.
- check_doc_numbers.py derived five values it never checked, and those were
  the ones that had drifted. The collected-test count (README said 1274, the
  tree had 1369) and the A/B pass-rate and Fixed columns are now gated.
- benchmarks/README.md's v1.4.0 table was impossible on its face -- 21
  passing plus 5 fixed is 26, not 24 -- and contradicted the report it
  linked, because `ab-v1.4.0-vs-v1.5.0.json` was regenerated from a tree that
  already carried the dimension fixes and so never measured the v1.5.0 tag.
  Replaced with a real run: ab-v1.4.0-vs-v1.5.1.json, 21/26 -> 26/26.

Release gate
- release.yml ran one ubuntu/3.11 lane, so a Windows-only defect could reach
  PyPI with no Windows job having executed. That is not hypothetical: v1.5.0
  shipped `except pywintypes.com_error` raising NameError wherever pywin32 is
  absent. It now runs the same three lanes as ci.yml, Windows included.

1368 passed, 1 xfailed. ruff clean. Tool inventory and all 16 gated document
numbers match the tree.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 23:07:50 +03:00

245 lines
10 KiB
Python

"""Headless dimensions must be readable ISO drawings, not ezdxf defaults.
`drawing_new` called `ezdxf.new(dxfversion=...)` without `setup=True`, and
nothing in the repo ever created or configured a dimstyle. So every headless
dimension rendered through ezdxf's bare `Standard` style: **1.0 mm** text with a
**comma** decimal marker. ISO 3098 puts the minimum lettering height at 2.5 mm,
and ISO 129 wants a point.
Two things made that worse than a cosmetic default:
* The document's own header already said ``$DIMTXT 2.5``. ezdxf renders from the
dimstyle table entry and never reads the header, so the file asserted one
height and drew another.
* A live AutoCAD seat renders from whatever its template carries, so the two
engines printed a different text height *and* a different decimal marker for
byte-identical tool calls, with no capability key or test recording it.
"""
from __future__ import annotations
import pytest
pytestmark = pytest.mark.asyncio
#: ISO 3098 minimum lettering height for technical drawings.
ISO_MIN_TEXT_MM = 2.5
def _rendered(backend, handle):
"""The text and height a drafter actually sees, out of the dimension block."""
dim = backend._doc.entitydb.get(handle)
block = backend._doc.blocks.get(dim.dxf.geometry)
for entity in block:
if entity.dxftype() == "MTEXT":
return entity.text, float(entity.dxf.char_height)
if entity.dxftype() == "TEXT":
return entity.dxf.text, float(entity.dxf.height)
raise AssertionError("the dimension block carries no text")
async def test_a_headless_dimension_is_lettered_at_iso_height(backend):
dim = await backend.dimension_linear(0, 0, 100, 0, 50, -20, layer="DIM")
_text, height = _rendered(backend, dim.handle)
assert height >= ISO_MIN_TEXT_MM, (
f"rendered at {height} mm; ISO 3098 minimum is {ISO_MIN_TEXT_MM} mm"
)
async def test_the_decimal_marker_is_a_point(backend):
"""ISO 129 wants a point. ezdxf's bare default is a comma."""
dim = await backend.dimension_linear(0, 0, 33.333, 0, 16, -20, layer="DIM")
text, _height = _rendered(backend, dim.handle)
assert "," not in text, f"rendered {text!r} with a comma decimal marker"
assert "33.33" in text
async def test_the_header_and_the_dimstyle_agree_about_text_height(backend):
"""The file used to assert $DIMTXT 2.5 and draw 1.0."""
header = float(backend._doc.header.get("$DIMTXT"))
style = backend._doc.dimstyles.get("Standard")
assert float(style.dxf.dimtxt) == pytest.approx(header)
async def test_a_reopened_drawing_keeps_the_style(backend, tmp_path):
"""The style has to be in the file, not applied in memory on the way out."""
import ezdxf
dim = await backend.dimension_linear(0, 0, 100, 0, 50, -20, layer="DIM")
path = tmp_path / "styled.dxf"
await backend.drawing_save_as(str(path))
reopened = ezdxf.readfile(str(path))
style = reopened.dimstyles.get("Standard")
assert float(style.dxf.dimtxt) >= ISO_MIN_TEXT_MM
assert int(style.dxf.dimdsep) == ord(".")
assert reopened.entitydb.get(dim.handle) is not None
# ── the gate has to be able to see it ───────────────────────────────────────
async def test_the_iso_critique_catches_sub_iso_dimension_text(backend):
"""A drawing whose dimensions are 1.0 mm used to critique clean.
Fixing the default stops this server *producing* such a drawing, but the
gate still has to catch one — a template, an opened file, or a caller who
sets the style back can all reintroduce it, and `drawing_critique`
returning [] is the claim that the sheet is ISO-conformant.
Both the style and the header are pushed down here. Since the DIM* header
fold, the header outranks the style at render time, so setting the style
alone no longer produces a 1.0 mm drawing — it would leave this test
asserting a defect that is not on the sheet.
"""
from engineering.critique import run_critique
backend._doc.dimstyles.get("Standard").dxf.dimtxt = 1.0
await backend.system_set_variable("DIMTXT", 1.0)
await backend.dimension_linear(0, 0, 100, 0, 50, -20, layer="DIM")
issues = await run_critique(backend, focus=["iso128"])
assert issues, "1.0 mm dimension text is below the ISO 3098 minimum"
assert any("2.5" in issue.message for issue in issues)
async def test_the_iso_critique_catches_a_comma_decimal_marker(backend):
from engineering.critique import run_critique
backend._doc.dimstyles.get("Standard").dxf.dimdsep = ord(",")
await backend.system_set_variable("DIMDSEP", ord(","))
await backend.dimension_linear(0, 0, 33.333, 0, 16, -20, layer="DIM")
issues = await run_critique(backend, focus=["iso128"])
assert any("decimal" in issue.message.lower() for issue in issues)
async def test_a_conformant_drawing_still_critiques_clean(backend):
"""The rule must not fire on the styles this server now ships."""
from engineering.critique import run_critique
await backend.drawing_apply_iso_layers("mech")
await backend.dimension_linear(0, 0, 100, 0, 50, -20, layer="DIM")
assert await run_critique(backend, focus=["iso128"]) == []
async def test_the_critique_grades_what_will_actually_be_drawn(backend):
"""When the header and the style disagree, the header is what gets drawn.
Reading the style alone made `drawing_critique` able to be wrong in both
directions once DIM* header variables started reaching the renderer: it
would report a 1.0 mm defect on a sheet lettered at 2.5, and clear a sheet
lettered at 1.0 because the style still said 2.5. The gate has to resolve
the same header-then-style order the renderer does.
"""
from engineering.critique import run_critique
# Style below the minimum, header above it -> the sheet is conformant.
backend._doc.dimstyles.get("Standard").dxf.dimtxt = 1.0
await backend.system_set_variable("DIMTXT", 2.5)
dim = await backend.dimension_linear(0, 0, 100, 0, 50, -20, layer="DIM")
assert _rendered(backend, dim.handle)[1] == pytest.approx(2.5)
assert await run_critique(backend, focus=["iso128"]) == []
# Style above it, header below -> the sheet is not.
backend._doc.dimstyles.get("Standard").dxf.dimtxt = 2.5
await backend.system_set_variable("DIMTXT", 1.0)
dim = await backend.dimension_linear(0, 0, 100, 0, 50, -60, layer="DIM")
assert _rendered(backend, dim.handle)[1] == pytest.approx(1.0)
assert await run_critique(backend, focus=["iso128"])
# ── the DIM* header fold must not fight the ISO defaults ────────────────────
async def test_the_iso_defaults_survive_the_header_fold(backend):
"""The regression that fails if the fold is keyed on the wrong baseline.
The fold skips a variable when the header already agrees with what the
renderer would use. That baseline is ezdxf's *renderer* fallback (dimtxt
1.0, dimasz 0.25, dimdsep 0 -> comma), not the DXF schema default (2.5,
2.5, 44). Key it on the schema defaults and a fresh drawing starts folding
values it should leave alone — most visibly reintroducing the comma this
file exists to keep out.
"""
from engineering.critique import run_critique
await backend.drawing_apply_iso_layers("mech")
dim = await backend.dimension_linear(0, 0, 33.333, 0, 16, -20, layer="DIM")
text, height = _rendered(backend, dim.handle)
assert height == pytest.approx(ISO_MIN_TEXT_MM)
assert text == "33.33"
block = backend._doc.blocks.get(backend._doc.entitydb.get(dim.handle).dxf.geometry)
arrows = {round(float(e.dxf.get("xscale", 1.0)), 3) for e in block if e.dxftype() == "INSERT"}
assert arrows == {2.5}
assert await run_critique(backend, focus=["iso128"]) == []
# ── DIMRND 0.0 means "no rounding", not "round to whole units" ──────────────
async def _reopened(backend, tmp_path):
path = tmp_path / "roundtrip.dxf"
await backend.drawing_save_as(str(path))
await backend.drawing_open(str(path))
return path
def _text(backend, handle) -> str:
dim = backend._doc.entitydb.get(handle)
for entity in backend._doc.blocks.get(dim.dxf.geometry):
if entity.dxftype() == "MTEXT":
return entity.text
if entity.dxftype() == "TEXT":
return entity.dxf.text
raise AssertionError("the dimension block carries no text")
async def test_a_reopened_drawing_does_not_round_every_dimension_to_a_whole_unit(backend, tmp_path):
"""A saved file stores `dimrnd = 0.0`, and ezdxf rounds on it.
AutoCAD's DIMRND 0 means *no* rounding. ezdxf applies `xround(value,
dimrnd)` whenever the attribute is present at all, and `xround(33.333, 0.0)`
is 33 — so every dimension on any reopened file was silently rounded to a
whole unit *before* DIMDEC ever formatted it. Measured on a file this
server saved itself: a 12.75 mm feature dimensioned as 13.
"""
await _reopened(backend, tmp_path)
await backend.drawing_settings({"dim_decimals": 2})
dim = await backend.dimension_linear(0, 0, 33.333, 0, 16, -30)
assert _text(backend, dim.handle) == "33.33"
async def test_dim_decimals_still_works_after_a_reopen(backend, tmp_path):
await _reopened(backend, tmp_path)
for decimals, expected in ((0, "33"), (1, "33.3"), (3, "33.333")):
await backend.drawing_settings({"dim_decimals": decimals})
dim = await backend.dimension_linear(0, 0, 33.333, 0, 16, -30 * (decimals + 2))
assert _text(backend, dim.handle) == expected
async def test_a_real_rounding_value_is_left_alone(backend, tmp_path):
"""Only the 0.0 sentinel is discarded — a drafter who asked for 0.5 mm
rounding must keep it."""
backend._doc.dimstyles.get("Standard").dxf.dimrnd = 0.5
await _reopened(backend, tmp_path)
assert float(backend._doc.dimstyles.get("Standard").dxf.dimrnd) == 0.5
dim = await backend.dimension_linear(0, 0, 33.333, 0, 16, -30)
# 33.5, not 33.33: the rounding survived. The trailing zero is gone because
# DIMZIN defaults to 8, which suppresses it — that is formatting, and it is
# a different knob from the rounding this test is about.
assert _text(backend, dim.handle) == "33.5"