release: prepare v1.3.0
This commit is contained in:
@@ -0,0 +1,108 @@
|
||||
"""Fair benchmark v2 runner contract."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import subprocess
|
||||
import sys
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from benchmarks.adapters.autocad_mcp_pro import AutoCADMCPProAdapter
|
||||
from benchmarks.adapters.base import BenchmarkAdapter, TaskResult
|
||||
from benchmarks.run_competitors import run_tasks
|
||||
from benchmarks.tasks_v2 import TaskSpec
|
||||
|
||||
|
||||
class FakeAdapter(BenchmarkAdapter):
|
||||
name = "fake"
|
||||
|
||||
async def setup(self, artifact_dir):
|
||||
self.artifact_dir = artifact_dir
|
||||
|
||||
async def run_task(self, task):
|
||||
if task.task_id == "slow":
|
||||
await asyncio.sleep(0.2)
|
||||
if task.task_id == "unsupported":
|
||||
return TaskResult(task_id=task.task_id, status="unsupported", score=0.0)
|
||||
return TaskResult(task_id=task.task_id, status="pass", score=100.0)
|
||||
|
||||
async def cleanup(self):
|
||||
return None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_benchmark_timeout_does_not_stop_later_tasks(tmp_path):
|
||||
tasks = [
|
||||
TaskSpec("slow", "reliability", "Times out", 1.0),
|
||||
TaskSpec("next", "reliability", "Still runs", 1.0),
|
||||
]
|
||||
|
||||
report = await run_tasks(FakeAdapter(), tasks, tmp_path, timeout=0.01)
|
||||
|
||||
assert [item["status"] for item in report["results"]] == ["timeout", "pass"]
|
||||
assert report["summary"]["attempted"] == 2
|
||||
assert report["summary"]["passed"] == 1
|
||||
assert report["git_sha"]
|
||||
assert report["environment"]["python"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unsupported_tasks_count_as_zero_in_fixed_matrix_score(tmp_path):
|
||||
tasks = [
|
||||
TaskSpec("supported", "coverage", "Implemented", 1.0),
|
||||
TaskSpec("unsupported", "coverage", "Not implemented", 9.0),
|
||||
]
|
||||
|
||||
report = await run_tasks(FakeAdapter(), tasks, tmp_path)
|
||||
|
||||
assert report["summary"]["score"] == 10.0
|
||||
assert report["summary"]["supported"] == 1
|
||||
assert report["summary"]["coverage_percent"] == 10.0
|
||||
|
||||
|
||||
def test_task_result_has_machine_readable_status():
|
||||
result = TaskResult(
|
||||
task_id="table",
|
||||
status="unsupported",
|
||||
score=0.0,
|
||||
message="TABLE is unavailable",
|
||||
)
|
||||
|
||||
assert result.to_dict() == {
|
||||
"task_id": "table",
|
||||
"status": "unsupported",
|
||||
"score": 0.0,
|
||||
"message": "TABLE is unavailable",
|
||||
"duration_ms": 0.0,
|
||||
"artifacts": [],
|
||||
"metrics": {},
|
||||
"stdout_summary": "",
|
||||
"stderr_summary": "",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reference_adapter_selects_real_com_backend_class(tmp_path):
|
||||
with patch("backends.com_backend.ComBackend") as backend_class:
|
||||
backend_class.return_value.connect = AsyncMock()
|
||||
adapter = AutoCADMCPProAdapter(backend="com")
|
||||
|
||||
await adapter.setup(tmp_path)
|
||||
|
||||
backend_class.assert_called_once_with()
|
||||
assert adapter.backend is backend_class.return_value
|
||||
|
||||
|
||||
@pytest.mark.parametrize("args", [[], ["--help"]])
|
||||
def test_correctness_suite_help_is_not_a_traceback(args):
|
||||
completed = subprocess.run(
|
||||
[sys.executable, "benchmarks/correctness_suite.py", *args],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
)
|
||||
|
||||
assert completed.returncode == 0
|
||||
assert "usage" in completed.stdout.lower()
|
||||
@@ -0,0 +1,74 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from backends.base import CapabilityMap, FeatureCapability
|
||||
|
||||
|
||||
def test_capability_types_serialize_stably():
|
||||
caps = CapabilityMap(
|
||||
backend="demo",
|
||||
features={"table": FeatureCapability(True, "native")},
|
||||
)
|
||||
|
||||
assert caps.to_dict() == {
|
||||
"backend": "demo",
|
||||
"features": {
|
||||
"table": {"supported": True, "mode": "native", "reason": None},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ezdxf_capabilities_are_machine_readable(backend):
|
||||
caps = backend.capabilities().to_dict()
|
||||
|
||||
assert caps["backend"] == "ezdxf"
|
||||
assert caps["features"]["table"] == {
|
||||
"supported": True,
|
||||
"mode": "composite",
|
||||
"reason": None,
|
||||
}
|
||||
assert caps["features"]["mleader"]["mode"] == "composite"
|
||||
assert caps["features"]["paper_space"]["supported"] is False
|
||||
assert caps["features"]["solid_3d"]["supported"] is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ezdxf_render_capabilities_follow_optional_dependency(backend):
|
||||
with patch("backends.ezdxf_backend.find_spec", return_value=None):
|
||||
caps = backend.capabilities().to_dict()
|
||||
|
||||
assert caps["features"]["pdf"] == {
|
||||
"supported": False,
|
||||
"mode": None,
|
||||
"reason": "optional_dependency_missing:matplotlib",
|
||||
}
|
||||
assert caps["features"]["png"]["supported"] is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_system_capabilities_tool_uses_backend_map(backend):
|
||||
import server
|
||||
|
||||
class Ctx:
|
||||
lifespan_context = {"backend": backend}
|
||||
|
||||
result = await server.system_capabilities(ctx=Ctx())
|
||||
|
||||
assert result == backend.capabilities().to_dict()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_system_about_uses_package_version_and_capability_summary(backend):
|
||||
import server
|
||||
|
||||
class Ctx:
|
||||
lifespan_context = {"backend": backend}
|
||||
|
||||
result = await server.system_about(ctx=Ctx())
|
||||
|
||||
assert result["version"] == "1.3.0"
|
||||
assert result["capabilities"]["table"]["mode"] == "composite"
|
||||
@@ -11,6 +11,7 @@ Strategy:
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
@@ -19,6 +20,34 @@ import pytest
|
||||
from backends.base import normalize_lineweight
|
||||
from backends.com_backend import ComBackend
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_active_document_switch_clears_com_document_state(monkeypatch):
|
||||
backend = ComBackend()
|
||||
documents = SimpleNamespace(Count=1)
|
||||
app = SimpleNamespace(
|
||||
Documents=documents,
|
||||
ActiveDocument=SimpleNamespace(Name="First.dwg", FullName="C:/First.dwg"),
|
||||
)
|
||||
|
||||
async def run_inline(function, *args, **kwargs):
|
||||
return function(*args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(backend, "_run", run_inline)
|
||||
monkeypatch.setattr("backends.com_backend._acad_app", lambda: app)
|
||||
|
||||
await backend._ensure_document_state()
|
||||
backend._plan_spec = object()
|
||||
backend._preflight_result = object()
|
||||
backend._gdt_datums_defined = {"A"}
|
||||
app.ActiveDocument = SimpleNamespace(Name="Second.dwg", FullName="C:/Second.dwg")
|
||||
|
||||
await backend._ensure_document_state()
|
||||
|
||||
assert backend._plan_spec is None
|
||||
assert backend._preflight_result is None
|
||||
assert backend._gdt_datums_defined == set()
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# normalize_lineweight — parametrized table
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
"""Release delivery contract: artifacts, hashes, validation and DXF parity."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
import server
|
||||
from engineering.delivery import compare_drawing_snapshots, deliver_drawing, drawing_snapshot
|
||||
from engineering.layers import ensure_engineering_layers, ensure_standard_linetypes
|
||||
|
||||
|
||||
async def _clean_drawing(backend) -> None:
|
||||
await ensure_standard_linetypes(backend)
|
||||
await ensure_engineering_layers(backend)
|
||||
await backend.entity_create_line(0, 0, 40, 0, layer="GEOMETRY")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delivery_writes_hashed_manifest_and_reopen_parity(backend, tmp_path):
|
||||
await _clean_drawing(backend)
|
||||
|
||||
result = await deliver_drawing(
|
||||
backend,
|
||||
tmp_path,
|
||||
formats=["dxf"],
|
||||
min_score=0,
|
||||
strict_critique=False,
|
||||
)
|
||||
|
||||
assert result.status == "success"
|
||||
manifest_path = tmp_path / "manifest.json"
|
||||
validation_path = tmp_path / "validation.json"
|
||||
assert manifest_path.is_file()
|
||||
assert validation_path.is_file()
|
||||
|
||||
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||
dxf = next(item for item in manifest["artifacts"] if item["format"] == "dxf")
|
||||
artifact_path = tmp_path / dxf["filename"]
|
||||
assert dxf["status"] == "created"
|
||||
assert dxf["sha256"] == hashlib.sha256(artifact_path.read_bytes()).hexdigest()
|
||||
assert manifest["parity"]["ok"] is True
|
||||
assert manifest["version"] == "1.3.0"
|
||||
assert manifest["capabilities"]["backend"] == "ezdxf"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delivery_keeps_artifacts_when_validation_fails(backend, tmp_path):
|
||||
await _clean_drawing(backend)
|
||||
await backend.construction_xline(0, 0, 45)
|
||||
|
||||
result = await deliver_drawing(
|
||||
backend,
|
||||
tmp_path,
|
||||
formats=["dxf"],
|
||||
min_score=100,
|
||||
strict_critique=True,
|
||||
)
|
||||
|
||||
assert result.status == "failed_validation"
|
||||
assert (tmp_path / "drawing.dxf").is_file()
|
||||
manifest = json.loads((tmp_path / "manifest.json").read_text(encoding="utf-8"))
|
||||
assert manifest["status"] == "failed_validation"
|
||||
assert any(issue["focus"] == "construction_left" for issue in manifest["critique"])
|
||||
|
||||
|
||||
def test_snapshot_comparison_reports_type_and_bounds_drift():
|
||||
source = {
|
||||
"entity_count": 2,
|
||||
"types": {"LINE": 2},
|
||||
"layers": {"GEOMETRY": 2},
|
||||
"extents_min": [0.0, 0.0],
|
||||
"extents_max": [10.0, 5.0],
|
||||
}
|
||||
reopened = {
|
||||
"entity_count": 2,
|
||||
"types": {"LINE": 1, "CIRCLE": 1},
|
||||
"layers": {"GEOMETRY": 2},
|
||||
"extents_min": [0.0, 0.0],
|
||||
"extents_max": [10.5, 5.0],
|
||||
}
|
||||
|
||||
result = compare_drawing_snapshots(source, reopened)
|
||||
|
||||
assert result["ok"] is False
|
||||
assert {item["field"] for item in result["differences"]} == {"types", "extents_max"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_snapshot_parity_rejects_incomplete_entity_inventory(backend, monkeypatch):
|
||||
await backend.entity_create_line(0, 0, 10, 0)
|
||||
await backend.entity_create_circle(5, 5, 2)
|
||||
original_entity_list = backend.entity_list
|
||||
|
||||
async def incomplete_entity_list(*args, **kwargs):
|
||||
return (await original_entity_list(*args, **kwargs))[:1]
|
||||
|
||||
monkeypatch.setattr(backend, "entity_list", incomplete_entity_list)
|
||||
source = await drawing_snapshot(backend)
|
||||
reopened = dict(source, inventory_complete=True, listed_entity_count=2)
|
||||
|
||||
result = compare_drawing_snapshots(source, reopened)
|
||||
|
||||
assert source["entity_count"] == 2
|
||||
assert source["listed_entity_count"] == 1
|
||||
assert source["inventory_complete"] is False
|
||||
assert result["ok"] is False
|
||||
assert "source_inventory_complete" in {
|
||||
item["field"] for item in result["differences"]
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delivery_rejects_unknown_format(backend, tmp_path):
|
||||
await _clean_drawing(backend)
|
||||
|
||||
with pytest.raises(ValueError, match="Unsupported delivery format"):
|
||||
await deliver_drawing(backend, tmp_path, formats=["step"])
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_server_drawing_deliver_exposes_manifest_tool(backend, tmp_path):
|
||||
await _clean_drawing(backend)
|
||||
|
||||
class Ctx:
|
||||
lifespan_context = {"backend": backend}
|
||||
|
||||
result = await server.drawing_deliver(
|
||||
output_dir=str(tmp_path),
|
||||
formats=["dxf"],
|
||||
min_score=0,
|
||||
strict_critique=False,
|
||||
ctx=Ctx(),
|
||||
)
|
||||
|
||||
assert result["status"] == "success"
|
||||
assert result["manifest_path"].endswith("manifest.json")
|
||||
@@ -0,0 +1,166 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from engineering.preflight import preflight_drawing
|
||||
|
||||
|
||||
def _complete_requirements() -> dict:
|
||||
return {
|
||||
"units": "mm",
|
||||
"part_type": "bracket",
|
||||
"dimensions": {"width": 80.0, "height": 50.0, "thickness": 8.0},
|
||||
"tolerance_policy": "ISO 2768-m",
|
||||
}
|
||||
|
||||
|
||||
def test_preflight_missing_production_fields_is_not_ready():
|
||||
result = preflight_drawing("L bracket", requirements={})
|
||||
|
||||
assert result.ready is False
|
||||
assert {q.code for q in result.questions} == {
|
||||
"MISSING_UNITS",
|
||||
"MISSING_PART_TYPE",
|
||||
"MISSING_DIMENSIONS",
|
||||
"MISSING_TOLERANCE_POLICY",
|
||||
}
|
||||
assert result.spec_hash.startswith("sha256:")
|
||||
|
||||
|
||||
def test_preflight_normalizes_complete_spec_and_hash_is_deterministic():
|
||||
first = preflight_drawing(
|
||||
" L bracket ",
|
||||
requirements=_complete_requirements(),
|
||||
sheet_size="a3",
|
||||
layer_set_id="MECH",
|
||||
)
|
||||
second = preflight_drawing(
|
||||
"L bracket",
|
||||
requirements={
|
||||
"tolerance_policy": "ISO 2768-m",
|
||||
"dimensions": {"thickness": 8, "height": 50, "width": 80},
|
||||
"part_type": "bracket",
|
||||
"units": "MM",
|
||||
},
|
||||
sheet_size="A3",
|
||||
layer_set_id="mech",
|
||||
)
|
||||
|
||||
assert first.ready is True
|
||||
assert first.normalized_spec["requirements"]["units"] == "mm"
|
||||
assert first.spec_hash == second.spec_hash
|
||||
|
||||
|
||||
def test_preflight_detects_conflicting_constraints():
|
||||
requirements = _complete_requirements()
|
||||
requirements["constraints"] = [
|
||||
{"field": "dimensions.width", "value": 80},
|
||||
{"field": "dimensions.width", "value": 90},
|
||||
]
|
||||
|
||||
result = preflight_drawing("L bracket", requirements=requirements)
|
||||
|
||||
assert result.ready is False
|
||||
assert result.conflicts[0].code == "CONFLICTING_REQUIREMENT"
|
||||
assert result.conflicts[0].field == "dimensions.width"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("kwargs", "code"),
|
||||
[
|
||||
({"sheet_size": "LETTER"}, "INVALID_SHEET_SIZE"),
|
||||
({"scale": 0}, "INVALID_SCALE"),
|
||||
({"view_count": 0}, "INVALID_VIEW_COUNT"),
|
||||
({"dim_style": "guess"}, "INVALID_DIM_STYLE"),
|
||||
({"layer_set_id": "custom"}, "INVALID_LAYER_SET"),
|
||||
],
|
||||
)
|
||||
def test_preflight_rejects_invalid_planning_parameters(kwargs, code):
|
||||
result = preflight_drawing(
|
||||
"L bracket",
|
||||
requirements=_complete_requirements(),
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
assert result.ready is False
|
||||
assert code in {conflict.code for conflict in result.conflicts}
|
||||
|
||||
|
||||
def test_preflight_does_not_invent_values_when_assumptions_disabled():
|
||||
result = preflight_drawing(
|
||||
"L bracket",
|
||||
requirements={"part_type": "bracket"},
|
||||
allow_assumptions=False,
|
||||
)
|
||||
|
||||
assert result.assumptions == []
|
||||
assert "units" not in result.normalized_spec["requirements"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_backend_plan_accepts_matching_ready_preflight(backend):
|
||||
preflight = await backend.drawing_preflight(
|
||||
"L bracket", requirements=_complete_requirements(),
|
||||
)
|
||||
|
||||
plan = await backend.drawing_plan(
|
||||
"L bracket",
|
||||
requirements=preflight.normalized_spec["requirements"],
|
||||
spec_hash=preflight.spec_hash,
|
||||
)
|
||||
|
||||
assert plan.preflight_status == "ready"
|
||||
assert plan.spec_hash == preflight.spec_hash
|
||||
assert plan.requirements["units"] == "mm"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_backend_plan_rejects_not_ready_preflight_hash(backend):
|
||||
preflight = await backend.drawing_preflight("L bracket", requirements={})
|
||||
|
||||
with pytest.raises(RuntimeError, match="not ready"):
|
||||
await backend.drawing_plan("L bracket", spec_hash=preflight.spec_hash)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_backend_plan_rejects_fields_that_do_not_match_preflight_hash(backend):
|
||||
preflight = await backend.drawing_preflight(
|
||||
"L bracket", requirements=_complete_requirements(),
|
||||
)
|
||||
|
||||
with pytest.raises(RuntimeError, match="do not match"):
|
||||
await backend.drawing_plan(
|
||||
"Different part",
|
||||
requirements=preflight.normalized_spec["requirements"],
|
||||
spec_hash=preflight.spec_hash,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_legacy_plan_remains_compatible_and_marks_preflight_skipped(backend):
|
||||
plan = await backend.drawing_plan("Legacy drawing")
|
||||
|
||||
assert plan.preflight_status == "skipped"
|
||||
assert plan.spec_hash is None
|
||||
assert plan.requirements == {}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_document_change_clears_plan_preflight_and_gdt_state(backend):
|
||||
preflight = await backend.drawing_preflight(
|
||||
"First drawing", requirements=_complete_requirements(),
|
||||
)
|
||||
await backend.drawing_plan(
|
||||
"First drawing",
|
||||
requirements=preflight.normalized_spec["requirements"],
|
||||
spec_hash=preflight.spec_hash,
|
||||
)
|
||||
backend._gdt_datums_defined = {"A"}
|
||||
backend._gdt_datums_referenced = {"A", "B"}
|
||||
|
||||
await backend.drawing_new()
|
||||
|
||||
assert backend.get_plan_spec() is None
|
||||
assert backend._preflight_result is None
|
||||
assert backend._gdt_datums_defined == set()
|
||||
assert backend._gdt_datums_referenced == set()
|
||||
@@ -0,0 +1,139 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from engineering.refiner import RepairRegistry, refine_drawing
|
||||
|
||||
pytestmark = pytest.mark.asyncio
|
||||
|
||||
|
||||
async def test_transaction_stack_is_separate_from_drawing_undo(backend):
|
||||
await backend.entity_create_line(0, 0, 10, 0)
|
||||
await backend.transaction_begin()
|
||||
|
||||
undo = await backend.drawing_undo()
|
||||
status = await backend.system_status()
|
||||
|
||||
assert undo["ok"] is False
|
||||
assert status["transaction_depth"] == 1
|
||||
await backend.transaction_rollback()
|
||||
|
||||
|
||||
async def test_transaction_rollback_restores_document(backend):
|
||||
await backend.entity_create_line(0, 0, 10, 0)
|
||||
await backend.transaction_begin()
|
||||
await backend.entity_create_circle(5, 5, 2)
|
||||
|
||||
await backend.transaction_rollback()
|
||||
|
||||
entities = await backend.entity_list(limit=100)
|
||||
assert [entity.type for entity in entities] == ["LINE"]
|
||||
|
||||
|
||||
async def test_refiner_does_not_mutate_when_transaction_cannot_start(backend, monkeypatch):
|
||||
await backend.entity_create_line(0, 0, 20, 0)
|
||||
await backend.entity_create_line(0, 0, 20, 0)
|
||||
|
||||
async def transaction_rejected():
|
||||
return {"ok": False, "error": "A transaction is already active"}
|
||||
|
||||
async def unexpected_transaction_end():
|
||||
raise AssertionError("refiner must not end a transaction it did not start")
|
||||
|
||||
monkeypatch.setattr(backend, "transaction_begin", transaction_rejected)
|
||||
monkeypatch.setattr(backend, "transaction_commit", unexpected_transaction_end)
|
||||
monkeypatch.setattr(backend, "transaction_rollback", unexpected_transaction_end)
|
||||
|
||||
result = await refine_drawing(backend, focus=["duplicate_entities"])
|
||||
|
||||
assert result.status == "transaction_unavailable"
|
||||
assert result.rounds[0].transaction == "not_started"
|
||||
assert len(await backend.entity_list(type_filter="LINE")) == 2
|
||||
|
||||
|
||||
async def test_refiner_repairs_construction_and_duplicates(backend):
|
||||
await backend.entity_create_line(0, 0, 20, 0)
|
||||
await backend.entity_create_line(0, 0, 20, 0)
|
||||
await backend.construction_xline(0, 0, 0)
|
||||
|
||||
result = await refine_drawing(
|
||||
backend,
|
||||
max_rounds=3,
|
||||
min_score=95,
|
||||
focus=["duplicate_entities", "construction_left"],
|
||||
)
|
||||
|
||||
assert result.status == "threshold_met"
|
||||
assert result.final_score == 100.0
|
||||
assert result.rounds[0].score_after > result.rounds[0].score_before
|
||||
assert await backend.drawing_critique(
|
||||
["duplicate_entities", "construction_left"]
|
||||
) == []
|
||||
|
||||
|
||||
async def test_refiner_dry_run_does_not_mutate_drawing(backend):
|
||||
await backend.entity_create_line(0, 0, 20, 0)
|
||||
await backend.entity_create_line(0, 0, 20, 0)
|
||||
|
||||
result = await refine_drawing(
|
||||
backend,
|
||||
focus=["duplicate_entities"],
|
||||
dry_run=True,
|
||||
)
|
||||
|
||||
assert result.status == "dry_run"
|
||||
assert len(await backend.entity_list(type_filter="LINE")) == 2
|
||||
assert result.rounds[0].actions[0].status == "planned"
|
||||
|
||||
|
||||
async def test_refiner_rolls_back_when_custom_repair_lowers_score(backend):
|
||||
await backend.entity_create_line(0, 0, 20, 0)
|
||||
await backend.entity_create_line(0, 0, 20, 0)
|
||||
|
||||
registry = RepairRegistry()
|
||||
|
||||
async def harmful_repair(active_backend, issue):
|
||||
await active_backend.construction_xline(0, 0, 0)
|
||||
return {"created": "construction"}
|
||||
|
||||
registry.register("duplicate_entities", harmful_repair)
|
||||
before = len(await backend.entity_list(limit=100))
|
||||
|
||||
result = await refine_drawing(
|
||||
backend,
|
||||
focus=["duplicate_entities", "construction_left"],
|
||||
registry=registry,
|
||||
)
|
||||
|
||||
assert result.status == "rolled_back"
|
||||
assert result.rounds[0].transaction == "rolled_back"
|
||||
assert len(await backend.entity_list(limit=100)) == before
|
||||
|
||||
|
||||
async def test_refiner_leaves_missing_gdt_datum_for_manual_resolution(backend):
|
||||
backend._gdt_datums_referenced = {"A"}
|
||||
|
||||
result = await refine_drawing(backend, focus=["gdt"])
|
||||
|
||||
assert result.status == "manual_required"
|
||||
assert result.remaining_issues[0]["focus"] == "gdt"
|
||||
|
||||
|
||||
async def test_server_drawing_refine_returns_structured_payload(backend):
|
||||
import server
|
||||
|
||||
class Ctx:
|
||||
lifespan_context = {"backend": backend}
|
||||
|
||||
await backend.entity_create_line(0, 0, 20, 0)
|
||||
await backend.entity_create_line(0, 0, 20, 0)
|
||||
|
||||
result = await server.drawing_refine(
|
||||
max_rounds=3,
|
||||
min_score=95,
|
||||
focus=["duplicate_entities"],
|
||||
ctx=Ctx(),
|
||||
)
|
||||
|
||||
assert result["status"] == "threshold_met"
|
||||
assert result["final_score"] == 100.0
|
||||
@@ -0,0 +1,41 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import tomllib
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import server
|
||||
|
||||
|
||||
def test_packaged_console_script_targets_callable_main():
|
||||
metadata = tomllib.loads(Path("pyproject.toml").read_text(encoding="utf-8"))
|
||||
|
||||
assert metadata["project"]["scripts"]["autocad-mcp"] == "server:main"
|
||||
assert callable(server.main)
|
||||
|
||||
|
||||
def test_main_starts_stdio_transport(monkeypatch):
|
||||
run = MagicMock()
|
||||
monkeypatch.setattr(server.mcp, "run", run)
|
||||
monkeypatch.setattr(sys, "argv", ["autocad-mcp"])
|
||||
|
||||
server.main()
|
||||
|
||||
run.assert_called_once_with()
|
||||
|
||||
|
||||
def test_main_help_exits_without_starting_server(monkeypatch, capsys):
|
||||
run = MagicMock()
|
||||
monkeypatch.setattr(server.mcp, "run", run)
|
||||
monkeypatch.setattr(sys, "argv", ["autocad-mcp", "--help"])
|
||||
|
||||
try:
|
||||
server.main()
|
||||
except SystemExit as exc:
|
||||
assert exc.code == 0
|
||||
else:
|
||||
raise AssertionError("--help must exit without starting the server")
|
||||
|
||||
assert "usage:" in capsys.readouterr().out.lower()
|
||||
run.assert_not_called()
|
||||
@@ -113,11 +113,12 @@ async def test_rollback_without_transaction_returns_error(backend):
|
||||
async def test_transaction_begin_commit_roundtrip(backend):
|
||||
begin = await backend.transaction_begin()
|
||||
assert begin["ok"] is True
|
||||
assert len(backend._undo_stack) == 1
|
||||
assert len(backend._transaction_stack) == 1
|
||||
assert len(backend._undo_stack) == 0
|
||||
|
||||
commit = await backend.transaction_commit()
|
||||
assert commit["ok"] is True
|
||||
assert len(backend._undo_stack) == 0
|
||||
assert len(backend._transaction_stack) == 0
|
||||
|
||||
|
||||
async def test_transaction_rollback_restores_snapshot(backend):
|
||||
@@ -129,7 +130,7 @@ async def test_transaction_rollback_restores_snapshot(backend):
|
||||
|
||||
res = await backend.transaction_rollback()
|
||||
assert res["ok"] is True
|
||||
assert len(backend._undo_stack) == 0
|
||||
assert len(backend._transaction_stack) == 0
|
||||
|
||||
# The line is gone after rollback.
|
||||
listed = await backend.entity_list(type_filter="LINE")
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from backends.base import EntityInfo
|
||||
from backends.com_backend import ComBackend
|
||||
|
||||
pytestmark = pytest.mark.asyncio
|
||||
|
||||
|
||||
async def test_ezdxf_table_returns_composite_contract(backend):
|
||||
result = await backend.entity_create_table(
|
||||
10,
|
||||
100,
|
||||
rows=[["P-01", "4"], ["P-02", "2"]],
|
||||
headers=["Part", "Qty"],
|
||||
title="BOM",
|
||||
layer="TEXT",
|
||||
)
|
||||
|
||||
assert result.type == "TABLE"
|
||||
assert result.layer == "TEXT"
|
||||
assert result.properties["representation"] == "composite"
|
||||
assert result.properties["logical_group_id"].startswith("table:")
|
||||
assert len(result.properties["child_handles"]) >= 10
|
||||
assert result.properties["bounds"] == {"min": [10.0, 72.0], "max": [30.0, 100.0]}
|
||||
|
||||
|
||||
async def test_ezdxf_table_survives_save_and_reopen(backend, tmp_path):
|
||||
result = await backend.entity_create_table(
|
||||
0, 30, rows=[["A", "1"]], headers=["Name", "Qty"]
|
||||
)
|
||||
path = tmp_path / "table.dxf"
|
||||
await backend.drawing_save_as(str(path), fmt="dxf")
|
||||
await backend.drawing_open(str(path))
|
||||
|
||||
entities = await backend.entity_list(limit=100)
|
||||
handles = {entity.handle for entity in entities}
|
||||
texts = {
|
||||
entity.properties.get("text")
|
||||
for entity in entities
|
||||
if entity.type in {"TEXT", "MTEXT"}
|
||||
}
|
||||
assert set(result.properties["child_handles"]).issubset(handles)
|
||||
assert {"Name", "Qty", "A", "1"}.issubset(texts)
|
||||
|
||||
|
||||
async def test_table_rejects_ragged_rows_and_size_limit(backend):
|
||||
with pytest.raises(RuntimeError, match="same number of columns"):
|
||||
await backend.entity_create_table(0, 0, rows=[["A", "B"], ["C"]])
|
||||
|
||||
with pytest.raises(RuntimeError, match="200 rows"):
|
||||
await backend.entity_create_table(0, 0, rows=[["A"]] * 201)
|
||||
|
||||
|
||||
async def test_ezdxf_mleader_returns_composite_contract(backend):
|
||||
result = await backend.leader_create_mleader(
|
||||
[[0, 0], [10, 5], [30, 5]],
|
||||
"SURFACE A",
|
||||
layer="DIM",
|
||||
)
|
||||
|
||||
assert result.type == "MLEADER"
|
||||
assert result.properties["representation"] == "composite"
|
||||
assert result.properties["logical_group_id"].startswith("mleader:")
|
||||
assert result.properties["text"] == "SURFACE A"
|
||||
assert len(result.properties["child_handles"]) == 3
|
||||
|
||||
|
||||
async def test_mleader_requires_two_points(backend):
|
||||
with pytest.raises(RuntimeError, match="at least two points"):
|
||||
await backend.leader_create_mleader([[0, 0]], "note")
|
||||
|
||||
|
||||
async def test_com_table_uses_native_add_table():
|
||||
backend = ComBackend()
|
||||
|
||||
async def run_inline(func, *args, **kwargs):
|
||||
return func(*args, **kwargs)
|
||||
|
||||
backend._run = run_inline
|
||||
mspace = MagicMock()
|
||||
table = MagicMock()
|
||||
table.Handle = "A1"
|
||||
mspace.AddTable.return_value = table
|
||||
info = EntityInfo("A1", "ACAD_TABLE", "TEXT", 256, "ByLayer", True, {})
|
||||
|
||||
with (
|
||||
patch("backends.com_backend._msp", return_value=mspace),
|
||||
patch("backends.com_backend._apoint", side_effect=lambda *p: tuple(p)),
|
||||
patch("backends.com_backend._entity_info", return_value=info),
|
||||
patch("backends.com_backend._apply_entity_attrs"),
|
||||
patch("backends.com_backend._regen"),
|
||||
):
|
||||
result = await backend.entity_create_table(
|
||||
0, 50, [["A", "1"]], headers=["Name", "Qty"], title="BOM"
|
||||
)
|
||||
|
||||
mspace.AddTable.assert_called_once()
|
||||
assert mspace.AddTable.call_args[0][1:3] == (3, 2)
|
||||
assert table.SetText.call_count == 6
|
||||
assert result.type == "TABLE"
|
||||
assert result.properties["representation"] == "native"
|
||||
|
||||
|
||||
async def test_com_mleader_uses_native_add_mleader():
|
||||
backend = ComBackend()
|
||||
|
||||
async def run_inline(func, *args, **kwargs):
|
||||
return func(*args, **kwargs)
|
||||
|
||||
backend._run = run_inline
|
||||
mspace = MagicMock()
|
||||
leader = MagicMock()
|
||||
leader.Handle = "B1"
|
||||
mspace.AddMLeader.return_value = leader
|
||||
info = EntityInfo("B1", "ACAD_MLEADER", "DIM", 256, "ByLayer", True, {})
|
||||
|
||||
with (
|
||||
patch("backends.com_backend._msp", return_value=mspace),
|
||||
patch("backends.com_backend._av", side_effect=lambda values: values),
|
||||
patch("backends.com_backend._entity_info", return_value=info),
|
||||
patch("backends.com_backend._apply_entity_attrs"),
|
||||
patch("backends.com_backend._regen"),
|
||||
):
|
||||
result = await backend.leader_create_mleader([[0, 0], [20, 5]], "NOTE")
|
||||
|
||||
mspace.AddMLeader.assert_called_once_with([0.0, 0.0, 0.0, 20.0, 5.0, 0.0], 0)
|
||||
assert leader.TextString == "NOTE"
|
||||
assert result.type == "MLEADER"
|
||||
assert result.properties["representation"] == "native"
|
||||
|
||||
|
||||
async def test_server_registers_table_and_mleader_tools():
|
||||
import server
|
||||
|
||||
names = {tool.name for tool in await server._registered_tools()}
|
||||
assert "entity_create_table" in names
|
||||
assert "leader_create_mleader" in names
|
||||
Reference in New Issue
Block a user