Files
geMoldInsight/tests/test_moldinsight_regressions.py
cjw cf65ceed1c fix(moldinsight): 方案预览契约断裂——工艺参数面板全 N/A + 备选方案预览无法切换
多方案重构后 cavity_data 与可视化端字段契约断裂,两处收口:

- 契约桥接:CalculationService.attach_scheme_info_contract 把方案级
  key_info 内嵌回 cavity_data(mold_cavities.cavity_key_info / cavity_count /
  manufacturing_info 展示字段),multi_scheme_planner._build_scheme 生成后
  立即接线——3D 预览"关键工艺参数"面板此前 10 项 8 项恒 N/A,
  前端型腔数恒回退 1 腔
- 方案级预览恢复:_attach_scheme_previews 改为每方案生成完整预览
  (HTML + 摘要/数据 JSON 直传 RustFS)并写 scheme["html_file"],
  前端切方案即切 iframe(此前仅最优方案一份,备选永远显示推荐预览);
  删除零消费的 summary_file 死产物;任务级 HTML 复用推荐方案不再重复生成
- 前端 3D 预览标题栏加"当前:方案名"标识

已知取舍:报告键存储随方案数线性增长(记 TECH_DEBT D11 追记);
历史任务不回填,需重新分析。接口面零变化,openapi 不重导出。

测试:193 passed, 13 skipped(净增契约桥接回归测试 1 项,OCC-free);
vue-tsc 通过;本地无 pythonocc,planner 运行时路径待 conda 环境补跑。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-27 00:46:16 +08:00

