"""#9 孔型:hole_wizard.thread(装饰螺纹)必须可执行并降级为光滑孔。 中文说明 -------- 这个文件在测试什么(issue #9「HoleSpec 仅支持简单圆柱、沉头、沉孔」的回归测试): 1. 背景:SolidWorks 螺纹孔(hole_wizard + thread,如"M5 螺纹孔"、 "底部螺纹孔")在真实语料里大量存在,修复前 capabilities 把 thread 误判为"当前 CDSL runtime 无法表示的几何"而报 unsupported_hole_subtype blocker,≈712 个带 thread 的孔特征因此整体被拒(runtime_eligible=False), 零件无法重建。 实际上 thread 只是装饰信息: - 数据形态只有 {diameter_mm, depth_mm, class},没有螺距; - SolidWorks/STEP 的螺纹孔实体几何就是光滑圆柱孔(装饰螺纹不进 实体、不进 STEP); - HoleSpec.from_feature 只读直径/深度/位置/沉头/沉孔,thread 天然 不参与几何计算。 因此正确合同是"接受 thread、按光滑孔执行",并在 runtime 记录 info 级诊断(thread_decoration_ignored)便于批量报告追溯降级数量。 2. 本测试套件把"thread 孔必须可执行且降级为光滑孔"固定下来: - 主契约:带 thread 的 hole_wizard → runtime_eligible=True (不再被拒绝); - 回归护栏:不带 thread 的 hole_wizard → 仍可执行(防止把检查 加过头,所有孔都被拒); - 文档契约:hole_wizard + thread 通过 cdsl_schema.json 校验 (字段本就在 holeWizardParams 里); - 几何契约:thread 孔切出的体积 = 光滑圆柱孔体积(thread 不建模, 与 SolidWorks/STEP 语义一致); - 可追溯:rebuild 结果里带 thread_decoration_ignored 信息诊断 (降级不是静默发生的)。 3. sys.path 说明:把 backend/engine 加入搜索路径,是为了直接 import cdsl_engine 包做端到端测试(与 test_engine_hole_thread_contract 等 既有测试风格一致)。 函数功能一览 ------------ _workplane() 构造默认草图工作平面(XY 平面)。 _rectangle() 构造 XY 平面内的矩形轮廓(2D 多边形)。 _base_block() 构造 10×10×10 拉伸主体文档(体积 1000)。 _thread_wizard_feature() 构造 hole_wizard 特征(with_thread 决定 是否携带 thread 装饰字段)。 _validate_against_cdsl_schema() 对整张文档跑 cdsl_schema.json 校验。 HoleThreadContractTests 见各测试方法 docstring。 """ from __future__ import annotations import json import math import sys import tempfile import unittest from copy import deepcopy from pathlib import Path ROOT = Path(__file__).resolve().parents[2] sys.path.insert(0, str(ROOT / "backend")) sys.path.insert(0, str(ROOT / "backend" / "engine")) import jsonschema # noqa: E402 import cdsl_engine # noqa: E402 from cdsl_engine.runtime import analyze_cdsl, rebuild_cdsl # noqa: E402 # cdsl_schema.json 路径:随 cdsl_engine 包部署。 _SCHEMA_PATH = Path(cdsl_engine.__file__).parent / "cdsl_schema.json" _SCHEMA = json.loads(_SCHEMA_PATH.read_text(encoding="utf-8")) try: import build123d # noqa: F401 _HAS_BUILD123D = True except ImportError: _HAS_BUILD123D = False # --------------------------------------------------------------------------- # 测试夹具 # --------------------------------------------------------------------------- def _workplane() -> dict: """默认草图工作平面:原点在 (0,0,0)、x 轴沿 +X、法向沿 +Z。""" return {"origin_mm": [0, 0, 0], "x_dir": [1, 0, 0], "normal": [0, 0, 1]} def _rectangle(minimum: list[float], maximum: list[float]) -> dict: """XY 平面内的矩形轮廓(2D 多边形),顶点逆时针。""" return {"type": "polygon", "vertices": [ [minimum[0], minimum[1]], [maximum[0], minimum[1]], [maximum[0], maximum[1]], [minimum[0], maximum[1]], ]} def _base_block() -> dict: """10×10×10 拉伸主体:体积 1000,顶面位于 z=10。""" return { "schema": "cad.cdsl.llm.v1", "schema_version": "1.1.0", "kind": "part", "part_id": "hole-thread-contract", "meta": {"unit": "mm"}, "geometry": {"sketches": [{ "id": "base", "workplane": _workplane(), "profile": _rectangle([-5, -5], [5, 5]), }]}, "features": [{ "id": "base_add", "atomic_id": "extrude_add_blind", "depends_on": [], "params": {"distance_mm": 10}, "sketch_id": "base", }], } def _thread_wizard_feature(*, with_thread: bool = True) -> dict: """构造 hole_wizard 特征(仿真实数据"M5 螺纹孔")。 - with_thread=True:携带 thread 装饰字段 {"diameter_mm", "depth_mm", "class"}(真实语料形态,无螺距); - with_thread=False:不写 thread(回归护栏用)。 """ params: dict = { "hole_type": "底部螺纹孔", "diameter_mm": 5.0, "depth_mm": 10.0, "end_condition": {"type": "blind", "solidworks_code": 0}, "positions": [{"mm": [0.0, 0.0, 0.0]}], # 合法的 selectorRef(cdsl_schema.json hostFace oneOf 分支), # 满足机器 schema 的 required: [kind, stable_id, source, confidence]。 "host_face": {"kind": "face", "stable_id": "top", "source": "inferred_from_step", "confidence": 1}, } if with_thread: params["thread"] = {"diameter_mm": 5.0, "depth_mm": 10.0, "class": "1B"} return {"id": "hole", "atomic_id": "hole_wizard", "depends_on": ["base_add"], "params": params} def _validate_against_cdsl_schema(doc: dict) -> None: """对整张 CDSL 文档跑 cdsl_schema.json 校验;任何字段不通过都会抛 ValidationError。""" jsonschema.validate(instance=doc, schema=_SCHEMA) # --------------------------------------------------------------------------- # 测试套件 # --------------------------------------------------------------------------- class HoleThreadContractTests(unittest.TestCase): """thread 装饰螺纹「文档格式-能力边界-运行时-几何」四方合同的回归测试。""" def test_thread_hole_wizard_is_executable(self) -> None: """主契约:带 thread 的 hole_wizard 必须 runtime_eligible。 修复前(当前):capabilities 报 unsupported_hole_subtype → runtime_eligible=False → 零件无法重建 → 本测试红灯。 修复后:capabilities 不再拒绝 thread → runtime_eligible=True, hole 特征无任何 blocker → 绿灯。 """ cdsl = _base_block() cdsl["features"].append(_thread_wizard_feature(with_thread=True)) analysis = analyze_cdsl(cdsl) hole = next(item for item in analysis.feature_results if item.feature_id == "hole") self.assertTrue(analysis.runtime_eligible) self.assertTrue(hole.executable) self.assertNotIn("unsupported_hole_subtype", [blocker.code for blocker in hole.blockers]) def test_threadless_hole_wizard_stays_eligible(self) -> None: """回归护栏:不带 thread 的 hole_wizard 仍必须 runtime_eligible。 修复前/修复后均应通过。这条测试防止我们把修复做成"所有孔都被拒" (例如误删 hole 检查整段)。 """ cdsl = _base_block() cdsl["features"].append(_thread_wizard_feature(with_thread=False)) analysis = analyze_cdsl(cdsl) self.assertTrue(analysis.runtime_eligible) def test_thread_hole_passes_machine_schema(self) -> None: """文档契约:hole_wizard + thread 必须通过 cdsl_schema.json 校验。 thread 字段本就在 holeWizardParams.properties 里(允许携带), 修复策略是"能力层接受并降级",不是"schema 层拒绝"——这条测试锁死 文档格式对 thread 的认可,防止未来把 schema 改过头。 """ cdsl = _base_block() cdsl["features"].append(_thread_wizard_feature(with_thread=True)) _validate_against_cdsl_schema(cdsl) @unittest.skipUnless(_HAS_BUILD123D, "build123d is not available") def test_thread_hole_cuts_plain_cylindrical_bore(self) -> None: """几何契约:thread 孔切出的体积 = 光滑圆柱孔体积(thread 不建模)。 10×10×10 主体,在顶面(z=10)中心打一个 d=2、深 5 的 thread 盲孔: 体积 = 1000 - π·1²·5。若 thread 参与几何(或孔整体被拒),体积断言 都会失败。这条测试锁死"降级为光滑孔"的几何语义——与 SolidWorks/ STEP 的螺纹孔表示一致。 """ base = _base_block() with tempfile.TemporaryDirectory() as directory: root = Path(directory) baseline = rebuild_cdsl(base, root / "baseline.step") top_face = next( item for item in baseline["topology_records"] if item["kind"] == "face" and item["geometry"]["surface_type"] == "plane" and item["geometry"]["normal"][2] > 0.9 ) feature = _thread_wizard_feature(with_thread=True) feature["params"]["diameter_mm"] = 2.0 feature["params"]["depth_mm"] = 5.0 feature["params"]["positions"] = [{"mm": [0.0, 0.0, 10.0]}] feature["params"]["host_face"] = { "kind": "face", "stable_id": "top", "source": "inferred_from_step", "confidence": 1, "geometry": top_face["geometry"], } feature["selectors"] = [ {"kind": "face", "stable_id": "top", "source": "inferred_from_step", "confidence": 1, "geometry": top_face["geometry"]}, ] with_hole = deepcopy(base) with_hole["features"].append(feature) holed = rebuild_cdsl(with_hole, root / "thread-hole.step") self.assertAlmostEqual(holed["volume_mm3"], 1000 - 5 * math.pi, places=5) @unittest.skipUnless(_HAS_BUILD123D, "build123d is not available") def test_thread_fallback_reports_info_diagnostic(self) -> None: """可追溯:thread 降级必须留下 thread_decoration_ignored 信息诊断。 降级不是静默发生的:runtime 在 wizard 模式且携带 thread 时记录 info 级诊断,批量报告(summary-by-diagnostic 或 per-part report) 可以统计降级数量。这条测试锁死"降级可观测"。 """ base = _base_block() with tempfile.TemporaryDirectory() as directory: root = Path(directory) baseline = rebuild_cdsl(base, root / "baseline.step") top_face = next( item for item in baseline["topology_records"] if item["kind"] == "face" and item["geometry"]["surface_type"] == "plane" and item["geometry"]["normal"][2] > 0.9 ) feature = _thread_wizard_feature(with_thread=True) feature["params"]["positions"] = [{"mm": [0.0, 0.0, 10.0]}] feature["params"]["host_face"] = { "kind": "face", "stable_id": "top", "source": "inferred_from_step", "confidence": 1, "geometry": top_face["geometry"], } feature["selectors"] = [ {"kind": "face", "stable_id": "top", "source": "inferred_from_step", "confidence": 1, "geometry": top_face["geometry"]}, ] with_hole = deepcopy(base) with_hole["features"].append(feature) holed = rebuild_cdsl(with_hole, root / "thread-hole.step") hole_result = next(item for item in holed["feature_results"] if item["feature_id"] == "hole") codes = [diagnostic["code"] for diagnostic in hole_result["diagnostics"]] self.assertIn("thread_decoration_ignored", codes) if __name__ == "__main__": unittest.main()