fix: correctness, cross-backend parity & enforced quality gate (audit Sprints 2-4)

Audit-driven fixes across the dual backend, the premium workflow, and the
engineering layer. 318 tests, ruff-clean.

Quality gate (the moat):
- drawing_finalize now runs the premium ISO-128 critique in addition to the
  8-step validator; leftover construction, non-ISO lineweights, untrimmed
  corners, duplicates and dim overlap block the gate (strict_critique fails on
  warnings too).
- dim_overlap critique no longer a no-op (dim reference point + bbox fallback);
  dim/construction layers resolve from the active layer set (iso13567 too).

Stability / correctness:
- view_screenshot & drawing_export_pdf render headless via Agg (no more
  worker-thread SIGSEGV).
- gear tooth profile no longer self-overlaps at high tooth counts; section view
  no longer duplicates bore lines; validator keyway heuristic fixed.
- ezdxf aligned/angular dim API, full-circle polar array duplicate already
  covered by Sprint 1; this bundle adds save-format-from-extension,
  entity_offset side selection, ARC length, properties parity (bounding_box /
  rotation / char_height), mtext rotation.

COM robustness (mocked-COM tested):
- CoUninitialize on teardown; transaction commit/rollback try/finally + UNDO via
  the CMDACTIVE guard; system_run_lisp guard + USERS1 value capture;
  set_variable type coercion; HWND via Application.HWND; BlockInfo.description.

Security:
- HTTP remote-bind guard fires on every launch path (incl. `fastmcp run`);
  StaticTokenVerifier wired into FastMCP; AutoLISP allowlist bypass-vector tests.

Hygiene: linetype-on-demand in _apply_attrs, honest view_zoom returns, snap-on-arc
sweep handling, point_tangent acos clamp, dynamic tool_groups / tool count,
word-boundary SPUR/HELICAL, undo-stack locking.

