diff --git a/backends/__init__.py b/backends/__init__.py index 4779698..d87b99a 100644 --- a/backends/__init__.py +++ b/backends/__init__.py @@ -1,4 +1,5 @@ """AutoCAD MCP Pro – backend package.""" + from .base import AutoCADBackend, BlockInfo, DrawingInfo, EntityInfo, LayerInfo from .com_backend import ComBackend from .ezdxf_backend import EzdxfBackend diff --git a/benchmarks/adapters/autocad_mcp_pro.py b/benchmarks/adapters/autocad_mcp_pro.py index 149a7ed..8df9162 100644 --- a/benchmarks/adapters/autocad_mcp_pro.py +++ b/benchmarks/adapters/autocad_mcp_pro.py @@ -90,10 +90,11 @@ class AutoCADMCPProAdapter(BenchmarkAdapter): table = await self.backend.entity_create_table( 0, 20, [["A", "1"]], headers=["ITEM", "QTY"], layer="TEXT" ) - leader = await self.backend.leader_create_mleader( - [[0, 0], [10, 10]], "NOTE", layer="DIM" - ) - representations = [table.properties.get("representation"), leader.properties.get("representation")] + leader = await self.backend.leader_create_mleader([[0, 0], [10, 10]], "NOTE", layer="DIM") + representations = [ + table.properties.get("representation"), + leader.properties.get("representation"), + ] return all(representations), {"representations": representations}, [] async def _task_transactions(self): diff --git a/benchmarks/compare_versions.py b/benchmarks/compare_versions.py index 781520e..2798700 100644 --- a/benchmarks/compare_versions.py +++ b/benchmarks/compare_versions.py @@ -11,6 +11,7 @@ recorded as a miss instead of taking down the whole run. The older ref is checked out into a throwaway git worktree, benchmarked, and removed. The "new" side is always the current repository checkout. """ + from __future__ import annotations import json @@ -27,8 +28,7 @@ PY = sys.executable def _run(cmd, cwd, repo_on_path): env = {**os.environ, "PYTHONPATH": repo_on_path} - return subprocess.run(cmd, cwd=cwd, env=env, capture_output=True, - text=True, timeout=120) + return subprocess.run(cmd, cwd=cwd, env=env, capture_output=True, text=True, timeout=120) def list_checks(): @@ -43,9 +43,14 @@ def list_checks(): def run_one(repo, suite, name): try: - r = subprocess.run([PY, suite, name], cwd=repo, - env={**os.environ, "PYTHONPATH": repo}, - capture_output=True, text=True, timeout=120) + r = subprocess.run( + [PY, suite, name], + cwd=repo, + env={**os.environ, "PYTHONPATH": repo}, + capture_output=True, + text=True, + timeout=120, + ) except subprocess.TimeoutExpired: return "timeout" last = (r.stdout.strip().splitlines() or [""])[-1].strip() @@ -82,22 +87,30 @@ def main(): op = sum(r["old"] == "pass" for r in rows) npass = sum(r["new"] == "pass" for r in rows) summary = { - "baseline_ref": ref, "total": total, - "old_pass": op, "new_pass": npass, - "old_pct": round(100 * op / total, 1), "new_pct": round(100 * npass / total, 1), + "baseline_ref": ref, + "total": total, + "old_pass": op, + "new_pass": npass, + "old_pct": round(100 * op / total, 1), + "new_pct": round(100 * npass / total, 1), "fixed": sum(r["old"] != "pass" and r["new"] == "pass" for r in rows), "regressed": sum(r["old"] == "pass" and r["new"] != "pass" for r in rows), } print(f"{'CHECK':<32} {'CATEGORY':<13} {'baseline':<8} {'current':<8}") print("-" * 66) for r in rows: - mark = " ->FIXED" if (r["old"] != "pass" and r["new"] == "pass") else ( - " !!REGRESS" if (r["old"] == "pass" and r["new"] != "pass") else "") + mark = ( + " ->FIXED" + if (r["old"] != "pass" and r["new"] == "pass") + else (" !!REGRESS" if (r["old"] == "pass" and r["new"] != "pass") else "") + ) print(f"{r['check']:<32} {r['category']:<13} {r['old']:<8} {r['new']:<8}{mark}") print("-" * 66) - print(f"baseline {ref}: {op}/{total} ({summary['old_pct']}%) " - f"current: {npass}/{total} ({summary['new_pct']}%) " - f"fixed: {summary['fixed']} regressed: {summary['regressed']}") + print( + f"baseline {ref}: {op}/{total} ({summary['old_pct']}%) " + f"current: {npass}/{total} ({summary['new_pct']}%) " + f"fixed: {summary['fixed']} regressed: {summary['regressed']}" + ) if json_out: with open(json_out, "w") as fh: json.dump({"summary": summary, "rows": rows}, fh, indent=2) diff --git a/benchmarks/correctness_suite.py b/benchmarks/correctness_suite.py index 3339b02..2e74b98 100644 --- a/benchmarks/correctness_suite.py +++ b/benchmarks/correctness_suite.py @@ -12,6 +12,7 @@ The same file is executed against both the baseline worktree and the current checkout (PYTHONPATH + cwd point at each repo), so a check that exercises a method the old version lacks simply raises AttributeError -> miss. """ + from __future__ import annotations import asyncio @@ -23,6 +24,7 @@ import tempfile async def _b(): from backends.ezdxf_backend import EzdxfBackend + be = EzdxfBackend() await be.connect() await be.drawing_new() @@ -31,53 +33,62 @@ async def _b(): # ── Core capability (expected to pass on BOTH versions — sanity / not rigged) ── + async def core_line_length(): b = await _b() ln = await b.entity_create_line(0, 0, 3, 4) info = await b.entity_get(ln.handle) return abs(float(info.properties["length"]) - 5.0) < 1e-6 + async def core_circle_radius(): b = await _b() c = await b.entity_create_circle(0, 0, 10) info = await b.entity_get(c.handle) return abs(float(info.properties["radius"]) - 10.0) < 1e-6 + async def core_layer_create(): b = await _b() await b.layer_create("BENCH", color=1) names = {lyr.name for lyr in await b.layer_list()} return "BENCH" in names + async def core_polyline_closed(): b = await _b() p = await b.entity_create_polyline([[0, 0], [10, 0], [10, 10]], closed=True) info = await b.entity_get(p.handle) return bool(info.properties.get("closed")) + async def core_linear_dim(): b = await _b() d = await b.dimension_linear(0, 0, 100, 0, 50, 20) info = await b.entity_get(d.handle) return "DIM" in info.type.upper() + async def core_save_dxf_roundtrip(): b = await _b() await b.entity_create_line(0, 0, 10, 10) path = os.path.join(tempfile.mkdtemp(), "rt.dxf") await b.drawing_save_as(path, "dxf") import ezdxf + ezdxf.readfile(path) # raises if not a valid DXF return os.path.getsize(path) > 0 # ── Dimensions (I16) ────────────────────────────────────────────────────────── + async def dim_aligned_no_error(): b = await _b() d = await b.dimension_aligned(0, 0, 50, 50, 30, 70) return d is not None + async def dim_angular_no_error(): b = await _b() d = await b.dimension_angular(0, 0, 100, 0, 0, 100, 60, 60) @@ -86,6 +97,7 @@ async def dim_angular_no_error(): # ── Polar array full circle (R9) ────────────────────────────────────────────── + async def array_polar_360_distinct(): b = await _b() c = await b.entity_create_circle(10, 0, 1) @@ -101,6 +113,7 @@ async def array_polar_360_distinct(): # ── Deterministic geometry (I9) ──────────────────────────────────────────────── + async def point_intersection_line_line(): b = await _b() l1 = await b.entity_create_line(0, 0, 10, 0) @@ -108,6 +121,7 @@ async def point_intersection_line_line(): pt = await b.point_intersection(l1.handle, l2.handle) return abs(pt[0] - 5.0) < 1e-6 and abs(pt[1] - 0.0) < 1e-6 + async def point_tangent_external(): b = await _b() c = await b.entity_create_circle(0, 0, 3) @@ -120,18 +134,21 @@ async def point_tangent_external(): # ── Selection / property parity (N3, N5) ─────────────────────────────────────── + async def arc_has_length(): b = await _b() a = await b.entity_create_arc(0, 0, 10, 0, 90) info = await b.entity_get(a.handle) return abs(float(info.properties["length"]) - (10 * math.pi / 2)) < 1e-3 + async def arc_select_by_length(): b = await _b() await b.entity_create_arc(0, 0, 10, 0, 90) # length ~15.7 sel = await b.entity_select_smart({"type": "ARC", "length_range": [15, 16]}) return len(sel) == 1 + async def ezdxf_bounding_box(): b = await _b() ln = await b.entity_create_line(0, 0, 30, 40) @@ -139,6 +156,7 @@ async def ezdxf_bounding_box(): bb = info.properties.get("bounding_box") return isinstance(bb, dict) and set(bb) == {"min", "max"} + async def mtext_rotation_roundtrip(): b = await _b() mt = await b.entity_create_mtext("r", 0, 0, width=50, height=2.5, rotation=30.0) @@ -148,6 +166,7 @@ async def mtext_rotation_roundtrip(): # ── Rendering (screenshot Agg / SIGSEGV) ─────────────────────────────────────── + async def screenshot_png(): b = await _b() await b.entity_create_line(0, 0, 100, 100) @@ -157,6 +176,7 @@ async def screenshot_png(): # ── Quality gate / critique (R4, N4) ─────────────────────────────────────────── + async def dim_overlap_critique_fires(): b = await _b() await b.drawing_apply_iso_layers("mech") @@ -165,6 +185,7 @@ async def dim_overlap_critique_fires(): issues = await b.drawing_critique(focus=["dim_overlap"]) return len(issues) >= 1 + async def iso13567_dim_layer(): b = await _b() await b.drawing_apply_iso_layers("iso13567") @@ -172,6 +193,7 @@ async def iso13567_dim_layer(): dims = await b.dimension_auto([ln.handle], style="chain") return dims[0].layer == "M-DIMEN-T-N" + async def construction_left_iso_caught(): b = await _b() await b.drawing_apply_iso_layers("iso13567") @@ -182,8 +204,10 @@ async def construction_left_iso_caught(): # ── Engineering (gear) ────────────────────────────────────────────────────────── + async def gear_no_self_overlap(): from engineering.gear import generate_full_gear_outline + pts = generate_full_gear_outline(2, 50, 20.0) # z=50 -> root_r >= base_r root_r = (2 * 50 / 2.0) - 1.25 * 2 return min(math.hypot(x, y) for x, y in pts) >= root_r - 1e-6 @@ -191,6 +215,7 @@ async def gear_no_self_overlap(): # ── Linetype on demand (R24) ─────────────────────────────────────────────────── + async def center_linetype_applied(): b = await _b() ln = await b.entity_create_line(0, 0, 50, 0, linetype="CENTER") diff --git a/engineering/critique.py b/engineering/critique.py index ba9ce9f..a6f1528 100644 --- a/engineering/critique.py +++ b/engineering/critique.py @@ -29,8 +29,13 @@ _EXPECTED_LAYER_COLOR: dict[str, int] = { # Dimension entity types that should sit on the DIM layer. _DIM_TYPES = { - "DIMENSION", "DIMLINEAR", "DIMALIGNED", "DIMANGULAR", - "DIMRADIUS", "DIMDIAMETER", "DIMORDINATE", + "DIMENSION", + "DIMLINEAR", + "DIMALIGNED", + "DIMANGULAR", + "DIMRADIUS", + "DIMDIAMETER", + "DIMORDINATE", } @@ -57,15 +62,17 @@ async def _check_iso128(backend: AutoCADBackend) -> list[Issue]: # (e.g. 25 == 0.25mm) — they are NOT pre-normalised to mm, so accept # either the raw mm value or hundredths/100. if lw not in allowed and (lw / 100.0) not in allowed: - issues.append(Issue( - severity="warning", - focus="iso128", - message=( - f"Layer '{lyr.name}' lineweight {lw} mm is not in the " - f"ISO 128 set {sorted(allowed)}." - ), - detail={"layer": lyr.name, "lineweight": lw}, - )) + issues.append( + Issue( + severity="warning", + focus="iso128", + message=( + f"Layer '{lyr.name}' lineweight {lw} mm is not in the " + f"ISO 128 set {sorted(allowed)}." + ), + detail={"layer": lyr.name, "lineweight": lw}, + ) + ) return issues @@ -79,17 +86,19 @@ async def _check_layer_color(backend: AutoCADBackend) -> list[Issue]: for lyr in layers: expected = _EXPECTED_LAYER_COLOR.get(lyr.name) if expected is None: - continue # unknown layer — caller's responsibility + continue # unknown layer — caller's responsibility if int(lyr.color) != int(expected): - issues.append(Issue( - severity="warning", - focus="layer_color", - message=( - f"Layer '{lyr.name}' has color {lyr.color}; " - f"engineering standard expects {expected}." - ), - detail={"layer": lyr.name, "got": int(lyr.color), "expected": expected}, - )) + issues.append( + Issue( + severity="warning", + focus="layer_color", + message=( + f"Layer '{lyr.name}' has color {lyr.color}; " + f"engineering standard expects {expected}." + ), + detail={"layer": lyr.name, "got": int(lyr.color), "expected": expected}, + ) + ) return issues @@ -107,17 +116,21 @@ async def _check_construction_left(backend: AutoCADBackend) -> list[Issue]: leftover = [e for e in ents if "CONST" in (e.layer or "").upper()] if not leftover: return [] - return [Issue( - severity="error", - focus="construction_left", - message=( - f"{len(leftover)} entity/entities still on a construction layer. " - "Call construction_clear() before drawing_finalize." - ), - handles=[e.handle for e in leftover[:50]], - detail={"count": len(leftover), - "layers": sorted({e.layer for e in leftover if e.layer})}, - )] + return [ + Issue( + severity="error", + focus="construction_left", + message=( + f"{len(leftover)} entity/entities still on a construction layer. " + "Call construction_clear() before drawing_finalize." + ), + handles=[e.handle for e in leftover[:50]], + detail={ + "count": len(leftover), + "layers": sorted({e.layer for e in leftover if e.layer}), + }, + ) + ] async def _check_untrimmed_corner(backend: AutoCADBackend) -> list[Issue]: @@ -150,16 +163,18 @@ async def _check_untrimmed_corner(backend: AutoCADBackend) -> list[Issue]: if key in seen: continue seen.add(key) - issues.append(Issue( - severity="warning", - focus="untrimmed_corner", - message=( - f"Lines {h1} and {h2} have endpoints {d:.3f} mm apart — " - "likely an untrimmed corner (use entity_trim or entity_fillet)." - ), - handles=[h1, h2], - detail={"gap_mm": d, "p1": list(p1), "p2": list(p2)}, - )) + issues.append( + Issue( + severity="warning", + focus="untrimmed_corner", + message=( + f"Lines {h1} and {h2} have endpoints {d:.3f} mm apart — " + "likely an untrimmed corner (use entity_trim or entity_fillet)." + ), + handles=[h1, h2], + detail={"gap_mm": d, "p1": list(p1), "p2": list(p2)}, + ) + ) return issues @@ -180,12 +195,14 @@ async def _check_duplicate_entities(backend: AutoCADBackend) -> list[Issue]: k2 = (k1[2], k1[3], k1[0], k1[1]) for key in (k1, k2): if key in seen: - issues.append(Issue( - severity="warning", - focus="duplicate_entities", - message=f"Lines {seen[key]} and {ln.handle} are duplicates.", - handles=[seen[key], ln.handle], - )) + issues.append( + Issue( + severity="warning", + focus="duplicate_entities", + message=f"Lines {seen[key]} and {ln.handle} are duplicates.", + handles=[seen[key], ln.handle], + ) + ) break else: seen[k1] = ln.handle @@ -243,12 +260,14 @@ async def _check_dim_overlap(backend: AutoCADBackend) -> list[Issue]: if key in seen: continue seen.add(key) - issues.append(Issue( - severity="warning", - focus="dim_overlap", - message=f"Dimensions {h1} and {h2} are {d:.2f} mm apart — likely overlapping.", - handles=[h1, h2], - )) + issues.append( + Issue( + severity="warning", + focus="dim_overlap", + message=f"Dimensions {h1} and {h2} are {d:.2f} mm apart — likely overlapping.", + handles=[h1, h2], + ) + ) return issues @@ -267,19 +286,21 @@ async def _check_gdt(backend: AutoCADBackend) -> list[Issue]: missing = sorted(referenced - defined) if not missing: return [] - return [Issue( - severity="error", - focus="gdt", - message=( - f"Feature control frame(s) reference datum(s) {missing} with no " - "matching datum feature. Add draw_datum_feature for each datum." - ), - detail={ - "missing_datums": missing, - "defined": sorted(defined), - "referenced": sorted(referenced), - }, - )] + return [ + Issue( + severity="error", + focus="gdt", + message=( + f"Feature control frame(s) reference datum(s) {missing} with no " + "matching datum feature. Add draw_datum_feature for each datum." + ), + detail={ + "missing_datums": missing, + "defined": sorted(defined), + "referenced": sorted(referenced), + }, + ) + ] _FOCUS_DISPATCH = { @@ -303,11 +324,13 @@ async def run_critique( for f in foci: check = _FOCUS_DISPATCH.get(f) if check is None: - issues.append(Issue( - severity="warning", - focus=f, # type: ignore[arg-type] - message=f"Unknown critique focus '{f}' (closed enum).", - )) + issues.append( + Issue( + severity="warning", + focus=f, # type: ignore[arg-type] + message=f"Unknown critique focus '{f}' (closed enum).", + ) + ) continue issues.extend(await check(backend)) return issues diff --git a/engineering/delivery.py b/engineering/delivery.py index 042f1bc..9a627b9 100644 --- a/engineering/delivery.py +++ b/engineering/delivery.py @@ -163,7 +163,9 @@ async def deliver_drawing( expected: dict | None = None, ) -> DeliveryResult: """Export, validate, hash and re-open a release-ready drawing bundle.""" - requested = list(dict.fromkeys(item.lower().strip() for item in (formats or ["dxf", "pdf", "png"]))) + requested = list( + dict.fromkeys(item.lower().strip() for item in (formats or ["dxf", "pdf", "png"])) + ) unknown = [item for item in requested if item not in _FORMAT_FILENAMES] if unknown: raise ValueError(f"Unsupported delivery format(s): {', '.join(unknown)}") @@ -210,9 +212,7 @@ async def deliver_drawing( artifact_path.write_bytes(png) artifacts.append(_artifact_record(fmt, artifact_path, status="created")) except Exception as exc: - artifacts.append( - _artifact_record(fmt, artifact_path, status="failed", error=str(exc)) - ) + artifacts.append(_artifact_record(fmt, artifact_path, status="failed", error=str(exc))) finally: timings[f"export_{fmt}_ms"] = round((time.perf_counter() - export_started) * 1000, 2) diff --git a/engineering/gear.py b/engineering/gear.py index 5e2a21c..852649b 100644 --- a/engineering/gear.py +++ b/engineering/gear.py @@ -67,16 +67,18 @@ def generate_involute_flank( return points -def _rotate_points( - pts: list[tuple[float, float]], angle: float -) -> list[tuple[float, float]]: +def _rotate_points(pts: list[tuple[float, float]], angle: float) -> list[tuple[float, float]]: c, s = math.cos(angle), math.sin(angle) return [(c * x - s * y, s * x + c * y) for x, y in pts] def _arc_points( - cx: float, cy: float, radius: float, - start_angle: float, end_angle: float, n: int = 5, + cx: float, + cy: float, + radius: float, + start_angle: float, + end_angle: float, + n: int = 5, ) -> list[tuple[float, float]]: """Sample n points along an arc (start_angle → end_angle, radians, ccw).""" if n < 2: @@ -232,20 +234,29 @@ async def draw_helical_gear_front_view( root_circ = await backend.entity_create_circle(cx, cy, root_r, layer="GEOMETRY") pts = generate_full_gear_outline( - module=module, teeth=teeth, pressure_angle=pressure_angle, - helix_angle=helix_angle, hand=hand, center=(cx, cy), + module=module, + teeth=teeth, + pressure_angle=pressure_angle, + helix_angle=helix_angle, + hand=hand, + center=(cx, cy), ) outline = await backend.entity_create_polyline( [[float(x), float(y)] for x, y in pts], - closed=True, layer="GEOMETRY", + closed=True, + layer="GEOMETRY", ) helix_handles: list[str] | None = None helix_label: str | None = None if helix_angle and abs(helix_angle) > 1e-9: helix_handles, helix_label = await _draw_helix_symbol( - backend, cx=cx, cy=cy, outer_r=outer_r, - helix_angle=helix_angle, hand=hand, + backend, + cx=cx, + cy=cy, + outer_r=outer_r, + helix_angle=helix_angle, + hand=hand, ) bore_handle: str | None = None @@ -263,7 +274,10 @@ async def draw_helical_gear_front_view( keyway_dict = keyed else: bore_only = await backend.entity_create_circle( - cx, cy, bore_diameter / 2.0, layer="GEOMETRY", + cx, + cy, + bore_diameter / 2.0, + layer="GEOMETRY", ) bore_handle = bore_only.handle @@ -385,10 +399,18 @@ async def draw_gear_section_aa( bore_handles = [keyway_dict["bore_top"], keyway_dict["bore_bottom"]] else: bore_top = await backend.entity_create_line( - x_left, cy + bore_r, x_right, cy + bore_r, layer="GEOMETRY", + x_left, + cy + bore_r, + x_right, + cy + bore_r, + layer="GEOMETRY", ) bore_bot = await backend.entity_create_line( - x_left, cy - bore_r, x_right, cy - bore_r, layer="GEOMETRY", + x_left, + cy - bore_r, + x_right, + cy - bore_r, + layer="GEOMETRY", ) bore_handles = [bore_top.handle, bore_bot.handle] diff --git a/engineering/keyway.py b/engineering/keyway.py index 0ebdc9e..51d707b 100644 --- a/engineering/keyway.py +++ b/engineering/keyway.py @@ -45,7 +45,9 @@ def keyway_dimensions(bore_diameter: float) -> dict: f"({DIN6885_TABLE[-1][1]} mm)." ) for b_min, b_max, width, height, t1 in DIN6885_TABLE: - if b_min < bore_diameter <= b_max or (bore_diameter == b_min and b_min == DIN6885_TABLE[0][0]): + if b_min < bore_diameter <= b_max or ( + bore_diameter == b_min and b_min == DIN6885_TABLE[0][0] + ): return { "width": float(width), "height": float(height), @@ -95,10 +97,18 @@ async def draw_keyed_bore( overshoot = max(r * 0.2, 5.0) horiz = await backend.entity_create_line( - cx - r - overshoot, cy, cx + r + overshoot, cy, layer=center_layer, + cx - r - overshoot, + cy, + cx + r + overshoot, + cy, + layer=center_layer, ) vert = await backend.entity_create_line( - cx, cy - r - overshoot, cx, cy + r + depth + overshoot, layer=center_layer, + cx, + cy - r - overshoot, + cx, + cy + r + depth + overshoot, + layer=center_layer, ) return { @@ -142,23 +152,47 @@ async def draw_keyway_section( notch_right = cx + half_kw bore_top = await backend.entity_create_line( - x_left, top_y, x_right, top_y, layer=layer, + x_left, + top_y, + x_right, + top_y, + layer=layer, ) bore_bottom = await backend.entity_create_line( - x_left, bot_y, x_right, bot_y, layer=layer, + x_left, + bot_y, + x_right, + bot_y, + layer=layer, ) notch_bottom = await backend.entity_create_line( - notch_left, top_y, notch_right, top_y, layer=layer, + notch_left, + top_y, + notch_right, + top_y, + layer=layer, ) notch_left_wall = await backend.entity_create_line( - notch_left, top_y, notch_left, notch_top_y, layer=layer, + notch_left, + top_y, + notch_left, + notch_top_y, + layer=layer, ) notch_top = await backend.entity_create_line( - notch_left, notch_top_y, notch_right, notch_top_y, layer=layer, + notch_left, + notch_top_y, + notch_right, + notch_top_y, + layer=layer, ) notch_right_wall = await backend.entity_create_line( - notch_right, notch_top_y, notch_right, top_y, layer=layer, + notch_right, + notch_top_y, + notch_right, + top_y, + layer=layer, ) return { diff --git a/engineering/layers.py b/engineering/layers.py index ee28b65..cb4af2b 100644 --- a/engineering/layers.py +++ b/engineering/layers.py @@ -13,52 +13,52 @@ log = logging.getLogger(__name__) ENGINEERING_LAYERS: list[tuple[str, int, str, float, str]] = [ # (name, color, linetype, lineweight, description) - ("0", 7, "Continuous", 0.25, "default"), - ("GEOMETRY", 7, "Continuous", 0.50, "visible solid edges"), - ("HIDDEN", 3, "HIDDEN", 0.25, "hidden edges"), - ("CENTER", 1, "CENTER", 0.18, "centerlines"), - ("PHANTOM", 6, "PHANTOM", 0.18, "section / cutting planes"), - ("DIM", 2, "Continuous", 0.18, "dimensions"), - ("TEXT", 7, "Continuous", 0.25, "annotations"), - ("HATCH", 8, "Continuous", 0.13, "section hatching"), - ("TITLEBLOCK", 7, "Continuous", 0.50, "drawing border + title"), + ("0", 7, "Continuous", 0.25, "default"), + ("GEOMETRY", 7, "Continuous", 0.50, "visible solid edges"), + ("HIDDEN", 3, "HIDDEN", 0.25, "hidden edges"), + ("CENTER", 1, "CENTER", 0.18, "centerlines"), + ("PHANTOM", 6, "PHANTOM", 0.18, "section / cutting planes"), + ("DIM", 2, "Continuous", 0.18, "dimensions"), + ("TEXT", 7, "Continuous", 0.25, "annotations"), + ("HATCH", 8, "Continuous", 0.13, "section hatching"), + ("TITLEBLOCK", 7, "Continuous", 0.50, "drawing border + title"), ("CONSTRUCTION", 250, "Continuous", 0.05, "scratch / construction"), ] # P&ID standard layer set (de-facto, distilled from ISO 10628 + common practice). PID_LAYERS: list[tuple[str, int, str, float, str]] = [ - ("0", 7, "Continuous", 0.25, "default"), - ("PROCESS-PIPING-MAIN", 1, "Continuous", 0.70, "main process line (heavy)"), - ("PROCESS-PIPING-SECONDARY", 2, "Continuous", 0.50, "secondary process line"), - ("PROCESS-EQUIPMENT", 7, "Continuous", 0.50, "vessels / pumps / drums"), - ("PROCESS-VALVES", 3, "Continuous", 0.35, "valves / fittings"), - ("INSTRUMENT-SYMBOL", 4, "Continuous", 0.35, "instrument bubbles / FCFs"), - ("INSTRUMENT-LINE-SIGNAL", 4, "DASHED", 0.18, "pneumatic / electrical signal"), - ("INSTRUMENT-TAG-TEXT", 7, "Continuous", 0.25, "tag labels"), - ("ELECTRICAL-LINE", 6, "DASHDOT", 0.18, "electrical line"), - ("UTILITY-LINE", 5, "PHANTOM", 0.25, "utility (steam, water, air)"), - ("INSULATION-HATCH", 8, "Continuous", 0.13, "insulation hatch"), - ("DIM", 2, "Continuous", 0.18, "dimensions"), - ("TEXT-NOTES", 7, "Continuous", 0.25, "free-form notes"), - ("TITLEBLOCK", 7, "Continuous", 0.50, "drawing border + title"), - ("CONSTRUCTION", 250, "Continuous", 0.05, "scratch / construction"), + ("0", 7, "Continuous", 0.25, "default"), + ("PROCESS-PIPING-MAIN", 1, "Continuous", 0.70, "main process line (heavy)"), + ("PROCESS-PIPING-SECONDARY", 2, "Continuous", 0.50, "secondary process line"), + ("PROCESS-EQUIPMENT", 7, "Continuous", 0.50, "vessels / pumps / drums"), + ("PROCESS-VALVES", 3, "Continuous", 0.35, "valves / fittings"), + ("INSTRUMENT-SYMBOL", 4, "Continuous", 0.35, "instrument bubbles / FCFs"), + ("INSTRUMENT-LINE-SIGNAL", 4, "DASHED", 0.18, "pneumatic / electrical signal"), + ("INSTRUMENT-TAG-TEXT", 7, "Continuous", 0.25, "tag labels"), + ("ELECTRICAL-LINE", 6, "DASHDOT", 0.18, "electrical line"), + ("UTILITY-LINE", 5, "PHANTOM", 0.25, "utility (steam, water, air)"), + ("INSULATION-HATCH", 8, "Continuous", 0.13, "insulation hatch"), + ("DIM", 2, "Continuous", 0.18, "dimensions"), + ("TEXT-NOTES", 7, "Continuous", 0.25, "free-form notes"), + ("TITLEBLOCK", 7, "Continuous", 0.50, "drawing border + title"), + ("CONSTRUCTION", 250, "Continuous", 0.05, "scratch / construction"), ] # ISO 13567-style layer naming kit (Agent2-Element6-Presentation2-Status1). # This is a *starter* set — extend as needed per discipline. ISO13567_LAYERS: list[tuple[str, int, str, float, str]] = [ - ("0", 7, "Continuous", 0.25, "default"), - ("M-GEOMET-E-N", 7, "Continuous", 0.50, "Mechanical / geometry / edge / new"), - ("M-HIDDEN-E-N", 3, "HIDDEN", 0.25, "Mechanical / hidden edge"), - ("M-CENTL-E-N", 1, "CENTER", 0.18, "Mechanical / centerlines"), - ("M-PHANT-E-N", 6, "PHANTOM", 0.18, "Mechanical / phantom"), - ("M-DIMEN-T-N", 2, "Continuous", 0.18, "Mechanical / dimension text"), - ("M-TEXT-T-N", 7, "Continuous", 0.25, "Mechanical / annotation text"), - ("M-HATCH-H-N", 8, "Continuous", 0.13, "Mechanical / hatch"), - ("M-TITLE-T-N", 7, "Continuous", 0.50, "Title block"), - ("M-CONST-E-N", 250, "Continuous", 0.05, "Construction (scratch)"), + ("0", 7, "Continuous", 0.25, "default"), + ("M-GEOMET-E-N", 7, "Continuous", 0.50, "Mechanical / geometry / edge / new"), + ("M-HIDDEN-E-N", 3, "HIDDEN", 0.25, "Mechanical / hidden edge"), + ("M-CENTL-E-N", 1, "CENTER", 0.18, "Mechanical / centerlines"), + ("M-PHANT-E-N", 6, "PHANTOM", 0.18, "Mechanical / phantom"), + ("M-DIMEN-T-N", 2, "Continuous", 0.18, "Mechanical / dimension text"), + ("M-TEXT-T-N", 7, "Continuous", 0.25, "Mechanical / annotation text"), + ("M-HATCH-H-N", 8, "Continuous", 0.13, "Mechanical / hatch"), + ("M-TITLE-T-N", 7, "Continuous", 0.50, "Title block"), + ("M-CONST-E-N", 250, "Continuous", 0.05, "Construction (scratch)"), ] @@ -73,9 +73,9 @@ LAYER_SET_REGISTRY: dict[str, list[tuple[str, int, str, float, str]]] = { # "DIM"/"CONSTRUCTION" (which only exist in the mech/pid sets — iso13567 uses # M-DIMEN-T-N / M-CONST-E-N). LAYER_ROLES: dict[str, dict[str, str]] = { - "mech": {"dim": "DIM", "construction": "CONSTRUCTION"}, - "pid": {"dim": "DIM", "construction": "CONSTRUCTION"}, - "iso13567": {"dim": "M-DIMEN-T-N", "construction": "M-CONST-E-N"}, + "mech": {"dim": "DIM", "construction": "CONSTRUCTION"}, + "pid": {"dim": "DIM", "construction": "CONSTRUCTION"}, + "iso13567": {"dim": "M-DIMEN-T-N", "construction": "M-CONST-E-N"}, } @@ -85,6 +85,7 @@ def resolve_role_layer(layer_set_id: str | None, role: str) -> str: roles = LAYER_ROLES.get(layer_set_id or "mech", LAYER_ROLES["mech"]) return roles.get(role, LAYER_ROLES["mech"][role]) + STANDARD_LINETYPES: list[str] = ["CENTER", "HIDDEN", "PHANTOM", "DASHED", "DASHDOT"] @@ -115,14 +116,14 @@ async def ensure_engineering_layers(backend: AutoCADBackend) -> dict[str, str]: async def apply_layer_set( - backend: AutoCADBackend, standard: str = "mech", + backend: AutoCADBackend, + standard: str = "mech", ) -> dict[str, str]: """Idempotently apply a named layer set ('mech', 'pid', 'iso13567').""" layer_set = LAYER_SET_REGISTRY.get(standard) if layer_set is None: raise RuntimeError( - f"Unknown layer set '{standard}'. " - f"Choose one of: {sorted(LAYER_SET_REGISTRY)}" + f"Unknown layer set '{standard}'. Choose one of: {sorted(LAYER_SET_REGISTRY)}" ) await ensure_standard_linetypes(backend) @@ -139,7 +140,10 @@ async def apply_layer_set( continue try: await backend.layer_create( - name=name, color=color, linetype=linetype, lineweight=lineweight, + name=name, + color=color, + linetype=linetype, + lineweight=lineweight, ) results[name] = "created" except Exception as exc: diff --git a/engineering/plan_spec.py b/engineering/plan_spec.py index 5e9f147..ac72e2a 100644 --- a/engineering/plan_spec.py +++ b/engineering/plan_spec.py @@ -91,5 +91,13 @@ class Issue: # ISO 128 lineweight set (mm). Any lineweight outside this set is flagged. ISO_128_LINEWEIGHTS_MM: tuple[float, ...] = ( - 0.13, 0.18, 0.25, 0.35, 0.50, 0.70, 1.00, 1.40, 2.00, + 0.13, + 0.18, + 0.25, + 0.35, + 0.50, + 0.70, + 1.00, + 1.40, + 2.00, ) diff --git a/engineering/preflight.py b/engineering/preflight.py index adb212f..cf605fe 100644 --- a/engineering/preflight.py +++ b/engineering/preflight.py @@ -99,9 +99,7 @@ def preflight_drawing( assumptions.append({"field": "units", "value": "mm", "reason": "default"}) if allow_assumptions and not req.get("tolerance_policy"): req["tolerance_policy"] = "general" - assumptions.append( - {"field": "tolerance_policy", "value": "general", "reason": "default"} - ) + assumptions.append({"field": "tolerance_policy", "value": "general", "reason": "default"}) if isinstance(req.get("units"), str): req["units"] = req["units"].lower() diff --git a/engineering/repairs.py b/engineering/repairs.py index 7859470..72629d9 100644 --- a/engineering/repairs.py +++ b/engineering/repairs.py @@ -64,12 +64,8 @@ async def repair_untrimmed_corner(backend: AutoCADBackend, issue: Issue) -> dict midpoint = ((float(p1[0]) + float(p2[0])) / 2, (float(p1[1]) + float(p2[1])) / 2) first = await backend.entity_get(issue.handles[0]) second = await backend.entity_get(issue.handles[1]) - await backend.entity_edit_geometry( - issue.handles[0], **_endpoint_kwargs(first, p1, midpoint) - ) - await backend.entity_edit_geometry( - issue.handles[1], **_endpoint_kwargs(second, p2, midpoint) - ) + await backend.entity_edit_geometry(issue.handles[0], **_endpoint_kwargs(first, p1, midpoint)) + await backend.entity_edit_geometry(issue.handles[1], **_endpoint_kwargs(second, p2, midpoint)) return {"handles": list(issue.handles), "corner": list(midpoint)} diff --git a/engineering/titleblock.py b/engineering/titleblock.py index 5746526..97bf82a 100644 --- a/engineering/titleblock.py +++ b/engineering/titleblock.py @@ -72,7 +72,9 @@ async def apply_iso_a3_titleblock( [ox, oy + SHEET_H], ] outer = await backend.entity_create_polyline( - points=outer_pts, closed=True, layer=LAYER_BORDER, + points=outer_pts, + closed=True, + layer=LAYER_BORDER, ) inner_x0 = ox + LEFT_MARGIN @@ -86,7 +88,9 @@ async def apply_iso_a3_titleblock( [inner_x0, inner_y1], ] inner = await backend.entity_create_polyline( - points=inner_pts, closed=True, layer=LAYER_BORDER, + points=inner_pts, + closed=True, + layer=LAYER_BORDER, ) # ---- title block (lower-right corner of inner border) ---------------------- @@ -103,7 +107,9 @@ async def apply_iso_a3_titleblock( [tb_x0, tb_y1], ] tb_frame = await backend.entity_create_polyline( - points=tb_frame_pts, closed=True, layer=LAYER_BORDER, + points=tb_frame_pts, + closed=True, + layer=LAYER_BORDER, ) # Row baselines (from bottom up): row4_top, row3_top, row2_top @@ -115,13 +121,21 @@ async def apply_iso_a3_titleblock( async def hline(y: float) -> str: ent = await backend.entity_create_line( - tb_x0, y, tb_x1, y, layer=LAYER_BORDER, + tb_x0, + y, + tb_x1, + y, + layer=LAYER_BORDER, ) return ent.handle async def vline(x: float, y0: float, y1: float) -> str: ent = await backend.entity_create_line( - x, y0, x, y1, layer=LAYER_BORDER, + x, + y0, + x, + y1, + layer=LAYER_BORDER, ) return ent.handle @@ -157,13 +171,21 @@ async def apply_iso_a3_titleblock( async def label(text: str, x: float, y: float) -> str: ent = await backend.entity_create_text( - text=text, x=x, y=y, height=LABEL_HEIGHT, layer=LAYER_TEXT, + text=text, + x=x, + y=y, + height=LABEL_HEIGHT, + layer=LAYER_TEXT, ) return ent.handle async def value(text: str, x: float, y: float, height: float = VALUE_HEIGHT) -> str: ent = await backend.entity_create_text( - text=text, x=x, y=y, height=height, layer=LAYER_TEXT, + text=text, + x=x, + y=y, + height=height, + layer=LAYER_TEXT, ) return ent.handle diff --git a/engineering/validator.py b/engineering/validator.py index 98ef988..54cfe7a 100644 --- a/engineering/validator.py +++ b/engineering/validator.py @@ -12,8 +12,13 @@ if TYPE_CHECKING: REAL_DIMENSION_TYPES = { - "DIMENSION", "DIMLINEAR", "DIMALIGNED", "DIMANGULAR", - "DIMRADIUS", "DIMDIAMETER", "DIMORDINATE", + "DIMENSION", + "DIMLINEAR", + "DIMALIGNED", + "DIMANGULAR", + "DIMRADIUS", + "DIMDIAMETER", + "DIMORDINATE", } # Patterns that suggest a TEXT/MTEXT entity is masquerading as a dimension. @@ -35,7 +40,7 @@ _TITLE_LAYERS = {"TITLE", "TITLEBLOCK"} class ValidationFinding: """One issue surfaced by the validator.""" - severity: str # "error" | "warning" | "info" + severity: str # "error" | "warning" | "info" code: str message: str detail: dict = field(default_factory=dict) @@ -84,40 +89,47 @@ class DrawingValidator: try: ents = await backend.entity_list(limit=10000) text_likes = [ - e for e in ents + e + for e in ents if e.type in ("TEXT", "MTEXT") - and DIMENSION_LIKE_TEXT.search( - getattr(e, "properties", {}).get("text", "") or "" - ) + and DIMENSION_LIKE_TEXT.search(getattr(e, "properties", {}).get("text", "") or "") ] real_dims = [e for e in ents if e.type in REAL_DIMENSION_TYPES] if text_likes: - findings.append(ValidationFinding( - "warning", - "fake_dimension_text", - f"{len(text_likes)} TEXT/MTEXT entities look like dimensions " - "(Ø, ±, R, etc.) — use dimension_* tools instead", - { - "sample_handles": [t.handle for t in text_likes[:5]], - "real_dim_count": len(real_dims), - }, - )) + findings.append( + ValidationFinding( + "warning", + "fake_dimension_text", + f"{len(text_likes)} TEXT/MTEXT entities look like dimensions " + "(Ø, ±, R, etc.) — use dimension_* tools instead", + { + "sample_handles": [t.handle for t in text_likes[:5]], + "real_dim_count": len(real_dims), + }, + ) + ) except Exception as exc: - findings.append(ValidationFinding( - "warning", "dim_check_failed", str(exc), - )) + findings.append( + ValidationFinding( + "warning", + "dim_check_failed", + str(exc), + ) + ) # 2 — hidden orphans try: hidden = [e for e in ents if not e.visible] if hidden: - findings.append(ValidationFinding( - "error", - "hidden_orphans", - f"{len(hidden)} entities have visible=False — hide-fallback " - "is forbidden, use entity_delete", - {"sample_handles": [h.handle for h in hidden[:5]]}, - )) + findings.append( + ValidationFinding( + "error", + "hidden_orphans", + f"{len(hidden)} entities have visible=False — hide-fallback " + "is forbidden, use entity_delete", + {"sample_handles": [h.handle for h in hidden[:5]]}, + ) + ) except Exception: pass @@ -126,45 +138,53 @@ class DrawingValidator: lts = {x.lower() for x in await backend.linetype_list()} missing = [lt for lt in ("center", "hidden", "phantom") if lt not in lts] if missing: - findings.append(ValidationFinding( - "error", - "linetypes_missing", - f"Standard linetypes not loaded: {', '.join(missing).upper()}", - {"missing": missing}, - )) + findings.append( + ValidationFinding( + "error", + "linetypes_missing", + f"Standard linetypes not loaded: {', '.join(missing).upper()}", + {"missing": missing}, + ) + ) except Exception as exc: - findings.append(ValidationFinding( - "warning", "linetype_check_failed", str(exc), - )) + findings.append( + ValidationFinding( + "warning", + "linetype_check_failed", + str(exc), + ) + ) # 4 — gear views (heuristic, only if expected demands) if expected.get("must_have_bore") or expected.get("must_have_keyway"): - bore_circles = [ - e for e in ents - if e.type == "CIRCLE" and e.layer == "GEOMETRY" - ] + bore_circles = [e for e in ents if e.type == "CIRCLE" and e.layer == "GEOMETRY"] if expected.get("must_have_bore") and len(bore_circles) < 1: - findings.append(ValidationFinding( - "error", - "bore_missing", - "Expected bore but no circle on GEOMETRY layer found", - )) + findings.append( + ValidationFinding( + "error", + "bore_missing", + "Expected bore but no circle on GEOMETRY layer found", + ) + ) # draw_keyed_bore emits the keyway notch as an OPEN 4-point polyline # on GEOMETRY — requiring `closed` here made this a permanent # false-negative. Match a small (3-6 point) polyline, open or closed. keyway_polys = [ - e for e in ents + e + for e in ents if e.type in ("LWPOLYLINE", "POLYLINE") and e.layer == "GEOMETRY" and 3 <= len(e.properties.get("points", [])) <= 6 ] if expected.get("must_have_keyway") and len(keyway_polys) < 1: - findings.append(ValidationFinding( - "warning", - "keyway_section_unverified", - "Could not confirm keyway via heuristic " - "(small 3-6 point polyline on GEOMETRY)", - )) + findings.append( + ValidationFinding( + "warning", + "keyway_section_unverified", + "Could not confirm keyway via heuristic " + "(small 3-6 point polyline on GEOMETRY)", + ) + ) # 5 — title consistency helix = expected.get("helix_angle") @@ -190,19 +210,23 @@ class DrawingValidator: has_spur = bool(_SPUR_RE.search(joined)) has_helical = bool(_HELICAL_RE.search(joined)) if has_spur and has_helical: - findings.append(ValidationFinding( - "error", - "title_helical_spur", - "Title contains 'HELICAL SPUR GEAR' — invalid; use " - "'HELICAL GEAR' or 'SPUR GEAR'", - )) + findings.append( + ValidationFinding( + "error", + "title_helical_spur", + "Title contains 'HELICAL SPUR GEAR' — invalid; use " + "'HELICAL GEAR' or 'SPUR GEAR'", + ) + ) elif has_spur: - findings.append(ValidationFinding( - "error", - "title_says_spur_for_helical", - f"Title contains SPUR but part is helical (helix_angle={helix})", - {"sample": joined[:200]}, - )) + findings.append( + ValidationFinding( + "error", + "title_says_spur_for_helical", + f"Title contains SPUR but part is helical (helix_angle={helix})", + {"sample": joined[:200]}, + ) + ) # 6 — saved to disk try: @@ -210,46 +234,62 @@ class DrawingValidator: saved_attr = getattr(info, "saved", None) full_path = getattr(info, "full_path", "") or "" if not saved_attr: - findings.append(ValidationFinding( - "error", - "not_saved", - "Drawing has unsaved changes — call drawing_save before finalize", - {"full_path": full_path}, - )) + findings.append( + ValidationFinding( + "error", + "not_saved", + "Drawing has unsaved changes — call drawing_save before finalize", + {"full_path": full_path}, + ) + ) elif full_path and not Path(full_path).exists(): - findings.append(ValidationFinding( - "error", - "file_missing", - f"Drawing reports saved=True but file not on disk: {full_path}", - )) + findings.append( + ValidationFinding( + "error", + "file_missing", + f"Drawing reports saved=True but file not on disk: {full_path}", + ) + ) except Exception as exc: - findings.append(ValidationFinding( - "warning", "save_check_failed", str(exc), - )) + findings.append( + ValidationFinding( + "warning", + "save_check_failed", + str(exc), + ) + ) # 7 — screenshot try: shot = await backend.view_screenshot() if not shot: - findings.append(ValidationFinding( - "info", - "screenshot_unavailable", - "Screenshot returned no bytes (ezdxf needs matplotlib install)", - )) + findings.append( + ValidationFinding( + "info", + "screenshot_unavailable", + "Screenshot returned no bytes (ezdxf needs matplotlib install)", + ) + ) except Exception as exc: - findings.append(ValidationFinding( - "info", "screenshot_failed", str(exc), - )) + findings.append( + ValidationFinding( + "info", + "screenshot_failed", + str(exc), + ) + ) # 8 — dwg path returned try: info = await backend.drawing_info() if not getattr(info, "full_path", ""): - findings.append(ValidationFinding( - "warning", - "no_path", - "drawing_info.full_path is empty", - )) + findings.append( + ValidationFinding( + "warning", + "no_path", + "drawing_info.full_path is empty", + ) + ) except Exception: pass diff --git a/scripts/smoke_premium.py b/scripts/smoke_premium.py index bf1b975..2d87f26 100644 --- a/scripts/smoke_premium.py +++ b/scripts/smoke_premium.py @@ -61,10 +61,12 @@ async def _run() -> int: print(f"ok (center=({cx},{cy}), r=5)") print("[6/9] entity_select_smart ...", end=" ") - geom = await backend.entity_select_smart({ - "type": "LINE", - "layer": "GEOMETRY", - }) + geom = await backend.entity_select_smart( + { + "type": "LINE", + "layer": "GEOMETRY", + } + ) assert len(geom) >= 2, len(geom) print(f"ok ({len(geom)} lines selected)") diff --git a/security.py b/security.py index 39d2dff..6fb121b 100644 --- a/security.py +++ b/security.py @@ -106,32 +106,50 @@ def validate_path(path: str, *, allow_write: bool = False) -> Path: # --------------------------------------------------------------------------- _DANGEROUS_COMMANDS = [ - "ERASE", "_ERASE", - "DELETE", "_DELETE", - "PURGE", "_PURGE", - "QUIT", "_QUIT", - "CLOSE", "_CLOSE", - "QSAVE", "_QSAVE", - "SAVEAS", "_SAVEAS", - "NEW", "_NEW", - "OPEN", "_OPEN", - "RECOVER", "_RECOVER", - "WBLOCK", "_WBLOCK", - "INSERT", "_INSERT", - "XREF", "_XREF", - "SHELL", "_SHELL", - "SCRIPT", "_SCRIPT", - "APPLOAD", "_APPLOAD", - "NETLOAD", "_NETLOAD", - "VBARUN", "_VBARUN", - "VBALOAD", "_VBALOAD", - "SECURITYOPTIONS", "_SECURITYOPTIONS", + "ERASE", + "_ERASE", + "DELETE", + "_DELETE", + "PURGE", + "_PURGE", + "QUIT", + "_QUIT", + "CLOSE", + "_CLOSE", + "QSAVE", + "_QSAVE", + "SAVEAS", + "_SAVEAS", + "NEW", + "_NEW", + "OPEN", + "_OPEN", + "RECOVER", + "_RECOVER", + "WBLOCK", + "_WBLOCK", + "INSERT", + "_INSERT", + "XREF", + "_XREF", + "SHELL", + "_SHELL", + "SCRIPT", + "_SCRIPT", + "APPLOAD", + "_APPLOAD", + "NETLOAD", + "_NETLOAD", + "VBARUN", + "_VBARUN", + "VBALOAD", + "_VBALOAD", + "SECURITYOPTIONS", + "_SECURITYOPTIONS", ] _DANGEROUS_COMMAND_PATTERNS = re.compile( - r"\b(" - + "|".join(re.escape(cmd) for cmd in _DANGEROUS_COMMANDS) - + r")\b", + r"\b(" + "|".join(re.escape(cmd) for cmd in _DANGEROUS_COMMANDS) + r")\b", re.IGNORECASE, ) @@ -213,7 +231,9 @@ def sanitize_lisp(expression: str) -> str: raise ToolError("LISP expression cannot be empty.") if config.settings.dangerous_commands_enabled: - log.info("Dangerous commands enabled — bypassing LISP sanitization for: %s", expression[:80]) + log.info( + "Dangerous commands enabled — bypassing LISP sanitization for: %s", expression[:80] + ) return expression if _DANGEROUS_LISP_PATTERN.search(expression): diff --git a/tests/test_analysis.py b/tests/test_analysis.py index d591f89..4edc47a 100644 --- a/tests/test_analysis.py +++ b/tests/test_analysis.py @@ -68,7 +68,10 @@ async def test_analysis_entity_stats(backend): stats = await backend.analysis_stats() assert isinstance(stats, dict) by_type = stats.get("by_type", stats) - total = stats.get("total", stats.get("total_entities", sum(by_type.values()) if isinstance(by_type, dict) else 0)) + total = stats.get( + "total", + stats.get("total_entities", sum(by_type.values()) if isinstance(by_type, dict) else 0), + ) assert total >= 3 diff --git a/tests/test_batch_template.py b/tests/test_batch_template.py index d693bab..b9d7b2f 100644 --- a/tests/test_batch_template.py +++ b/tests/test_batch_template.py @@ -51,8 +51,12 @@ async def test_batch_modify_delete(backend): async def test_template_architectural_layers(backend): """Architectural template should create standard layers.""" layer_defs = [ - ("WALLS", 7), ("DOORS", 3), ("WINDOWS", 4), - ("FURNITURE", 8), ("DIMENSIONS", 2), ("TEXT", 7), + ("WALLS", 7), + ("DOORS", 3), + ("WINDOWS", 4), + ("FURNITURE", 8), + ("DIMENSIONS", 2), + ("TEXT", 7), ] for name, color in layer_defs: await backend.layer_create(name, color=color) @@ -67,8 +71,11 @@ async def test_template_architectural_layers(backend): async def test_template_mechanical_layers(backend): """Mechanical template layers.""" layer_defs = [ - ("VISIBLE", 7), ("HIDDEN", 1), ("CENTER", 3), - ("DIMENSIONS", 2), ("SECTION", 5), + ("VISIBLE", 7), + ("HIDDEN", 1), + ("CENTER", 3), + ("DIMENSIONS", 2), + ("SECTION", 5), ] for name, color in layer_defs: await backend.layer_create(name, color=color) diff --git a/tests/test_com_backend.py b/tests/test_com_backend.py index fcee8af..6957f22 100644 --- a/tests/test_com_backend.py +++ b/tests/test_com_backend.py @@ -48,6 +48,7 @@ async def test_active_document_switch_clears_com_document_state(monkeypatch): assert backend._preflight_result is None assert backend._gdt_datums_defined == set() + # --------------------------------------------------------------------------- # normalize_lineweight — parametrized table # --------------------------------------------------------------------------- @@ -107,7 +108,12 @@ def test_normalize_lineweight_non_numeric_passthrough(): # _safe_send_command — CMDACTIVE polling / timeout / var-restore # --------------------------------------------------------------------------- -def _make_mock_doc(cmdactive_sequence: list[int], modelspace_handles_pre: set[str] | None = None, modelspace_handles_post: set[str] | None = None) -> MagicMock: + +def _make_mock_doc( + cmdactive_sequence: list[int], + modelspace_handles_pre: set[str] | None = None, + modelspace_handles_post: set[str] | None = None, +) -> MagicMock: """Build a minimal mock AutoCAD document for _safe_send_command tests.""" doc = MagicMock() @@ -270,9 +276,9 @@ async def test_dimension_linear_uses_add_dim_rotated(): mspace.AddDimRotated.assert_called_once() mspace.AddDimLinear.assert_not_called() args = mspace.AddDimRotated.call_args[0] - assert args[0] == (0.0, 0.0) # ext line 1 origin - assert args[1] == (100.0, 0.0) # ext line 2 origin - assert args[2] == (50.0, 20.0) # dim line location + assert args[0] == (0.0, 0.0) # ext line 1 origin + assert args[1] == (100.0, 0.0) # ext line 2 origin + assert args[2] == (50.0, 20.0) # dim line location assert args[3] == pytest.approx(0.0) # rotation in radians diff --git a/tests/test_com_sprint3.py b/tests/test_com_sprint3.py index 7489c02..743ff11 100644 --- a/tests/test_com_sprint3.py +++ b/tests/test_com_sprint3.py @@ -53,6 +53,7 @@ class _Doc: # ── 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 @@ -69,6 +70,7 @@ async def test_set_variable_coerces_string_to_sysvar_int_type(monkeypatch): # ── 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 @@ -90,12 +92,13 @@ async def test_run_lisp_captures_value_from_users1(monkeypatch): b = _backend_no_executor() out = await b.system_run_lisp("(+ 1 2)") - assert out["result"] == "3" # captured, not the bogus "nil" + 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() @@ -136,10 +139,11 @@ async def test_transaction_rollback_uses_safe_send_command(monkeypatch): # ── 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) + 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) diff --git a/tests/test_drawing.py b/tests/test_drawing.py index 5c4cd69..f447957 100644 --- a/tests/test_drawing.py +++ b/tests/test_drawing.py @@ -33,6 +33,7 @@ async def test_drawing_export_dxf(backend, tmp_path): result = await backend.drawing_export_dxf(export_path) assert result.get("ok") is True import os + assert os.path.exists(export_path) diff --git a/tests/test_drawing_settings.py b/tests/test_drawing_settings.py index 757f5b3..ea8dae8 100644 --- a/tests/test_drawing_settings.py +++ b/tests/test_drawing_settings.py @@ -33,9 +33,13 @@ async def test_set_units_cm_and_inch_codes(backend): async def test_set_numeric_settings(backend): - res = await backend.drawing_settings({ - "dimscale": 2.0, "linear_precision": 3, "ltscale": 0.5, - }) + res = await backend.drawing_settings( + { + "dimscale": 2.0, + "linear_precision": 3, + "ltscale": 0.5, + } + ) assert res["ok"] is True snap = (await backend.drawing_settings())["settings"] assert snap["dimscale"] == 2.0 @@ -57,6 +61,7 @@ async def test_bad_unit_value_is_reported(backend): # ── server tool wiring ────────────────────────────────────────────────────── + class _FakeCtx: def __init__(self, backend): self.lifespan_context = {"backend": backend} diff --git a/tests/test_engineering_gear.py b/tests/test_engineering_gear.py index b5f7199..a0c4a37 100644 --- a/tests/test_engineering_gear.py +++ b/tests/test_engineering_gear.py @@ -66,19 +66,41 @@ async def test_helical_gear_front_view_returns_metadata(backend): await ensure_standard_linetypes(backend) await ensure_engineering_layers(backend) res = await draw_helical_gear_front_view( - backend, module=3, teeth=24, helix_angle=20.0, hand="RH", + backend, + module=3, + teeth=24, + helix_angle=20.0, + hand="RH", center=(0.0, 0.0), ) expected_keys = { - "outline", "pitch_circle", "base_circle", "outer_circle", "root_circle", - "helix_symbol", "helix_label", "bore", "keyway", "metadata", + "outline", + "pitch_circle", + "base_circle", + "outer_circle", + "root_circle", + "helix_symbol", + "helix_label", + "bore", + "keyway", + "metadata", } assert expected_keys.issubset(res.keys()) md = res["metadata"] for k in ( - "module", "teeth", "pressure_angle", "helix_angle", "hand", "center", - "bore_diameter", "keyway_width", "keyway_depth", - "pitch_radius", "outer_radius", "base_radius", "root_radius", + "module", + "teeth", + "pressure_angle", + "helix_angle", + "hand", + "center", + "bore_diameter", + "keyway_width", + "keyway_depth", + "pitch_radius", + "outer_radius", + "base_radius", + "root_radius", ): assert k in md, f"metadata missing key {k!r}" @@ -88,8 +110,13 @@ async def test_helical_gear_with_bore_and_keyway_handles_present(backend): await ensure_standard_linetypes(backend) await ensure_engineering_layers(backend) res = await draw_helical_gear_front_view( - backend, module=3, teeth=24, helix_angle=20.0, - bore_diameter=25.0, keyway_width=8.0, keyway_depth=4.0, + backend, + module=3, + teeth=24, + helix_angle=20.0, + bore_diameter=25.0, + keyway_width=8.0, + keyway_depth=4.0, ) assert res["bore"] is not None assert res["keyway"] is not None @@ -102,7 +129,11 @@ async def test_helical_gear_metadata_radii_correct(backend): await ensure_standard_linetypes(backend) await ensure_engineering_layers(backend) res = await draw_helical_gear_front_view( - backend, module=3, teeth=24, helix_angle=20.0, pressure_angle=20.0, + backend, + module=3, + teeth=24, + helix_angle=20.0, + pressure_angle=20.0, ) md = res["metadata"] assert math.isclose(md["pitch_radius"], 36.0, abs_tol=1e-6) @@ -116,12 +147,19 @@ async def test_section_aa_does_not_draw_helix_lines(backend): await ensure_standard_linetypes(backend) await ensure_engineering_layers(backend) front = await draw_helical_gear_front_view( - backend, module=3, teeth=24, helix_angle=20.0, - bore_diameter=25.0, keyway_width=8.0, keyway_depth=4.0, + backend, + module=3, + teeth=24, + helix_angle=20.0, + bore_diameter=25.0, + keyway_width=8.0, + keyway_depth=4.0, ) section = await draw_gear_section_aa( - backend, gear_metadata=front["metadata"], - x_offset=200.0, face_width=40.0, + backend, + gear_metadata=front["metadata"], + x_offset=200.0, + face_width=40.0, ) assert "helix" not in section assert "helix_symbol" not in section diff --git a/tests/test_engineering_keyway.py b/tests/test_engineering_keyway.py index 02f6eca..0e0aa35 100644 --- a/tests/test_engineering_keyway.py +++ b/tests/test_engineering_keyway.py @@ -37,8 +37,11 @@ async def test_draw_keyed_bore_returns_expected_handles(backend): await ensure_standard_linetypes(backend) await ensure_engineering_layers(backend) res = await draw_keyed_bore( - backend, center=(0.0, 0.0), bore_diameter=25.0, - keyway_width=8.0, keyway_depth=4.0, + backend, + center=(0.0, 0.0), + bore_diameter=25.0, + keyway_width=8.0, + keyway_depth=4.0, ) for k in ("bore", "keyway_polyline", "centerline_h", "centerline_v"): assert k in res @@ -50,8 +53,12 @@ async def test_draw_keyway_section_geometry(backend): await ensure_standard_linetypes(backend) await ensure_engineering_layers(backend) res = await draw_keyway_section( - backend, center=(0.0, 0.0), bore_diameter=25.0, face_width=40.0, - keyway_width=8.0, keyway_depth=4.0, + backend, + center=(0.0, 0.0), + bore_diameter=25.0, + face_width=40.0, + keyway_width=8.0, + keyway_depth=4.0, ) assert "bore_top" in res assert "bore_bottom" in res diff --git a/tests/test_engineering_titleblock.py b/tests/test_engineering_titleblock.py index e8a2f58..767891d 100644 --- a/tests/test_engineering_titleblock.py +++ b/tests/test_engineering_titleblock.py @@ -39,7 +39,8 @@ async def test_titleblock_outer_border_dimensions(backend): async def test_title_text_exact_match(backend): """Title text in the drawing must equal metadata.title verbatim.""" result = await apply_iso_a3_titleblock( - backend, metadata=_meta(title="HELICAL GEAR"), + backend, + metadata=_meta(title="HELICAL GEAR"), ) title_ent = await backend.entity_get(result["title_text"]) assert title_ent.type == "TEXT" @@ -50,9 +51,16 @@ async def test_titleblock_creates_text_for_each_metadata_field(backend): """value_texts must contain a handle for every metadata field.""" result = await apply_iso_a3_titleblock(backend, metadata=_meta()) expected_keys = { - "drawing_no", "revision", "sheet", - "part_no", "material", "scale", - "drawn_by", "checked_by", "date", "company", + "drawing_no", + "revision", + "sheet", + "part_no", + "material", + "scale", + "drawn_by", + "checked_by", + "date", + "company", } assert set(result["value_texts"].keys()) == expected_keys # All handles must resolve to TEXT entities @@ -64,7 +72,9 @@ async def test_titleblock_creates_text_for_each_metadata_field(backend): async def test_titleblock_origin_offset(backend): """Passing origin=(100,50) must shift the bbox by that amount.""" result = await apply_iso_a3_titleblock( - backend, metadata=_meta(), origin=(100.0, 50.0), + backend, + metadata=_meta(), + origin=(100.0, 50.0), ) bbox = result["bbox"] assert bbox["min"][0] == pytest.approx(100.0) diff --git a/tests/test_engineering_validator.py b/tests/test_engineering_validator.py index 05a6cfe..0fd1f82 100644 --- a/tests/test_engineering_validator.py +++ b/tests/test_engineering_validator.py @@ -73,7 +73,11 @@ async def test_validator_catches_helical_spur_title(backend, tmp_path): await ensure_standard_linetypes(backend) await ensure_engineering_layers(backend) await backend.entity_create_text( - text="HELICAL SPUR GEAR", x=0, y=0, height=5, layer="TEXT", + text="HELICAL SPUR GEAR", + x=0, + y=0, + height=5, + layer="TEXT", ) await backend.drawing_save_as(str(tmp_path / "tb.dxf")) result = await DrawingValidator().run(backend, expected={"helix_angle": 20}) @@ -86,7 +90,11 @@ async def test_validator_catches_fake_dimension_text(backend, tmp_path): await ensure_standard_linetypes(backend) await ensure_engineering_layers(backend) await backend.entity_create_text( - text="Ø78", x=10, y=10, height=3.5, layer="DIM", + text="Ø78", + x=10, + y=10, + height=3.5, + layer="DIM", ) await backend.drawing_save_as(str(tmp_path / "fake.dxf")) result = await DrawingValidator().run(backend) @@ -124,7 +132,8 @@ async def test_validator_bore_missing_when_expected(backend, tmp_path): # No circle drawn at all await backend.drawing_save_as(str(tmp_path / "nobore.dxf")) result = await DrawingValidator().run( - backend, expected={"must_have_bore": True}, + backend, + expected={"must_have_bore": True}, ) assert "bore_missing" in _codes(result) diff --git a/tests/test_entity_corner_ops.py b/tests/test_entity_corner_ops.py index 4e85118..1b92ece 100644 --- a/tests/test_entity_corner_ops.py +++ b/tests/test_entity_corner_ops.py @@ -15,6 +15,7 @@ def _close(a, b, tol=1e-6): # ─── TRIM ────────────────────────────────────────────────────────────────── + class TestTrim: async def test_trim_keep_left(self, backend): target = await backend.entity_create_line(0, 0, 100, 0) @@ -55,6 +56,7 @@ class TestTrim: # ─── EXTEND ──────────────────────────────────────────────────────────────── + class TestExtend: async def test_extend_auto_endpoint(self, backend): # Target ends at x=40; boundary is vertical at x=80. @@ -84,6 +86,7 @@ class TestExtend: # ─── FILLET ──────────────────────────────────────────────────────────────── + class TestFillet: async def test_fillet_90_degree_corner(self, backend): # L-shape: horizontal (0,0)-(100,0) + vertical (100,0)-(100,80). @@ -139,6 +142,7 @@ class TestFillet: # ─── CHAMFER ─────────────────────────────────────────────────────────────── + class TestChamfer: async def test_chamfer_symmetric(self, backend): l1 = await backend.entity_create_line(0, 0, 100, 0) diff --git a/tests/test_entity_edit.py b/tests/test_entity_edit.py index 508c781..ea0d702 100644 --- a/tests/test_entity_edit.py +++ b/tests/test_entity_edit.py @@ -14,10 +14,11 @@ pytestmark = pytest.mark.asyncio # ── entity_edit_text ──────────────────────────────────────────────────────── + async def test_edit_text_changes_content_keeps_handle(backend): t = await backend.entity_create_text("OLD LABEL", 0, 0, height=2.5, layer="TEXT") updated = await backend.entity_edit_text(t.handle, text="NEW LABEL") - assert updated.handle == t.handle # handle preserved + assert updated.handle == t.handle # handle preserved assert updated.properties["text"] == "NEW LABEL" @@ -44,6 +45,7 @@ async def test_edit_text_rejects_non_text(backend): # ── entity_edit_geometry ──────────────────────────────────────────────────── + async def test_edit_circle_radius_and_center(backend): c = await backend.entity_create_circle(0, 0, 10, layer="GEOMETRY") updated = await backend.entity_edit_geometry(c.handle, cx=5, cy=5, radius=20) diff --git a/tests/test_ezdxf_backend.py b/tests/test_ezdxf_backend.py index 531c915..ccf2662 100644 --- a/tests/test_ezdxf_backend.py +++ b/tests/test_ezdxf_backend.py @@ -1,6 +1,5 @@ """Tests for ezdxf backend — no AutoCAD required.""" - import pytest pytestmark = pytest.mark.asyncio diff --git a/tests/test_http_auth.py b/tests/test_http_auth.py index 4e3544c..32a34a7 100644 --- a/tests/test_http_auth.py +++ b/tests/test_http_auth.py @@ -46,15 +46,18 @@ def _reload_server_with_token(token: str | None): # Auth object construction # --------------------------------------------------------------------------- + def test_auth_object_built_when_token_set(): """_auth is a StaticTokenVerifier when MCP_AUTH_TOKEN is non-empty.""" with patch.dict("os.environ", {"MCP_AUTH_TOKEN": "secret-test-token-123"}): import config as cfg + old_token = cfg.settings.mcp_auth_token cfg.settings.mcp_auth_token = "secret-test-token-123" try: sys.modules.pop("server", None) import server as srv + assert srv._auth is not None assert isinstance(srv._auth, StaticTokenVerifier) finally: @@ -65,11 +68,13 @@ def test_auth_object_built_when_token_set(): def test_auth_object_none_when_no_token(): """_auth is None when MCP_AUTH_TOKEN is empty (anonymous loopback mode).""" import config as cfg + old_token = cfg.settings.mcp_auth_token cfg.settings.mcp_auth_token = "" try: sys.modules.pop("server", None) import server as srv + assert srv._auth is None finally: cfg.settings.mcp_auth_token = old_token @@ -79,11 +84,13 @@ def test_auth_object_none_when_no_token(): def test_mcp_receives_auth_when_set(): """The FastMCP instance's auth attribute matches the _auth object.""" import config as cfg + old_token = cfg.settings.mcp_auth_token cfg.settings.mcp_auth_token = "another-secret" try: sys.modules.pop("server", None) import server as srv + # FastMCP stores auth as _auth_provider or similar; we check it via # the _auth module-level variable matching what was passed in. assert srv._auth is not None @@ -97,11 +104,13 @@ def test_mcp_receives_auth_when_set(): # _validate_http_bind guard # --------------------------------------------------------------------------- + def test_validate_http_bind_loopback_no_error(): """Loopback hosts pass validation regardless of token/allow_remote flags.""" sys.modules.pop("server", None) import config as cfg import server as srv + old_remote = cfg.settings.allow_remote_http old_token = cfg.settings.mcp_auth_token cfg.settings.allow_remote_http = False @@ -121,6 +130,7 @@ def test_validate_http_bind_remote_without_flag_raises(): sys.modules.pop("server", None) import config as cfg import server as srv + old = cfg.settings.allow_remote_http cfg.settings.allow_remote_http = False try: @@ -135,6 +145,7 @@ def test_validate_http_bind_remote_without_token_raises(): sys.modules.pop("server", None) import config as cfg import server as srv + old_remote = cfg.settings.allow_remote_http old_token = cfg.settings.mcp_auth_token cfg.settings.allow_remote_http = True @@ -150,9 +161,11 @@ def test_validate_http_bind_remote_without_token_raises(): def test_validate_http_bind_remote_with_token_logs_warning(caplog): """Remote bind with both flag and token logs a warning but does not raise.""" import logging + sys.modules.pop("server", None) import config as cfg import server as srv + old_remote = cfg.settings.allow_remote_http old_token = cfg.settings.mcp_auth_token cfg.settings.allow_remote_http = True @@ -160,7 +173,9 @@ def test_validate_http_bind_remote_with_token_logs_warning(caplog): try: with caplog.at_level(logging.WARNING, logger="server"): srv._validate_http_bind("0.0.0.0") # should not raise - assert any("non-loopback" in r.message.lower() or "0.0.0.0" in r.message for r in caplog.records) + assert any( + "non-loopback" in r.message.lower() or "0.0.0.0" in r.message for r in caplog.records + ) finally: cfg.settings.allow_remote_http = old_remote cfg.settings.mcp_auth_token = old_token diff --git a/tests/test_meta_tools.py b/tests/test_meta_tools.py index e1f9a3c..71b848d 100644 --- a/tests/test_meta_tools.py +++ b/tests/test_meta_tools.py @@ -16,10 +16,13 @@ def _close(a, b, tol=1e-6): # ─── drawing_plan ────────────────────────────────────────────────────────── + class TestDrawingPlan: async def test_plan_returns_spec_with_intent(self, backend): plan = await backend.drawing_plan( - "L-bracket 50x80", sheet_size="A4", scale=1.0, + "L-bracket 50x80", + sheet_size="A4", + scale=1.0, ) assert plan.intent == "L-bracket 50x80" assert plan.sheet_size == "A4" @@ -36,6 +39,7 @@ class TestDrawingPlan: # ─── drawing_critique ────────────────────────────────────────────────────── + class TestDrawingCritique: async def test_critique_clean_drawing(self, backend): # Bootstrap layer set so layer_color/iso128 don't trigger. @@ -73,6 +77,7 @@ class TestDrawingCritique: # ─── point_from_snap ─────────────────────────────────────────────────────── + class TestPointFromSnap: async def test_snap_end_default(self, backend): line = await backend.entity_create_line(0, 0, 100, 0) @@ -122,6 +127,7 @@ class TestPointFromSnap: # ─── construction layer ─────────────────────────────────────────────────── + class TestConstructionLayer: async def test_xline_creates_on_construction_layer(self, backend): xl = await backend.construction_xline(0, 0, 45) @@ -140,6 +146,7 @@ class TestConstructionLayer: # ─── drawing_apply_iso_layers ────────────────────────────────────────────── + class TestApplyISOLayers: async def test_mech_layer_set(self, backend): result = await backend.drawing_apply_iso_layers("mech") @@ -170,13 +177,15 @@ class TestApplyISOLayers: # ─── dimension_auto ──────────────────────────────────────────────────────── + class TestDimensionAuto: async def test_chain_creates_one_dim_per_segment(self, backend): l1 = await backend.entity_create_line(0, 0, 30, 0) l2 = await backend.entity_create_line(30, 0, 70, 0) l3 = await backend.entity_create_line(70, 0, 100, 0) dims = await backend.dimension_auto( - [l1.handle, l2.handle, l3.handle], style="chain", + [l1.handle, l2.handle, l3.handle], + style="chain", ) assert len(dims) == 3 @@ -198,6 +207,7 @@ class TestDimensionAuto: # ─── entity_select_smart ─────────────────────────────────────────────────── + class TestEntitySelectSmart: async def test_select_by_type(self, backend): await backend.entity_create_line(0, 0, 50, 0) @@ -214,13 +224,13 @@ class TestEntitySelectSmart: assert len(result) == 1 async def test_select_by_near(self, backend): - await backend.entity_create_line(0, 0, 10, 0) # near origin + await backend.entity_create_line(0, 0, 10, 0) # near origin await backend.entity_create_line(1000, 1000, 1010, 1000) # far result = await backend.entity_select_smart({"type": "LINE", "near": [0, 0, 50]}) assert len(result) == 1 async def test_select_by_length_range(self, backend): - await backend.entity_create_line(0, 0, 10, 0) # length 10 + await backend.entity_create_line(0, 0, 10, 0) # length 10 await backend.entity_create_line(0, 50, 100, 50) # length 100 result = await backend.entity_select_smart({"type": "LINE", "length_range": [50, 200]}) assert len(result) == 1 @@ -228,6 +238,7 @@ class TestEntitySelectSmart: # ─── Integration: plan → draw → critique pipeline ────────────────────────── + class TestPremiumPipeline: async def test_full_l_bracket_pipeline_clean(self, backend): """plan → apply_layers → draw + fillet → clear construction → critique = [].""" diff --git a/tests/test_meta_tools_geometry.py b/tests/test_meta_tools_geometry.py index 734807c..dcb4676 100644 --- a/tests/test_meta_tools_geometry.py +++ b/tests/test_meta_tools_geometry.py @@ -13,6 +13,7 @@ pytestmark = pytest.mark.asyncio # point_intersection # --------------------------------------------------------------------------- + class TestPointIntersection: async def test_line_line_perpendicular(self, backend): """Horizontal and vertical lines cross at (50, 50).""" @@ -91,6 +92,7 @@ class TestPointIntersection: # point_tangent # --------------------------------------------------------------------------- + class TestPointTangent: async def test_tangent_from_outside(self, backend): """From (0, 0) to unit circle at (5, 0): tangent point is at (25/5, ±12/5).""" diff --git a/tests/test_preflight.py b/tests/test_preflight.py index d9d4037..3bec70a 100644 --- a/tests/test_preflight.py +++ b/tests/test_preflight.py @@ -100,7 +100,8 @@ def test_preflight_does_not_invent_values_when_assumptions_disabled(): @pytest.mark.asyncio async def test_backend_plan_accepts_matching_ready_preflight(backend): preflight = await backend.drawing_preflight( - "L bracket", requirements=_complete_requirements(), + "L bracket", + requirements=_complete_requirements(), ) plan = await backend.drawing_plan( @@ -125,7 +126,8 @@ async def test_backend_plan_rejects_not_ready_preflight_hash(backend): @pytest.mark.asyncio async def test_backend_plan_rejects_fields_that_do_not_match_preflight_hash(backend): preflight = await backend.drawing_preflight( - "L bracket", requirements=_complete_requirements(), + "L bracket", + requirements=_complete_requirements(), ) with pytest.raises(RuntimeError, match="do not match"): @@ -148,7 +150,8 @@ async def test_legacy_plan_remains_compatible_and_marks_preflight_skipped(backen @pytest.mark.asyncio async def test_document_change_clears_plan_preflight_and_gdt_state(backend): preflight = await backend.drawing_preflight( - "First drawing", requirements=_complete_requirements(), + "First drawing", + requirements=_complete_requirements(), ) await backend.drawing_plan( "First drawing", diff --git a/tests/test_refiner.py b/tests/test_refiner.py index 7786830..1e0d748 100644 --- a/tests/test_refiner.py +++ b/tests/test_refiner.py @@ -66,9 +66,7 @@ async def test_refiner_repairs_construction_and_duplicates(backend): assert result.status == "threshold_met" assert result.final_score == 100.0 assert result.rounds[0].score_after > result.rounds[0].score_before - assert await backend.drawing_critique( - ["duplicate_entities", "construction_left"] - ) == [] + assert await backend.drawing_critique(["duplicate_entities", "construction_left"]) == [] async def test_refiner_dry_run_does_not_mutate_drawing(backend): diff --git a/tests/test_security.py b/tests/test_security.py index b4fe15f..4b06c5b 100644 --- a/tests/test_security.py +++ b/tests/test_security.py @@ -73,6 +73,7 @@ class TestValidatePath: monkeypatch.setenv("ALLOWED_PATHS", str(allowed_dir)) import config + config.settings = Settings() blocked = tmp_path / "blocked" / "file.dxf" @@ -98,6 +99,7 @@ class TestValidatePath: monkeypatch.setenv("ALLOWED_PATHS", str(allowed_dir)) import config + config.settings = Settings() result = validate_path(str(test_file)) @@ -160,6 +162,7 @@ class TestSanitizeCommand: monkeypatch.setenv("DANGEROUS_COMMANDS_ENABLED", "true") import config + config.settings = Settings() result = sanitize_command("ERASE ALL") @@ -222,6 +225,7 @@ class TestSanitizeLisp: monkeypatch.setenv("DANGEROUS_COMMANDS_ENABLED", "true") import config + config.settings = Settings() result = sanitize_lisp('(startapp "cmd")') diff --git a/tests/test_sprint2_fixes.py b/tests/test_sprint2_fixes.py index 0ed25f0..9165e6a 100644 --- a/tests/test_sprint2_fixes.py +++ b/tests/test_sprint2_fixes.py @@ -32,6 +32,7 @@ class _FakeCtx: # ── 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.""" @@ -43,6 +44,7 @@ async def test_screenshot_renders_png_without_gui_thread_crash(backend): # ── 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 @@ -68,6 +70,7 @@ async def test_finalize_runs_critique_and_blocks_on_construction(backend, tmp_pa # ── 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. @@ -80,6 +83,7 @@ async def test_dim_overlap_critique_is_not_a_noop(backend): # ── 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" @@ -102,6 +106,7 @@ async def test_iso13567_dim_and_construction_layers_resolve(backend): # ── NEW-AUTH-1 — bind guard on the run_async launch path ──────────────────── + async def test_run_async_invokes_bind_guard_for_http(monkeypatch): import server @@ -121,8 +126,7 @@ 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)) + monkeypatch.setattr(server, "_validate_http_bind", lambda h: seen.setdefault("host", h)) async def fake_orig(*args, **kwargs): return "stdio-ran" @@ -135,6 +139,7 @@ async def test_run_async_skips_guard_for_stdio(monkeypatch): # ── 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 @@ -144,19 +149,28 @@ async def test_gear_outline_stays_outside_root_circle(): 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}" + 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, + 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"]) @@ -167,7 +181,8 @@ async def test_gear_front_view_base_circle_persists_and_keyway_validates(backend # 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}, + backend, + expected={"must_have_bore": True, "must_have_keyway": True}, ) assert "keyway_section_unverified" not in [f.code for f in vr.findings] @@ -178,9 +193,15 @@ async def test_gear_section_does_not_duplicate_bore_lines(backend): 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, + 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 == [] diff --git a/tests/test_sprint3_fixes.py b/tests/test_sprint3_fixes.py index ded4f4c..3f771e4 100644 --- a/tests/test_sprint3_fixes.py +++ b/tests/test_sprint3_fixes.py @@ -1,10 +1,10 @@ """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 +* 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 @@ -27,6 +27,7 @@ class _Ctx: # ── N2 — format follows the file extension ────────────────────────────────── + async def test_save_as_derives_format_from_extension(backend): import server @@ -42,6 +43,7 @@ async def test_save_as_derives_format_from_extension(backend): # ── 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) @@ -55,6 +57,7 @@ async def test_arc_has_length_and_is_selectable(backend): # ── 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) @@ -78,6 +81,7 @@ async def test_mtext_honors_caller_rotation(backend): # ── 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") @@ -90,6 +94,7 @@ async def test_baseline_dim_runs_parallel_to_rotated_baseline(backend): # ── 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) diff --git a/tests/test_sprint4_base.py b/tests/test_sprint4_base.py index 3ba8829..fdb712e 100644 --- a/tests/test_sprint4_base.py +++ b/tests/test_sprint4_base.py @@ -25,6 +25,7 @@ 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).""" @@ -41,10 +42,12 @@ def _on_arc(pt, cx, cy, r, sa_deg, ea_deg, tol=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): @@ -107,6 +110,7 @@ class TestN7ArcAngleMissing: # 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 @@ -145,6 +149,7 @@ class TestNewBase1TangentClamp: # 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 @@ -208,6 +213,7 @@ class TestNewSnapArcSweep: # 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 diff --git a/tests/test_sprint4_com.py b/tests/test_sprint4_com.py index 56ef536..fdbc3a1 100644 --- a/tests/test_sprint4_com.py +++ b/tests/test_sprint4_com.py @@ -34,6 +34,7 @@ def _backend_no_executor() -> ComBackend: # ── 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.""" @@ -98,6 +99,7 @@ async def test_find_hwnd_returns_none_when_win32_unavailable(monkeypatch): # ── S3 — block_list populates BlockInfo.description from .Comments ─────────── + class _Block: """Minimal stand-in for an AutoCAD block definition.""" @@ -167,10 +169,12 @@ async def test_block_list_description_empty_when_comments_none(monkeypatch): async def test_block_list_skips_anonymous_layout_blocks(monkeypatch): - doc = _doc_with_blocks([ - _Block("*Model_Space", comments="ignored"), - _Block("GEAR", comments="spur gear"), - ]) + 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() diff --git a/tests/test_sprint4_ezdxf.py b/tests/test_sprint4_ezdxf.py index 3b5be66..a718fe2 100644 --- a/tests/test_sprint4_ezdxf.py +++ b/tests/test_sprint4_ezdxf.py @@ -21,6 +21,7 @@ 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 @@ -53,6 +54,7 @@ async def test_create_line_continuous_does_not_error(backend): # ── 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) @@ -76,6 +78,7 @@ async def test_block_list_description_defaults_empty(backend): # ── 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 @@ -92,6 +95,7 @@ async def test_zoom_window_reports_not_applied(backend): # ── 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 diff --git a/tests/test_sprint4_server.py b/tests/test_sprint4_server.py index c1eec22..72f67ca 100644 --- a/tests/test_sprint4_server.py +++ b/tests/test_sprint4_server.py @@ -35,6 +35,7 @@ class _Ctx: # ── 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 @@ -59,6 +60,7 @@ async def test_registered_tools_returns_objects_with_name_and_tags(): # ── R15 — system_about tool_groups is dynamic + correct ────────────────────── + async def test_system_about_includes_engineering_group(backend): import server @@ -122,6 +124,7 @@ async def test_drawing_close_appears_in_groups(backend): # ── R20 — system_status reports a real tool_count, never -1 ────────────────── + async def test_system_status_reports_real_tool_count(backend): import server @@ -134,6 +137,7 @@ async def test_system_status_reports_real_tool_count(backend): # ── N8 — drawing_plan docstring corrected ──────────────────────────────────── + async def test_drawing_plan_docstring_not_misleading(): import server diff --git a/tests/test_sprint4_validator.py b/tests/test_sprint4_validator.py index 681d000..ca58f7c 100644 --- a/tests/test_sprint4_validator.py +++ b/tests/test_sprint4_validator.py @@ -37,7 +37,11 @@ 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", + 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}) @@ -50,7 +54,11 @@ 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", + 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}) @@ -63,7 +71,11 @@ 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", + 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}) @@ -74,7 +86,11 @@ async def test_substring_in_titleblock_layer_does_not_false_match(backend, tmp_p """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", + 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}) @@ -87,7 +103,11 @@ 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", + 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}) @@ -99,7 +119,11 @@ 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", + 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) @@ -116,10 +140,18 @@ async def test_titleblock_layer_takes_precedence_over_text_layer(backend, tmp_pa """ await _bootstrap(backend) await backend.entity_create_text( - text="HELICAL GEAR", x=0, y=0, height=5, layer="TITLEBLOCK", + 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", + 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}) diff --git a/tests/test_table_leader.py b/tests/test_table_leader.py index de7dc11..d43d8e7 100644 --- a/tests/test_table_leader.py +++ b/tests/test_table_leader.py @@ -29,9 +29,7 @@ async def test_ezdxf_table_returns_composite_contract(backend): async def test_ezdxf_table_survives_save_and_reopen(backend, tmp_path): - result = await backend.entity_create_table( - 0, 30, rows=[["A", "1"]], headers=["Name", "Qty"] - ) + result = await backend.entity_create_table(0, 30, rows=[["A", "1"]], headers=["Name", "Qty"]) path = tmp_path / "table.dxf" await backend.drawing_save_as(str(path), fmt="dxf") await backend.drawing_open(str(path)) @@ -39,9 +37,7 @@ async def test_ezdxf_table_survives_save_and_reopen(backend, tmp_path): entities = await backend.entity_list(limit=100) handles = {entity.handle for entity in entities} texts = { - entity.properties.get("text") - for entity in entities - if entity.type in {"TEXT", "MTEXT"} + entity.properties.get("text") for entity in entities if entity.type in {"TEXT", "MTEXT"} } assert set(result.properties["child_handles"]).issubset(handles) assert {"Name", "Qty", "A", "1"}.issubset(texts)