2026-08-31 18:01:34 +08:00
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-27 00:46:16 +08:00
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-08-31 18:01:34 +08:00
|
|
|
|
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}
|