Deferred: R22 (undo/transaction stack isolation) lands with the I1 refiner (1.2),
which depends on it.
This commit is contained in:
Umutcan Edizsalan
2026-06-19 08:36:58 +03:00
parent 00e14423df
commit 32a68be919
18 changed files with 2016 additions and 166 deletions
+4
View File
@@ -39,6 +39,10 @@ NL_TABLE: list[tuple[Any, Any]] = [
(50, 50),
(100, 100),
(200, 200),
# Ambiguous bare ints (<= 2.05) — the P0 truncation hazard: treated as mm,
# NOT as hundredths (NEW-test-com-1). 2 -> 2.00mm -> 200, 1 -> 1.00mm -> 100.
(1, 100),
(2, 200),
# Sentinels: -1 = ByLayer, -2 = ByBlock, -3 = Default
(-1, -1),
(-2, -2),
+158
View File
@@ -0,0 +1,158 @@
"""Mocked-COM regression tests for the Sprint-3 COM fixes.
These exercise the COM `_sync` bodies without a live AutoCAD by:
* bypassing the STA executor (replace ``_run`` with a direct caller), and
* patching the module-level ``_acad_app`` / ``_acad_doc`` / ``_entity_info``.
Covers: N6/R23 (run_lisp guard + USERS1 capture), set_variable coercion,
R16 (transaction flag cleared on error), R13 (offset honors side_x/side_y).
"""
from __future__ import annotations
from unittest.mock import MagicMock
import pytest
import backends.com_backend as cb
from backends.com_backend import ComBackend
pytestmark = pytest.mark.asyncio
def _backend_no_executor():
b = ComBackend()
async def _run(func, *args, **kwargs): # bypass the ThreadPoolExecutor
return func(*args, **kwargs)
b._run = _run
return b
class _Doc:
"""Idle doc: GetVariable returns 0 (CMDACTIVE clear) and ModelSpace is empty."""
def __init__(self):
self.sent: list[str] = []
self._vars: dict = {}
self.ModelSpace: list = []
def GetVariable(self, name):
return self._vars.get(name, 0)
def SetVariable(self, name, val):
self._vars[name] = val
def SendCommand(self, cmd):
self.sent.append(cmd)
def EndUndoMark(self):
pass
# ── NEW-com-set-variable-coercion ───────────────────────────────────────────
async def test_set_variable_coerces_string_to_sysvar_int_type(monkeypatch):
app = MagicMock()
app.GetVariable.return_value = 4159 # OSMODE current value is an int
monkeypatch.setattr(cb, "_acad_app", lambda: app)
b = _backend_no_executor()
out = await b.system_set_variable("OSMODE", "0")
name, val = app.SetVariable.call_args[0]
assert name == "OSMODE"
assert val == 0 and isinstance(val, int) # coerced from "0" to int 0
assert out["value"] == 0
# ── R23 / N6 — run_lisp guard + value capture ───────────────────────────────
async def test_run_lisp_refuses_when_cmdactive(monkeypatch):
app = MagicMock()
app.GetVariable.side_effect = lambda n: 1 if n == "CMDACTIVE" else 0
monkeypatch.setattr(cb, "_acad_app", lambda: app)
monkeypatch.setattr(cb, "_acad_doc", lambda: _Doc())
b = _backend_no_executor()
with pytest.raises(RuntimeError):
await b.system_run_lisp("(+ 1 2)")
async def test_run_lisp_captures_value_from_users1(monkeypatch):
app = MagicMock()
app.GetVariable.side_effect = lambda n: {"CMDACTIVE": 0, "USERS1": "3"}.get(n, 0)
doc = _Doc()
monkeypatch.setattr(cb, "_acad_app", lambda: app)
monkeypatch.setattr(cb, "_acad_doc", lambda: doc)
b = _backend_no_executor()
out = await b.system_run_lisp("(+ 1 2)")
assert out["result"] == "3" # captured, not the bogus "nil"
assert any("USERS1" in c for c in doc.sent) # value stashed out-of-band
# ── R16 — transaction flag cleared even on error ────────────────────────────
async def test_transaction_begin_sets_flag_and_marks_undo(monkeypatch):
doc = _Doc()
doc.StartUndoMark = MagicMock()
monkeypatch.setattr(cb, "_acad_doc", lambda: doc)
b = _backend_no_executor()
out = await b.transaction_begin()
assert out["ok"] is True
assert b._transaction_active is True
doc.StartUndoMark.assert_called_once()
async def test_transaction_commit_clears_flag_on_error():
b = _backend_no_executor()
async def boom(func, *a, **k):
raise RuntimeError("EndUndoMark failed")
b._run = boom
b._transaction_active = True
with pytest.raises(RuntimeError):
await b.transaction_commit()
assert b._transaction_active is False # not left stale
async def test_transaction_rollback_uses_safe_send_command(monkeypatch):
doc = _Doc() # idle: CMDACTIVE=0 so _safe_send_command returns promptly
monkeypatch.setattr(cb, "_acad_doc", lambda: doc)
b = _backend_no_executor()
b._transaction_active = True
out = await b.transaction_rollback()
assert out["ok"] is True
assert b._transaction_active is False
# UNDO B went through _safe_send_command (which appends a trailing newline).
assert any("_UNDO B" in c for c in doc.sent)
# ── R13 — offset honors side_x/side_y and deletes the unused copy ───────────
async def test_offset_picks_copy_nearest_side_point(monkeypatch):
# ent.Offset(+d) -> copy centred at (0, +10); ent.Offset(-d) -> (0, -10).
pos_copy = MagicMock(name="pos")
pos_copy.GetBoundingBox.return_value = ((-1, 9), (1, 11)) # centre (0, 10)
neg_copy = MagicMock(name="neg")
neg_copy.GetBoundingBox.return_value = ((-1, -11), (1, -9)) # centre (0, -10)
ent = MagicMock()
ent.Offset.side_effect = lambda d: [pos_copy] if d > 0 else [neg_copy]
doc = MagicMock()
doc.HandleToObject.return_value = ent
monkeypatch.setattr(cb, "_acad_doc", lambda: doc)
monkeypatch.setattr(cb, "_entity_info", lambda e: e) # return the entity itself
b = _backend_no_executor()
# Side point near the negative copy -> keep neg, delete pos.
kept = await b.entity_offset("AA", 5.0, side_x=0.0, side_y=-10.0)
assert kept is neg_copy
pos_copy.Delete.assert_called_once()
neg_copy.Delete.assert_not_called()
+43
View File
@@ -229,3 +229,46 @@ class TestSanitizeLisp:
monkeypatch.delenv("DANGEROUS_COMMANDS_ENABLED", raising=False)
config.settings = Settings()
class TestSanitizerBypassVectors:
"""R8 — adversarial coverage: the deny-list must hold against common evasion
tricks. Each asserts the guard REJECTS the payload (defensive verification),
plus that legitimate input is NOT over-blocked."""
def test_command_newline_injected_second_command(self):
# A safe head command with a dangerous one smuggled after a newline.
with pytest.raises(ToolError, match="restricted command"):
sanitize_command("_ZOOM E\n_ERASE")
def test_command_case_insensitive(self):
with pytest.raises(ToolError, match="restricted command"):
sanitize_command("eRaSe all")
def test_command_legitimate_not_overblocked(self):
assert sanitize_command("_LINE 0,0 10,10") == "_LINE 0,0 10,10"
def test_lisp_symbol_aliasing_blocked(self):
# (setq f command) references `command` as a value to dodge head-only
# matching — the bare-token scan must still catch it.
with pytest.raises(ToolError, match="restricted function"):
sanitize_lisp("(setq f command)(f)")
def test_lisp_activex_family_blocked(self):
with pytest.raises(ToolError, match="restricted function"):
sanitize_lisp('(vlax-invoke obj "SendCommand" "x")')
def test_lisp_express_tools_shell_blocked(self):
with pytest.raises(ToolError, match="restricted function"):
sanitize_lisp('(acet-sys-shell "cmd")')
def test_lisp_custom_command_invocation_blocked(self):
with pytest.raises(ToolError, match="restricted function"):
sanitize_lisp("(c:mycmd)")
def test_lisp_dangerous_token_inside_wrapper_blocked(self):
with pytest.raises(ToolError, match="restricted function"):
sanitize_lisp('(startapp (strcat "c" "md"))')
def test_lisp_legitimate_not_overblocked(self):
assert sanitize_lisp("(setq x (* 2 3))") == "(setq x (* 2 3))"
+186
View File
@@ -0,0 +1,186 @@
"""Regression tests for the Sprint-2 audit fixes.
Covers, one block per audit finding:
* NEW-screenshot-1 — headless Agg render returns a valid PNG (no GUI-thread crash)
* I15 — drawing_finalize runs the premium critique as part of the gate
* R4 — dim_overlap critique actually fires (was a guaranteed no-op)
* N4 — dim / construction layers resolve per the active layer set
* NEW-AUTH-1 — the remote-bind guard runs on the run_async launch path
* NEW-gear-roottooth— gear outline never dips inside the root circle (high tooth counts)
* NEW-section-dupbore / NEW-validator-keyway / NEW-gear-baseconstr
"""
from __future__ import annotations
import math
import pytest
pytestmark = pytest.mark.asyncio
class _FakeCtx:
"""Minimal Context stand-in: drawing_finalize only needs lifespan_context + warning."""
def __init__(self, backend):
self.lifespan_context = {"backend": backend}
self.warnings: list[str] = []
async def warning(self, message):
self.warnings.append(message)
# ── NEW-screenshot-1 ────────────────────────────────────────────────────────
async def test_screenshot_renders_png_without_gui_thread_crash(backend):
"""view_screenshot runs in a worker thread; the Agg figure must not crash and
must return valid PNG bytes."""
await backend.entity_create_line(0, 0, 100, 100)
png = await backend.view_screenshot()
assert png is not None
assert png[:4] == b"\x89PNG" # PNG magic
# ── I15 — critique is part of the finalize gate ─────────────────────────────
async def test_finalize_runs_critique_and_blocks_on_construction(backend, tmp_path):
from fastmcp.exceptions import ToolError
import server
await backend.drawing_apply_iso_layers("mech")
await backend.entity_create_line(0, 0, 100, 0, layer="GEOMETRY")
await backend.construction_xline(0, 0, 90) # leftover scaffold -> construction_left ERROR
ctx = _FakeCtx(backend)
save = str(tmp_path / "part.dxf")
with pytest.raises(ToolError) as exc:
await server.drawing_finalize(save_path=save, ctx=ctx)
assert "critique" in str(exc.value).lower()
# Clear the scaffold; finalize now passes and the payload carries the critique.
await backend.construction_clear()
payload = await server.drawing_finalize(save_path=save, ctx=ctx)
assert payload["ok"] is True
assert "critique" in payload
assert payload["critique_summary"]["error"] == 0
# ── R4 — dim_overlap critique fires ─────────────────────────────────────────
async def test_dim_overlap_critique_is_not_a_noop(backend):
await backend.drawing_apply_iso_layers("mech")
# Two dimensions measuring the same segment overlap exactly.
await backend.dimension_linear(0, 0, 100, 0, 50, 20, layer="DIM")
await backend.dimension_linear(0, 0, 100, 0, 50, 20, layer="DIM")
issues = await backend.drawing_critique(focus=["dim_overlap"])
assert len(issues) >= 1
assert all(i.focus == "dim_overlap" for i in issues)
# ── N4 — dim / construction layers follow the active layer set ──────────────
async def test_iso13567_dim_and_construction_layers_resolve(backend):
await backend.drawing_apply_iso_layers("iso13567")
assert backend._active_layer_set_id() == "iso13567"
assert backend._role_layer("dim") == "M-DIMEN-T-N"
assert backend._role_layer("construction") == "M-CONST-E-N"
ln = await backend.entity_create_line(0, 0, 80, 0, layer="M-GEOMET-E-N")
dims = await backend.dimension_auto([ln.handle], style="chain")
assert dims[0].layer == "M-DIMEN-T-N"
xl = await backend.construction_xline(0, 0, 90)
assert xl.layer == "M-CONST-E-N"
# construction_left must catch iso13567 scaffold (layer name independent).
left = await backend.drawing_critique(focus=["construction_left"])
assert len(left) == 1 and left[0].severity == "error"
cleared = await backend.construction_clear()
assert cleared["deleted"] == 1
assert await backend.drawing_critique(focus=["construction_left"]) == []
# ── NEW-AUTH-1 — bind guard on the run_async launch path ────────────────────
async def test_run_async_invokes_bind_guard_for_http(monkeypatch):
import server
seen = {}
def fake_guard(host):
seen["host"] = host
raise SystemExit("blocked before bind")
monkeypatch.setattr(server, "_validate_http_bind", fake_guard)
with pytest.raises(SystemExit):
await server.mcp.run_async(transport="http", host="0.0.0.0")
assert seen["host"] == "0.0.0.0"
async def test_run_async_skips_guard_for_stdio(monkeypatch):
import server
seen = {}
monkeypatch.setattr(server, "_validate_http_bind",
lambda h: seen.setdefault("host", h))
async def fake_orig(*args, **kwargs):
return "stdio-ran"
monkeypatch.setattr(server, "_orig_run_async", fake_orig)
out = await server.mcp.run_async(transport="stdio")
assert out == "stdio-ran"
assert seen == {} # guard not invoked for stdio
# ── NEW-gear-roottooth — outline never dips inside the root circle ──────────
async def test_gear_outline_stays_outside_root_circle():
from engineering.gear import generate_full_gear_outline
for module, teeth, pa in [(2, 20, 20.0), (2, 50, 20.0), (3, 60, 20.0), (1, 6, 20.0)]:
pitch_r = module * teeth / 2.0
root_r = pitch_r - 1.25 * module
pts = generate_full_gear_outline(module, teeth, pa)
radii = [math.hypot(x, y) for x, y in pts]
# No point may sit inside the root circle (the self-overlap bug for z>=~42).
assert min(radii) >= root_r - 1e-6, f"m={module} z={teeth}: min_r {min(radii)} < root_r {root_r}"
# ── NEW-section-dupbore / NEW-validator-keyway / NEW-gear-baseconstr ─────────
async def test_gear_front_view_base_circle_persists_and_keyway_validates(backend, tmp_path):
from engineering.gear import draw_helical_gear_front_view
from engineering.validator import DrawingValidator
await backend.drawing_apply_iso_layers("mech")
res = await draw_helical_gear_front_view(
backend, module=2, teeth=20, helix_angle=0, center=(0, 0),
bore_diameter=20, keyway_width=6, keyway_depth=2.8,
)
# NEW-gear-baseconstr: base circle is a persistent PHANTOM reference, not CONSTRUCTION.
base = await backend.entity_get(res["base_circle"])
assert base.layer == "PHANTOM"
await backend.construction_clear()
assert await backend.entity_get(res["base_circle"]) # survives, handle still valid
# NEW-validator-keyway: the open 4-point keyway polyline is now recognised.
await backend.drawing_save_as(str(tmp_path / "gear.dxf"))
vr = await DrawingValidator().run(
backend, expected={"must_have_bore": True, "must_have_keyway": True},
)
assert "keyway_section_unverified" not in [f.code for f in vr.findings]
async def test_gear_section_does_not_duplicate_bore_lines(backend):
from engineering.gear import draw_gear_section_aa
await backend.drawing_apply_iso_layers("mech")
await draw_gear_section_aa(
backend,
gear_metadata={"outer_radius": 22, "center": [0, 0], "bore_diameter": 20,
"keyway_width": 6, "keyway_depth": 2.8},
x_offset=100, face_width=30,
)
dups = await backend.drawing_critique(focus=["duplicate_entities"])
assert dups == []
+107
View File
@@ -0,0 +1,107 @@
"""Regression tests for the CI-verifiable (ezdxf / shared) Sprint-3 fixes.
* N2 — drawing_save_as derives the on-disk format from the path extension
* N3 — ARC carries a `length` property; entity_select_smart length_range selects it
* N5 — properties parity: ezdxf sets bounding_box; MTEXT carries char_height + rotation
* NEW-dimauto-baseline-rotation — baseline dims run parallel to a rotated baseline
* NEW-dimauto-ordinate-misnomer — ordinate dims are staggered, not stacked at one offset
"""
from __future__ import annotations
import os
import tempfile
import pytest
pytestmark = pytest.mark.asyncio
class _Ctx:
def __init__(self, backend):
self.lifespan_context = {"backend": backend}
async def info(self, message):
pass
# ── N2 — format follows the file extension ──────────────────────────────────
async def test_save_as_derives_format_from_extension(backend):
import server
ctx = _Ctx(backend)
d = tempfile.mkdtemp()
res = await server.drawing_save_as(path=os.path.join(d, "part.dxf"), ctx=ctx)
assert res.get("format") == "dxf" # not the old "dwg" default
# ezdxf cannot write real DWG — must refuse rather than mislabel a .dwg file.
with pytest.raises(RuntimeError):
await server.drawing_save_as(path=os.path.join(d, "part.dwg"), ctx=ctx)
# ── N3 — ARC length + length_range selection ────────────────────────────────
async def test_arc_has_length_and_is_selectable(backend):
arc = await backend.entity_create_arc(0, 0, 10, 0, 90) # r=10, 90deg -> len ~15.708
info = await backend.entity_get(arc.handle)
assert info.properties.get("length") == pytest.approx(15.70796, rel=1e-4)
sel = await backend.entity_select_smart({"type": "ARC", "length_range": [15, 16]})
assert len(sel) == 1
# Out-of-range excludes it (was: all arcs silently rejected for lack of length).
assert await backend.entity_select_smart({"type": "ARC", "length_range": [1, 2]}) == []
# ── N5 — cross-backend property parity ──────────────────────────────────────
async def test_ezdxf_populates_bounding_box(backend):
line = await backend.entity_create_line(0, 0, 30, 40)
info = await backend.entity_get(line.handle)
bb = info.properties.get("bounding_box")
assert bb is not None and set(bb) == {"min", "max"} # same shape as COM
async def test_mtext_has_char_height_and_rotation(backend):
mt = await backend.entity_create_mtext("hello", 5, 5, height=2.5)
info = await backend.entity_get(mt.handle)
assert "char_height" in info.properties
assert "rotation" in info.properties
async def test_mtext_honors_caller_rotation(backend):
# NEW-mtext-1: entity_create_mtext now accepts a rotation that round-trips.
mt = await backend.entity_create_mtext("rot", 0, 0, width=50, height=2.5, rotation=30.0)
info = await backend.entity_get(mt.handle)
assert float(info.properties.get("rotation", 0.0)) == pytest.approx(30.0, abs=1e-6)
# ── NEW-dimauto-baseline-rotation ───────────────────────────────────────────
async def test_baseline_dim_runs_parallel_to_rotated_baseline(backend):
ln = await backend.entity_create_line(0, 0, 40, 40) # 45-degree baseline
dims = await backend.dimension_auto([ln.handle], style="baseline")
doc = backend._require_doc()
ent = doc.entitydb[dims[0].handle]
# Dimension line angle must match the baseline (45), not the old hardcoded 0.
assert ent.dxf.get("angle", 0.0) == pytest.approx(45.0, abs=1e-6)
assert ent.get_measurement() == pytest.approx(56.5685, rel=1e-3) # true len, not projection
# ── NEW-dimauto-ordinate-misnomer ───────────────────────────────────────────
async def test_ordinate_dims_are_staggered_not_overlapping(backend):
l1 = await backend.entity_create_line(0, 0, 20, 10)
l2 = await backend.entity_create_line(0, 0, 40, 30)
dims = await backend.dimension_auto([l1.handle, l2.handle], style="ordinate")
# Each segment emits an X and a Y dim; with per-feature staggering the
# X-dim reference Y-offsets differ between the two features.
doc = backend._require_doc()
y_offsets = []
for d in dims:
ent = doc.entitydb[d.handle]
dp = ent.dxf.get("defpoint", None)
if dp is not None:
y_offsets.append(round(float(dp[1]), 3))
# Not every dim collapses onto a single offset value.
assert len(set(y_offsets)) > 1
+223
View File
@@ -0,0 +1,223 @@
"""Sprint-4 regression tests for backends/base.py concrete helpers.
Covers:
- N7 — point_from_snap ARC end/mid raises a descriptive RuntimeError (not an
opaque KeyError) when start_angle/end_angle are absent.
- NEW-base-1 — point_tangent does not raise a raw ValueError ("math domain
error") for a from-point exactly on / marginally inside the perimeter band.
- NEW-snap-quad-arc / NEW-snap-near-arc — quad/near snaps on an ARC return a
point that lies on the arc sweep (not an off-sweep full-circle point).
- N8 — get_plan_spec() makes the stored PlanSpec readable.
"""
from __future__ import annotations
import math
import pytest
from backends.base import EntityInfo
pytestmark = pytest.mark.asyncio
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _on_arc(pt, cx, cy, r, sa_deg, ea_deg, tol=1e-6):
"""True if pt lies on the circle of radius r AND within the [sa, ea] sweep
(degrees, CCW, with wraparound)."""
if abs(math.hypot(pt[0] - cx, pt[1] - cy) - r) > tol:
return False
theta = math.degrees(math.atan2(pt[1] - cy, pt[0] - cx)) % 360.0
sa = sa_deg % 360.0
sweep = (ea_deg - sa_deg) % 360.0
rel = (theta - sa) % 360.0
return rel <= sweep + 1e-6
# ---------------------------------------------------------------------------
# N7 — ARC snap angle-missing raises RuntimeError, not KeyError
# ---------------------------------------------------------------------------
class TestN7ArcAngleMissing:
async def _patch_entity_get(self, backend, info: EntityInfo):
async def _fake(handle):
return info
backend.entity_get = _fake # type: ignore[method-assign]
async def test_end_snap_missing_angles_raises_runtimeerror(self, backend):
"""An ARC entity whose properties lack start_angle/end_angle must raise
a descriptive RuntimeError, not a KeyError."""
bad = EntityInfo(
handle="DEAD",
type="ARC",
layer="0",
color=256,
linetype="ByLayer",
visible=True,
properties={"center": [0.0, 0.0], "radius": 10.0}, # no angles
)
await self._patch_entity_get(backend, bad)
with pytest.raises(RuntimeError, match="start_angle/end_angle"):
await backend.point_from_snap("DEAD", "end")
async def test_mid_snap_missing_angles_raises_runtimeerror(self, backend):
bad = EntityInfo(
handle="DEAD",
type="ARC",
layer="0",
color=256,
linetype="ByLayer",
visible=True,
properties={"center": [0.0, 0.0], "radius": 10.0},
)
await self._patch_entity_get(backend, bad)
with pytest.raises(RuntimeError, match="start_angle/end_angle"):
await backend.point_from_snap("DEAD", "mid")
async def test_end_snap_missing_angles_not_keyerror(self, backend):
"""Explicitly assert the failure is NOT the old opaque KeyError."""
bad = EntityInfo(
handle="DEAD",
type="ARC",
layer="0",
color=256,
linetype="ByLayer",
visible=True,
properties={"center": [0.0, 0.0], "radius": 10.0},
)
await self._patch_entity_get(backend, bad)
with pytest.raises(Exception) as excinfo:
await backend.point_from_snap("DEAD", "end")
assert not isinstance(excinfo.value, KeyError)
assert isinstance(excinfo.value, RuntimeError)
async def test_real_arc_end_snap_still_works(self, backend):
"""Sanity: a well-formed ARC still resolves end-snap correctly."""
arc = await backend.entity_create_arc(0, 0, 10, 0, 90)
sp = await backend.point_from_snap(arc.handle, "end", ref_x=11, ref_y=0)
# start angle 0 -> (10, 0)
assert abs(sp[0] - 10.0) < 1e-6
assert abs(sp[1] - 0.0) < 1e-6
# ---------------------------------------------------------------------------
# NEW-base-1 — point_tangent on/near the perimeter must not raise ValueError
# ---------------------------------------------------------------------------
class TestNewBase1TangentClamp:
async def test_from_point_exactly_on_circle_no_valueerror(self, backend):
"""A from-point exactly on the perimeter (d == r) degenerates to the
tangent-at-that-point; -r/d == -1 must not blow up math.acos."""
circ = await backend.entity_create_circle(0, 0, 10)
# Point exactly on perimeter at (10, 0).
pt = await backend.point_tangent(circ.handle, 10.0, 0.0)
# Tangent point coincides with the from-point (on the circle).
assert abs(math.hypot(pt[0], pt[1]) - 10.0) < 1e-6
assert abs(pt[0] - 10.0) < 1e-6
assert abs(pt[1] - 0.0) < 1e-6
async def test_from_point_in_narrow_inside_band_no_valueerror(self, backend):
"""A from-point a hair inside the perimeter (within 1e-9 of r, so past
the d < r-1e-9 guard) used to raise a raw 'math domain error'
ValueError. After clamping it must degenerate without ValueError."""
circ = await backend.entity_create_circle(0, 0, 10)
# d = 10 - 5e-10 -> inside r but NOT below r-1e-9, so the guard passes
# and -r/d is marginally < -1.
fx = 10.0 - 5e-10
try:
pt = await backend.point_tangent(circ.handle, fx, 0.0)
except ValueError as exc: # pragma: no cover - this is the bug we fixed
pytest.fail(f"point_tangent raised ValueError (NEW-base-1): {exc}")
# Result still lies on the circle.
assert abs(math.hypot(pt[0], pt[1]) - 10.0) < 1e-4
async def test_genuine_internal_point_still_raises_runtimeerror(self, backend):
"""The internal-point guard (d < r) must still raise RuntimeError."""
circ = await backend.entity_create_circle(0, 0, 10)
with pytest.raises(RuntimeError, match="inside"):
await backend.point_tangent(circ.handle, 1.0, 1.0)
# ---------------------------------------------------------------------------
# NEW-snap-quad-arc / NEW-snap-near-arc — snaps stay on the arc sweep
# ---------------------------------------------------------------------------
class TestNewSnapArcSweep:
async def test_quad_on_quarter_arc_stays_on_sweep(self, backend):
"""Quarter arc 0..90 deg, r=10 at origin. Only the 0-deg and 90-deg
quadrants lie on the sweep; quad snap must return one of those, never
the 180/270 quadrant points that are off the arc."""
arc = await backend.entity_create_arc(0, 0, 10, 0, 90)
# ref near the (10,0) quadrant
q = await backend.point_from_snap(arc.handle, "quad", ref_x=12, ref_y=0)
assert _on_arc(q, 0, 0, 10, 0, 90), f"quad point {q} off sweep"
# ref near the (0,10) quadrant
q2 = await backend.point_from_snap(arc.handle, "quad", ref_x=0, ref_y=12)
assert _on_arc(q2, 0, 0, 10, 0, 90), f"quad point {q2} off sweep"
async def test_quad_ref_toward_offsweep_quadrant_falls_back_to_sweep(self, backend):
"""Even when ref points toward the (-10,0) quadrant (off the 0..90
sweep), the returned quad point must remain on the sweep."""
arc = await backend.entity_create_arc(0, 0, 10, 0, 90)
q = await backend.point_from_snap(arc.handle, "quad", ref_x=-50, ref_y=-50)
assert _on_arc(q, 0, 0, 10, 0, 90), f"quad point {q} off sweep"
async def test_quad_no_quadrant_on_sweep_falls_back_to_endpoint(self, backend):
"""A narrow arc (10..80 deg) contains no axis quadrant; quad must fall
back to an arc endpoint, which is on the sweep."""
arc = await backend.entity_create_arc(0, 0, 10, 10, 80)
q = await backend.point_from_snap(arc.handle, "quad", ref_x=20, ref_y=0)
assert _on_arc(q, 0, 0, 10, 10, 80), f"quad point {q} off sweep"
async def test_near_within_sweep_projects_onto_arc(self, backend):
"""A ref inside the 0..90 sweep direction projects onto the arc."""
arc = await backend.entity_create_arc(0, 0, 10, 0, 90)
# 45-degree direction is inside the sweep.
n = await backend.point_from_snap(arc.handle, "near", ref_x=5, ref_y=5)
assert _on_arc(n, 0, 0, 10, 0, 90), f"near point {n} off sweep"
# Should be ~ (10/sqrt2, 10/sqrt2)
assert abs(n[0] - 10 / math.sqrt(2)) < 1e-6
assert abs(n[1] - 10 / math.sqrt(2)) < 1e-6
async def test_near_outside_sweep_clamps_to_endpoint(self, backend):
"""A ref in the (-x,-y) direction is outside the 0..90 sweep; near must
clamp to the nearer arc endpoint (on the sweep), not return an off-sweep
full-circle projection."""
arc = await backend.entity_create_arc(0, 0, 10, 0, 90)
# Direction ~ -27deg (just below the x-axis) is off the 0..90 sweep.
# Of the two endpoints (10,0) and (0,10), the start (10,0) is clearly
# nearer to (10, -5), so near must clamp there.
n = await backend.point_from_snap(arc.handle, "near", ref_x=10, ref_y=-5)
assert _on_arc(n, 0, 0, 10, 0, 90), f"near point {n} off sweep"
# Endpoints are (10,0) [start] and (0,10) [end]; (10,0) is nearer.
assert abs(n[0] - 10.0) < 1e-6
assert abs(n[1] - 0.0) < 1e-6
async def test_circle_quad_unchanged(self, backend):
"""CIRCLE quad behaviour is preserved (all four quadrants available)."""
circ = await backend.entity_create_circle(0, 0, 10)
q = await backend.point_from_snap(circ.handle, "quad", ref_x=-12, ref_y=0)
assert abs(q[0] - (-10.0)) < 1e-6
assert abs(q[1] - 0.0) < 1e-6
# ---------------------------------------------------------------------------
# N8 — get_plan_spec accessor exposes the stored PlanSpec
# ---------------------------------------------------------------------------
class TestN8GetPlanSpec:
async def test_none_before_plan(self, backend):
assert backend.get_plan_spec() is None
async def test_returns_dict_after_plan(self, backend):
await backend.drawing_plan("test shaft", sheet_size="A3", scale=2.0)
spec = backend.get_plan_spec()
assert isinstance(spec, dict)
assert spec["intent"] == "test shaft"
assert spec["sheet_size"] == "A3"
assert spec["scale"] == 2.0
# Returned object is a plain dict snapshot (not the live dataclass).
assert "layer_set_id" in spec
+180
View File
@@ -0,0 +1,180 @@
"""Mocked-COM regression tests for the Sprint-4 COM fixes.
These run without a live AutoCAD (and OS-agnostically) by:
* patching the module-level ``_acad_app`` / ``_acad_doc`` with MagicMocks, and
* forcing ``_WIN32_AVAILABLE`` True so the win32-guarded paths execute
(no real win32 calls are made — every win32 attribute is mocked).
* bypassing the STA executor (replace ``_run`` with a direct caller).
Covers: R28 (_find_autocad_hwnd prefers Application.HWND over EnumWindows),
S3 (block_list populates BlockInfo.description from the definition .Comments).
"""
from __future__ import annotations
from unittest.mock import MagicMock
import pytest
import backends.com_backend as cb
from backends.com_backend import ComBackend
pytestmark = pytest.mark.asyncio
def _backend_no_executor() -> ComBackend:
b = ComBackend()
async def _run(func, *args, **kwargs): # bypass the ThreadPoolExecutor
return func(*args, **kwargs)
b._run = _run
return b
# ── R28 — _find_autocad_hwnd prefers Application.HWND over EnumWindows ───────
async def test_find_hwnd_prefers_application_hwnd(monkeypatch):
"""The real main-frame handle from the COM app wins; EnumWindows is not
consulted when the HWND read succeeds."""
app = MagicMock()
app.HWND = 0xABCD # known int handle
monkeypatch.setattr(cb, "_acad_app", lambda: app)
monkeypatch.setattr(cb, "_WIN32_AVAILABLE", True)
enum = MagicMock(side_effect=AssertionError("EnumWindows must not be called"))
monkeypatch.setattr(cb, "win32gui", MagicMock(EnumWindows=enum))
assert cb._find_autocad_hwnd() == 0xABCD
enum.assert_not_called()
async def test_find_hwnd_coerces_hwnd_to_int(monkeypatch):
"""A COM HWND that arrives as a numeric string is coerced to int."""
app = MagicMock()
app.HWND = "43981" # COM can hand back a stringy handle
monkeypatch.setattr(cb, "_acad_app", lambda: app)
monkeypatch.setattr(cb, "_WIN32_AVAILABLE", True)
monkeypatch.setattr(cb, "win32gui", MagicMock())
result = cb._find_autocad_hwnd()
assert result == 43981
assert isinstance(result, int)
async def test_find_hwnd_falls_back_to_enumwindows(monkeypatch):
"""When the HWND read raises, the EnumWindows title scan is used as a
fallback and its discovered handle is returned."""
app = MagicMock()
type(app).HWND = property( # reading .HWND raises
lambda self: (_ for _ in ()).throw(RuntimeError("no HWND"))
)
monkeypatch.setattr(cb, "_acad_app", lambda: app)
monkeypatch.setattr(cb, "_WIN32_AVAILABLE", True)
def _enum_windows(cb_fn, _extra):
# Simulate one visible AutoCAD top-level window.
cb_fn(0x1234, None)
win32gui = MagicMock()
win32gui.EnumWindows.side_effect = _enum_windows
win32gui.GetWindowText.return_value = "AutoCAD 2025 - [Drawing1.dwg]"
win32gui.IsWindowVisible.return_value = True
monkeypatch.setattr(cb, "win32gui", win32gui)
assert cb._find_autocad_hwnd() == 0x1234
win32gui.EnumWindows.assert_called_once()
async def test_find_hwnd_returns_none_when_win32_unavailable(monkeypatch):
"""Short-circuits to None on non-Windows without touching the COM app."""
sentinel = MagicMock(side_effect=AssertionError("_acad_app must not be called"))
monkeypatch.setattr(cb, "_acad_app", sentinel)
monkeypatch.setattr(cb, "_WIN32_AVAILABLE", False)
assert cb._find_autocad_hwnd() is None
sentinel.assert_not_called()
# ── S3 — block_list populates BlockInfo.description from .Comments ───────────
class _Block:
"""Minimal stand-in for an AutoCAD block definition."""
def __init__(self, name, comments="", count=0, is_xref=False, raise_comments=False):
self.Name = name
self._comments = comments
self.Count = count
self.IsXRef = is_xref
self.Origin = (0.0, 0.0, 0.0)
self._raise_comments = raise_comments
@property
def Comments(self):
if self._raise_comments:
raise RuntimeError("Comments not supported")
return self._comments
def Item(self, _j): # no attribute definitions in these fixtures
return MagicMock(ObjectName="AcDbEntity")
class _Blocks:
def __init__(self, items):
self._items = items
self.Count = len(items)
def Item(self, i):
return self._items[i]
def _doc_with_blocks(items):
doc = MagicMock()
doc.Blocks = _Blocks(items)
return doc
async def test_block_list_populates_description_from_comments(monkeypatch):
doc = _doc_with_blocks([_Block("BOLT", comments="M8 hex bolt")])
monkeypatch.setattr(cb, "_acad_doc", lambda: doc)
b = _backend_no_executor()
blocks = await b.block_list()
assert len(blocks) == 1
assert blocks[0].name == "BOLT"
assert blocks[0].description == "M8 hex bolt"
async def test_block_list_description_defaults_empty_when_comments_raise(monkeypatch):
doc = _doc_with_blocks([_Block("NUT", raise_comments=True)])
monkeypatch.setattr(cb, "_acad_doc", lambda: doc)
b = _backend_no_executor()
blocks = await b.block_list()
assert blocks[0].description == "" # try/except default, not a crash
async def test_block_list_description_empty_when_comments_none(monkeypatch):
doc = _doc_with_blocks([_Block("WASHER", comments=None)])
monkeypatch.setattr(cb, "_acad_doc", lambda: doc)
b = _backend_no_executor()
blocks = await b.block_list()
assert blocks[0].description == "" # `Comments or ""` normalises None
async def test_block_list_skips_anonymous_layout_blocks(monkeypatch):
doc = _doc_with_blocks([
_Block("*Model_Space", comments="ignored"),
_Block("GEAR", comments="spur gear"),
])
monkeypatch.setattr(cb, "_acad_doc", lambda: doc)
b = _backend_no_executor()
blocks = await b.block_list()
assert [blk.name for blk in blocks] == ["GEAR"]
assert blocks[0].description == "spur gear"
+136
View File
@@ -0,0 +1,136 @@
"""Regression tests for the Sprint-4 ezdxf-backend fixes.
* R24 — _apply_attrs loads CENTER/HIDDEN on demand so entity_create_line(
linetype=...) actually renders the dashed linetype instead of
silently falling back to Continuous.
* S3 — block_list surfaces the block definition's description instead of "".
* R26/N9 — view_zoom_extents / view_zoom_window return a consistent
{"ok": True, "applied": False, ...} shape (no fake framing success).
* NEW-undo-1 — drawing_undo / transaction_* serialize _undo_stack access
(no read/mutate outside self._lock); behaviour still correct.
All tests are backend-agnostic ezdxf and use the shared `backend` fixture.
"""
from __future__ import annotations
import pytest
pytestmark = pytest.mark.asyncio
# ── R24 — CENTER/HIDDEN linetype actually loaded + applied ──────────────────
async def test_create_line_loads_center_linetype(backend):
# Fresh doc: CENTER is not loaded yet.
assert "CENTER" not in backend._doc.linetypes
line = await backend.entity_create_line(0, 0, 100, 0, linetype="CENTER")
# The linetype must now be defined in the document (a real dashed pattern),
# not silently dropped — otherwise it renders as Continuous.
assert "CENTER" in backend._doc.linetypes
# And it must be assigned to the entity.
info = await backend.entity_get(line.handle)
assert info.linetype == "CENTER"
async def test_create_line_loads_hidden_linetype(backend):
assert "HIDDEN" not in backend._doc.linetypes
line = await backend.entity_create_line(0, 0, 50, 50, linetype="HIDDEN")
assert "HIDDEN" in backend._doc.linetypes
info = await backend.entity_get(line.handle)
assert info.linetype == "HIDDEN"
async def test_create_line_continuous_does_not_error(backend):
# Built-in linetypes are a no-op for the loader — must not raise.
line = await backend.entity_create_line(0, 0, 10, 0, linetype="Continuous")
info = await backend.entity_get(line.handle)
assert info.linetype == "Continuous"
# ── S3 — block description surfaced from the definition ─────────────────────
async def test_block_list_surfaces_description(backend):
# Create a block from an entity, then stamp a description on its definition.
seg = await backend.entity_create_line(0, 0, 10, 0)
await backend.block_create_from_entities("WIDGET", [seg.handle], 0.0, 0.0)
backend._doc.blocks.get("WIDGET").block.dxf.description = "Mechanical widget"
blocks = await backend.block_list()
widget = next(b for b in blocks if b.name == "WIDGET")
assert widget.description == "Mechanical widget"
async def test_block_list_description_defaults_empty(backend):
# A block with no description must still surface "" (no crash, default).
seg = await backend.entity_create_line(0, 0, 5, 0)
await backend.block_create_from_entities("PLAIN", [seg.handle], 0.0, 0.0)
blocks = await backend.block_list()
plain = next(b for b in blocks if b.name == "PLAIN")
assert plain.description == ""
# ── R26 / N9 — honest zoom payload shape ────────────────────────────────────
async def test_zoom_extents_reports_not_applied(backend):
res = await backend.view_zoom_extents()
assert res["ok"] is True
assert res["applied"] is False
assert "message" in res
async def test_zoom_window_reports_not_applied(backend):
res = await backend.view_zoom_window(0, 0, 100, 100)
assert res["ok"] is True
assert res["applied"] is False
assert "message" in res
# ── NEW-undo-1 — undo/transaction stack behaviour under lock ────────────────
async def test_undo_empty_stack_returns_error(backend):
res = await backend.drawing_undo()
assert res["ok"] is False
assert "undo" in res["error"].lower()
async def test_commit_without_transaction_returns_error(backend):
res = await backend.transaction_commit()
assert res["ok"] is False
assert "transaction" in res["error"].lower()
async def test_rollback_without_transaction_returns_error(backend):
res = await backend.transaction_rollback()
assert res["ok"] is False
assert "transaction" in res["error"].lower()
async def test_transaction_begin_commit_roundtrip(backend):
begin = await backend.transaction_begin()
assert begin["ok"] is True
assert len(backend._undo_stack) == 1
commit = await backend.transaction_commit()
assert commit["ok"] is True
assert len(backend._undo_stack) == 0
async def test_transaction_rollback_restores_snapshot(backend):
# Snapshot an empty doc, add an entity, then roll back to the snapshot.
await backend.transaction_begin()
line = await backend.entity_create_line(0, 0, 10, 0)
before = await backend.entity_get(line.handle)
assert before.handle # entity exists
res = await backend.transaction_rollback()
assert res["ok"] is True
assert len(backend._undo_stack) == 0
# The line is gone after rollback.
listed = await backend.entity_list(type_filter="LINE")
assert listed == []
+143
View File
@@ -0,0 +1,143 @@
"""Regression tests for the Sprint-4 server.py fixes.
* R20 — _registered_tool_count() uses the public FastMCP accessor
(mcp._list_tools) and returns a real positive count, never -1/None.
* R15 — system_about's tool_groups is derived dynamically from each tool's
tags: it now includes the engineering/premium groups and no longer
misfiles entity_delete_many under entity_creation.
* N8 — drawing_plan docstring no longer claims the PlanSpec is "replayed by
drawing_critique".
These tests drive server.py's underlying async functions directly with a
minimal fake ctx (lifespan_context + async info/warning), the same pattern the
other sprint test files use. They are ezdxf-backend-agnostic.
"""
from __future__ import annotations
import pytest
pytestmark = pytest.mark.asyncio
class _Ctx:
"""Minimal async context mimicking fastmcp.Context for direct tool calls."""
def __init__(self, backend):
self.lifespan_context = {"backend": backend}
async def info(self, message):
pass
async def warning(self, message):
pass
# ── R20 — tool count comes from the public accessor and is a real number ─────
async def test_registered_tool_count_is_positive_never_minus_one():
import server
count = await server._registered_tool_count()
assert count is not None # never the old sentinel state
assert isinstance(count, int)
assert count != -1 # the old silent-failure value must never surface
assert count > 50 # the server registers dozens of tools
async def test_registered_tools_returns_objects_with_name_and_tags():
import server
tools = await server._registered_tools()
assert len(tools) > 50
# Every entry is a real tool object usable for tag-based grouping.
for t in tools:
assert getattr(t, "name", None)
# tags is a set (possibly empty) on FunctionTool
assert hasattr(t, "tags")
# ── R15 — system_about tool_groups is dynamic + correct ──────────────────────
async def test_system_about_includes_engineering_group(backend):
import server
about = await server.system_about(ctx=_Ctx(backend))
groups = about["tool_groups"]
# An engineering tool must appear somewhere in the breakdown (the old
# static dict omitted all engineering tools entirely).
all_listed = {name for names in groups.values() for name in names}
assert "gear_draw_section_aa" in all_listed
# The engineering group itself is present and non-empty.
assert groups.get("engineering")
assert "gear_draw_section_aa" in groups["engineering"]
async def test_system_about_includes_premium_group(backend):
import server
about = await server.system_about(ctx=_Ctx(backend))
groups = about["tool_groups"]
all_listed = {name for names in groups.values() for name in names}
# Premium meta-tools (e.g. drawing_plan) used to be missing from the dict.
assert "drawing_plan" in all_listed
assert groups.get("premium")
async def test_entity_delete_many_not_under_entity_creation(backend):
import server
about = await server.system_about(ctx=_Ctx(backend))
groups = about["tool_groups"]
# The bug: entity_delete_many was hardcoded under entity_creation.
assert "entity_delete_many" not in groups.get("entity_creation", [])
# It is tagged {"entity", "modify"} so it belongs with modification.
assert "entity_delete_many" in groups.get("entity_modification", [])
async def test_system_about_total_tools_matches_count(backend):
import server
about = await server.system_about(ctx=_Ctx(backend))
count = await server._registered_tool_count()
# total_tools is reported dynamically (never hardcoded) and agrees with
# the count helper.
assert about.get("total_tools") == count
assert about["total_tools"] > 50
# The sum of all grouped tools equals the total (every tool buckets once).
grouped = sum(len(v) for v in about["tool_groups"].values())
assert grouped == about["total_tools"]
async def test_drawing_close_appears_in_groups(backend):
import server
about = await server.system_about(ctx=_Ctx(backend))
all_listed = {name for names in about["tool_groups"].values() for name in names}
# drawing_close was omitted by the old static dict.
assert "drawing_close" in all_listed
# ── R20 — system_status reports a real tool_count, never -1 ──────────────────
async def test_system_status_reports_real_tool_count(backend):
import server
status = await server.system_status(ctx=_Ctx(backend))
# tool_count is present and a real positive number (omitted only if unknown).
assert status.get("tool_count") is not None
assert status["tool_count"] != -1
assert status["tool_count"] > 50
# ── N8 — drawing_plan docstring corrected ────────────────────────────────────
async def test_drawing_plan_docstring_not_misleading():
import server
doc = server.drawing_plan.__doc__ or ""
# The corrected docstring must not claim the plan is replayed as a critique.
assert "replayed by `drawing_critique`" not in doc
assert "stored on the backend" in doc
+128
View File
@@ -0,0 +1,128 @@
"""Sprint-4 R27 regression tests for engineering.validator step 5.
R27: title/helix consistency must match SPUR / HELICAL as whole words (not
substrings) and scope the scan to title-block text where possible.
"""
import pytest
from engineering.layers import ensure_engineering_layers, ensure_standard_linetypes
from engineering.validator import DrawingValidator
pytestmark = pytest.mark.asyncio
def _codes(result) -> set[str]:
return {f.code for f in result.findings}
async def _ensure_hidden_linetype(backend) -> None:
"""ezdxf doesn't ship a HIDDEN pattern; add a synthetic one."""
doc = backend._require_doc()
if "HIDDEN" not in doc.linetypes:
doc.linetypes.add(
"HIDDEN",
pattern=[6.35, 5.0, -1.27],
description="Hidden _ _ _ _ _ _",
)
async def _bootstrap(backend):
await ensure_standard_linetypes(backend)
await _ensure_hidden_linetype(backend)
await ensure_engineering_layers(backend)
async def test_spur_as_substring_does_not_false_match(backend, tmp_path):
"""R27: 'SPURIOUS' contains 'spur' but must NOT trip a SPUR mismatch."""
await _bootstrap(backend)
await backend.entity_create_text(
text="SPURIOUS HELICAL GEAR", x=0, y=0, height=5, layer="TEXT",
)
await backend.drawing_save_as(str(tmp_path / "spurious.dxf"))
result = await DrawingValidator().run(backend, expected={"helix_angle": 15})
codes = _codes(result)
assert "title_says_spur_for_helical" not in codes
assert "title_helical_spur" not in codes
async def test_genuine_spur_title_flags_when_helical_expected(backend, tmp_path):
"""R27: a real 'SPUR GEAR' title still flags the mismatch when helix declared."""
await _bootstrap(backend)
await backend.entity_create_text(
text="SPUR GEAR", x=0, y=0, height=5, layer="TEXT",
)
await backend.drawing_save_as(str(tmp_path / "spur.dxf"))
result = await DrawingValidator().run(backend, expected={"helix_angle": 15})
codes = _codes(result)
assert "title_says_spur_for_helical" in codes
assert result.ok is False
async def test_spur_word_in_titleblock_layer_flags(backend, tmp_path):
"""R27: title-block-scoped scan — SPUR on TITLEBLOCK layer still flags."""
await _bootstrap(backend)
await backend.entity_create_text(
text="SPUR GEAR", x=0, y=0, height=5, layer="TITLEBLOCK",
)
await backend.drawing_save_as(str(tmp_path / "spur_tb.dxf"))
result = await DrawingValidator().run(backend, expected={"helix_angle": 15})
assert "title_says_spur_for_helical" in _codes(result)
async def test_substring_in_titleblock_layer_does_not_false_match(backend, tmp_path):
"""R27: 'SPURIOUS' on TITLEBLOCK layer must not trip a SPUR match either."""
await _bootstrap(backend)
await backend.entity_create_text(
text="SPURIOUS NOTE", x=0, y=0, height=5, layer="TITLEBLOCK",
)
await backend.drawing_save_as(str(tmp_path / "spurious_tb.dxf"))
result = await DrawingValidator().run(backend, expected={"helix_angle": 15})
codes = _codes(result)
assert "title_says_spur_for_helical" not in codes
assert "title_helical_spur" not in codes
async def test_helical_spur_combo_still_flags_invalid(backend, tmp_path):
"""R27: both whole words present -> invalid combo error preserved."""
await _bootstrap(backend)
await backend.entity_create_text(
text="HELICAL SPUR GEAR", x=0, y=0, height=5, layer="TEXT",
)
await backend.drawing_save_as(str(tmp_path / "combo.dxf"))
result = await DrawingValidator().run(backend, expected={"helix_angle": 15})
assert "title_helical_spur" in _codes(result)
assert result.ok is False
async def test_no_helix_expected_skips_title_check(backend, tmp_path):
"""Gating preserved: without helix_angle, a SPUR title raises no title finding."""
await _bootstrap(backend)
await backend.entity_create_text(
text="SPUR GEAR", x=0, y=0, height=5, layer="TEXT",
)
await backend.drawing_save_as(str(tmp_path / "nohelix.dxf"))
result = await DrawingValidator().run(backend)
codes = _codes(result)
assert "title_says_spur_for_helical" not in codes
assert "title_helical_spur" not in codes
async def test_titleblock_layer_takes_precedence_over_text_layer(backend, tmp_path):
"""R27 scoping: when title-block text exists, the TEXT-layer scan is not used.
SPUR sits on the generic TEXT layer (a body note), while the title block on
TITLEBLOCK is clean — the scoped scan should ignore the body note.
"""
await _bootstrap(backend)
await backend.entity_create_text(
text="HELICAL GEAR", x=0, y=0, height=5, layer="TITLEBLOCK",
)
await backend.entity_create_text(
text="SPUR TOOTH DETAIL NOTE", x=0, y=-20, height=3.5, layer="TEXT",
)
await backend.drawing_save_as(str(tmp_path / "scope.dxf"))
result = await DrawingValidator().run(backend, expected={"helix_angle": 15})
codes = _codes(result)
assert "title_says_spur_for_helical" not in codes
assert "title_helical_spur" not in codes