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}