280 lines
10 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.
import io
import pytest
from fastapi import UploadFile
from moldinsight.core.parting_candidate_generator import PartingCandidateGenerator
from moldinsight.core.parting_scheme_scorer import PartingSchemeScorer
from moldinsight.services.calculation_service import CalculationService
from moldinsight.services.cost_estimate_service import estimate_cost_by_rules
from moldinsight.services.material_service import MaterialService
from shared.services.redis_task_manager import RedisTaskManager
from shared.utils.file_handler import FileHandler
VALID_STEP_BYTES = (
b"ISO-10303-21;\n"
b"HEADER;\n"
b"FILE_DESCRIPTION(('STEP AP214'),'1');\n"
b"ENDSEC;\n"
b"DATA;\n"
b"ENDSEC;\n"
b"END-ISO-10303-21;\n"
)
@pytest.mark.asyncio
async def test_file_handler_sanitizes_step_filename(tmp_path):
handler = FileHandler(upload_dir=str(tmp_path))
upload = UploadFile(filename="../../bad name?.step", file=io.BytesIO(VALID_STEP_BYTES))
file_path, file_size, meta = await handler.save_uploaded_file(upload)
assert file_path.exists()
assert file_size == len(VALID_STEP_BYTES)
assert file_path.parent == tmp_path
assert ".." not in file_path.name
assert meta["safe_original_name"] == "bad_name.step"
assert len(meta["sha256"]) == 64
@pytest.mark.asyncio
async def test_file_handler_rejects_invalid_step_content(tmp_path):
handler = FileHandler(upload_dir=str(tmp_path))
upload = UploadFile(filename="fake.step", file=io.BytesIO(b"not-a-step"))
with pytest.raises(ValueError, match="不是有效的 STP/STEP 数据"):
await handler.save_uploaded_file(upload)
def test_calculation_service_attaches_injection_system_summary():
plan_result = {
"best_scheme_id": "scheme_1",
"candidate_schemes": [
{
"scheme_id": "scheme_1",
"cavity_data": {
"product_analysis": {
"bounding_box": {"dimensions": [100, 80, 30]}
},
"manufacturing_info": {
"estimated_mold_size": {"length": 200, "width": 180, "height": 110}
},
"mold_cavities": {"cavity_count": 1},
},
"key_info": {},
}
],
}
result = CalculationService.attach_injection_system_summaries(plan_result, "ABS")
best_scheme = result["candidate_schemes"][0]
assert "injection_system" in best_scheme["cavity_data"]
assert best_scheme["cavity_data"]["manufacturing_info"]["cooling_summary"]["channel_count"] >= 1
assert best_scheme["cavity_data"]["manufacturing_info"]["gating_summary"]["gate_type"] in {
"auto", "side", "center", "submarine", "fan"
}
assert "injection_system" in result
def test_calculation_service_bridges_scheme_info_contract():
"""多方案 cavity_data 必须自包含可视化面板依赖的 legacy 字段。
3D 预览"关键工艺参数"面板只拿得到 cavity_data;key_info 不内嵌回去则
面板整片 N/A、前端型腔数恒为 1(2026-09 事故根因)。
"""
key_info = {
"mold_parameters": {
"shrinkage_rate": "0.50%",
"draft_angle": "2.0°",
"parting_line_length": 620.0,
},
"geometric_characteristics": {
"product_volume": "12.00 cm³",
"product_weight": "12.60 g",
"wall_thickness_range": "1.40 - 2.60 mm",
},
"manufacturing_requirements": {
"cavity_material": "P20钢材",
"hardness": "HRC 28-32",
"surface_finish": "SPI A2",
"estimated_cycle_time": "25 秒",
},
}
cavity_data = {
"metadata": {"shrinkage_rate": 0.005, "draft_angle": 2.0},
"manufacturing_info": {"recommended_material": "P20钢材"},
"mold_cavities": {"cavity": {"vertex_count": 1}, "core": {"vertex_count": 1}},
}
CalculationService.attach_scheme_info_contract(cavity_data, key_info)
mold_cavities = cavity_data["mold_cavities"]
assert mold_cavities["cavity_count"] == 1
assert (
mold_cavities["cavity_key_info"]["geometric_characteristics"]["product_volume"]
== "12.00 cm³"
)
manufacturing = cavity_data["manufacturing_info"]
assert manufacturing["mold_material"] == "P20钢材"
assert manufacturing["mold_hardness"] == "HRC 28-32"
assert manufacturing["surface_finish"] == "SPI A2"
assert manufacturing["estimated_cycle_time"] == "25 秒"
assert manufacturing["parting_line_length"] == 620.0
def test_material_service_falls_back_to_abs_for_unknown_material():
material = MaterialService.get_material("UNKNOWN")
assert material["name"] == "ABS"
assert MaterialService.resolve_material("UNKNOWN") == "ABS"
assert MaterialService.is_foam_material("UNKNOWN") is False
def test_parting_candidate_generator_prioritizes_z_for_foam_material():
generator = PartingCandidateGenerator()
analysis = {
"bounding_box": {"dimensions": [120, 80, 60]},
"inertia_matrix": [[10, 0, 0], [0, 12, 0], [0, 0, 8]],
"axis_normal_stats": {"X": 20.0, "Y": 30.0, "Z": 50.0},
}
candidates = generator.generate_candidates(analysis, is_foam_material=True)
assert candidates[0]["axis"] == "Z"
assert candidates[0]["method"] == "foam_axis_rule"
assert "泡沫模具优先上下开模" in candidates[0]["reason"]
def test_parting_scheme_scorer_prefers_scheme_without_undercuts():
scorer = PartingSchemeScorer()
schemes = [
{
"scheme_id": "clean",
"priority_score": 90,
"offset_ratio": 0.0,
"method": "geometric_primary",
"parting": {"line": [[0, 0, 0], [10, 0, 0]]},
"core_required": True,
"cavity_data": {
"mold_cavities": {
"cavity": {"vertex_count": 120},
"core": {"vertex_count": 120},
},
"quality_checks": {
"parting_line_smoothness": 92,
"undercut_regions": [],
"side_actions": {
"summary": {"total_mechanism_count": 0, "complexity": "simple"},
"slider_mechanisms": [],
"lifter_mechanisms": [],
"undercut_analysis": {"total_undercut_area": 0},
},
},
"manufacturing_info": {
"estimated_mold_size": {"length": 220, "width": 180, "height": 120},
"estimated_clamping_force": "180",
},
"metadata": {"draft_angle": 2.0},
},
"key_info": {
"quality_considerations": {"warpage_risk": "low"},
"geometric_characteristics": {"wall_thickness_range": "1.8-3.2mm"},
},
},
{
"scheme_id": "complex",
"priority_score": 85,
"offset_ratio": 0.1,
"method": "geometric_primary",
"parting": {"line": [[0, 0, 0], [100, 0, 0], [100, 50, 0]]},
"core_required": True,
"cavity_data": {
"mold_cavities": {
"cavity": {"vertex_count": 120},
"core": {"vertex_count": 120},
},
"quality_checks": {
"parting_line_smoothness": 75,
"undercut_regions": [{"id": 1}, {"id": 2}],
"side_actions": {
"summary": {"total_mechanism_count": 2, "complexity": "complex"},
"slider_mechanisms": [{"actuation": "pneumatic"}],
"lifter_mechanisms": [{"actuation": "mechanical"}],
"undercut_analysis": {"total_undercut_area": 1800},
},
},
"manufacturing_info": {
"estimated_mold_size": {"length": 420, "width": 360, "height": 180},
"estimated_clamping_force": "620",
},
"metadata": {"draft_angle": 2.0},
},
"key_info": {
"quality_considerations": {"warpage_risk": "high"},
"geometric_characteristics": {"wall_thickness_range": "0.8-7.0mm"},
},
},
]
ranked = scorer.score_schemes(schemes)
assert ranked[0]["scheme_id"] == "clean"
assert ranked[0]["rank"] == 1
assert ranked[0]["title"] == "推荐方案"
assert ranked[0]["score"] > ranked[1]["score"]
assert ranked[0]["dfm_violation_count"] <= ranked[1]["dfm_violation_count"]
def test_cost_estimate_rules_returns_expected_shape():
analysis_result = {
"geometry_data": {
"bounding_box": {"dimensions": [100, 80, 30]},
"volume": 24000,
}
}
detailed = {
"candidate_schemes": [
{
"cavity_data": {
"mold_cavities": {"cavity_count": 2},
"manufacturing_info": {
"mold_material": "P20",
"estimated_mold_size": {"length": 320, "width": 260, "height": 140},
"estimated_cycle_time": "35秒",
},
"metadata": {"selected_material": "ABS"},
"side_actions": {
"summary": {"total_slider_count": 1, "total_lifter_count": 0}
},
}
}
]
}
result = estimate_cost_by_rules(analysis_result, detailed)
assert result["source"] == "rules"
assert "mold_cost" in result
assert "part_cost" in result
assert result["confidence"] == 0.55
assert result["total_mold_cost"].startswith("¥")
assert result["cost_per_part"].startswith("¥")
def test_redis_task_manager_make_serializable_handles_enums_and_datetime():
from datetime import datetime
from shared.models.schemas import ProcessingStatus
payload = {
"status": ProcessingStatus.COMPLETED,
"completed_at": datetime(2026, 8, 31, 12, 0, 0),
"nested": {"flag": True},
}
serialized = RedisTaskManager._make_serializable(payload)
assert serialized["status"] == "completed"
assert serialized["completed_at"] == "2026-08-31T12:00:00"
assert serialized["nested"] == {"flag": True}