Files
cdsl-cad/backend/tests/test_engine_hole_thread_contract.py

264 lines
12 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""#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]}],
# 合法的 selectorRefcdsl_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()