"""Worker AI 任务单元测试. 测试覆盖: - AI推荐(豆包调用成功/失败/降级) - 推荐响应解析(多种格式) - 封面生成降级 """ from __future__ import annotations import json import sys import unittest from unittest.mock import MagicMock, patch sys.path.insert(0, "apps/worker") sys.path.insert(0, "packages") from worker_app.tasks.ai_tasks import ( # noqa: E402 _fallback_recommend_clips, _parse_recommend_response, run_ai_recommend, run_generate_cover, ) class TestFallbackRecommend(unittest.TestCase): """降级推荐方案测试.""" def test_fallback_returns_expected_structure(self): """降级推荐返回正确结构.""" result = _fallback_recommend_clips( plan_id="plan-1", template_id="tpl-1", asset_ids=["a1", "a2", "a3"], editing_mode="one_take", target_duration=30.0, ) self.assertIn("clips", result) self.assertIn("config", result) self.assertIn("total_duration", result) self.assertIn("confidence", result) def test_fallback_clips_structure(self): """每个片段都有必要字段.""" result = _fallback_recommend_clips( plan_id="plan-1", template_id="tpl-1", asset_ids=["a1", "a2"], editing_mode="one_take", target_duration=20.0, ) clips = result["clips"] self.assertTrue(len(clips) >= 3) # intro + showcase + outro for clip in clips: self.assertIn("clip_type", clip) self.assertIn("order", clip) self.assertIn("text_content", clip) self.assertIn("duration", clip) self.assertIn("transition_effect", clip) self.assertIn("asset_id", clip) self.assertIn("start_time", clip) self.assertIn("config", clip) def test_fallback_first_is_intro_last_is_outro(self): """第一个是开场,最后一个是结尾.""" result = _fallback_recommend_clips( plan_id="plan-1", template_id="tpl-1", asset_ids=["a1", "a2", "a3"], editing_mode="one_take", target_duration=30.0, ) clips = result["clips"] self.assertEqual(clips[0]["clip_type"], "intro") self.assertEqual(clips[-1]["clip_type"], "outro") def test_fallback_order_sequential(self): """order 连续递增.""" result = _fallback_recommend_clips( plan_id="plan-1", template_id="tpl-1", asset_ids=["a1", "a2"], editing_mode="one_take", target_duration=30.0, ) for i, clip in enumerate(result["clips"]): self.assertEqual(clip["order"], i) def test_fallback_empty_assets(self): """空素材列表也能生成.""" result = _fallback_recommend_clips( plan_id="plan-1", template_id="tpl-1", asset_ids=[], editing_mode="one_take", target_duration=10.0, ) self.assertTrue(len(result["clips"]) >= 2) def test_fallback_confidence_in_range(self): """置信度在0-1之间.""" result = _fallback_recommend_clips( plan_id="plan-1", template_id="tpl-1", asset_ids=["a1"], editing_mode="one_take", target_duration=10.0, ) self.assertGreaterEqual(result["confidence"], 0.0) self.assertLessEqual(result["confidence"], 1.0) class TestRecommendResponseParsing(unittest.TestCase): """推荐响应解析测试.""" def _asset_ids(self): return ["a1", "a2", "a3"] def test_parse_valid_response(self): """解析正常响应.""" data = { "clips": [ { "clip_type": "intro", "order": 0, "text_content": "开场", "duration": 3.0, "transition_effect": "fade", "asset_id": "a1", "start_time": 0.0, "config": {}, }, { "clip_type": "showcase", "order": 1, "text_content": "展示", "duration": 5.0, "transition_effect": "cut", "asset_id": "a2", "start_time": 1.0, "config": {}, }, { "clip_type": "outro", "order": 2, "text_content": "结尾", "duration": 2.0, "transition_effect": "fade", "asset_id": "", "start_time": 0.0, "config": {}, }, ], "title": "精彩视频", "confidence": 0.85, } result = _parse_recommend_response(json.dumps(data), self._asset_ids(), 30.0) self.assertIsNotNone(result) self.assertEqual(len(result["clips"]), 3) self.assertEqual(result["clips"][0]["clip_type"], "intro") self.assertEqual(result["confidence"], 0.85) self.assertIn("精彩视频", result["config"].get("title", {}).get("text", "")) def test_parse_markdown_code_block(self): """解析markdown代码块.""" data = { "clips": [ { "clip_type": "showcase", "order": 0, "text_content": "t", "duration": 3, "transition_effect": "cut", "asset_id": "a1", "start_time": 0, "config": {}, } ], "confidence": 0.7, } content = "```json\n" + json.dumps(data) + "\n```" result = _parse_recommend_response(content, self._asset_ids(), 30.0) self.assertIsNotNone(result) self.assertEqual(len(result["clips"]), 1) def test_parse_empty_content(self): """空内容返回None.""" result = _parse_recommend_response("", self._asset_ids(), 30.0) self.assertIsNone(result) def test_parse_invalid_json(self): """无效JSON返回None.""" result = _parse_recommend_response("不是json", self._asset_ids(), 30.0) self.assertIsNone(result) def test_parse_no_clips(self): """无clips字段返回None.""" result = _parse_recommend_response(json.dumps({"title": "abc"}), self._asset_ids(), 30.0) self.assertIsNone(result) def test_parse_filters_invalid_asset_ids(self): """过滤不在输入列表中的asset_id.""" data = { "clips": [ { "clip_type": "showcase", "order": 0, "text_content": "t", "duration": 3, "transition_effect": "cut", "asset_id": "fake-id", "start_time": 0, "config": {}, } ], "confidence": 0.7, } result = _parse_recommend_response(json.dumps(data), self._asset_ids(), 30.0) self.assertIsNotNone(result) # 非法asset_id被清空 self.assertEqual(result["clips"][0]["asset_id"], "") def test_parse_clamps_duration(self): """时长被限制在合理范围.""" data = { "clips": [ { "clip_type": "showcase", "order": 0, "text_content": "t", "duration": 100, "transition_effect": "cut", "asset_id": "a1", "start_time": 0, "config": {}, } ] } result = _parse_recommend_response(json.dumps(data), self._asset_ids(), 30.0) self.assertIsNotNone(result) self.assertLessEqual(result["clips"][0]["duration"], 30.0) def test_parse_reorders_clips(self): """clips按order排序并重新编号.""" data = { "clips": [ { "clip_type": "showcase", "order": 5, "text_content": "b", "duration": 3, "transition_effect": "cut", "asset_id": "a2", "start_time": 0, "config": {}, }, { "clip_type": "intro", "order": 0, "text_content": "a", "duration": 3, "transition_effect": "fade", "asset_id": "a1", "start_time": 0, "config": {}, }, ] } result = _parse_recommend_response(json.dumps(data), self._asset_ids(), 30.0) self.assertIsNotNone(result) # 第一个应该是order=0的intro self.assertEqual(result["clips"][0]["clip_type"], "intro") # order被重新编号为连续 self.assertEqual(result["clips"][0]["order"], 0) self.assertEqual(result["clips"][1]["order"], 1) def test_parse_confidence_clamped(self): """confidence被限制在0-1.""" data = { "clips": [ { "clip_type": "showcase", "order": 0, "text_content": "t", "duration": 3, "transition_effect": "cut", "asset_id": "a1", "start_time": 0, "config": {}, } ], "confidence": 2.5, } result = _parse_recommend_response(json.dumps(data), self._asset_ids(), 30.0) self.assertIsNotNone(result) self.assertLessEqual(result["confidence"], 1.0) class TestRunAIRecommend(unittest.TestCase): """run_ai_recommend 集成测试.""" def test_fallback_when_client_unavailable(self): """客户端不可用时走降级.""" mock_client = MagicMock() mock_client.is_available = False mock_client.chat_completion = MagicMock(return_value=None) with patch("packages.shared.ai_service.get_doubao_client", return_value=mock_client): result = run_ai_recommend( plan_id="plan-1", template_id="tpl-1", asset_ids=["a1", "a2"], editing_mode="one_take", target_duration=20.0, ) self.assertIn("clips", result) self.assertIn("total_duration", result) mock_client.chat_completion.assert_not_called() def test_doubao_success(self): """豆包调用成功路径.""" mock_client = MagicMock() mock_client.is_available = True mock_response = { "clips": [ { "clip_type": "intro", "order": 0, "text_content": "开场", "duration": 3.0, "transition_effect": "fade", "asset_id": "a1", "start_time": 0.0, "config": {}, }, { "clip_type": "outro", "order": 1, "text_content": "结尾", "duration": 2.0, "transition_effect": "fade", "asset_id": "a2", "start_time": 0.0, "config": {}, }, ], "title": "AI生成标题", "confidence": 0.9, } mock_client.chat_completion = MagicMock(return_value=json.dumps(mock_response)) with patch("packages.shared.ai_service.get_doubao_client", return_value=mock_client): result = run_ai_recommend( plan_id="plan-1", template_id="tpl-1", asset_ids=["a1", "a2"], editing_mode="one_take", target_duration=30.0, ) self.assertEqual(result["confidence"], 0.9) self.assertEqual(len(result["clips"]), 2) mock_client.chat_completion.assert_called_once() def test_doubao_failure_fallback(self): """豆包调用失败降级.""" mock_client = MagicMock() mock_client.is_available = True mock_client.chat_completion = MagicMock(return_value=None) with patch("packages.shared.ai_service.get_doubao_client", return_value=mock_client): result = run_ai_recommend( plan_id="plan-1", template_id="tpl-1", asset_ids=["a1"], editing_mode="one_take", target_duration=10.0, ) # 降级后有结果 self.assertTrue(len(result["clips"]) >= 2) mock_client.chat_completion.assert_called_once() def test_doubao_unparseable_fallback(self): """豆包返回无法解析时降级.""" mock_client = MagicMock() mock_client.is_available = True mock_client.chat_completion = MagicMock(return_value="一堆废话不是json") with patch("packages.shared.ai_service.get_doubao_client", return_value=mock_client): result = run_ai_recommend( plan_id="plan-1", template_id="tpl-1", asset_ids=["a1"], editing_mode="one_take", target_duration=10.0, ) # 降级后有结果 self.assertTrue(len(result["clips"]) >= 2) class TestGenerateCover(unittest.TestCase): """封面生成测试.""" def test_ai_frame_type_raises_without_mediakit(self): """AI封面模式在MediaKit不可用时抛出RuntimeError.""" with self.assertRaises(RuntimeError): run_generate_cover( plan_id="plan-1", asset_ids=["a1"], cover_type="ai_frame", ) def test_manual_type(self): """手动选帧模式.""" result = run_generate_cover( plan_id="plan-1", asset_ids=["a1"], cover_type="manual", frame_time=5.0, ) self.assertEqual(result["type"], "manual") self.assertEqual(result["frame_time"], 5.0) def test_upload_type(self): """上传封面模式.""" result = run_generate_cover( plan_id="plan-1", asset_ids=["a1"], cover_type="upload", ) self.assertEqual(result["type"], "upload") if __name__ == "__main__": unittest.main()