203 lines
8.8 KiB
Python
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)
|