from __future__ import annotations import json import tempfile import unittest from pathlib import Path from unittest.mock import patch from app.services.ops_service import get_ops_doctor_decision class OpsServiceDoctorDecisionTests(unittest.TestCase): @patch("app.services.ops_service.get_ops_contract_registry") @patch("app.services.ops_service._build_live_doctor_payload") @patch("app.services.ops_service._find_latest_go_live_bundle_manifest") def test_doctor_decision_falls_back_to_live_payload_when_bundle_missing( self, mock_find_latest_go_live_bundle_manifest, mock_build_live_doctor_payload, mock_get_ops_contract_registry, ) -> None: mock_find_latest_go_live_bundle_manifest.return_value = (None, Path("/tmp/go-live-bundles")) mock_build_live_doctor_payload.return_value = ( { "status": "attention", "reason_code": "live_fallback", "headline": "当前先补 attention 项", "detail": "需要先顺着 stack next 收口。", "preferred_surface": "stack-summary", "next_action_code": "review_stack_next", "recommended_commands": ["bash primary"], "evidence": {}, }, { "ok": False, "required_failures": [], "optional_unavailable": [], "scene_log_reports_total": 0, "scene_log_reports_ok": 0, "scene_log_status_counts": {}, "scene_log_reports": [], "contract_surface_gaps": [], }, ) mock_get_ops_contract_registry.return_value = { "contracts_by_key": { "ops_doctor_decision_contract": {"version": "v1"}, } } payload = get_ops_doctor_decision(base_url="http://127.0.0.1:8100") self.assertEqual("attention", payload["status"]) self.assertEqual("主决策待复核", payload["status_label"]) self.assertEqual("live_fallback", payload["source"]) self.assertEqual("review_stack_next", payload["decision"]["next_action_code"]) self.assertIn("doctor-decision", payload["recommended_commands"]["doctor_decision"]) self.assertEqual("bash primary", payload["recommended_commands"]["primary_recovery"]) self.assertEqual( "bash domain-api/deploy/multi-region/drive_ops_center.sh stack-diagnosis http://127.0.0.1:8100 summary", payload["recommended_commands"]["stack_check"], ) self.assertEqual( payload["recommended_commands"]["stack_check"], payload["recommended_commands"]["stack_diagnosis"], ) @patch("app.services.ops_service.get_ops_contract_registry") def test_doctor_decision_reads_bundle_doctor_artifact_when_available(self, mock_get_ops_contract_registry) -> None: mock_get_ops_contract_registry.return_value = { "contracts_by_key": { "ops_doctor_decision_contract": {"version": "v1"}, } } with tempfile.TemporaryDirectory() as temp_dir: report_dir = Path(temp_dir) / "go-live-bundle-001" report_dir.mkdir(parents=True, exist_ok=True) doctor_artifact_path = report_dir / "07_doctor_decision.json" doctor_artifact_path.write_text( json.dumps( { "summary": { "ok": False, "required_failures": ["06_contracts_registry.txt"], "optional_unavailable": ["12_activity_preview.txt"], "scene_log_reports_total": 2, "scene_log_reports_ok": 1, "scene_log_status_counts": {"ok": 1, "missing": 1}, "scene_log_reports": [ {"node_code": "mainland-worker-01", "status": "ok", "ok": True}, {"node_code": "mainland-controller-01", "status": "missing", "ok": False}, ], "contract_surface_gaps": ["activity-stream"], "launchpad_recommended_target_node_code": "mainland-worker-01", "launchpad_recommended_recovery_label": "先补协议面", "launchpad_recommended_recovery_summary": "先补 driver / activity 的协议导航。", }, "decision": { "status": "blocked", "reason_code": "contracts_registry_unavailable", "headline": "contract registry 当前不可用", "detail": "先恢复 contracts registry 再继续自动驾驶。", "preferred_surface": "contracts", "next_action_code": "restore_contract_registry", "recommended_commands": [ "bash domain-api/deploy/multi-region/drive_ops_center.sh contracts http://127.0.0.1:8100", "bash domain-api/deploy/multi-region/drive_ops_center.sh runtime-refresh-recover http://127.0.0.1:8100", ], "evidence": {"surface_http_status": {"contracts_registry": 503}}, }, }, ensure_ascii=False, indent=2, ), encoding="utf-8", ) manifest_path = report_dir / "manifest.json" manifest_path.write_text( json.dumps( { "report_dir": str(report_dir), "artifacts": [ {"key": "doctor_decision", "path": str(doctor_artifact_path), "ok": True}, ], }, ensure_ascii=False, indent=2, ), encoding="utf-8", ) with patch( "app.services.ops_service._find_latest_go_live_bundle_manifest", return_value=(manifest_path, report_dir), ): payload = get_ops_doctor_decision(base_url="http://127.0.0.1:8100") self.assertEqual("blocked", payload["status"]) self.assertEqual("bundle_manifest", payload["source"]) self.assertEqual("contracts", payload["decision"]["preferred_surface"]) self.assertEqual(["06_contracts_registry.txt"], payload["summary"]["required_failures"]) self.assertEqual(["mainland-controller-01"], payload["summary"]["scene_log_problem_node_codes"]) self.assertIn("contracts", payload["recommended_commands"]["primary_recovery"]) self.assertIn(str(manifest_path), payload["recommended_commands"]["doctor_decision"]) if __name__ == "__main__": unittest.main()