Files
cdsl-cad/backend/tests/test_engine_extrude_draft_contract.py
T

227 lines
11 KiB
Python
Raw 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.
"""#2 draft 假接受陷阱:extrudeParams.draft 必须被显式拒绝,而不是静默忽略。
中文说明
--------
这个文件在测试什么(issue #2「draft 被 schema 接受但 runtime 未执行」的回归测试):
1. 背景:三方合同错位——
- 机器契约 cdsl_schema.jsonextrudeParams,约 101 行)允许
"draft": {"type": "object"},且是空 object(无任何子字段约束),
即"文档格式假接受"
- 人读契约 profile_schema.json 的 extrude_add_blind /
extrude_add_two_sided / extrude_cut_blind 均**未**声明 draft
为 optional_params
- runtimebuild123d_adapter.extrude 仅调 Solid.extrude,无锥形
拉伸)也完全没有 draft 实现,遇到 draft 就静默忽略。
- 隐患:importertranslator.py 已解析 SolidWorks 的
draft_angle_rad / reverse_draft_angle_rad)一旦把拔模角写进
CDSL paramsruntime 会静默产出**无拔模角的直壁实体**——
注塑件/压铸件丢失脱模斜度,脱模卡死、分型面配合错误,且全程
无警告(与 #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
blockerruntime_eligible=False
- 回归护栏:不带 draft 的 extrude 特征 → 仍 runtime_eligible
- 文档格式契约:带 draft 的文档仍能通过 cdsl_schema.json(拒绝
发生在能力层,不是 schema 层);
- 覆盖:extrude_add_blind / extrude_add_two_sided / extrude_cut_blind
三种原子都报同一 blocker(同一检查全类生效);
- 端到端:rebuild_cdslstrict)带 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_cdslstrict),
带 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.jsonextrudeParams)保留 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_PREFIXESextrude_/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_cdslstrict)对 draft 视而不见 → 正常返回实体,
volume > 0,但几何是**没有拔模角的直壁**——静默错误。
修复后:rebuild_cdslstrict)因 runtime_eligible=False 抛
ValueErrorfeature 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()