diff --git a/backend/engine/cdsl_engine/capabilities.py b/backend/engine/cdsl_engine/capabilities.py index 97df99ba..7b385db4 100644 --- a/backend/engine/cdsl_engine/capabilities.py +++ b/backend/engine/cdsl_engine/capabilities.py @@ -234,6 +234,17 @@ class CapabilityAnalyzer: sketch_id=node.sketch_id, )) if node.atomic_id.startswith(_SKETCH_ATOM_PREFIXES): + # #2 draft:extrudeParams.draft 在 cdsl_schema.json 中被允许, + # 但 runtime 的拉伸执行器(build123d Solid.extrude)没有锥形 + # 拉伸能力,人读契约 profile_schema.json 也未声明该参数。 + # 若 importer 把 SolidWorks 的 draft_angle_rad 写进 CDSL, + # 当前 runtime 会静默产出无拔模角的直壁实体。这里把它从 + # "静默忽略"改为"显式拒绝"(与 unsupported_extent 同模式)。 + if params.get("draft"): + blockers.append(self._blocker( + node.feature_id, "unsupported_draft", + "Extrude draft/taper is not implemented; the runtime would silently ignore it", + )) end_condition = params.get("end_condition") or {"type": "blind"} end_type = end_condition.get("type") required.append(f"extent:{end_type}") diff --git a/backend/tests/test_engine_extrude_draft_contract.py b/backend/tests/test_engine_extrude_draft_contract.py new file mode 100644 index 00000000..62e8da92 --- /dev/null +++ b/backend/tests/test_engine_extrude_draft_contract.py @@ -0,0 +1,226 @@ +"""#2 draft 假接受陷阱:extrudeParams.draft 必须被显式拒绝,而不是静默忽略。 + +中文说明 +-------- +这个文件在测试什么(issue #2「draft 被 schema 接受但 runtime 未执行」的回归测试): + + 1. 背景:三方合同错位—— + - 机器契约 cdsl_schema.json(extrudeParams,约 101 行)允许 + "draft": {"type": "object"},且是空 object(无任何子字段约束), + 即"文档格式假接受"; + - 人读契约 profile_schema.json 的 extrude_add_blind / + extrude_add_two_sided / extrude_cut_blind 均**未**声明 draft + 为 optional_params; + - runtime(build123d_adapter.extrude 仅调 Solid.extrude,无锥形 + 拉伸)也完全没有 draft 实现,遇到 draft 就静默忽略。 + - 隐患:importer(translator.py 已解析 SolidWorks 的 + draft_angle_rad / reverse_draft_angle_rad)一旦把拔模角写进 + CDSL params,runtime 会静默产出**无拔模角的直壁实体**—— + 注塑件/压铸件丢失脱模斜度,脱模卡死、分型面配合错误,且全程 + 无警告(与 #1 y_dir / #3 revolve.reverse 同族的静默错误)。 + + 2. 修复策略:因为 build123d 内核没有锥形拉伸能力、且人读契约未声明 + draft,正确的合同是"显式拒绝"而不是"实现拔模"—— + capabilities.py 对携带 draft 的 extrude 特征报 unsupported_draft + blocker(与 unsupported_extent 同模式)。schema 字段保留(文档格式 + 契约,importer 未来可能产出),能力层明确划界。 + + 3. 本测试套件把"draft 必须显式拒绝"固定下来: + - 主契约:带 draft 的 extrude 特征 → analyze 报 unsupported_draft + blocker,runtime_eligible=False; + - 回归护栏:不带 draft 的 extrude 特征 → 仍 runtime_eligible; + - 文档格式契约:带 draft 的文档仍能通过 cdsl_schema.json(拒绝 + 发生在能力层,不是 schema 层); + - 覆盖:extrude_add_blind / extrude_add_two_sided / extrude_cut_blind + 三种原子都报同一 blocker(同一检查全类生效); + - 端到端:rebuild_cdsl(strict)带 draft → 抛 ValueError(大声失败), + analyze_document → runtime_eligible=False 且不 built(批量重建 + 不被静默污染)。 + + 4. sys.path 说明:把 backend/engine 加入搜索路径,是为了直接 import + cdsl_engine 包做端到端测试(与 test_engine_revolve_reverse.py 风格 + 一致)。 + +函数功能一览 +------------ + _rectangle() 构造 XY 平面内的矩形轮廓(2D 多边形)。 + _extrude_cdsl() 构造最小 extrude 文档;draft 参数决定 + 是否携带 draft 字段、atomic_id 可切换 + 三种 extrude 原子。 + _rebuild() 在临时目录内调用 rebuild_cdsl(strict), + 带 draft 时预期抛 ValueError。 + _validate_against_cdsl_schema() 对整张文档跑 cdsl_schema.json 校验。 + RevolveReverseContractTests (见各测试方法 docstring)。 +""" + +from __future__ import annotations + +import json +import sys +import tempfile +import unittest +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.batch_rebuild import analyze_document # 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")) + + +# --------------------------------------------------------------------------- +# 测试夹具 +# --------------------------------------------------------------------------- + + +def _rectangle(minimum: list[float], maximum: list[float]) -> dict: + """XY 平面内的矩形轮廓(2D 多边形),顶点逆时针。 + + 用于拉伸特征的最小闭合轮廓:x∈[minimum[0], maximum[0]]、 + y∈[minimum[1], maximum[1]]。 + """ + return {"type": "polygon", "vertices": [ + [minimum[0], minimum[1]], [maximum[0], minimum[1]], + [maximum[0], maximum[1]], [minimum[0], maximum[1]], + ]} + + +def _extrude_cdsl(*, atomic_id: str = "extrude_add_blind", with_draft: bool = True) -> dict: + """构造最小 extrude 文档。 + + - with_draft=True 时 params 携带 draft 对象(任意非空 object 即可, + 因为 cdsl_schema.json 对 draft 没有子字段约束); + - with_draft=False 时完全不写 draft 字段(回归护栏用)。 + - atomic_id 可切换 extrude_add_blind / extrude_add_two_sided / + extrude_cut_blind 三种原子,验证同一 blocker 检查对全类生效。 + """ + params: dict = {"distance_mm": 10.0} + if with_draft: + params["draft"] = {"angle_deg": 5.0, "direction": "toward_sketch"} + return { + "schema": "cad.cdsl.llm.v1", "schema_version": "1.1.0", "kind": "part", + "part_id": "draft-contract", "meta": {"unit": "mm"}, + "geometry": {"sketches": [{ + "id": "base", + "workplane": {"origin_mm": [0, 0, 0], "x_dir": [1, 0, 0], "normal": [0, 0, 1]}, + "profile": _rectangle([-5, -5], [5, 5]), + }]}, + "features": [{ + "id": "base_add", "atomic_id": atomic_id, "depends_on": [], "sketch_id": "base", + "params": params, + }], + } + + +def _validate_against_cdsl_schema(doc: dict) -> None: + """对整张 CDSL 文档跑 cdsl_schema.json 校验;任何字段不通过都会抛 ValidationError。""" + jsonschema.validate(instance=doc, schema=_SCHEMA) + + +# --------------------------------------------------------------------------- +# 测试套件 +# --------------------------------------------------------------------------- + + +class ExtrudeDraftContractTests(unittest.TestCase): + """draft 假接受陷阱「文档格式-能力边界-运行时」三方合同的回归测试。""" + + def test_draft_extrude_is_explicitly_blocked(self) -> None: + """主契约:带 draft 的 extrude 特征必须被显式拒绝,而不是静默通过。 + + 修复前(当前):capabilities 对 extrude 只检查 end_condition, + draft 字段完全无人过问 → analyze 报 runtime_eligible=True, + rebuild 静默产出直壁实体 → 本测试红灯。 + 修复后:capabilities 报 unsupported_draft blocker → + runtime_eligible=False → 绿灯。 + """ + analysis = analyze_cdsl(_extrude_cdsl(with_draft=True)) + + self.assertFalse(analysis.runtime_eligible) + result = next(item for item in analysis.feature_results if item.feature_id == "base_add") + self.assertIn("unsupported_draft", [blocker.code for blocker in result.blockers]) + + def test_draft_free_extrude_stays_eligible(self) -> None: + """回归护栏:不带 draft 的 extrude 特征仍必须 runtime_eligible。 + + 修复前/修复后均应通过。这条测试防止我们把检查加过头—— + 一旦把"携带 draft"错写成"所有 extrude 都拒绝",护栏会变红。 + """ + analysis = analyze_cdsl(_extrude_cdsl(with_draft=False)) + + self.assertTrue(analysis.runtime_eligible) + + def test_draft_passes_machine_schema(self) -> None: + """文档格式契约:带 draft 的文档仍能通过 cdsl_schema.json 校验。 + + cdsl_schema.json(extrudeParams)保留 draft 字段,拒绝发生在 + 能力层(capabilities),不是 schema 层。这条测试锁死"schema 允许 + + 能力拒绝"的分层职责,防止未来把 schema 改过头(删掉字段后 + importer 未来产出 draft 会直接被 schema 打回,失去可诊断性)。 + """ + _validate_against_cdsl_schema(_extrude_cdsl(atomic_id="extrude_add_blind", with_draft=True)) + _validate_against_cdsl_schema(_extrude_cdsl(atomic_id="extrude_add_two_sided", with_draft=True)) + _validate_against_cdsl_schema(_extrude_cdsl(atomic_id="extrude_cut_blind", with_draft=True)) + + def test_draft_blocks_every_extrude_atomic(self) -> None: + """覆盖:三种 extrude 原子都报同一个 unsupported_draft blocker。 + + draft 检查挂在 _SKETCH_ATOM_PREFIXES(extrude_/revolve_)公共入口, + 必须对 extrude_add_blind / extrude_add_two_sided / extrude_cut_blind + 同时生效,而不是只修了某一个。 + """ + for atomic_id in ("extrude_add_blind", "extrude_add_two_sided", "extrude_cut_blind"): + with self.subTest(atomic_id=atomic_id): + analysis = analyze_cdsl(_extrude_cdsl(atomic_id=atomic_id, with_draft=True)) + result = next(item for item in analysis.feature_results if item.feature_id == "base_add") + self.assertIn("unsupported_draft", [blocker.code for blocker in result.blockers]) + + def test_draft_rebuild_fails_loudly_not_silently(self) -> None: + """端到端:draft 文档的重建必须大声失败,而不是静默产出直壁实体。 + + 修复前:rebuild_cdsl(strict)对 draft 视而不见 → 正常返回实体, + volume > 0,但几何是**没有拔模角的直壁**——静默错误。 + 修复后:rebuild_cdsl(strict)因 runtime_eligible=False 抛 + ValueError(feature is not runtime eligible: unsupported_draft); + analyze_document 报告 runtime_eligible=False 且不 built—— + 批量重建不会被静默污染。 + """ + # 1) strict 重建直接抛错(大声失败)。 + with self.assertRaisesRegex(ValueError, "unsupported_draft"): + with tempfile.TemporaryDirectory() as directory: + rebuild_cdsl(_extrude_cdsl(with_draft=True), Path(directory) / "part.step") + + # 2) 批量层 analyze_document 报告不可执行且不产出 STEP。 + cdsl = _extrude_cdsl(with_draft=True) + with tempfile.TemporaryDirectory() as directory: + out_step = Path(directory) / "part.step" + report = _analyze_inline(cdsl, out_step) + self.assertFalse(report["runtime_eligible"]) + self.assertFalse(report.get("built", False)) + + +def _analyze_inline(cdsl: dict, out_step: Path) -> dict: + """把 cdsl 写入临时 json 后走 analyze_document(与批量层同一入口)。 + + analyze_document 接受文件路径,这里把内存中的文档落地成临时文件, + 保证测试走的路径与 batch_rebuild 完全一致。 + """ + import tempfile as _tf + + with _tf.TemporaryDirectory() as directory: + source = Path(directory) / "draft.cdsl.json" + source.write_text(json.dumps(cdsl), encoding="utf-8") + return analyze_document(source, out_step=out_step) + + +if __name__ == "__main__": + unittest.main()