389d1e4401
CI/CD Pipeline / Check if frontend-only change (push) Has been cancelled
CI/CD Pipeline / Validate - Code Quality (push) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (push) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / PR Build API Image (push) Has been cancelled
CI/CD Pipeline / PR Build Web Image (push) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (push) Has been cancelled
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
438 lines
15 KiB
Python
Executable File
438 lines
15 KiB
Python
Executable File
"""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(self):
|
|
"""AI封面模式返回预期结构."""
|
|
result = run_generate_cover(
|
|
plan_id="plan-1",
|
|
asset_ids=["a1"],
|
|
cover_type="ai_frame",
|
|
)
|
|
self.assertIn("type", result)
|
|
self.assertEqual(result["type"], "ai_frame")
|
|
self.assertIn("image_url", result)
|
|
|
|
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()
|