Files
cdsl-cad/backend/tests/test_design_intent.py
T
2026-08-24 14:52:08 +08:00

203 lines
8.8 KiB
Python

from __future__ import annotations
import copy
import sys
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT / "backend"))
def workplane(z: float = 0.0) -> dict[str, list[float]]:
return {
"origin_mm": [0.0, 0.0, z],
"x_dir": [1.0, 0.0, 0.0],
"y_dir": [0.0, 1.0, 0.0],
"normal": [0.0, 0.0, 1.0],
}
def mounting_plate_intent() -> dict:
return {
"schema": "cad.cdsl.design-intent.v1",
"schema_version": "1.0",
"mode": "create",
"request": "Create a mounting plate with four holes and a center slot",
"base_revision_id": "",
"structures": [
{
"id": "base_plate",
"cdsl_feature_id": "base_add",
"role": "base",
"purpose": "Rectangular mounting plate",
"depends_on": [],
"cdsl_strategy": {
"atomic_id": "extrude_add_blind",
"profile_type": "rectangle",
"parameter_roles": ["width_mm", "height_mm", "thickness_mm"],
"selector_roles": [],
},
},
{
"id": "mounting_holes",
"cdsl_feature_id": "hole_cut",
"role": "subtractive",
"purpose": "Four mounting holes",
"depends_on": ["base_plate"],
"cdsl_strategy": {
"atomic_id": "extrude_cut_blind",
"profile_type": "circle_grid",
"parameter_roles": ["diameter_mm", "count_x", "count_y"],
"selector_roles": [],
},
},
{
"id": "adjustment_slot",
"cdsl_feature_id": "slot_cut",
"role": "subtractive",
"purpose": "Center adjustment slot",
"depends_on": ["mounting_holes"],
"cdsl_strategy": {
"atomic_id": "extrude_cut_blind",
"profile_type": "obround",
"parameter_roles": ["length_mm", "width_mm", "depth_mm"],
"selector_roles": [],
},
},
],
"feature_order": ["base_plate", "mounting_holes", "adjustment_slot"],
"assumptions": [],
"open_questions": [],
"capability_gaps": [],
"verification_expectations": [
{"type": "feature_count", "feature_id": "mounting_holes", "expected": 4},
{"type": "symmetry", "axis": "x"},
{"type": "bbox", "expected_mm": {"x": 100, "y": 60, "z": 10}},
],
"status": "ready",
}
def mounting_plate_cdsl() -> dict:
return {
"schema": "cad.cdsl.llm.v1",
"schema_version": "1.0",
"kind": "part",
"part_id": "mounting-plate",
"geometry": {
"sketches": [
{"id": "base_sketch", "workplane": workplane(), "profile": {"type": "rectangle", "center": [0, 0], "width_mm": 100, "height_mm": 60}},
{"id": "holes_sketch", "workplane": workplane(10), "profile": {"type": "circle_grid", "radius_mm": 3, "count_x": 2, "count_y": 2, "spacing_x_mm": 80, "spacing_y_mm": 40, "center_mm": [0, 0]}},
{"id": "slot_sketch", "workplane": workplane(10), "profile": {"type": "obround", "center": [0, 0], "length_mm": 32, "width_mm": 10}},
],
},
"features": [
{"id": "base_add", "atomic_id": "extrude_add_blind", "depends_on": [], "sketch_id": "base_sketch", "params": {"distance_mm": 10}},
{"id": "hole_cut", "atomic_id": "extrude_cut_blind", "depends_on": ["base_add"], "sketch_id": "holes_sketch", "params": {"distance_mm": 10, "reverse": True}},
{"id": "slot_cut", "atomic_id": "extrude_cut_blind", "depends_on": ["hole_cut"], "sketch_id": "slot_sketch", "params": {"distance_mm": 10, "reverse": True}},
],
}
class DesignIntentValidationTests(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
sys.path.insert(0, str(ROOT / "backend" / "engine"))
import cdsl_engine
cls.engine = cdsl_engine
def assert_plan_error(self, intent: dict, code: str = "INVALID_DESIGN_INTENT") -> None:
with self.assertRaises(self.engine.DesignIntentError) as context:
self.engine.validate_design_intent(intent, self.engine)
self.assertEqual(context.exception.code, code)
def test_valid_mounting_plate_plan_and_cdsl_match(self) -> None:
intent = mounting_plate_intent()
validated = self.engine.validate_design_intent(intent, self.engine)
self.assertEqual(validated["feature_order"], ["base_plate", "mounting_holes", "adjustment_slot"])
self.engine.validate_intent_cdsl(validated, mounting_plate_cdsl(), self.engine)
def test_revise_requires_current_base_revision(self) -> None:
intent = mounting_plate_intent()
intent["mode"] = "revise"
intent["base_revision_id"] = ""
self.assert_plan_error(intent)
intent["base_revision_id"] = "rev_001"
self.engine.validate_design_intent(intent, self.engine, current_revision_id="rev_001")
with self.assertRaises(self.engine.DesignIntentError) as context:
self.engine.validate_design_intent(intent, self.engine, current_revision_id="rev_002")
self.assertEqual(context.exception.code, "INVALID_DESIGN_INTENT")
def test_rejects_duplicate_ids_bad_order_and_unregistered_capabilities(self) -> None:
duplicate = mounting_plate_intent()
duplicate["structures"][1]["id"] = "base_plate"
self.assert_plan_error(duplicate)
cyclic = mounting_plate_intent()
cyclic["structures"][0]["depends_on"] = ["mounting_holes"]
self.assert_plan_error(cyclic)
unsupported = mounting_plate_intent()
unsupported["structures"][0]["cdsl_strategy"]["atomic_id"] = "thread_add"
self.assert_plan_error(unsupported)
incomplete = mounting_plate_intent()
incomplete["feature_order"].pop()
self.assert_plan_error(incomplete)
no_profile = mounting_plate_intent()
no_profile["structures"][0]["cdsl_strategy"].pop("profile_type")
self.assert_plan_error(no_profile)
unknown_expectation = mounting_plate_intent()
unknown_expectation["verification_expectations"][0]["feature_id"] = "missing"
self.assert_plan_error(unknown_expectation)
def test_blockers_and_nonblocking_approximations_are_explicit(self) -> None:
clarification = mounting_plate_intent()
clarification["open_questions"] = [{"id": "thickness", "question": "What thickness is required?", "blocking": True}]
clarification["status"] = "needs_clarification"
self.engine.validate_design_intent(clarification, self.engine)
with self.assertRaises(self.engine.DesignIntentError) as context:
self.engine.validate_intent_cdsl(clarification, mounting_plate_cdsl(), self.engine)
self.assertEqual(context.exception.code, "DESIGN_INTENT_BLOCKED")
approximation = mounting_plate_intent()
approximation["capability_gaps"] = [{
"code": "unsupported_thread_geometry",
"structure_id": "mounting_holes",
"message": "Only a cylindrical bore is available.",
"blocking": False,
}]
self.assert_plan_error(approximation)
approximation["assumptions"] = ["Thread geometry is approximated by cylindrical bores."]
self.engine.validate_design_intent(approximation, self.engine)
def test_rejects_cdsl_feature_atomic_profile_and_selector_mismatches(self) -> None:
missing_feature = mounting_plate_cdsl()
missing_feature["features"].pop()
with self.assertRaises(self.engine.DesignIntentError) as context:
self.engine.validate_intent_cdsl(mounting_plate_intent(), missing_feature, self.engine)
self.assertEqual(context.exception.code, "INTENT_CDSL_MISMATCH")
atomic = mounting_plate_cdsl()
atomic["features"][1]["atomic_id"] = "extrude_add_blind"
with self.assertRaises(self.engine.DesignIntentError):
self.engine.validate_intent_cdsl(mounting_plate_intent(), atomic, self.engine)
profile = mounting_plate_cdsl()
profile["geometry"]["sketches"][1]["profile"]["type"] = "circles"
with self.assertRaises(self.engine.DesignIntentError):
self.engine.validate_intent_cdsl(mounting_plate_intent(), profile, self.engine)
selector_plan = copy.deepcopy(mounting_plate_intent())
selector_plan["structures"][2]["cdsl_strategy"]["selector_roles"] = ["host_face"]
with self.assertRaises(self.engine.DesignIntentError):
self.engine.validate_intent_cdsl(selector_plan, mounting_plate_cdsl(), self.engine)