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:
@@ -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),
|
||||
|
||||
@@ -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()
|
||||
@@ -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))"
|
||||
|
||||
@@ -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 == []
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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 == []
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user