117 lines
4.1 KiB
Python
117 lines
4.1 KiB
Python
|
|
"""OCC 方案 B(常驻进程池)契约测试,见 docs/topics/performance/OCC_THROUGHPUT.md。
|
|||
|
|
|
|||
|
|
覆盖:spawn 子进程 + 管道往返、操作错误回传、超时换新补位、真实 OCC 解析
|
|||
|
|
(盒体 STP 端到端)。OCC(pythonocc)仅 conda 环境提供,无 OCC 环境自动跳过。
|
|||
|
|
"""
|
|||
|
|
import os
|
|||
|
|
from pathlib import Path
|
|||
|
|
|
|||
|
|
import pytest
|
|||
|
|
|
|||
|
|
pytest.importorskip("OCC", reason="需要 pythonocc 运行 OCC 进程池测试")
|
|||
|
|
|
|||
|
|
from moldinsight.services.occ_process_pool import OccProcessPool
|
|||
|
|
|
|||
|
|
|
|||
|
|
@pytest.fixture(scope="module", autouse=True)
|
|||
|
|
def _child_pythonpath():
|
|||
|
|
"""spawn 子进程不继承 pytest 的 sys.path,需经 PYTHONPATH 传递 src 目录。"""
|
|||
|
|
src_dir = str(Path(__file__).resolve().parent.parent / "src")
|
|||
|
|
prev = os.environ.get("PYTHONPATH", "")
|
|||
|
|
os.environ["PYTHONPATH"] = src_dir if not prev else f"{src_dir}{os.pathsep}{prev}"
|
|||
|
|
yield
|
|||
|
|
if prev:
|
|||
|
|
os.environ["PYTHONPATH"] = prev
|
|||
|
|
else:
|
|||
|
|
os.environ.pop("PYTHONPATH", None)
|
|||
|
|
|
|||
|
|
|
|||
|
|
@pytest.fixture
|
|||
|
|
async def pool():
|
|||
|
|
_pool = OccProcessPool()
|
|||
|
|
try:
|
|||
|
|
yield _pool
|
|||
|
|
finally:
|
|||
|
|
await _pool.shutdown()
|
|||
|
|
|
|||
|
|
|
|||
|
|
async def test_ping_roundtrip(pool):
|
|||
|
|
result = await pool.run("ping", {})
|
|||
|
|
assert result == {"pong": True}
|
|||
|
|
|
|||
|
|
|
|||
|
|
async def test_unknown_op_raises(pool):
|
|||
|
|
with pytest.raises(RuntimeError, match="未知 OCC 操作"):
|
|||
|
|
await pool.run("no_such_op", {})
|
|||
|
|
|
|||
|
|
|
|||
|
|
async def test_child_error_surfaces(pool):
|
|||
|
|
# parse_stp 指向不存在的文件:子进程内抛错,父进程应收到 RuntimeError
|
|||
|
|
with pytest.raises(RuntimeError):
|
|||
|
|
await pool.run("parse_stp", {"stp_path": "D:/no/such/file.stp"})
|
|||
|
|
|
|||
|
|
|
|||
|
|
async def test_timeout_replaces_worker(pool):
|
|||
|
|
# 子进程挂 15s,父进程 1s 超时 → 换新补位后池仍可用
|
|||
|
|
with pytest.raises(Exception, match=".*"):
|
|||
|
|
await pool.run("sleep", {"seconds": 15}, timeout=1)
|
|||
|
|
# 新 worker 上 ping 正常
|
|||
|
|
result = await pool.run("ping", {})
|
|||
|
|
assert result == {"pong": True}
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _write_box_stp(path: Path) -> Path:
|
|||
|
|
from OCC.Core.BRepPrimAPI import BRepPrimAPI_MakeBox
|
|||
|
|
from OCC.Core.IFSelect import IFSelect_RetDone
|
|||
|
|
from OCC.Core.STEPControl import STEPControl_AsIs, STEPControl_Writer
|
|||
|
|
from OCC.Core.gp import gp_Pnt
|
|||
|
|
|
|||
|
|
box = BRepPrimAPI_MakeBox(gp_Pnt(0, 0, 0), 100.0, 60.0, 40.0).Shape()
|
|||
|
|
writer = STEPControl_Writer()
|
|||
|
|
writer.Transfer(box, STEPControl_AsIs)
|
|||
|
|
assert writer.Write(str(path)) == IFSelect_RetDone
|
|||
|
|
return path
|
|||
|
|
|
|||
|
|
|
|||
|
|
async def test_parse_stp_end_to_end(pool, tmp_path):
|
|||
|
|
stp_path = _write_box_stp(tmp_path / "box.stp")
|
|||
|
|
geometry = await pool.run("parse_stp", {"stp_path": str(stp_path)})
|
|||
|
|
assert geometry["volume"] == pytest.approx(100 * 60 * 40, rel=0.01)
|
|||
|
|
assert "bounding_box" in geometry
|
|||
|
|
|
|||
|
|
|
|||
|
|
async def test_generate_cavity_persists_export_steps(pool, tmp_path):
|
|||
|
|
"""最复杂的迁移点:分模 + 方案形状 STEP 导出全部在子进程内完成,
|
|||
|
|
TopoDS 不跨进程传输,export_manifest 携带落盘文件清单。"""
|
|||
|
|
from moldinsight.services.material_service import MaterialService
|
|||
|
|
|
|||
|
|
stp_path = _write_box_stp(tmp_path / "box.stp")
|
|||
|
|
export_dir = tmp_path / "exports"
|
|||
|
|
result = await pool.run(
|
|||
|
|
"generate_cavity",
|
|||
|
|
{
|
|||
|
|
"stp_path": str(stp_path),
|
|||
|
|
"task_id": "test-task",
|
|||
|
|
"material": dict(MaterialService.get_material("ABS")),
|
|||
|
|
"is_foam_material": False,
|
|||
|
|
"process_params": None,
|
|||
|
|
"export_out_dir": str(export_dir),
|
|||
|
|
},
|
|||
|
|
timeout=120,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
plan_result = result["plan_result"]
|
|||
|
|
schemes = plan_result["candidate_schemes"]
|
|||
|
|
assert 1 <= len(schemes) <= 3
|
|||
|
|
assert plan_result["best_scheme_id"] == schemes[0]["scheme_id"]
|
|||
|
|
# TopoDS 形状不得跨进程(子进程已 pop 掉 _export_shapes)
|
|||
|
|
assert "_export_shapes" not in plan_result
|
|||
|
|
|
|||
|
|
manifest = result["export_manifest"]
|
|||
|
|
assert manifest is not None
|
|||
|
|
assert manifest["schemes"]
|
|||
|
|
best = manifest["schemes"][plan_result["best_scheme_id"]]
|
|||
|
|
assert best["total_files"] >= 1
|
|||
|
|
# 落盘文件真实存在于子进程写入的 export_out_dir
|
|||
|
|
assert any((export_dir / f.get("relative_path", "")).exists() for f in best["files"])
|