style: apply ruff format across the repository
ruff format was never run repo-wide; CI now enforces 'ruff format --check', so normalize every module once. No behavior changes. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
4a2e9d658f
commit
9ec6c4a989
@@ -1,4 +1,5 @@
|
|||||||
"""AutoCAD MCP Pro – backend package."""
|
"""AutoCAD MCP Pro – backend package."""
|
||||||
|
|
||||||
from .base import AutoCADBackend, BlockInfo, DrawingInfo, EntityInfo, LayerInfo
|
from .base import AutoCADBackend, BlockInfo, DrawingInfo, EntityInfo, LayerInfo
|
||||||
from .com_backend import ComBackend
|
from .com_backend import ComBackend
|
||||||
from .ezdxf_backend import EzdxfBackend
|
from .ezdxf_backend import EzdxfBackend
|
||||||
|
|||||||
@@ -90,10 +90,11 @@ class AutoCADMCPProAdapter(BenchmarkAdapter):
|
|||||||
table = await self.backend.entity_create_table(
|
table = await self.backend.entity_create_table(
|
||||||
0, 20, [["A", "1"]], headers=["ITEM", "QTY"], layer="TEXT"
|
0, 20, [["A", "1"]], headers=["ITEM", "QTY"], layer="TEXT"
|
||||||
)
|
)
|
||||||
leader = await self.backend.leader_create_mleader(
|
leader = await self.backend.leader_create_mleader([[0, 0], [10, 10]], "NOTE", layer="DIM")
|
||||||
[[0, 0], [10, 10]], "NOTE", layer="DIM"
|
representations = [
|
||||||
)
|
table.properties.get("representation"),
|
||||||
representations = [table.properties.get("representation"), leader.properties.get("representation")]
|
leader.properties.get("representation"),
|
||||||
|
]
|
||||||
return all(representations), {"representations": representations}, []
|
return all(representations), {"representations": representations}, []
|
||||||
|
|
||||||
async def _task_transactions(self):
|
async def _task_transactions(self):
|
||||||
|
|||||||
@@ -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
|
The older ref is checked out into a throwaway git worktree, benchmarked, and
|
||||||
removed. The "new" side is always the current repository checkout.
|
removed. The "new" side is always the current repository checkout.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
@@ -27,8 +28,7 @@ PY = sys.executable
|
|||||||
|
|
||||||
def _run(cmd, cwd, repo_on_path):
|
def _run(cmd, cwd, repo_on_path):
|
||||||
env = {**os.environ, "PYTHONPATH": repo_on_path}
|
env = {**os.environ, "PYTHONPATH": repo_on_path}
|
||||||
return subprocess.run(cmd, cwd=cwd, env=env, capture_output=True,
|
return subprocess.run(cmd, cwd=cwd, env=env, capture_output=True, text=True, timeout=120)
|
||||||
text=True, timeout=120)
|
|
||||||
|
|
||||||
|
|
||||||
def list_checks():
|
def list_checks():
|
||||||
@@ -43,9 +43,14 @@ def list_checks():
|
|||||||
|
|
||||||
def run_one(repo, suite, name):
|
def run_one(repo, suite, name):
|
||||||
try:
|
try:
|
||||||
r = subprocess.run([PY, suite, name], cwd=repo,
|
r = subprocess.run(
|
||||||
|
[PY, suite, name],
|
||||||
|
cwd=repo,
|
||||||
env={**os.environ, "PYTHONPATH": repo},
|
env={**os.environ, "PYTHONPATH": repo},
|
||||||
capture_output=True, text=True, timeout=120)
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=120,
|
||||||
|
)
|
||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
return "timeout"
|
return "timeout"
|
||||||
last = (r.stdout.strip().splitlines() or [""])[-1].strip()
|
last = (r.stdout.strip().splitlines() or [""])[-1].strip()
|
||||||
@@ -82,22 +87,30 @@ def main():
|
|||||||
op = sum(r["old"] == "pass" for r in rows)
|
op = sum(r["old"] == "pass" for r in rows)
|
||||||
npass = sum(r["new"] == "pass" for r in rows)
|
npass = sum(r["new"] == "pass" for r in rows)
|
||||||
summary = {
|
summary = {
|
||||||
"baseline_ref": ref, "total": total,
|
"baseline_ref": ref,
|
||||||
"old_pass": op, "new_pass": npass,
|
"total": total,
|
||||||
"old_pct": round(100 * op / total, 1), "new_pct": round(100 * npass / total, 1),
|
"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),
|
"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),
|
"regressed": sum(r["old"] == "pass" and r["new"] != "pass" for r in rows),
|
||||||
}
|
}
|
||||||
print(f"{'CHECK':<32} {'CATEGORY':<13} {'baseline':<8} {'current':<8}")
|
print(f"{'CHECK':<32} {'CATEGORY':<13} {'baseline':<8} {'current':<8}")
|
||||||
print("-" * 66)
|
print("-" * 66)
|
||||||
for r in rows:
|
for r in rows:
|
||||||
mark = " ->FIXED" if (r["old"] != "pass" and r["new"] == "pass") else (
|
mark = (
|
||||||
" !!REGRESS" if (r["old"] == "pass" and r["new"] != "pass") else "")
|
" ->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(f"{r['check']:<32} {r['category']:<13} {r['old']:<8} {r['new']:<8}{mark}")
|
||||||
print("-" * 66)
|
print("-" * 66)
|
||||||
print(f"baseline {ref}: {op}/{total} ({summary['old_pct']}%) "
|
print(
|
||||||
|
f"baseline {ref}: {op}/{total} ({summary['old_pct']}%) "
|
||||||
f"current: {npass}/{total} ({summary['new_pct']}%) "
|
f"current: {npass}/{total} ({summary['new_pct']}%) "
|
||||||
f"fixed: {summary['fixed']} regressed: {summary['regressed']}")
|
f"fixed: {summary['fixed']} regressed: {summary['regressed']}"
|
||||||
|
)
|
||||||
if json_out:
|
if json_out:
|
||||||
with open(json_out, "w") as fh:
|
with open(json_out, "w") as fh:
|
||||||
json.dump({"summary": summary, "rows": rows}, fh, indent=2)
|
json.dump({"summary": summary, "rows": rows}, fh, indent=2)
|
||||||
|
|||||||
@@ -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
|
checkout (PYTHONPATH + cwd point at each repo), so a check that exercises a
|
||||||
method the old version lacks simply raises AttributeError -> miss.
|
method the old version lacks simply raises AttributeError -> miss.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
@@ -23,6 +24,7 @@ import tempfile
|
|||||||
|
|
||||||
async def _b():
|
async def _b():
|
||||||
from backends.ezdxf_backend import EzdxfBackend
|
from backends.ezdxf_backend import EzdxfBackend
|
||||||
|
|
||||||
be = EzdxfBackend()
|
be = EzdxfBackend()
|
||||||
await be.connect()
|
await be.connect()
|
||||||
await be.drawing_new()
|
await be.drawing_new()
|
||||||
@@ -31,53 +33,62 @@ async def _b():
|
|||||||
|
|
||||||
# ── Core capability (expected to pass on BOTH versions — sanity / not rigged) ──
|
# ── Core capability (expected to pass on BOTH versions — sanity / not rigged) ──
|
||||||
|
|
||||||
|
|
||||||
async def core_line_length():
|
async def core_line_length():
|
||||||
b = await _b()
|
b = await _b()
|
||||||
ln = await b.entity_create_line(0, 0, 3, 4)
|
ln = await b.entity_create_line(0, 0, 3, 4)
|
||||||
info = await b.entity_get(ln.handle)
|
info = await b.entity_get(ln.handle)
|
||||||
return abs(float(info.properties["length"]) - 5.0) < 1e-6
|
return abs(float(info.properties["length"]) - 5.0) < 1e-6
|
||||||
|
|
||||||
|
|
||||||
async def core_circle_radius():
|
async def core_circle_radius():
|
||||||
b = await _b()
|
b = await _b()
|
||||||
c = await b.entity_create_circle(0, 0, 10)
|
c = await b.entity_create_circle(0, 0, 10)
|
||||||
info = await b.entity_get(c.handle)
|
info = await b.entity_get(c.handle)
|
||||||
return abs(float(info.properties["radius"]) - 10.0) < 1e-6
|
return abs(float(info.properties["radius"]) - 10.0) < 1e-6
|
||||||
|
|
||||||
|
|
||||||
async def core_layer_create():
|
async def core_layer_create():
|
||||||
b = await _b()
|
b = await _b()
|
||||||
await b.layer_create("BENCH", color=1)
|
await b.layer_create("BENCH", color=1)
|
||||||
names = {lyr.name for lyr in await b.layer_list()}
|
names = {lyr.name for lyr in await b.layer_list()}
|
||||||
return "BENCH" in names
|
return "BENCH" in names
|
||||||
|
|
||||||
|
|
||||||
async def core_polyline_closed():
|
async def core_polyline_closed():
|
||||||
b = await _b()
|
b = await _b()
|
||||||
p = await b.entity_create_polyline([[0, 0], [10, 0], [10, 10]], closed=True)
|
p = await b.entity_create_polyline([[0, 0], [10, 0], [10, 10]], closed=True)
|
||||||
info = await b.entity_get(p.handle)
|
info = await b.entity_get(p.handle)
|
||||||
return bool(info.properties.get("closed"))
|
return bool(info.properties.get("closed"))
|
||||||
|
|
||||||
|
|
||||||
async def core_linear_dim():
|
async def core_linear_dim():
|
||||||
b = await _b()
|
b = await _b()
|
||||||
d = await b.dimension_linear(0, 0, 100, 0, 50, 20)
|
d = await b.dimension_linear(0, 0, 100, 0, 50, 20)
|
||||||
info = await b.entity_get(d.handle)
|
info = await b.entity_get(d.handle)
|
||||||
return "DIM" in info.type.upper()
|
return "DIM" in info.type.upper()
|
||||||
|
|
||||||
|
|
||||||
async def core_save_dxf_roundtrip():
|
async def core_save_dxf_roundtrip():
|
||||||
b = await _b()
|
b = await _b()
|
||||||
await b.entity_create_line(0, 0, 10, 10)
|
await b.entity_create_line(0, 0, 10, 10)
|
||||||
path = os.path.join(tempfile.mkdtemp(), "rt.dxf")
|
path = os.path.join(tempfile.mkdtemp(), "rt.dxf")
|
||||||
await b.drawing_save_as(path, "dxf")
|
await b.drawing_save_as(path, "dxf")
|
||||||
import ezdxf
|
import ezdxf
|
||||||
|
|
||||||
ezdxf.readfile(path) # raises if not a valid DXF
|
ezdxf.readfile(path) # raises if not a valid DXF
|
||||||
return os.path.getsize(path) > 0
|
return os.path.getsize(path) > 0
|
||||||
|
|
||||||
|
|
||||||
# ── Dimensions (I16) ──────────────────────────────────────────────────────────
|
# ── Dimensions (I16) ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
async def dim_aligned_no_error():
|
async def dim_aligned_no_error():
|
||||||
b = await _b()
|
b = await _b()
|
||||||
d = await b.dimension_aligned(0, 0, 50, 50, 30, 70)
|
d = await b.dimension_aligned(0, 0, 50, 50, 30, 70)
|
||||||
return d is not None
|
return d is not None
|
||||||
|
|
||||||
|
|
||||||
async def dim_angular_no_error():
|
async def dim_angular_no_error():
|
||||||
b = await _b()
|
b = await _b()
|
||||||
d = await b.dimension_angular(0, 0, 100, 0, 0, 100, 60, 60)
|
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) ──────────────────────────────────────────────
|
# ── Polar array full circle (R9) ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
async def array_polar_360_distinct():
|
async def array_polar_360_distinct():
|
||||||
b = await _b()
|
b = await _b()
|
||||||
c = await b.entity_create_circle(10, 0, 1)
|
c = await b.entity_create_circle(10, 0, 1)
|
||||||
@@ -101,6 +113,7 @@ async def array_polar_360_distinct():
|
|||||||
|
|
||||||
# ── Deterministic geometry (I9) ────────────────────────────────────────────────
|
# ── Deterministic geometry (I9) ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
async def point_intersection_line_line():
|
async def point_intersection_line_line():
|
||||||
b = await _b()
|
b = await _b()
|
||||||
l1 = await b.entity_create_line(0, 0, 10, 0)
|
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)
|
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
|
return abs(pt[0] - 5.0) < 1e-6 and abs(pt[1] - 0.0) < 1e-6
|
||||||
|
|
||||||
|
|
||||||
async def point_tangent_external():
|
async def point_tangent_external():
|
||||||
b = await _b()
|
b = await _b()
|
||||||
c = await b.entity_create_circle(0, 0, 3)
|
c = await b.entity_create_circle(0, 0, 3)
|
||||||
@@ -120,18 +134,21 @@ async def point_tangent_external():
|
|||||||
|
|
||||||
# ── Selection / property parity (N3, N5) ───────────────────────────────────────
|
# ── Selection / property parity (N3, N5) ───────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
async def arc_has_length():
|
async def arc_has_length():
|
||||||
b = await _b()
|
b = await _b()
|
||||||
a = await b.entity_create_arc(0, 0, 10, 0, 90)
|
a = await b.entity_create_arc(0, 0, 10, 0, 90)
|
||||||
info = await b.entity_get(a.handle)
|
info = await b.entity_get(a.handle)
|
||||||
return abs(float(info.properties["length"]) - (10 * math.pi / 2)) < 1e-3
|
return abs(float(info.properties["length"]) - (10 * math.pi / 2)) < 1e-3
|
||||||
|
|
||||||
|
|
||||||
async def arc_select_by_length():
|
async def arc_select_by_length():
|
||||||
b = await _b()
|
b = await _b()
|
||||||
await b.entity_create_arc(0, 0, 10, 0, 90) # length ~15.7
|
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]})
|
sel = await b.entity_select_smart({"type": "ARC", "length_range": [15, 16]})
|
||||||
return len(sel) == 1
|
return len(sel) == 1
|
||||||
|
|
||||||
|
|
||||||
async def ezdxf_bounding_box():
|
async def ezdxf_bounding_box():
|
||||||
b = await _b()
|
b = await _b()
|
||||||
ln = await b.entity_create_line(0, 0, 30, 40)
|
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")
|
bb = info.properties.get("bounding_box")
|
||||||
return isinstance(bb, dict) and set(bb) == {"min", "max"}
|
return isinstance(bb, dict) and set(bb) == {"min", "max"}
|
||||||
|
|
||||||
|
|
||||||
async def mtext_rotation_roundtrip():
|
async def mtext_rotation_roundtrip():
|
||||||
b = await _b()
|
b = await _b()
|
||||||
mt = await b.entity_create_mtext("r", 0, 0, width=50, height=2.5, rotation=30.0)
|
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) ───────────────────────────────────────
|
# ── Rendering (screenshot Agg / SIGSEGV) ───────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
async def screenshot_png():
|
async def screenshot_png():
|
||||||
b = await _b()
|
b = await _b()
|
||||||
await b.entity_create_line(0, 0, 100, 100)
|
await b.entity_create_line(0, 0, 100, 100)
|
||||||
@@ -157,6 +176,7 @@ async def screenshot_png():
|
|||||||
|
|
||||||
# ── Quality gate / critique (R4, N4) ───────────────────────────────────────────
|
# ── Quality gate / critique (R4, N4) ───────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
async def dim_overlap_critique_fires():
|
async def dim_overlap_critique_fires():
|
||||||
b = await _b()
|
b = await _b()
|
||||||
await b.drawing_apply_iso_layers("mech")
|
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"])
|
issues = await b.drawing_critique(focus=["dim_overlap"])
|
||||||
return len(issues) >= 1
|
return len(issues) >= 1
|
||||||
|
|
||||||
|
|
||||||
async def iso13567_dim_layer():
|
async def iso13567_dim_layer():
|
||||||
b = await _b()
|
b = await _b()
|
||||||
await b.drawing_apply_iso_layers("iso13567")
|
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")
|
dims = await b.dimension_auto([ln.handle], style="chain")
|
||||||
return dims[0].layer == "M-DIMEN-T-N"
|
return dims[0].layer == "M-DIMEN-T-N"
|
||||||
|
|
||||||
|
|
||||||
async def construction_left_iso_caught():
|
async def construction_left_iso_caught():
|
||||||
b = await _b()
|
b = await _b()
|
||||||
await b.drawing_apply_iso_layers("iso13567")
|
await b.drawing_apply_iso_layers("iso13567")
|
||||||
@@ -182,8 +204,10 @@ async def construction_left_iso_caught():
|
|||||||
|
|
||||||
# ── Engineering (gear) ──────────────────────────────────────────────────────────
|
# ── Engineering (gear) ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
async def gear_no_self_overlap():
|
async def gear_no_self_overlap():
|
||||||
from engineering.gear import generate_full_gear_outline
|
from engineering.gear import generate_full_gear_outline
|
||||||
|
|
||||||
pts = generate_full_gear_outline(2, 50, 20.0) # z=50 -> root_r >= base_r
|
pts = generate_full_gear_outline(2, 50, 20.0) # z=50 -> root_r >= base_r
|
||||||
root_r = (2 * 50 / 2.0) - 1.25 * 2
|
root_r = (2 * 50 / 2.0) - 1.25 * 2
|
||||||
return min(math.hypot(x, y) for x, y in pts) >= root_r - 1e-6
|
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) ───────────────────────────────────────────────────
|
# ── Linetype on demand (R24) ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
async def center_linetype_applied():
|
async def center_linetype_applied():
|
||||||
b = await _b()
|
b = await _b()
|
||||||
ln = await b.entity_create_line(0, 0, 50, 0, linetype="CENTER")
|
ln = await b.entity_create_line(0, 0, 50, 0, linetype="CENTER")
|
||||||
|
|||||||
+43
-20
@@ -29,8 +29,13 @@ _EXPECTED_LAYER_COLOR: dict[str, int] = {
|
|||||||
|
|
||||||
# Dimension entity types that should sit on the DIM layer.
|
# Dimension entity types that should sit on the DIM layer.
|
||||||
_DIM_TYPES = {
|
_DIM_TYPES = {
|
||||||
"DIMENSION", "DIMLINEAR", "DIMALIGNED", "DIMANGULAR",
|
"DIMENSION",
|
||||||
"DIMRADIUS", "DIMDIAMETER", "DIMORDINATE",
|
"DIMLINEAR",
|
||||||
|
"DIMALIGNED",
|
||||||
|
"DIMANGULAR",
|
||||||
|
"DIMRADIUS",
|
||||||
|
"DIMDIAMETER",
|
||||||
|
"DIMORDINATE",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -57,7 +62,8 @@ async def _check_iso128(backend: AutoCADBackend) -> list[Issue]:
|
|||||||
# (e.g. 25 == 0.25mm) — they are NOT pre-normalised to mm, so accept
|
# (e.g. 25 == 0.25mm) — they are NOT pre-normalised to mm, so accept
|
||||||
# either the raw mm value or hundredths/100.
|
# either the raw mm value or hundredths/100.
|
||||||
if lw not in allowed and (lw / 100.0) not in allowed:
|
if lw not in allowed and (lw / 100.0) not in allowed:
|
||||||
issues.append(Issue(
|
issues.append(
|
||||||
|
Issue(
|
||||||
severity="warning",
|
severity="warning",
|
||||||
focus="iso128",
|
focus="iso128",
|
||||||
message=(
|
message=(
|
||||||
@@ -65,7 +71,8 @@ async def _check_iso128(backend: AutoCADBackend) -> list[Issue]:
|
|||||||
f"ISO 128 set {sorted(allowed)}."
|
f"ISO 128 set {sorted(allowed)}."
|
||||||
),
|
),
|
||||||
detail={"layer": lyr.name, "lineweight": lw},
|
detail={"layer": lyr.name, "lineweight": lw},
|
||||||
))
|
)
|
||||||
|
)
|
||||||
return issues
|
return issues
|
||||||
|
|
||||||
|
|
||||||
@@ -81,7 +88,8 @@ async def _check_layer_color(backend: AutoCADBackend) -> list[Issue]:
|
|||||||
if expected is None:
|
if expected is None:
|
||||||
continue # unknown layer — caller's responsibility
|
continue # unknown layer — caller's responsibility
|
||||||
if int(lyr.color) != int(expected):
|
if int(lyr.color) != int(expected):
|
||||||
issues.append(Issue(
|
issues.append(
|
||||||
|
Issue(
|
||||||
severity="warning",
|
severity="warning",
|
||||||
focus="layer_color",
|
focus="layer_color",
|
||||||
message=(
|
message=(
|
||||||
@@ -89,7 +97,8 @@ async def _check_layer_color(backend: AutoCADBackend) -> list[Issue]:
|
|||||||
f"engineering standard expects {expected}."
|
f"engineering standard expects {expected}."
|
||||||
),
|
),
|
||||||
detail={"layer": lyr.name, "got": int(lyr.color), "expected": expected},
|
detail={"layer": lyr.name, "got": int(lyr.color), "expected": expected},
|
||||||
))
|
)
|
||||||
|
)
|
||||||
return issues
|
return issues
|
||||||
|
|
||||||
|
|
||||||
@@ -107,7 +116,8 @@ async def _check_construction_left(backend: AutoCADBackend) -> list[Issue]:
|
|||||||
leftover = [e for e in ents if "CONST" in (e.layer or "").upper()]
|
leftover = [e for e in ents if "CONST" in (e.layer or "").upper()]
|
||||||
if not leftover:
|
if not leftover:
|
||||||
return []
|
return []
|
||||||
return [Issue(
|
return [
|
||||||
|
Issue(
|
||||||
severity="error",
|
severity="error",
|
||||||
focus="construction_left",
|
focus="construction_left",
|
||||||
message=(
|
message=(
|
||||||
@@ -115,9 +125,12 @@ async def _check_construction_left(backend: AutoCADBackend) -> list[Issue]:
|
|||||||
"Call construction_clear() before drawing_finalize."
|
"Call construction_clear() before drawing_finalize."
|
||||||
),
|
),
|
||||||
handles=[e.handle for e in leftover[:50]],
|
handles=[e.handle for e in leftover[:50]],
|
||||||
detail={"count": len(leftover),
|
detail={
|
||||||
"layers": sorted({e.layer for e in leftover if e.layer})},
|
"count": len(leftover),
|
||||||
)]
|
"layers": sorted({e.layer for e in leftover if e.layer}),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
async def _check_untrimmed_corner(backend: AutoCADBackend) -> list[Issue]:
|
async def _check_untrimmed_corner(backend: AutoCADBackend) -> list[Issue]:
|
||||||
@@ -150,7 +163,8 @@ async def _check_untrimmed_corner(backend: AutoCADBackend) -> list[Issue]:
|
|||||||
if key in seen:
|
if key in seen:
|
||||||
continue
|
continue
|
||||||
seen.add(key)
|
seen.add(key)
|
||||||
issues.append(Issue(
|
issues.append(
|
||||||
|
Issue(
|
||||||
severity="warning",
|
severity="warning",
|
||||||
focus="untrimmed_corner",
|
focus="untrimmed_corner",
|
||||||
message=(
|
message=(
|
||||||
@@ -159,7 +173,8 @@ async def _check_untrimmed_corner(backend: AutoCADBackend) -> list[Issue]:
|
|||||||
),
|
),
|
||||||
handles=[h1, h2],
|
handles=[h1, h2],
|
||||||
detail={"gap_mm": d, "p1": list(p1), "p2": list(p2)},
|
detail={"gap_mm": d, "p1": list(p1), "p2": list(p2)},
|
||||||
))
|
)
|
||||||
|
)
|
||||||
return issues
|
return issues
|
||||||
|
|
||||||
|
|
||||||
@@ -180,12 +195,14 @@ async def _check_duplicate_entities(backend: AutoCADBackend) -> list[Issue]:
|
|||||||
k2 = (k1[2], k1[3], k1[0], k1[1])
|
k2 = (k1[2], k1[3], k1[0], k1[1])
|
||||||
for key in (k1, k2):
|
for key in (k1, k2):
|
||||||
if key in seen:
|
if key in seen:
|
||||||
issues.append(Issue(
|
issues.append(
|
||||||
|
Issue(
|
||||||
severity="warning",
|
severity="warning",
|
||||||
focus="duplicate_entities",
|
focus="duplicate_entities",
|
||||||
message=f"Lines {seen[key]} and {ln.handle} are duplicates.",
|
message=f"Lines {seen[key]} and {ln.handle} are duplicates.",
|
||||||
handles=[seen[key], ln.handle],
|
handles=[seen[key], ln.handle],
|
||||||
))
|
)
|
||||||
|
)
|
||||||
break
|
break
|
||||||
else:
|
else:
|
||||||
seen[k1] = ln.handle
|
seen[k1] = ln.handle
|
||||||
@@ -243,12 +260,14 @@ async def _check_dim_overlap(backend: AutoCADBackend) -> list[Issue]:
|
|||||||
if key in seen:
|
if key in seen:
|
||||||
continue
|
continue
|
||||||
seen.add(key)
|
seen.add(key)
|
||||||
issues.append(Issue(
|
issues.append(
|
||||||
|
Issue(
|
||||||
severity="warning",
|
severity="warning",
|
||||||
focus="dim_overlap",
|
focus="dim_overlap",
|
||||||
message=f"Dimensions {h1} and {h2} are {d:.2f} mm apart — likely overlapping.",
|
message=f"Dimensions {h1} and {h2} are {d:.2f} mm apart — likely overlapping.",
|
||||||
handles=[h1, h2],
|
handles=[h1, h2],
|
||||||
))
|
)
|
||||||
|
)
|
||||||
return issues
|
return issues
|
||||||
|
|
||||||
|
|
||||||
@@ -267,7 +286,8 @@ async def _check_gdt(backend: AutoCADBackend) -> list[Issue]:
|
|||||||
missing = sorted(referenced - defined)
|
missing = sorted(referenced - defined)
|
||||||
if not missing:
|
if not missing:
|
||||||
return []
|
return []
|
||||||
return [Issue(
|
return [
|
||||||
|
Issue(
|
||||||
severity="error",
|
severity="error",
|
||||||
focus="gdt",
|
focus="gdt",
|
||||||
message=(
|
message=(
|
||||||
@@ -279,7 +299,8 @@ async def _check_gdt(backend: AutoCADBackend) -> list[Issue]:
|
|||||||
"defined": sorted(defined),
|
"defined": sorted(defined),
|
||||||
"referenced": sorted(referenced),
|
"referenced": sorted(referenced),
|
||||||
},
|
},
|
||||||
)]
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
_FOCUS_DISPATCH = {
|
_FOCUS_DISPATCH = {
|
||||||
@@ -303,11 +324,13 @@ async def run_critique(
|
|||||||
for f in foci:
|
for f in foci:
|
||||||
check = _FOCUS_DISPATCH.get(f)
|
check = _FOCUS_DISPATCH.get(f)
|
||||||
if check is None:
|
if check is None:
|
||||||
issues.append(Issue(
|
issues.append(
|
||||||
|
Issue(
|
||||||
severity="warning",
|
severity="warning",
|
||||||
focus=f, # type: ignore[arg-type]
|
focus=f, # type: ignore[arg-type]
|
||||||
message=f"Unknown critique focus '{f}' (closed enum).",
|
message=f"Unknown critique focus '{f}' (closed enum).",
|
||||||
))
|
)
|
||||||
|
)
|
||||||
continue
|
continue
|
||||||
issues.extend(await check(backend))
|
issues.extend(await check(backend))
|
||||||
return issues
|
return issues
|
||||||
|
|||||||
@@ -163,7 +163,9 @@ async def deliver_drawing(
|
|||||||
expected: dict | None = None,
|
expected: dict | None = None,
|
||||||
) -> DeliveryResult:
|
) -> DeliveryResult:
|
||||||
"""Export, validate, hash and re-open a release-ready drawing bundle."""
|
"""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]
|
unknown = [item for item in requested if item not in _FORMAT_FILENAMES]
|
||||||
if unknown:
|
if unknown:
|
||||||
raise ValueError(f"Unsupported delivery format(s): {', '.join(unknown)}")
|
raise ValueError(f"Unsupported delivery format(s): {', '.join(unknown)}")
|
||||||
@@ -210,9 +212,7 @@ async def deliver_drawing(
|
|||||||
artifact_path.write_bytes(png)
|
artifact_path.write_bytes(png)
|
||||||
artifacts.append(_artifact_record(fmt, artifact_path, status="created"))
|
artifacts.append(_artifact_record(fmt, artifact_path, status="created"))
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
artifacts.append(
|
artifacts.append(_artifact_record(fmt, artifact_path, status="failed", error=str(exc)))
|
||||||
_artifact_record(fmt, artifact_path, status="failed", error=str(exc))
|
|
||||||
)
|
|
||||||
finally:
|
finally:
|
||||||
timings[f"export_{fmt}_ms"] = round((time.perf_counter() - export_started) * 1000, 2)
|
timings[f"export_{fmt}_ms"] = round((time.perf_counter() - export_started) * 1000, 2)
|
||||||
|
|
||||||
|
|||||||
+35
-13
@@ -67,16 +67,18 @@ def generate_involute_flank(
|
|||||||
return points
|
return points
|
||||||
|
|
||||||
|
|
||||||
def _rotate_points(
|
def _rotate_points(pts: list[tuple[float, float]], angle: float) -> list[tuple[float, float]]:
|
||||||
pts: list[tuple[float, float]], angle: float
|
|
||||||
) -> list[tuple[float, float]]:
|
|
||||||
c, s = math.cos(angle), math.sin(angle)
|
c, s = math.cos(angle), math.sin(angle)
|
||||||
return [(c * x - s * y, s * x + c * y) for x, y in pts]
|
return [(c * x - s * y, s * x + c * y) for x, y in pts]
|
||||||
|
|
||||||
|
|
||||||
def _arc_points(
|
def _arc_points(
|
||||||
cx: float, cy: float, radius: float,
|
cx: float,
|
||||||
start_angle: float, end_angle: float, n: int = 5,
|
cy: float,
|
||||||
|
radius: float,
|
||||||
|
start_angle: float,
|
||||||
|
end_angle: float,
|
||||||
|
n: int = 5,
|
||||||
) -> list[tuple[float, float]]:
|
) -> list[tuple[float, float]]:
|
||||||
"""Sample n points along an arc (start_angle → end_angle, radians, ccw)."""
|
"""Sample n points along an arc (start_angle → end_angle, radians, ccw)."""
|
||||||
if n < 2:
|
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")
|
root_circ = await backend.entity_create_circle(cx, cy, root_r, layer="GEOMETRY")
|
||||||
|
|
||||||
pts = generate_full_gear_outline(
|
pts = generate_full_gear_outline(
|
||||||
module=module, teeth=teeth, pressure_angle=pressure_angle,
|
module=module,
|
||||||
helix_angle=helix_angle, hand=hand, center=(cx, cy),
|
teeth=teeth,
|
||||||
|
pressure_angle=pressure_angle,
|
||||||
|
helix_angle=helix_angle,
|
||||||
|
hand=hand,
|
||||||
|
center=(cx, cy),
|
||||||
)
|
)
|
||||||
outline = await backend.entity_create_polyline(
|
outline = await backend.entity_create_polyline(
|
||||||
[[float(x), float(y)] for x, y in pts],
|
[[float(x), float(y)] for x, y in pts],
|
||||||
closed=True, layer="GEOMETRY",
|
closed=True,
|
||||||
|
layer="GEOMETRY",
|
||||||
)
|
)
|
||||||
|
|
||||||
helix_handles: list[str] | None = None
|
helix_handles: list[str] | None = None
|
||||||
helix_label: str | None = None
|
helix_label: str | None = None
|
||||||
if helix_angle and abs(helix_angle) > 1e-9:
|
if helix_angle and abs(helix_angle) > 1e-9:
|
||||||
helix_handles, helix_label = await _draw_helix_symbol(
|
helix_handles, helix_label = await _draw_helix_symbol(
|
||||||
backend, cx=cx, cy=cy, outer_r=outer_r,
|
backend,
|
||||||
helix_angle=helix_angle, hand=hand,
|
cx=cx,
|
||||||
|
cy=cy,
|
||||||
|
outer_r=outer_r,
|
||||||
|
helix_angle=helix_angle,
|
||||||
|
hand=hand,
|
||||||
)
|
)
|
||||||
|
|
||||||
bore_handle: str | None = None
|
bore_handle: str | None = None
|
||||||
@@ -263,7 +274,10 @@ async def draw_helical_gear_front_view(
|
|||||||
keyway_dict = keyed
|
keyway_dict = keyed
|
||||||
else:
|
else:
|
||||||
bore_only = await backend.entity_create_circle(
|
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
|
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"]]
|
bore_handles = [keyway_dict["bore_top"], keyway_dict["bore_bottom"]]
|
||||||
else:
|
else:
|
||||||
bore_top = await backend.entity_create_line(
|
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(
|
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]
|
bore_handles = [bore_top.handle, bore_bot.handle]
|
||||||
|
|
||||||
|
|||||||
+43
-9
@@ -45,7 +45,9 @@ def keyway_dimensions(bore_diameter: float) -> dict:
|
|||||||
f"({DIN6885_TABLE[-1][1]} mm)."
|
f"({DIN6885_TABLE[-1][1]} mm)."
|
||||||
)
|
)
|
||||||
for b_min, b_max, width, height, t1 in DIN6885_TABLE:
|
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 {
|
return {
|
||||||
"width": float(width),
|
"width": float(width),
|
||||||
"height": float(height),
|
"height": float(height),
|
||||||
@@ -95,10 +97,18 @@ async def draw_keyed_bore(
|
|||||||
|
|
||||||
overshoot = max(r * 0.2, 5.0)
|
overshoot = max(r * 0.2, 5.0)
|
||||||
horiz = await backend.entity_create_line(
|
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(
|
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 {
|
return {
|
||||||
@@ -142,23 +152,47 @@ async def draw_keyway_section(
|
|||||||
notch_right = cx + half_kw
|
notch_right = cx + half_kw
|
||||||
|
|
||||||
bore_top = await backend.entity_create_line(
|
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(
|
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_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_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_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_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 {
|
return {
|
||||||
|
|||||||
@@ -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"])
|
roles = LAYER_ROLES.get(layer_set_id or "mech", LAYER_ROLES["mech"])
|
||||||
return roles.get(role, LAYER_ROLES["mech"][role])
|
return roles.get(role, LAYER_ROLES["mech"][role])
|
||||||
|
|
||||||
|
|
||||||
STANDARD_LINETYPES: list[str] = ["CENTER", "HIDDEN", "PHANTOM", "DASHED", "DASHDOT"]
|
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(
|
async def apply_layer_set(
|
||||||
backend: AutoCADBackend, standard: str = "mech",
|
backend: AutoCADBackend,
|
||||||
|
standard: str = "mech",
|
||||||
) -> dict[str, str]:
|
) -> dict[str, str]:
|
||||||
"""Idempotently apply a named layer set ('mech', 'pid', 'iso13567')."""
|
"""Idempotently apply a named layer set ('mech', 'pid', 'iso13567')."""
|
||||||
layer_set = LAYER_SET_REGISTRY.get(standard)
|
layer_set = LAYER_SET_REGISTRY.get(standard)
|
||||||
if layer_set is None:
|
if layer_set is None:
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
f"Unknown layer set '{standard}'. "
|
f"Unknown layer set '{standard}'. Choose one of: {sorted(LAYER_SET_REGISTRY)}"
|
||||||
f"Choose one of: {sorted(LAYER_SET_REGISTRY)}"
|
|
||||||
)
|
)
|
||||||
await ensure_standard_linetypes(backend)
|
await ensure_standard_linetypes(backend)
|
||||||
|
|
||||||
@@ -139,7 +140,10 @@ async def apply_layer_set(
|
|||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
await backend.layer_create(
|
await backend.layer_create(
|
||||||
name=name, color=color, linetype=linetype, lineweight=lineweight,
|
name=name,
|
||||||
|
color=color,
|
||||||
|
linetype=linetype,
|
||||||
|
lineweight=lineweight,
|
||||||
)
|
)
|
||||||
results[name] = "created"
|
results[name] = "created"
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
|
|||||||
@@ -91,5 +91,13 @@ class Issue:
|
|||||||
|
|
||||||
# ISO 128 lineweight set (mm). Any lineweight outside this set is flagged.
|
# ISO 128 lineweight set (mm). Any lineweight outside this set is flagged.
|
||||||
ISO_128_LINEWEIGHTS_MM: tuple[float, ...] = (
|
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,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -99,9 +99,7 @@ def preflight_drawing(
|
|||||||
assumptions.append({"field": "units", "value": "mm", "reason": "default"})
|
assumptions.append({"field": "units", "value": "mm", "reason": "default"})
|
||||||
if allow_assumptions and not req.get("tolerance_policy"):
|
if allow_assumptions and not req.get("tolerance_policy"):
|
||||||
req["tolerance_policy"] = "general"
|
req["tolerance_policy"] = "general"
|
||||||
assumptions.append(
|
assumptions.append({"field": "tolerance_policy", "value": "general", "reason": "default"})
|
||||||
{"field": "tolerance_policy", "value": "general", "reason": "default"}
|
|
||||||
)
|
|
||||||
|
|
||||||
if isinstance(req.get("units"), str):
|
if isinstance(req.get("units"), str):
|
||||||
req["units"] = req["units"].lower()
|
req["units"] = req["units"].lower()
|
||||||
|
|||||||
@@ -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)
|
midpoint = ((float(p1[0]) + float(p2[0])) / 2, (float(p1[1]) + float(p2[1])) / 2)
|
||||||
first = await backend.entity_get(issue.handles[0])
|
first = await backend.entity_get(issue.handles[0])
|
||||||
second = await backend.entity_get(issue.handles[1])
|
second = await backend.entity_get(issue.handles[1])
|
||||||
await backend.entity_edit_geometry(
|
await backend.entity_edit_geometry(issue.handles[0], **_endpoint_kwargs(first, p1, midpoint))
|
||||||
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[1], **_endpoint_kwargs(second, p2, midpoint)
|
|
||||||
)
|
|
||||||
return {"handles": list(issue.handles), "corner": list(midpoint)}
|
return {"handles": list(issue.handles), "corner": list(midpoint)}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -72,7 +72,9 @@ async def apply_iso_a3_titleblock(
|
|||||||
[ox, oy + SHEET_H],
|
[ox, oy + SHEET_H],
|
||||||
]
|
]
|
||||||
outer = await backend.entity_create_polyline(
|
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
|
inner_x0 = ox + LEFT_MARGIN
|
||||||
@@ -86,7 +88,9 @@ async def apply_iso_a3_titleblock(
|
|||||||
[inner_x0, inner_y1],
|
[inner_x0, inner_y1],
|
||||||
]
|
]
|
||||||
inner = await backend.entity_create_polyline(
|
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) ----------------------
|
# ---- title block (lower-right corner of inner border) ----------------------
|
||||||
@@ -103,7 +107,9 @@ async def apply_iso_a3_titleblock(
|
|||||||
[tb_x0, tb_y1],
|
[tb_x0, tb_y1],
|
||||||
]
|
]
|
||||||
tb_frame = await backend.entity_create_polyline(
|
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
|
# 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:
|
async def hline(y: float) -> str:
|
||||||
ent = await backend.entity_create_line(
|
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
|
return ent.handle
|
||||||
|
|
||||||
async def vline(x: float, y0: float, y1: float) -> str:
|
async def vline(x: float, y0: float, y1: float) -> str:
|
||||||
ent = await backend.entity_create_line(
|
ent = await backend.entity_create_line(
|
||||||
x, y0, x, y1, layer=LAYER_BORDER,
|
x,
|
||||||
|
y0,
|
||||||
|
x,
|
||||||
|
y1,
|
||||||
|
layer=LAYER_BORDER,
|
||||||
)
|
)
|
||||||
return ent.handle
|
return ent.handle
|
||||||
|
|
||||||
@@ -157,13 +171,21 @@ async def apply_iso_a3_titleblock(
|
|||||||
|
|
||||||
async def label(text: str, x: float, y: float) -> str:
|
async def label(text: str, x: float, y: float) -> str:
|
||||||
ent = await backend.entity_create_text(
|
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
|
return ent.handle
|
||||||
|
|
||||||
async def value(text: str, x: float, y: float, height: float = VALUE_HEIGHT) -> str:
|
async def value(text: str, x: float, y: float, height: float = VALUE_HEIGHT) -> str:
|
||||||
ent = await backend.entity_create_text(
|
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
|
return ent.handle
|
||||||
|
|
||||||
|
|||||||
+85
-45
@@ -12,8 +12,13 @@ if TYPE_CHECKING:
|
|||||||
|
|
||||||
|
|
||||||
REAL_DIMENSION_TYPES = {
|
REAL_DIMENSION_TYPES = {
|
||||||
"DIMENSION", "DIMLINEAR", "DIMALIGNED", "DIMANGULAR",
|
"DIMENSION",
|
||||||
"DIMRADIUS", "DIMDIAMETER", "DIMORDINATE",
|
"DIMLINEAR",
|
||||||
|
"DIMALIGNED",
|
||||||
|
"DIMANGULAR",
|
||||||
|
"DIMRADIUS",
|
||||||
|
"DIMDIAMETER",
|
||||||
|
"DIMORDINATE",
|
||||||
}
|
}
|
||||||
|
|
||||||
# Patterns that suggest a TEXT/MTEXT entity is masquerading as a dimension.
|
# Patterns that suggest a TEXT/MTEXT entity is masquerading as a dimension.
|
||||||
@@ -84,15 +89,15 @@ class DrawingValidator:
|
|||||||
try:
|
try:
|
||||||
ents = await backend.entity_list(limit=10000)
|
ents = await backend.entity_list(limit=10000)
|
||||||
text_likes = [
|
text_likes = [
|
||||||
e for e in ents
|
e
|
||||||
|
for e in ents
|
||||||
if e.type in ("TEXT", "MTEXT")
|
if e.type in ("TEXT", "MTEXT")
|
||||||
and DIMENSION_LIKE_TEXT.search(
|
and DIMENSION_LIKE_TEXT.search(getattr(e, "properties", {}).get("text", "") or "")
|
||||||
getattr(e, "properties", {}).get("text", "") or ""
|
|
||||||
)
|
|
||||||
]
|
]
|
||||||
real_dims = [e for e in ents if e.type in REAL_DIMENSION_TYPES]
|
real_dims = [e for e in ents if e.type in REAL_DIMENSION_TYPES]
|
||||||
if text_likes:
|
if text_likes:
|
||||||
findings.append(ValidationFinding(
|
findings.append(
|
||||||
|
ValidationFinding(
|
||||||
"warning",
|
"warning",
|
||||||
"fake_dimension_text",
|
"fake_dimension_text",
|
||||||
f"{len(text_likes)} TEXT/MTEXT entities look like dimensions "
|
f"{len(text_likes)} TEXT/MTEXT entities look like dimensions "
|
||||||
@@ -101,23 +106,30 @@ class DrawingValidator:
|
|||||||
"sample_handles": [t.handle for t in text_likes[:5]],
|
"sample_handles": [t.handle for t in text_likes[:5]],
|
||||||
"real_dim_count": len(real_dims),
|
"real_dim_count": len(real_dims),
|
||||||
},
|
},
|
||||||
))
|
)
|
||||||
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
findings.append(ValidationFinding(
|
findings.append(
|
||||||
"warning", "dim_check_failed", str(exc),
|
ValidationFinding(
|
||||||
))
|
"warning",
|
||||||
|
"dim_check_failed",
|
||||||
|
str(exc),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
# 2 — hidden orphans
|
# 2 — hidden orphans
|
||||||
try:
|
try:
|
||||||
hidden = [e for e in ents if not e.visible]
|
hidden = [e for e in ents if not e.visible]
|
||||||
if hidden:
|
if hidden:
|
||||||
findings.append(ValidationFinding(
|
findings.append(
|
||||||
|
ValidationFinding(
|
||||||
"error",
|
"error",
|
||||||
"hidden_orphans",
|
"hidden_orphans",
|
||||||
f"{len(hidden)} entities have visible=False — hide-fallback "
|
f"{len(hidden)} entities have visible=False — hide-fallback "
|
||||||
"is forbidden, use entity_delete",
|
"is forbidden, use entity_delete",
|
||||||
{"sample_handles": [h.handle for h in hidden[:5]]},
|
{"sample_handles": [h.handle for h in hidden[:5]]},
|
||||||
))
|
)
|
||||||
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -126,45 +138,53 @@ class DrawingValidator:
|
|||||||
lts = {x.lower() for x in await backend.linetype_list()}
|
lts = {x.lower() for x in await backend.linetype_list()}
|
||||||
missing = [lt for lt in ("center", "hidden", "phantom") if lt not in lts]
|
missing = [lt for lt in ("center", "hidden", "phantom") if lt not in lts]
|
||||||
if missing:
|
if missing:
|
||||||
findings.append(ValidationFinding(
|
findings.append(
|
||||||
|
ValidationFinding(
|
||||||
"error",
|
"error",
|
||||||
"linetypes_missing",
|
"linetypes_missing",
|
||||||
f"Standard linetypes not loaded: {', '.join(missing).upper()}",
|
f"Standard linetypes not loaded: {', '.join(missing).upper()}",
|
||||||
{"missing": missing},
|
{"missing": missing},
|
||||||
))
|
)
|
||||||
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
findings.append(ValidationFinding(
|
findings.append(
|
||||||
"warning", "linetype_check_failed", str(exc),
|
ValidationFinding(
|
||||||
))
|
"warning",
|
||||||
|
"linetype_check_failed",
|
||||||
|
str(exc),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
# 4 — gear views (heuristic, only if expected demands)
|
# 4 — gear views (heuristic, only if expected demands)
|
||||||
if expected.get("must_have_bore") or expected.get("must_have_keyway"):
|
if expected.get("must_have_bore") or expected.get("must_have_keyway"):
|
||||||
bore_circles = [
|
bore_circles = [e for e in ents if e.type == "CIRCLE" and e.layer == "GEOMETRY"]
|
||||||
e for e in ents
|
|
||||||
if e.type == "CIRCLE" and e.layer == "GEOMETRY"
|
|
||||||
]
|
|
||||||
if expected.get("must_have_bore") and len(bore_circles) < 1:
|
if expected.get("must_have_bore") and len(bore_circles) < 1:
|
||||||
findings.append(ValidationFinding(
|
findings.append(
|
||||||
|
ValidationFinding(
|
||||||
"error",
|
"error",
|
||||||
"bore_missing",
|
"bore_missing",
|
||||||
"Expected bore but no circle on GEOMETRY layer found",
|
"Expected bore but no circle on GEOMETRY layer found",
|
||||||
))
|
)
|
||||||
|
)
|
||||||
# draw_keyed_bore emits the keyway notch as an OPEN 4-point polyline
|
# draw_keyed_bore emits the keyway notch as an OPEN 4-point polyline
|
||||||
# on GEOMETRY — requiring `closed` here made this a permanent
|
# on GEOMETRY — requiring `closed` here made this a permanent
|
||||||
# false-negative. Match a small (3-6 point) polyline, open or closed.
|
# false-negative. Match a small (3-6 point) polyline, open or closed.
|
||||||
keyway_polys = [
|
keyway_polys = [
|
||||||
e for e in ents
|
e
|
||||||
|
for e in ents
|
||||||
if e.type in ("LWPOLYLINE", "POLYLINE")
|
if e.type in ("LWPOLYLINE", "POLYLINE")
|
||||||
and e.layer == "GEOMETRY"
|
and e.layer == "GEOMETRY"
|
||||||
and 3 <= len(e.properties.get("points", [])) <= 6
|
and 3 <= len(e.properties.get("points", [])) <= 6
|
||||||
]
|
]
|
||||||
if expected.get("must_have_keyway") and len(keyway_polys) < 1:
|
if expected.get("must_have_keyway") and len(keyway_polys) < 1:
|
||||||
findings.append(ValidationFinding(
|
findings.append(
|
||||||
|
ValidationFinding(
|
||||||
"warning",
|
"warning",
|
||||||
"keyway_section_unverified",
|
"keyway_section_unverified",
|
||||||
"Could not confirm keyway via heuristic "
|
"Could not confirm keyway via heuristic "
|
||||||
"(small 3-6 point polyline on GEOMETRY)",
|
"(small 3-6 point polyline on GEOMETRY)",
|
||||||
))
|
)
|
||||||
|
)
|
||||||
|
|
||||||
# 5 — title consistency
|
# 5 — title consistency
|
||||||
helix = expected.get("helix_angle")
|
helix = expected.get("helix_angle")
|
||||||
@@ -190,19 +210,23 @@ class DrawingValidator:
|
|||||||
has_spur = bool(_SPUR_RE.search(joined))
|
has_spur = bool(_SPUR_RE.search(joined))
|
||||||
has_helical = bool(_HELICAL_RE.search(joined))
|
has_helical = bool(_HELICAL_RE.search(joined))
|
||||||
if has_spur and has_helical:
|
if has_spur and has_helical:
|
||||||
findings.append(ValidationFinding(
|
findings.append(
|
||||||
|
ValidationFinding(
|
||||||
"error",
|
"error",
|
||||||
"title_helical_spur",
|
"title_helical_spur",
|
||||||
"Title contains 'HELICAL SPUR GEAR' — invalid; use "
|
"Title contains 'HELICAL SPUR GEAR' — invalid; use "
|
||||||
"'HELICAL GEAR' or 'SPUR GEAR'",
|
"'HELICAL GEAR' or 'SPUR GEAR'",
|
||||||
))
|
)
|
||||||
|
)
|
||||||
elif has_spur:
|
elif has_spur:
|
||||||
findings.append(ValidationFinding(
|
findings.append(
|
||||||
|
ValidationFinding(
|
||||||
"error",
|
"error",
|
||||||
"title_says_spur_for_helical",
|
"title_says_spur_for_helical",
|
||||||
f"Title contains SPUR but part is helical (helix_angle={helix})",
|
f"Title contains SPUR but part is helical (helix_angle={helix})",
|
||||||
{"sample": joined[:200]},
|
{"sample": joined[:200]},
|
||||||
))
|
)
|
||||||
|
)
|
||||||
|
|
||||||
# 6 — saved to disk
|
# 6 — saved to disk
|
||||||
try:
|
try:
|
||||||
@@ -210,46 +234,62 @@ class DrawingValidator:
|
|||||||
saved_attr = getattr(info, "saved", None)
|
saved_attr = getattr(info, "saved", None)
|
||||||
full_path = getattr(info, "full_path", "") or ""
|
full_path = getattr(info, "full_path", "") or ""
|
||||||
if not saved_attr:
|
if not saved_attr:
|
||||||
findings.append(ValidationFinding(
|
findings.append(
|
||||||
|
ValidationFinding(
|
||||||
"error",
|
"error",
|
||||||
"not_saved",
|
"not_saved",
|
||||||
"Drawing has unsaved changes — call drawing_save before finalize",
|
"Drawing has unsaved changes — call drawing_save before finalize",
|
||||||
{"full_path": full_path},
|
{"full_path": full_path},
|
||||||
))
|
)
|
||||||
|
)
|
||||||
elif full_path and not Path(full_path).exists():
|
elif full_path and not Path(full_path).exists():
|
||||||
findings.append(ValidationFinding(
|
findings.append(
|
||||||
|
ValidationFinding(
|
||||||
"error",
|
"error",
|
||||||
"file_missing",
|
"file_missing",
|
||||||
f"Drawing reports saved=True but file not on disk: {full_path}",
|
f"Drawing reports saved=True but file not on disk: {full_path}",
|
||||||
))
|
)
|
||||||
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
findings.append(ValidationFinding(
|
findings.append(
|
||||||
"warning", "save_check_failed", str(exc),
|
ValidationFinding(
|
||||||
))
|
"warning",
|
||||||
|
"save_check_failed",
|
||||||
|
str(exc),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
# 7 — screenshot
|
# 7 — screenshot
|
||||||
try:
|
try:
|
||||||
shot = await backend.view_screenshot()
|
shot = await backend.view_screenshot()
|
||||||
if not shot:
|
if not shot:
|
||||||
findings.append(ValidationFinding(
|
findings.append(
|
||||||
|
ValidationFinding(
|
||||||
"info",
|
"info",
|
||||||
"screenshot_unavailable",
|
"screenshot_unavailable",
|
||||||
"Screenshot returned no bytes (ezdxf needs matplotlib install)",
|
"Screenshot returned no bytes (ezdxf needs matplotlib install)",
|
||||||
))
|
)
|
||||||
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
findings.append(ValidationFinding(
|
findings.append(
|
||||||
"info", "screenshot_failed", str(exc),
|
ValidationFinding(
|
||||||
))
|
"info",
|
||||||
|
"screenshot_failed",
|
||||||
|
str(exc),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
# 8 — dwg path returned
|
# 8 — dwg path returned
|
||||||
try:
|
try:
|
||||||
info = await backend.drawing_info()
|
info = await backend.drawing_info()
|
||||||
if not getattr(info, "full_path", ""):
|
if not getattr(info, "full_path", ""):
|
||||||
findings.append(ValidationFinding(
|
findings.append(
|
||||||
|
ValidationFinding(
|
||||||
"warning",
|
"warning",
|
||||||
"no_path",
|
"no_path",
|
||||||
"drawing_info.full_path is empty",
|
"drawing_info.full_path is empty",
|
||||||
))
|
)
|
||||||
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|||||||
@@ -61,10 +61,12 @@ async def _run() -> int:
|
|||||||
print(f"ok (center=({cx},{cy}), r=5)")
|
print(f"ok (center=({cx},{cy}), r=5)")
|
||||||
|
|
||||||
print("[6/9] entity_select_smart ...", end=" ")
|
print("[6/9] entity_select_smart ...", end=" ")
|
||||||
geom = await backend.entity_select_smart({
|
geom = await backend.entity_select_smart(
|
||||||
|
{
|
||||||
"type": "LINE",
|
"type": "LINE",
|
||||||
"layer": "GEOMETRY",
|
"layer": "GEOMETRY",
|
||||||
})
|
}
|
||||||
|
)
|
||||||
assert len(geom) >= 2, len(geom)
|
assert len(geom) >= 2, len(geom)
|
||||||
print(f"ok ({len(geom)} lines selected)")
|
print(f"ok ({len(geom)} lines selected)")
|
||||||
|
|
||||||
|
|||||||
+44
-24
@@ -106,32 +106,50 @@ def validate_path(path: str, *, allow_write: bool = False) -> Path:
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
_DANGEROUS_COMMANDS = [
|
_DANGEROUS_COMMANDS = [
|
||||||
"ERASE", "_ERASE",
|
"ERASE",
|
||||||
"DELETE", "_DELETE",
|
"_ERASE",
|
||||||
"PURGE", "_PURGE",
|
"DELETE",
|
||||||
"QUIT", "_QUIT",
|
"_DELETE",
|
||||||
"CLOSE", "_CLOSE",
|
"PURGE",
|
||||||
"QSAVE", "_QSAVE",
|
"_PURGE",
|
||||||
"SAVEAS", "_SAVEAS",
|
"QUIT",
|
||||||
"NEW", "_NEW",
|
"_QUIT",
|
||||||
"OPEN", "_OPEN",
|
"CLOSE",
|
||||||
"RECOVER", "_RECOVER",
|
"_CLOSE",
|
||||||
"WBLOCK", "_WBLOCK",
|
"QSAVE",
|
||||||
"INSERT", "_INSERT",
|
"_QSAVE",
|
||||||
"XREF", "_XREF",
|
"SAVEAS",
|
||||||
"SHELL", "_SHELL",
|
"_SAVEAS",
|
||||||
"SCRIPT", "_SCRIPT",
|
"NEW",
|
||||||
"APPLOAD", "_APPLOAD",
|
"_NEW",
|
||||||
"NETLOAD", "_NETLOAD",
|
"OPEN",
|
||||||
"VBARUN", "_VBARUN",
|
"_OPEN",
|
||||||
"VBALOAD", "_VBALOAD",
|
"RECOVER",
|
||||||
"SECURITYOPTIONS", "_SECURITYOPTIONS",
|
"_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(
|
_DANGEROUS_COMMAND_PATTERNS = re.compile(
|
||||||
r"\b("
|
r"\b(" + "|".join(re.escape(cmd) for cmd in _DANGEROUS_COMMANDS) + r")\b",
|
||||||
+ "|".join(re.escape(cmd) for cmd in _DANGEROUS_COMMANDS)
|
|
||||||
+ r")\b",
|
|
||||||
re.IGNORECASE,
|
re.IGNORECASE,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -213,7 +231,9 @@ def sanitize_lisp(expression: str) -> str:
|
|||||||
raise ToolError("LISP expression cannot be empty.")
|
raise ToolError("LISP expression cannot be empty.")
|
||||||
|
|
||||||
if config.settings.dangerous_commands_enabled:
|
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
|
return expression
|
||||||
|
|
||||||
if _DANGEROUS_LISP_PATTERN.search(expression):
|
if _DANGEROUS_LISP_PATTERN.search(expression):
|
||||||
|
|||||||
@@ -68,7 +68,10 @@ async def test_analysis_entity_stats(backend):
|
|||||||
stats = await backend.analysis_stats()
|
stats = await backend.analysis_stats()
|
||||||
assert isinstance(stats, dict)
|
assert isinstance(stats, dict)
|
||||||
by_type = stats.get("by_type", stats)
|
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
|
assert total >= 3
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -51,8 +51,12 @@ async def test_batch_modify_delete(backend):
|
|||||||
async def test_template_architectural_layers(backend):
|
async def test_template_architectural_layers(backend):
|
||||||
"""Architectural template should create standard layers."""
|
"""Architectural template should create standard layers."""
|
||||||
layer_defs = [
|
layer_defs = [
|
||||||
("WALLS", 7), ("DOORS", 3), ("WINDOWS", 4),
|
("WALLS", 7),
|
||||||
("FURNITURE", 8), ("DIMENSIONS", 2), ("TEXT", 7),
|
("DOORS", 3),
|
||||||
|
("WINDOWS", 4),
|
||||||
|
("FURNITURE", 8),
|
||||||
|
("DIMENSIONS", 2),
|
||||||
|
("TEXT", 7),
|
||||||
]
|
]
|
||||||
for name, color in layer_defs:
|
for name, color in layer_defs:
|
||||||
await backend.layer_create(name, color=color)
|
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):
|
async def test_template_mechanical_layers(backend):
|
||||||
"""Mechanical template layers."""
|
"""Mechanical template layers."""
|
||||||
layer_defs = [
|
layer_defs = [
|
||||||
("VISIBLE", 7), ("HIDDEN", 1), ("CENTER", 3),
|
("VISIBLE", 7),
|
||||||
("DIMENSIONS", 2), ("SECTION", 5),
|
("HIDDEN", 1),
|
||||||
|
("CENTER", 3),
|
||||||
|
("DIMENSIONS", 2),
|
||||||
|
("SECTION", 5),
|
||||||
]
|
]
|
||||||
for name, color in layer_defs:
|
for name, color in layer_defs:
|
||||||
await backend.layer_create(name, color=color)
|
await backend.layer_create(name, color=color)
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ async def test_active_document_switch_clears_com_document_state(monkeypatch):
|
|||||||
assert backend._preflight_result is None
|
assert backend._preflight_result is None
|
||||||
assert backend._gdt_datums_defined == set()
|
assert backend._gdt_datums_defined == set()
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# normalize_lineweight — parametrized table
|
# normalize_lineweight — parametrized table
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -107,7 +108,12 @@ def test_normalize_lineweight_non_numeric_passthrough():
|
|||||||
# _safe_send_command — CMDACTIVE polling / timeout / var-restore
|
# _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."""
|
"""Build a minimal mock AutoCAD document for _safe_send_command tests."""
|
||||||
doc = MagicMock()
|
doc = MagicMock()
|
||||||
|
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ class _Doc:
|
|||||||
|
|
||||||
# ── NEW-com-set-variable-coercion ───────────────────────────────────────────
|
# ── NEW-com-set-variable-coercion ───────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
async def test_set_variable_coerces_string_to_sysvar_int_type(monkeypatch):
|
async def test_set_variable_coerces_string_to_sysvar_int_type(monkeypatch):
|
||||||
app = MagicMock()
|
app = MagicMock()
|
||||||
app.GetVariable.return_value = 4159 # OSMODE current value is an int
|
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 ───────────────────────────────
|
# ── R23 / N6 — run_lisp guard + value capture ───────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
async def test_run_lisp_refuses_when_cmdactive(monkeypatch):
|
async def test_run_lisp_refuses_when_cmdactive(monkeypatch):
|
||||||
app = MagicMock()
|
app = MagicMock()
|
||||||
app.GetVariable.side_effect = lambda n: 1 if n == "CMDACTIVE" else 0
|
app.GetVariable.side_effect = lambda n: 1 if n == "CMDACTIVE" else 0
|
||||||
@@ -96,6 +98,7 @@ async def test_run_lisp_captures_value_from_users1(monkeypatch):
|
|||||||
|
|
||||||
# ── R16 — transaction flag cleared even on error ────────────────────────────
|
# ── R16 — transaction flag cleared even on error ────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
async def test_transaction_begin_sets_flag_and_marks_undo(monkeypatch):
|
async def test_transaction_begin_sets_flag_and_marks_undo(monkeypatch):
|
||||||
doc = _Doc()
|
doc = _Doc()
|
||||||
doc.StartUndoMark = MagicMock()
|
doc.StartUndoMark = MagicMock()
|
||||||
@@ -136,6 +139,7 @@ async def test_transaction_rollback_uses_safe_send_command(monkeypatch):
|
|||||||
|
|
||||||
# ── R13 — offset honors side_x/side_y and deletes the unused copy ───────────
|
# ── R13 — offset honors side_x/side_y and deletes the unused copy ───────────
|
||||||
|
|
||||||
|
|
||||||
async def test_offset_picks_copy_nearest_side_point(monkeypatch):
|
async def test_offset_picks_copy_nearest_side_point(monkeypatch):
|
||||||
# ent.Offset(+d) -> copy centred at (0, +10); ent.Offset(-d) -> (0, -10).
|
# ent.Offset(+d) -> copy centred at (0, +10); ent.Offset(-d) -> (0, -10).
|
||||||
pos_copy = MagicMock(name="pos")
|
pos_copy = MagicMock(name="pos")
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ async def test_drawing_export_dxf(backend, tmp_path):
|
|||||||
result = await backend.drawing_export_dxf(export_path)
|
result = await backend.drawing_export_dxf(export_path)
|
||||||
assert result.get("ok") is True
|
assert result.get("ok") is True
|
||||||
import os
|
import os
|
||||||
|
|
||||||
assert os.path.exists(export_path)
|
assert os.path.exists(export_path)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -33,9 +33,13 @@ async def test_set_units_cm_and_inch_codes(backend):
|
|||||||
|
|
||||||
|
|
||||||
async def test_set_numeric_settings(backend):
|
async def test_set_numeric_settings(backend):
|
||||||
res = await backend.drawing_settings({
|
res = await backend.drawing_settings(
|
||||||
"dimscale": 2.0, "linear_precision": 3, "ltscale": 0.5,
|
{
|
||||||
})
|
"dimscale": 2.0,
|
||||||
|
"linear_precision": 3,
|
||||||
|
"ltscale": 0.5,
|
||||||
|
}
|
||||||
|
)
|
||||||
assert res["ok"] is True
|
assert res["ok"] is True
|
||||||
snap = (await backend.drawing_settings())["settings"]
|
snap = (await backend.drawing_settings())["settings"]
|
||||||
assert snap["dimscale"] == 2.0
|
assert snap["dimscale"] == 2.0
|
||||||
@@ -57,6 +61,7 @@ async def test_bad_unit_value_is_reported(backend):
|
|||||||
|
|
||||||
# ── server tool wiring ──────────────────────────────────────────────────────
|
# ── server tool wiring ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
class _FakeCtx:
|
class _FakeCtx:
|
||||||
def __init__(self, backend):
|
def __init__(self, backend):
|
||||||
self.lifespan_context = {"backend": backend}
|
self.lifespan_context = {"backend": backend}
|
||||||
|
|||||||
@@ -66,19 +66,41 @@ async def test_helical_gear_front_view_returns_metadata(backend):
|
|||||||
await ensure_standard_linetypes(backend)
|
await ensure_standard_linetypes(backend)
|
||||||
await ensure_engineering_layers(backend)
|
await ensure_engineering_layers(backend)
|
||||||
res = await draw_helical_gear_front_view(
|
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),
|
center=(0.0, 0.0),
|
||||||
)
|
)
|
||||||
expected_keys = {
|
expected_keys = {
|
||||||
"outline", "pitch_circle", "base_circle", "outer_circle", "root_circle",
|
"outline",
|
||||||
"helix_symbol", "helix_label", "bore", "keyway", "metadata",
|
"pitch_circle",
|
||||||
|
"base_circle",
|
||||||
|
"outer_circle",
|
||||||
|
"root_circle",
|
||||||
|
"helix_symbol",
|
||||||
|
"helix_label",
|
||||||
|
"bore",
|
||||||
|
"keyway",
|
||||||
|
"metadata",
|
||||||
}
|
}
|
||||||
assert expected_keys.issubset(res.keys())
|
assert expected_keys.issubset(res.keys())
|
||||||
md = res["metadata"]
|
md = res["metadata"]
|
||||||
for k in (
|
for k in (
|
||||||
"module", "teeth", "pressure_angle", "helix_angle", "hand", "center",
|
"module",
|
||||||
"bore_diameter", "keyway_width", "keyway_depth",
|
"teeth",
|
||||||
"pitch_radius", "outer_radius", "base_radius", "root_radius",
|
"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}"
|
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_standard_linetypes(backend)
|
||||||
await ensure_engineering_layers(backend)
|
await ensure_engineering_layers(backend)
|
||||||
res = await draw_helical_gear_front_view(
|
res = await draw_helical_gear_front_view(
|
||||||
backend, module=3, teeth=24, helix_angle=20.0,
|
backend,
|
||||||
bore_diameter=25.0, keyway_width=8.0, keyway_depth=4.0,
|
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["bore"] is not None
|
||||||
assert res["keyway"] 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_standard_linetypes(backend)
|
||||||
await ensure_engineering_layers(backend)
|
await ensure_engineering_layers(backend)
|
||||||
res = await draw_helical_gear_front_view(
|
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"]
|
md = res["metadata"]
|
||||||
assert math.isclose(md["pitch_radius"], 36.0, abs_tol=1e-6)
|
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_standard_linetypes(backend)
|
||||||
await ensure_engineering_layers(backend)
|
await ensure_engineering_layers(backend)
|
||||||
front = await draw_helical_gear_front_view(
|
front = await draw_helical_gear_front_view(
|
||||||
backend, module=3, teeth=24, helix_angle=20.0,
|
backend,
|
||||||
bore_diameter=25.0, keyway_width=8.0, keyway_depth=4.0,
|
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(
|
section = await draw_gear_section_aa(
|
||||||
backend, gear_metadata=front["metadata"],
|
backend,
|
||||||
x_offset=200.0, face_width=40.0,
|
gear_metadata=front["metadata"],
|
||||||
|
x_offset=200.0,
|
||||||
|
face_width=40.0,
|
||||||
)
|
)
|
||||||
assert "helix" not in section
|
assert "helix" not in section
|
||||||
assert "helix_symbol" not in section
|
assert "helix_symbol" not in section
|
||||||
|
|||||||
@@ -37,8 +37,11 @@ async def test_draw_keyed_bore_returns_expected_handles(backend):
|
|||||||
await ensure_standard_linetypes(backend)
|
await ensure_standard_linetypes(backend)
|
||||||
await ensure_engineering_layers(backend)
|
await ensure_engineering_layers(backend)
|
||||||
res = await draw_keyed_bore(
|
res = await draw_keyed_bore(
|
||||||
backend, center=(0.0, 0.0), bore_diameter=25.0,
|
backend,
|
||||||
keyway_width=8.0, keyway_depth=4.0,
|
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"):
|
for k in ("bore", "keyway_polyline", "centerline_h", "centerline_v"):
|
||||||
assert k in res
|
assert k in res
|
||||||
@@ -50,8 +53,12 @@ async def test_draw_keyway_section_geometry(backend):
|
|||||||
await ensure_standard_linetypes(backend)
|
await ensure_standard_linetypes(backend)
|
||||||
await ensure_engineering_layers(backend)
|
await ensure_engineering_layers(backend)
|
||||||
res = await draw_keyway_section(
|
res = await draw_keyway_section(
|
||||||
backend, center=(0.0, 0.0), bore_diameter=25.0, face_width=40.0,
|
backend,
|
||||||
keyway_width=8.0, keyway_depth=4.0,
|
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_top" in res
|
||||||
assert "bore_bottom" in res
|
assert "bore_bottom" in res
|
||||||
|
|||||||
@@ -39,7 +39,8 @@ async def test_titleblock_outer_border_dimensions(backend):
|
|||||||
async def test_title_text_exact_match(backend):
|
async def test_title_text_exact_match(backend):
|
||||||
"""Title text in the drawing must equal metadata.title verbatim."""
|
"""Title text in the drawing must equal metadata.title verbatim."""
|
||||||
result = await apply_iso_a3_titleblock(
|
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"])
|
title_ent = await backend.entity_get(result["title_text"])
|
||||||
assert title_ent.type == "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."""
|
"""value_texts must contain a handle for every metadata field."""
|
||||||
result = await apply_iso_a3_titleblock(backend, metadata=_meta())
|
result = await apply_iso_a3_titleblock(backend, metadata=_meta())
|
||||||
expected_keys = {
|
expected_keys = {
|
||||||
"drawing_no", "revision", "sheet",
|
"drawing_no",
|
||||||
"part_no", "material", "scale",
|
"revision",
|
||||||
"drawn_by", "checked_by", "date", "company",
|
"sheet",
|
||||||
|
"part_no",
|
||||||
|
"material",
|
||||||
|
"scale",
|
||||||
|
"drawn_by",
|
||||||
|
"checked_by",
|
||||||
|
"date",
|
||||||
|
"company",
|
||||||
}
|
}
|
||||||
assert set(result["value_texts"].keys()) == expected_keys
|
assert set(result["value_texts"].keys()) == expected_keys
|
||||||
# All handles must resolve to TEXT entities
|
# 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):
|
async def test_titleblock_origin_offset(backend):
|
||||||
"""Passing origin=(100,50) must shift the bbox by that amount."""
|
"""Passing origin=(100,50) must shift the bbox by that amount."""
|
||||||
result = await apply_iso_a3_titleblock(
|
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"]
|
bbox = result["bbox"]
|
||||||
assert bbox["min"][0] == pytest.approx(100.0)
|
assert bbox["min"][0] == pytest.approx(100.0)
|
||||||
|
|||||||
@@ -73,7 +73,11 @@ async def test_validator_catches_helical_spur_title(backend, tmp_path):
|
|||||||
await ensure_standard_linetypes(backend)
|
await ensure_standard_linetypes(backend)
|
||||||
await ensure_engineering_layers(backend)
|
await ensure_engineering_layers(backend)
|
||||||
await backend.entity_create_text(
|
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"))
|
await backend.drawing_save_as(str(tmp_path / "tb.dxf"))
|
||||||
result = await DrawingValidator().run(backend, expected={"helix_angle": 20})
|
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_standard_linetypes(backend)
|
||||||
await ensure_engineering_layers(backend)
|
await ensure_engineering_layers(backend)
|
||||||
await backend.entity_create_text(
|
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"))
|
await backend.drawing_save_as(str(tmp_path / "fake.dxf"))
|
||||||
result = await DrawingValidator().run(backend)
|
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
|
# No circle drawn at all
|
||||||
await backend.drawing_save_as(str(tmp_path / "nobore.dxf"))
|
await backend.drawing_save_as(str(tmp_path / "nobore.dxf"))
|
||||||
result = await DrawingValidator().run(
|
result = await DrawingValidator().run(
|
||||||
backend, expected={"must_have_bore": True},
|
backend,
|
||||||
|
expected={"must_have_bore": True},
|
||||||
)
|
)
|
||||||
assert "bore_missing" in _codes(result)
|
assert "bore_missing" in _codes(result)
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ def _close(a, b, tol=1e-6):
|
|||||||
|
|
||||||
# ─── TRIM ──────────────────────────────────────────────────────────────────
|
# ─── TRIM ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
class TestTrim:
|
class TestTrim:
|
||||||
async def test_trim_keep_left(self, backend):
|
async def test_trim_keep_left(self, backend):
|
||||||
target = await backend.entity_create_line(0, 0, 100, 0)
|
target = await backend.entity_create_line(0, 0, 100, 0)
|
||||||
@@ -55,6 +56,7 @@ class TestTrim:
|
|||||||
|
|
||||||
# ─── EXTEND ────────────────────────────────────────────────────────────────
|
# ─── EXTEND ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
class TestExtend:
|
class TestExtend:
|
||||||
async def test_extend_auto_endpoint(self, backend):
|
async def test_extend_auto_endpoint(self, backend):
|
||||||
# Target ends at x=40; boundary is vertical at x=80.
|
# Target ends at x=40; boundary is vertical at x=80.
|
||||||
@@ -84,6 +86,7 @@ class TestExtend:
|
|||||||
|
|
||||||
# ─── FILLET ────────────────────────────────────────────────────────────────
|
# ─── FILLET ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
class TestFillet:
|
class TestFillet:
|
||||||
async def test_fillet_90_degree_corner(self, backend):
|
async def test_fillet_90_degree_corner(self, backend):
|
||||||
# L-shape: horizontal (0,0)-(100,0) + vertical (100,0)-(100,80).
|
# L-shape: horizontal (0,0)-(100,0) + vertical (100,0)-(100,80).
|
||||||
@@ -139,6 +142,7 @@ class TestFillet:
|
|||||||
|
|
||||||
# ─── CHAMFER ───────────────────────────────────────────────────────────────
|
# ─── CHAMFER ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
class TestChamfer:
|
class TestChamfer:
|
||||||
async def test_chamfer_symmetric(self, backend):
|
async def test_chamfer_symmetric(self, backend):
|
||||||
l1 = await backend.entity_create_line(0, 0, 100, 0)
|
l1 = await backend.entity_create_line(0, 0, 100, 0)
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ pytestmark = pytest.mark.asyncio
|
|||||||
|
|
||||||
# ── entity_edit_text ────────────────────────────────────────────────────────
|
# ── entity_edit_text ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
async def test_edit_text_changes_content_keeps_handle(backend):
|
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")
|
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")
|
updated = await backend.entity_edit_text(t.handle, text="NEW LABEL")
|
||||||
@@ -44,6 +45,7 @@ async def test_edit_text_rejects_non_text(backend):
|
|||||||
|
|
||||||
# ── entity_edit_geometry ────────────────────────────────────────────────────
|
# ── entity_edit_geometry ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
async def test_edit_circle_radius_and_center(backend):
|
async def test_edit_circle_radius_and_center(backend):
|
||||||
c = await backend.entity_create_circle(0, 0, 10, layer="GEOMETRY")
|
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)
|
updated = await backend.entity_edit_geometry(c.handle, cx=5, cy=5, radius=20)
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
"""Tests for ezdxf backend — no AutoCAD required."""
|
"""Tests for ezdxf backend — no AutoCAD required."""
|
||||||
|
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
pytestmark = pytest.mark.asyncio
|
pytestmark = pytest.mark.asyncio
|
||||||
|
|||||||
+16
-1
@@ -46,15 +46,18 @@ def _reload_server_with_token(token: str | None):
|
|||||||
# Auth object construction
|
# Auth object construction
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def test_auth_object_built_when_token_set():
|
def test_auth_object_built_when_token_set():
|
||||||
"""_auth is a StaticTokenVerifier when MCP_AUTH_TOKEN is non-empty."""
|
"""_auth is a StaticTokenVerifier when MCP_AUTH_TOKEN is non-empty."""
|
||||||
with patch.dict("os.environ", {"MCP_AUTH_TOKEN": "secret-test-token-123"}):
|
with patch.dict("os.environ", {"MCP_AUTH_TOKEN": "secret-test-token-123"}):
|
||||||
import config as cfg
|
import config as cfg
|
||||||
|
|
||||||
old_token = cfg.settings.mcp_auth_token
|
old_token = cfg.settings.mcp_auth_token
|
||||||
cfg.settings.mcp_auth_token = "secret-test-token-123"
|
cfg.settings.mcp_auth_token = "secret-test-token-123"
|
||||||
try:
|
try:
|
||||||
sys.modules.pop("server", None)
|
sys.modules.pop("server", None)
|
||||||
import server as srv
|
import server as srv
|
||||||
|
|
||||||
assert srv._auth is not None
|
assert srv._auth is not None
|
||||||
assert isinstance(srv._auth, StaticTokenVerifier)
|
assert isinstance(srv._auth, StaticTokenVerifier)
|
||||||
finally:
|
finally:
|
||||||
@@ -65,11 +68,13 @@ def test_auth_object_built_when_token_set():
|
|||||||
def test_auth_object_none_when_no_token():
|
def test_auth_object_none_when_no_token():
|
||||||
"""_auth is None when MCP_AUTH_TOKEN is empty (anonymous loopback mode)."""
|
"""_auth is None when MCP_AUTH_TOKEN is empty (anonymous loopback mode)."""
|
||||||
import config as cfg
|
import config as cfg
|
||||||
|
|
||||||
old_token = cfg.settings.mcp_auth_token
|
old_token = cfg.settings.mcp_auth_token
|
||||||
cfg.settings.mcp_auth_token = ""
|
cfg.settings.mcp_auth_token = ""
|
||||||
try:
|
try:
|
||||||
sys.modules.pop("server", None)
|
sys.modules.pop("server", None)
|
||||||
import server as srv
|
import server as srv
|
||||||
|
|
||||||
assert srv._auth is None
|
assert srv._auth is None
|
||||||
finally:
|
finally:
|
||||||
cfg.settings.mcp_auth_token = old_token
|
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():
|
def test_mcp_receives_auth_when_set():
|
||||||
"""The FastMCP instance's auth attribute matches the _auth object."""
|
"""The FastMCP instance's auth attribute matches the _auth object."""
|
||||||
import config as cfg
|
import config as cfg
|
||||||
|
|
||||||
old_token = cfg.settings.mcp_auth_token
|
old_token = cfg.settings.mcp_auth_token
|
||||||
cfg.settings.mcp_auth_token = "another-secret"
|
cfg.settings.mcp_auth_token = "another-secret"
|
||||||
try:
|
try:
|
||||||
sys.modules.pop("server", None)
|
sys.modules.pop("server", None)
|
||||||
import server as srv
|
import server as srv
|
||||||
|
|
||||||
# FastMCP stores auth as _auth_provider or similar; we check it via
|
# FastMCP stores auth as _auth_provider or similar; we check it via
|
||||||
# the _auth module-level variable matching what was passed in.
|
# the _auth module-level variable matching what was passed in.
|
||||||
assert srv._auth is not None
|
assert srv._auth is not None
|
||||||
@@ -97,11 +104,13 @@ def test_mcp_receives_auth_when_set():
|
|||||||
# _validate_http_bind guard
|
# _validate_http_bind guard
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def test_validate_http_bind_loopback_no_error():
|
def test_validate_http_bind_loopback_no_error():
|
||||||
"""Loopback hosts pass validation regardless of token/allow_remote flags."""
|
"""Loopback hosts pass validation regardless of token/allow_remote flags."""
|
||||||
sys.modules.pop("server", None)
|
sys.modules.pop("server", None)
|
||||||
import config as cfg
|
import config as cfg
|
||||||
import server as srv
|
import server as srv
|
||||||
|
|
||||||
old_remote = cfg.settings.allow_remote_http
|
old_remote = cfg.settings.allow_remote_http
|
||||||
old_token = cfg.settings.mcp_auth_token
|
old_token = cfg.settings.mcp_auth_token
|
||||||
cfg.settings.allow_remote_http = False
|
cfg.settings.allow_remote_http = False
|
||||||
@@ -121,6 +130,7 @@ def test_validate_http_bind_remote_without_flag_raises():
|
|||||||
sys.modules.pop("server", None)
|
sys.modules.pop("server", None)
|
||||||
import config as cfg
|
import config as cfg
|
||||||
import server as srv
|
import server as srv
|
||||||
|
|
||||||
old = cfg.settings.allow_remote_http
|
old = cfg.settings.allow_remote_http
|
||||||
cfg.settings.allow_remote_http = False
|
cfg.settings.allow_remote_http = False
|
||||||
try:
|
try:
|
||||||
@@ -135,6 +145,7 @@ def test_validate_http_bind_remote_without_token_raises():
|
|||||||
sys.modules.pop("server", None)
|
sys.modules.pop("server", None)
|
||||||
import config as cfg
|
import config as cfg
|
||||||
import server as srv
|
import server as srv
|
||||||
|
|
||||||
old_remote = cfg.settings.allow_remote_http
|
old_remote = cfg.settings.allow_remote_http
|
||||||
old_token = cfg.settings.mcp_auth_token
|
old_token = cfg.settings.mcp_auth_token
|
||||||
cfg.settings.allow_remote_http = True
|
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):
|
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."""
|
"""Remote bind with both flag and token logs a warning but does not raise."""
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
sys.modules.pop("server", None)
|
sys.modules.pop("server", None)
|
||||||
import config as cfg
|
import config as cfg
|
||||||
import server as srv
|
import server as srv
|
||||||
|
|
||||||
old_remote = cfg.settings.allow_remote_http
|
old_remote = cfg.settings.allow_remote_http
|
||||||
old_token = cfg.settings.mcp_auth_token
|
old_token = cfg.settings.mcp_auth_token
|
||||||
cfg.settings.allow_remote_http = True
|
cfg.settings.allow_remote_http = True
|
||||||
@@ -160,7 +173,9 @@ def test_validate_http_bind_remote_with_token_logs_warning(caplog):
|
|||||||
try:
|
try:
|
||||||
with caplog.at_level(logging.WARNING, logger="server"):
|
with caplog.at_level(logging.WARNING, logger="server"):
|
||||||
srv._validate_http_bind("0.0.0.0") # should not raise
|
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:
|
finally:
|
||||||
cfg.settings.allow_remote_http = old_remote
|
cfg.settings.allow_remote_http = old_remote
|
||||||
cfg.settings.mcp_auth_token = old_token
|
cfg.settings.mcp_auth_token = old_token
|
||||||
|
|||||||
@@ -16,10 +16,13 @@ def _close(a, b, tol=1e-6):
|
|||||||
|
|
||||||
# ─── drawing_plan ──────────────────────────────────────────────────────────
|
# ─── drawing_plan ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
class TestDrawingPlan:
|
class TestDrawingPlan:
|
||||||
async def test_plan_returns_spec_with_intent(self, backend):
|
async def test_plan_returns_spec_with_intent(self, backend):
|
||||||
plan = await backend.drawing_plan(
|
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.intent == "L-bracket 50x80"
|
||||||
assert plan.sheet_size == "A4"
|
assert plan.sheet_size == "A4"
|
||||||
@@ -36,6 +39,7 @@ class TestDrawingPlan:
|
|||||||
|
|
||||||
# ─── drawing_critique ──────────────────────────────────────────────────────
|
# ─── drawing_critique ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
class TestDrawingCritique:
|
class TestDrawingCritique:
|
||||||
async def test_critique_clean_drawing(self, backend):
|
async def test_critique_clean_drawing(self, backend):
|
||||||
# Bootstrap layer set so layer_color/iso128 don't trigger.
|
# Bootstrap layer set so layer_color/iso128 don't trigger.
|
||||||
@@ -73,6 +77,7 @@ class TestDrawingCritique:
|
|||||||
|
|
||||||
# ─── point_from_snap ───────────────────────────────────────────────────────
|
# ─── point_from_snap ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
class TestPointFromSnap:
|
class TestPointFromSnap:
|
||||||
async def test_snap_end_default(self, backend):
|
async def test_snap_end_default(self, backend):
|
||||||
line = await backend.entity_create_line(0, 0, 100, 0)
|
line = await backend.entity_create_line(0, 0, 100, 0)
|
||||||
@@ -122,6 +127,7 @@ class TestPointFromSnap:
|
|||||||
|
|
||||||
# ─── construction layer ───────────────────────────────────────────────────
|
# ─── construction layer ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
class TestConstructionLayer:
|
class TestConstructionLayer:
|
||||||
async def test_xline_creates_on_construction_layer(self, backend):
|
async def test_xline_creates_on_construction_layer(self, backend):
|
||||||
xl = await backend.construction_xline(0, 0, 45)
|
xl = await backend.construction_xline(0, 0, 45)
|
||||||
@@ -140,6 +146,7 @@ class TestConstructionLayer:
|
|||||||
|
|
||||||
# ─── drawing_apply_iso_layers ──────────────────────────────────────────────
|
# ─── drawing_apply_iso_layers ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
class TestApplyISOLayers:
|
class TestApplyISOLayers:
|
||||||
async def test_mech_layer_set(self, backend):
|
async def test_mech_layer_set(self, backend):
|
||||||
result = await backend.drawing_apply_iso_layers("mech")
|
result = await backend.drawing_apply_iso_layers("mech")
|
||||||
@@ -170,13 +177,15 @@ class TestApplyISOLayers:
|
|||||||
|
|
||||||
# ─── dimension_auto ────────────────────────────────────────────────────────
|
# ─── dimension_auto ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
class TestDimensionAuto:
|
class TestDimensionAuto:
|
||||||
async def test_chain_creates_one_dim_per_segment(self, backend):
|
async def test_chain_creates_one_dim_per_segment(self, backend):
|
||||||
l1 = await backend.entity_create_line(0, 0, 30, 0)
|
l1 = await backend.entity_create_line(0, 0, 30, 0)
|
||||||
l2 = await backend.entity_create_line(30, 0, 70, 0)
|
l2 = await backend.entity_create_line(30, 0, 70, 0)
|
||||||
l3 = await backend.entity_create_line(70, 0, 100, 0)
|
l3 = await backend.entity_create_line(70, 0, 100, 0)
|
||||||
dims = await backend.dimension_auto(
|
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
|
assert len(dims) == 3
|
||||||
|
|
||||||
@@ -198,6 +207,7 @@ class TestDimensionAuto:
|
|||||||
|
|
||||||
# ─── entity_select_smart ───────────────────────────────────────────────────
|
# ─── entity_select_smart ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
class TestEntitySelectSmart:
|
class TestEntitySelectSmart:
|
||||||
async def test_select_by_type(self, backend):
|
async def test_select_by_type(self, backend):
|
||||||
await backend.entity_create_line(0, 0, 50, 0)
|
await backend.entity_create_line(0, 0, 50, 0)
|
||||||
@@ -228,6 +238,7 @@ class TestEntitySelectSmart:
|
|||||||
|
|
||||||
# ─── Integration: plan → draw → critique pipeline ──────────────────────────
|
# ─── Integration: plan → draw → critique pipeline ──────────────────────────
|
||||||
|
|
||||||
|
|
||||||
class TestPremiumPipeline:
|
class TestPremiumPipeline:
|
||||||
async def test_full_l_bracket_pipeline_clean(self, backend):
|
async def test_full_l_bracket_pipeline_clean(self, backend):
|
||||||
"""plan → apply_layers → draw + fillet → clear construction → critique = []."""
|
"""plan → apply_layers → draw + fillet → clear construction → critique = []."""
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ pytestmark = pytest.mark.asyncio
|
|||||||
# point_intersection
|
# point_intersection
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
class TestPointIntersection:
|
class TestPointIntersection:
|
||||||
async def test_line_line_perpendicular(self, backend):
|
async def test_line_line_perpendicular(self, backend):
|
||||||
"""Horizontal and vertical lines cross at (50, 50)."""
|
"""Horizontal and vertical lines cross at (50, 50)."""
|
||||||
@@ -91,6 +92,7 @@ class TestPointIntersection:
|
|||||||
# point_tangent
|
# point_tangent
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
class TestPointTangent:
|
class TestPointTangent:
|
||||||
async def test_tangent_from_outside(self, backend):
|
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)."""
|
"""From (0, 0) to unit circle at (5, 0): tangent point is at (25/5, ±12/5)."""
|
||||||
|
|||||||
@@ -100,7 +100,8 @@ def test_preflight_does_not_invent_values_when_assumptions_disabled():
|
|||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_backend_plan_accepts_matching_ready_preflight(backend):
|
async def test_backend_plan_accepts_matching_ready_preflight(backend):
|
||||||
preflight = await backend.drawing_preflight(
|
preflight = await backend.drawing_preflight(
|
||||||
"L bracket", requirements=_complete_requirements(),
|
"L bracket",
|
||||||
|
requirements=_complete_requirements(),
|
||||||
)
|
)
|
||||||
|
|
||||||
plan = await backend.drawing_plan(
|
plan = await backend.drawing_plan(
|
||||||
@@ -125,7 +126,8 @@ async def test_backend_plan_rejects_not_ready_preflight_hash(backend):
|
|||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_backend_plan_rejects_fields_that_do_not_match_preflight_hash(backend):
|
async def test_backend_plan_rejects_fields_that_do_not_match_preflight_hash(backend):
|
||||||
preflight = await backend.drawing_preflight(
|
preflight = await backend.drawing_preflight(
|
||||||
"L bracket", requirements=_complete_requirements(),
|
"L bracket",
|
||||||
|
requirements=_complete_requirements(),
|
||||||
)
|
)
|
||||||
|
|
||||||
with pytest.raises(RuntimeError, match="do not match"):
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_document_change_clears_plan_preflight_and_gdt_state(backend):
|
async def test_document_change_clears_plan_preflight_and_gdt_state(backend):
|
||||||
preflight = await backend.drawing_preflight(
|
preflight = await backend.drawing_preflight(
|
||||||
"First drawing", requirements=_complete_requirements(),
|
"First drawing",
|
||||||
|
requirements=_complete_requirements(),
|
||||||
)
|
)
|
||||||
await backend.drawing_plan(
|
await backend.drawing_plan(
|
||||||
"First drawing",
|
"First drawing",
|
||||||
|
|||||||
@@ -66,9 +66,7 @@ async def test_refiner_repairs_construction_and_duplicates(backend):
|
|||||||
assert result.status == "threshold_met"
|
assert result.status == "threshold_met"
|
||||||
assert result.final_score == 100.0
|
assert result.final_score == 100.0
|
||||||
assert result.rounds[0].score_after > result.rounds[0].score_before
|
assert result.rounds[0].score_after > result.rounds[0].score_before
|
||||||
assert await backend.drawing_critique(
|
assert await backend.drawing_critique(["duplicate_entities", "construction_left"]) == []
|
||||||
["duplicate_entities", "construction_left"]
|
|
||||||
) == []
|
|
||||||
|
|
||||||
|
|
||||||
async def test_refiner_dry_run_does_not_mutate_drawing(backend):
|
async def test_refiner_dry_run_does_not_mutate_drawing(backend):
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ class TestValidatePath:
|
|||||||
monkeypatch.setenv("ALLOWED_PATHS", str(allowed_dir))
|
monkeypatch.setenv("ALLOWED_PATHS", str(allowed_dir))
|
||||||
|
|
||||||
import config
|
import config
|
||||||
|
|
||||||
config.settings = Settings()
|
config.settings = Settings()
|
||||||
|
|
||||||
blocked = tmp_path / "blocked" / "file.dxf"
|
blocked = tmp_path / "blocked" / "file.dxf"
|
||||||
@@ -98,6 +99,7 @@ class TestValidatePath:
|
|||||||
monkeypatch.setenv("ALLOWED_PATHS", str(allowed_dir))
|
monkeypatch.setenv("ALLOWED_PATHS", str(allowed_dir))
|
||||||
|
|
||||||
import config
|
import config
|
||||||
|
|
||||||
config.settings = Settings()
|
config.settings = Settings()
|
||||||
|
|
||||||
result = validate_path(str(test_file))
|
result = validate_path(str(test_file))
|
||||||
@@ -160,6 +162,7 @@ class TestSanitizeCommand:
|
|||||||
monkeypatch.setenv("DANGEROUS_COMMANDS_ENABLED", "true")
|
monkeypatch.setenv("DANGEROUS_COMMANDS_ENABLED", "true")
|
||||||
|
|
||||||
import config
|
import config
|
||||||
|
|
||||||
config.settings = Settings()
|
config.settings = Settings()
|
||||||
|
|
||||||
result = sanitize_command("ERASE ALL")
|
result = sanitize_command("ERASE ALL")
|
||||||
@@ -222,6 +225,7 @@ class TestSanitizeLisp:
|
|||||||
monkeypatch.setenv("DANGEROUS_COMMANDS_ENABLED", "true")
|
monkeypatch.setenv("DANGEROUS_COMMANDS_ENABLED", "true")
|
||||||
|
|
||||||
import config
|
import config
|
||||||
|
|
||||||
config.settings = Settings()
|
config.settings = Settings()
|
||||||
|
|
||||||
result = sanitize_lisp('(startapp "cmd")')
|
result = sanitize_lisp('(startapp "cmd")')
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ class _FakeCtx:
|
|||||||
|
|
||||||
# ── NEW-screenshot-1 ────────────────────────────────────────────────────────
|
# ── NEW-screenshot-1 ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
async def test_screenshot_renders_png_without_gui_thread_crash(backend):
|
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
|
"""view_screenshot runs in a worker thread; the Agg figure must not crash and
|
||||||
must return valid PNG bytes."""
|
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 ─────────────────────────────
|
# ── I15 — critique is part of the finalize gate ─────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
async def test_finalize_runs_critique_and_blocks_on_construction(backend, tmp_path):
|
async def test_finalize_runs_critique_and_blocks_on_construction(backend, tmp_path):
|
||||||
from fastmcp.exceptions import ToolError
|
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 ─────────────────────────────────────────
|
# ── R4 — dim_overlap critique fires ─────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
async def test_dim_overlap_critique_is_not_a_noop(backend):
|
async def test_dim_overlap_critique_is_not_a_noop(backend):
|
||||||
await backend.drawing_apply_iso_layers("mech")
|
await backend.drawing_apply_iso_layers("mech")
|
||||||
# Two dimensions measuring the same segment overlap exactly.
|
# 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 ──────────────
|
# ── N4 — dim / construction layers follow the active layer set ──────────────
|
||||||
|
|
||||||
|
|
||||||
async def test_iso13567_dim_and_construction_layers_resolve(backend):
|
async def test_iso13567_dim_and_construction_layers_resolve(backend):
|
||||||
await backend.drawing_apply_iso_layers("iso13567")
|
await backend.drawing_apply_iso_layers("iso13567")
|
||||||
assert backend._active_layer_set_id() == "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 ────────────────────
|
# ── NEW-AUTH-1 — bind guard on the run_async launch path ────────────────────
|
||||||
|
|
||||||
|
|
||||||
async def test_run_async_invokes_bind_guard_for_http(monkeypatch):
|
async def test_run_async_invokes_bind_guard_for_http(monkeypatch):
|
||||||
import server
|
import server
|
||||||
|
|
||||||
@@ -121,8 +126,7 @@ async def test_run_async_skips_guard_for_stdio(monkeypatch):
|
|||||||
import server
|
import server
|
||||||
|
|
||||||
seen = {}
|
seen = {}
|
||||||
monkeypatch.setattr(server, "_validate_http_bind",
|
monkeypatch.setattr(server, "_validate_http_bind", lambda h: seen.setdefault("host", h))
|
||||||
lambda h: seen.setdefault("host", h))
|
|
||||||
|
|
||||||
async def fake_orig(*args, **kwargs):
|
async def fake_orig(*args, **kwargs):
|
||||||
return "stdio-ran"
|
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 ──────────
|
# ── NEW-gear-roottooth — outline never dips inside the root circle ──────────
|
||||||
|
|
||||||
|
|
||||||
async def test_gear_outline_stays_outside_root_circle():
|
async def test_gear_outline_stays_outside_root_circle():
|
||||||
from engineering.gear import generate_full_gear_outline
|
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)
|
pts = generate_full_gear_outline(module, teeth, pa)
|
||||||
radii = [math.hypot(x, y) for x, y in pts]
|
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).
|
# 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 ─────────
|
# ── NEW-section-dupbore / NEW-validator-keyway / NEW-gear-baseconstr ─────────
|
||||||
|
|
||||||
|
|
||||||
async def test_gear_front_view_base_circle_persists_and_keyway_validates(backend, tmp_path):
|
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.gear import draw_helical_gear_front_view
|
||||||
from engineering.validator import DrawingValidator
|
from engineering.validator import DrawingValidator
|
||||||
|
|
||||||
await backend.drawing_apply_iso_layers("mech")
|
await backend.drawing_apply_iso_layers("mech")
|
||||||
res = await draw_helical_gear_front_view(
|
res = await draw_helical_gear_front_view(
|
||||||
backend, module=2, teeth=20, helix_angle=0, center=(0, 0),
|
backend,
|
||||||
bore_diameter=20, keyway_width=6, keyway_depth=2.8,
|
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.
|
# NEW-gear-baseconstr: base circle is a persistent PHANTOM reference, not CONSTRUCTION.
|
||||||
base = await backend.entity_get(res["base_circle"])
|
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.
|
# NEW-validator-keyway: the open 4-point keyway polyline is now recognised.
|
||||||
await backend.drawing_save_as(str(tmp_path / "gear.dxf"))
|
await backend.drawing_save_as(str(tmp_path / "gear.dxf"))
|
||||||
vr = await DrawingValidator().run(
|
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]
|
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 backend.drawing_apply_iso_layers("mech")
|
||||||
await draw_gear_section_aa(
|
await draw_gear_section_aa(
|
||||||
backend,
|
backend,
|
||||||
gear_metadata={"outer_radius": 22, "center": [0, 0], "bore_diameter": 20,
|
gear_metadata={
|
||||||
"keyway_width": 6, "keyway_depth": 2.8},
|
"outer_radius": 22,
|
||||||
x_offset=100, face_width=30,
|
"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"])
|
dups = await backend.drawing_critique(focus=["duplicate_entities"])
|
||||||
assert dups == []
|
assert dups == []
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ class _Ctx:
|
|||||||
|
|
||||||
# ── N2 — format follows the file extension ──────────────────────────────────
|
# ── N2 — format follows the file extension ──────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
async def test_save_as_derives_format_from_extension(backend):
|
async def test_save_as_derives_format_from_extension(backend):
|
||||||
import server
|
import server
|
||||||
|
|
||||||
@@ -42,6 +43,7 @@ async def test_save_as_derives_format_from_extension(backend):
|
|||||||
|
|
||||||
# ── N3 — ARC length + length_range selection ────────────────────────────────
|
# ── N3 — ARC length + length_range selection ────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
async def test_arc_has_length_and_is_selectable(backend):
|
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
|
arc = await backend.entity_create_arc(0, 0, 10, 0, 90) # r=10, 90deg -> len ~15.708
|
||||||
info = await backend.entity_get(arc.handle)
|
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 ──────────────────────────────────────
|
# ── N5 — cross-backend property parity ──────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
async def test_ezdxf_populates_bounding_box(backend):
|
async def test_ezdxf_populates_bounding_box(backend):
|
||||||
line = await backend.entity_create_line(0, 0, 30, 40)
|
line = await backend.entity_create_line(0, 0, 30, 40)
|
||||||
info = await backend.entity_get(line.handle)
|
info = await backend.entity_get(line.handle)
|
||||||
@@ -78,6 +81,7 @@ async def test_mtext_honors_caller_rotation(backend):
|
|||||||
|
|
||||||
# ── NEW-dimauto-baseline-rotation ───────────────────────────────────────────
|
# ── NEW-dimauto-baseline-rotation ───────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
async def test_baseline_dim_runs_parallel_to_rotated_baseline(backend):
|
async def test_baseline_dim_runs_parallel_to_rotated_baseline(backend):
|
||||||
ln = await backend.entity_create_line(0, 0, 40, 40) # 45-degree baseline
|
ln = await backend.entity_create_line(0, 0, 40, 40) # 45-degree baseline
|
||||||
dims = await backend.dimension_auto([ln.handle], style="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 ───────────────────────────────────────────
|
# ── NEW-dimauto-ordinate-misnomer ───────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
async def test_ordinate_dims_are_staggered_not_overlapping(backend):
|
async def test_ordinate_dims_are_staggered_not_overlapping(backend):
|
||||||
l1 = await backend.entity_create_line(0, 0, 20, 10)
|
l1 = await backend.entity_create_line(0, 0, 20, 10)
|
||||||
l2 = await backend.entity_create_line(0, 0, 40, 30)
|
l2 = await backend.entity_create_line(0, 0, 40, 30)
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ pytestmark = pytest.mark.asyncio
|
|||||||
# Helpers
|
# Helpers
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def _on_arc(pt, cx, cy, r, sa_deg, ea_deg, tol=1e-6):
|
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
|
"""True if pt lies on the circle of radius r AND within the [sa, ea] sweep
|
||||||
(degrees, CCW, with wraparound)."""
|
(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
|
# N7 — ARC snap angle-missing raises RuntimeError, not KeyError
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
class TestN7ArcAngleMissing:
|
class TestN7ArcAngleMissing:
|
||||||
async def _patch_entity_get(self, backend, info: EntityInfo):
|
async def _patch_entity_get(self, backend, info: EntityInfo):
|
||||||
async def _fake(handle):
|
async def _fake(handle):
|
||||||
return info
|
return info
|
||||||
|
|
||||||
backend.entity_get = _fake # type: ignore[method-assign]
|
backend.entity_get = _fake # type: ignore[method-assign]
|
||||||
|
|
||||||
async def test_end_snap_missing_angles_raises_runtimeerror(self, backend):
|
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
|
# NEW-base-1 — point_tangent on/near the perimeter must not raise ValueError
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
class TestNewBase1TangentClamp:
|
class TestNewBase1TangentClamp:
|
||||||
async def test_from_point_exactly_on_circle_no_valueerror(self, backend):
|
async def test_from_point_exactly_on_circle_no_valueerror(self, backend):
|
||||||
"""A from-point exactly on the perimeter (d == r) degenerates to the
|
"""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
|
# NEW-snap-quad-arc / NEW-snap-near-arc — snaps stay on the arc sweep
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
class TestNewSnapArcSweep:
|
class TestNewSnapArcSweep:
|
||||||
async def test_quad_on_quarter_arc_stays_on_sweep(self, backend):
|
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
|
"""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
|
# N8 — get_plan_spec accessor exposes the stored PlanSpec
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
class TestN8GetPlanSpec:
|
class TestN8GetPlanSpec:
|
||||||
async def test_none_before_plan(self, backend):
|
async def test_none_before_plan(self, backend):
|
||||||
assert backend.get_plan_spec() is None
|
assert backend.get_plan_spec() is None
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ def _backend_no_executor() -> ComBackend:
|
|||||||
|
|
||||||
# ── R28 — _find_autocad_hwnd prefers Application.HWND over EnumWindows ───────
|
# ── R28 — _find_autocad_hwnd prefers Application.HWND over EnumWindows ───────
|
||||||
|
|
||||||
|
|
||||||
async def test_find_hwnd_prefers_application_hwnd(monkeypatch):
|
async def test_find_hwnd_prefers_application_hwnd(monkeypatch):
|
||||||
"""The real main-frame handle from the COM app wins; EnumWindows is not
|
"""The real main-frame handle from the COM app wins; EnumWindows is not
|
||||||
consulted when the HWND read succeeds."""
|
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 ───────────
|
# ── S3 — block_list populates BlockInfo.description from .Comments ───────────
|
||||||
|
|
||||||
|
|
||||||
class _Block:
|
class _Block:
|
||||||
"""Minimal stand-in for an AutoCAD block definition."""
|
"""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):
|
async def test_block_list_skips_anonymous_layout_blocks(monkeypatch):
|
||||||
doc = _doc_with_blocks([
|
doc = _doc_with_blocks(
|
||||||
|
[
|
||||||
_Block("*Model_Space", comments="ignored"),
|
_Block("*Model_Space", comments="ignored"),
|
||||||
_Block("GEAR", comments="spur gear"),
|
_Block("GEAR", comments="spur gear"),
|
||||||
])
|
]
|
||||||
|
)
|
||||||
monkeypatch.setattr(cb, "_acad_doc", lambda: doc)
|
monkeypatch.setattr(cb, "_acad_doc", lambda: doc)
|
||||||
|
|
||||||
b = _backend_no_executor()
|
b = _backend_no_executor()
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ pytestmark = pytest.mark.asyncio
|
|||||||
|
|
||||||
# ── R24 — CENTER/HIDDEN linetype actually loaded + applied ──────────────────
|
# ── R24 — CENTER/HIDDEN linetype actually loaded + applied ──────────────────
|
||||||
|
|
||||||
|
|
||||||
async def test_create_line_loads_center_linetype(backend):
|
async def test_create_line_loads_center_linetype(backend):
|
||||||
# Fresh doc: CENTER is not loaded yet.
|
# Fresh doc: CENTER is not loaded yet.
|
||||||
assert "CENTER" not in backend._doc.linetypes
|
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 ─────────────────────
|
# ── S3 — block description surfaced from the definition ─────────────────────
|
||||||
|
|
||||||
|
|
||||||
async def test_block_list_surfaces_description(backend):
|
async def test_block_list_surfaces_description(backend):
|
||||||
# Create a block from an entity, then stamp a description on its definition.
|
# Create a block from an entity, then stamp a description on its definition.
|
||||||
seg = await backend.entity_create_line(0, 0, 10, 0)
|
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 ────────────────────────────────────
|
# ── R26 / N9 — honest zoom payload shape ────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
async def test_zoom_extents_reports_not_applied(backend):
|
async def test_zoom_extents_reports_not_applied(backend):
|
||||||
res = await backend.view_zoom_extents()
|
res = await backend.view_zoom_extents()
|
||||||
assert res["ok"] is True
|
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 ────────────────
|
# ── NEW-undo-1 — undo/transaction stack behaviour under lock ────────────────
|
||||||
|
|
||||||
|
|
||||||
async def test_undo_empty_stack_returns_error(backend):
|
async def test_undo_empty_stack_returns_error(backend):
|
||||||
res = await backend.drawing_undo()
|
res = await backend.drawing_undo()
|
||||||
assert res["ok"] is False
|
assert res["ok"] is False
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ class _Ctx:
|
|||||||
|
|
||||||
# ── R20 — tool count comes from the public accessor and is a real number ─────
|
# ── R20 — tool count comes from the public accessor and is a real number ─────
|
||||||
|
|
||||||
|
|
||||||
async def test_registered_tool_count_is_positive_never_minus_one():
|
async def test_registered_tool_count_is_positive_never_minus_one():
|
||||||
import server
|
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 ──────────────────────
|
# ── R15 — system_about tool_groups is dynamic + correct ──────────────────────
|
||||||
|
|
||||||
|
|
||||||
async def test_system_about_includes_engineering_group(backend):
|
async def test_system_about_includes_engineering_group(backend):
|
||||||
import server
|
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 ──────────────────
|
# ── R20 — system_status reports a real tool_count, never -1 ──────────────────
|
||||||
|
|
||||||
|
|
||||||
async def test_system_status_reports_real_tool_count(backend):
|
async def test_system_status_reports_real_tool_count(backend):
|
||||||
import server
|
import server
|
||||||
|
|
||||||
@@ -134,6 +137,7 @@ async def test_system_status_reports_real_tool_count(backend):
|
|||||||
|
|
||||||
# ── N8 — drawing_plan docstring corrected ────────────────────────────────────
|
# ── N8 — drawing_plan docstring corrected ────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
async def test_drawing_plan_docstring_not_misleading():
|
async def test_drawing_plan_docstring_not_misleading():
|
||||||
import server
|
import server
|
||||||
|
|
||||||
|
|||||||
@@ -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."""
|
"""R27: 'SPURIOUS' contains 'spur' but must NOT trip a SPUR mismatch."""
|
||||||
await _bootstrap(backend)
|
await _bootstrap(backend)
|
||||||
await backend.entity_create_text(
|
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"))
|
await backend.drawing_save_as(str(tmp_path / "spurious.dxf"))
|
||||||
result = await DrawingValidator().run(backend, expected={"helix_angle": 15})
|
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."""
|
"""R27: a real 'SPUR GEAR' title still flags the mismatch when helix declared."""
|
||||||
await _bootstrap(backend)
|
await _bootstrap(backend)
|
||||||
await backend.entity_create_text(
|
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"))
|
await backend.drawing_save_as(str(tmp_path / "spur.dxf"))
|
||||||
result = await DrawingValidator().run(backend, expected={"helix_angle": 15})
|
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."""
|
"""R27: title-block-scoped scan — SPUR on TITLEBLOCK layer still flags."""
|
||||||
await _bootstrap(backend)
|
await _bootstrap(backend)
|
||||||
await backend.entity_create_text(
|
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"))
|
await backend.drawing_save_as(str(tmp_path / "spur_tb.dxf"))
|
||||||
result = await DrawingValidator().run(backend, expected={"helix_angle": 15})
|
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."""
|
"""R27: 'SPURIOUS' on TITLEBLOCK layer must not trip a SPUR match either."""
|
||||||
await _bootstrap(backend)
|
await _bootstrap(backend)
|
||||||
await backend.entity_create_text(
|
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"))
|
await backend.drawing_save_as(str(tmp_path / "spurious_tb.dxf"))
|
||||||
result = await DrawingValidator().run(backend, expected={"helix_angle": 15})
|
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."""
|
"""R27: both whole words present -> invalid combo error preserved."""
|
||||||
await _bootstrap(backend)
|
await _bootstrap(backend)
|
||||||
await backend.entity_create_text(
|
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"))
|
await backend.drawing_save_as(str(tmp_path / "combo.dxf"))
|
||||||
result = await DrawingValidator().run(backend, expected={"helix_angle": 15})
|
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."""
|
"""Gating preserved: without helix_angle, a SPUR title raises no title finding."""
|
||||||
await _bootstrap(backend)
|
await _bootstrap(backend)
|
||||||
await backend.entity_create_text(
|
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"))
|
await backend.drawing_save_as(str(tmp_path / "nohelix.dxf"))
|
||||||
result = await DrawingValidator().run(backend)
|
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 _bootstrap(backend)
|
||||||
await backend.entity_create_text(
|
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(
|
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"))
|
await backend.drawing_save_as(str(tmp_path / "scope.dxf"))
|
||||||
result = await DrawingValidator().run(backend, expected={"helix_angle": 15})
|
result = await DrawingValidator().run(backend, expected={"helix_angle": 15})
|
||||||
|
|||||||
@@ -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):
|
async def test_ezdxf_table_survives_save_and_reopen(backend, tmp_path):
|
||||||
result = await backend.entity_create_table(
|
result = await backend.entity_create_table(0, 30, rows=[["A", "1"]], headers=["Name", "Qty"])
|
||||||
0, 30, rows=[["A", "1"]], headers=["Name", "Qty"]
|
|
||||||
)
|
|
||||||
path = tmp_path / "table.dxf"
|
path = tmp_path / "table.dxf"
|
||||||
await backend.drawing_save_as(str(path), fmt="dxf")
|
await backend.drawing_save_as(str(path), fmt="dxf")
|
||||||
await backend.drawing_open(str(path))
|
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)
|
entities = await backend.entity_list(limit=100)
|
||||||
handles = {entity.handle for entity in entities}
|
handles = {entity.handle for entity in entities}
|
||||||
texts = {
|
texts = {
|
||||||
entity.properties.get("text")
|
entity.properties.get("text") for entity in entities if entity.type in {"TEXT", "MTEXT"}
|
||||||
for entity in entities
|
|
||||||
if entity.type in {"TEXT", "MTEXT"}
|
|
||||||
}
|
}
|
||||||
assert set(result.properties["child_handles"]).issubset(handles)
|
assert set(result.properties["child_handles"]).issubset(handles)
|
||||||
assert {"Name", "Qty", "A", "1"}.issubset(texts)
|
assert {"Name", "Qty", "A", "1"}.issubset(texts)
|
||||||
|
|||||||
Reference in New Issue
Block a user