@@ -0,0 +1,263 @@
""" #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 ( )