Files
xiaoxia-saas/tests/unit/test_ai_service.py
xiaoxia 27cb7381ad
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Successful in 35s
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Validate - Code Quality (push) Failing after 1m44s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 1m46s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 1m41s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m42s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 2m1s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 5m11s
CI/CD Pipeline / Integration Tests (push) Successful in 5m44s
CI/CD Pipeline / Unit Tests (push) Failing after 9m17s
CI/CD Pipeline / Build Staging API Image (push) Successful in 13m4s
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 / ACR Image Cleanup (push) Has been cancelled
style: 修复2个文件ruff告警+格式化对齐(smart_asset_selector + test_ai_service) (#759)
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-07-23 17:57:33 +08:00

433 lines
18 KiB
Python
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""AI 服务层单元测试.
测试覆盖:
- DoubaoAIClient 可用性检测
- 智能标题生成(降级模式)
- 标题解析(多种返回格式)
- 风格校验
- 参数边界
"""
from __future__ import annotations
import json
import sys
import unittest
from unittest.mock import MagicMock, patch
sys.path.insert(0, "apps/api")
from app.services.ai_service import ( # noqa: E402
TITLE_STYLES,
_generate_titles_fallback,
_parse_semantic_match_response,
_parse_titles_from_response,
_semantic_match_fallback,
generate_smart_titles,
semantic_match_assets,
)
class TestAIClientAvailability(unittest.TestCase):
"""AI客户端可用性检测(通过mock get_doubao_client."""
def test_generate_fallback_when_client_unavailable(self):
"""客户端不可用时走降级."""
mock_client = MagicMock()
mock_client.is_available = False
mock_client.chat_completion = MagicMock(return_value=None)
with patch("app.services.ai_service.get_doubao_client", return_value=mock_client):
result = generate_smart_titles("测试内容", "viral", 5)
self.assertEqual(result["source"], "fallback")
self.assertEqual(len(result["titles"]), 5)
# 不可用时不应调用 chat_completion
mock_client.chat_completion.assert_not_called()
def test_generate_calls_client_when_available(self):
"""客户端可用时调用API."""
mock_client = MagicMock()
mock_client.is_available = True
mock_client.chat_completion = MagicMock(
return_value=json.dumps(["AI标题1", "AI标题2", "AI标题3", "AI标题4", "AI标题5"])
)
with patch("app.services.ai_service.get_doubao_client", return_value=mock_client):
result = generate_smart_titles("测试", "viral", 5)
self.assertEqual(result["source"], "doubao")
mock_client.chat_completion.assert_called_once()
class TestTitleParsing(unittest.TestCase):
"""标题解析测试 — 覆盖多种返回格式."""
def test_parse_json_array(self):
"""解析 JSON 数组格式."""
content = json.dumps(["标题一", "标题二", "标题三"])
result = _parse_titles_from_response(content)
self.assertEqual(len(result), 3)
self.assertEqual(result[0], "标题一")
def test_parse_json_with_titles_key(self):
"""解析带 titles 字段的 JSON 对象."""
content = json.dumps({"titles": ["标题A", "标题B"]})
result = _parse_titles_from_response(content)
self.assertEqual(len(result), 2)
def test_parse_markdown_code_block_json(self):
"""解析 markdown 代码块包裹的 JSON."""
content = '```json\n["标题1", "标题2"]\n```'
result = _parse_titles_from_response(content)
self.assertEqual(len(result), 2)
def test_parse_numbered_list(self):
"""解析编号列表."""
content = "1. 第一个标题\n2. 第二个标题\n3. 第三个标题"
result = _parse_titles_from_response(content)
self.assertEqual(len(result), 3)
self.assertIn("第一个标题", result)
def test_parse_dash_list(self):
"""解析破折号列表."""
content = "- 标题甲\n- 标题乙\n- 标题丙"
result = _parse_titles_from_response(content)
self.assertEqual(len(result), 3)
def test_parse_chinese_numbered(self):
"""解析中文数字编号."""
content = "1、标题一\n2、标题二"
result = _parse_titles_from_response(content)
self.assertEqual(len(result), 2)
def test_parse_empty_content(self):
"""空内容返回空列表."""
result = _parse_titles_from_response("")
self.assertEqual(result, [])
def test_parse_filters_long_lines(self):
"""过滤过长的行."""
long_title = "这是一个非常长的标题" * 15 # 超过100字
content = f"1. 正常标题\n2. {long_title}\n3. 另一个标题"
result = _parse_titles_from_response(content)
self.assertEqual(len(result), 2)
self.assertNotIn(long_title, result)
def test_parse_invalid_json_falls_back_to_lines(self):
"""无效 JSON 回退到按行解析."""
content = '["标题1", "标题2", 无效'
result = _parse_titles_from_response(content)
# 至少能解析出一些内容
self.assertTrue(len(result) >= 0)
class TestFallbackGeneration(unittest.TestCase):
"""降级生成测试."""
def test_fallback_returns_requested_count(self):
"""返回请求的数量."""
result = _generate_titles_fallback("测试内容", "viral", 5)
self.assertEqual(len(result), 5)
def test_fallback_max_10(self):
"""最多返回10个."""
result = _generate_titles_fallback("测试内容", "viral", 20)
self.assertEqual(len(result), 10)
def test_fallback_different_styles(self):
"""不同风格都能生成."""
for style in ["viral", "emotional", "informative"]:
result = _generate_titles_fallback("测试", style, 3)
self.assertEqual(len(result), 3)
for title in result:
self.assertTrue(len(title) > 0)
def test_fallback_contains_keyword(self):
"""标题包含关键词."""
result = _generate_titles_fallback("旅行攻略", "viral", 5)
has_keyword = any("旅行" in t for t in result)
self.assertTrue(has_keyword)
class TestGenerateSmartTitles(unittest.TestCase):
"""智能标题生成集成测试."""
def test_generate_without_api_key_fallback(self):
"""无 API Key 时走降级路径."""
mock_client = MagicMock()
mock_client.is_available = False
with patch("app.services.ai_service.get_doubao_client", return_value=mock_client):
result = generate_smart_titles("测试视频内容", "viral", 5)
self.assertEqual(result["source"], "fallback")
self.assertEqual(result["style"], "viral")
self.assertEqual(len(result["titles"]), 5)
def test_generate_invalid_style_defaults_to_viral(self):
"""无效风格默认 viral."""
mock_client = MagicMock()
mock_client.is_available = False
with patch("app.services.ai_service.get_doubao_client", return_value=mock_client):
result = generate_smart_titles("测试", "invalid_style", 5)
self.assertEqual(result["style"], "viral")
def test_generate_count_bounds(self):
"""数量边界处理."""
mock_client = MagicMock()
mock_client.is_available = False
with patch("app.services.ai_service.get_doubao_client", return_value=mock_client):
# 小于最小值
result = generate_smart_titles("测试", "viral", 1)
self.assertEqual(len(result["titles"]), 3)
# 大于最大值
result = generate_smart_titles("测试", "viral", 100)
self.assertEqual(len(result["titles"]), 10)
def test_generate_with_api_success(self):
"""API 调用成功路径."""
mock_client = MagicMock()
mock_client.is_available = True
mock_client.chat_completion = MagicMock(
return_value=json.dumps(["AI标题1", "AI标题2", "AI标题3", "AI标题4", "AI标题5"])
)
with patch("app.services.ai_service.get_doubao_client", return_value=mock_client):
result = generate_smart_titles("测试视频", "viral", 5)
self.assertEqual(result["source"], "doubao")
self.assertEqual(len(result["titles"]), 5)
self.assertIn("AI标题1", result["titles"])
def test_generate_with_api_failure_fallback(self):
"""API 调用失败时降级."""
mock_client = MagicMock()
mock_client.is_available = True
mock_client.chat_completion = MagicMock(return_value=None)
with patch("app.services.ai_service.get_doubao_client", return_value=mock_client):
result = generate_smart_titles("测试视频", "viral", 5)
self.assertEqual(result["source"], "fallback")
self.assertEqual(len(result["titles"]), 5)
def test_generate_api_returns_unparseable_fallback(self):
"""API 返回无法解析时降级."""
mock_client = MagicMock()
mock_client.is_available = True
# 返回无法解析的内容(只有一个标题且格式异常)
mock_client.chat_completion = MagicMock(return_value="一段文字说明,不是标题列表")
with patch("app.services.ai_service.get_doubao_client", return_value=mock_client):
result = generate_smart_titles("测试视频", "viral", 5)
# 只有1个有效标题,不足2个触发降级
self.assertEqual(result["source"], "fallback")
class TestTitleStyles(unittest.TestCase):
"""标题风格定义测试."""
def test_all_styles_have_required_fields(self):
"""所有风格都有必要字段."""
for _key, info in TITLE_STYLES.items():
self.assertIn("name", info)
self.assertIn("description", info)
self.assertIn("examples", info)
self.assertTrue(len(info["examples"]) >= 2)
def test_three_styles_defined(self):
"""定义了三种风格."""
self.assertEqual(len(TITLE_STYLES), 3)
self.assertIn("viral", TITLE_STYLES)
self.assertIn("emotional", TITLE_STYLES)
self.assertIn("informative", TITLE_STYLES)
# ── 语义匹配测试 ──────────────────────────────────────────────────────────────
class TestSemanticMatchFallback(unittest.TestCase):
"""降级关键词匹配测试."""
def _make_assets(self):
return [
{"id": "a1", "name": "海边日落风景", "tags": ["风景", "海边", "日落"], "description": "美丽的海边日落"},
{"id": "a2", "name": "城市夜景航拍", "tags": ["城市", "夜景", "航拍"], "description": "城市夜景航拍素材"},
{"id": "a3", "name": "美食制作过程", "tags": ["美食", "烹饪", "教程"], "description": "美食制作教程"},
]
def test_fallback_returns_sorted_scores(self):
"""返回按匹配度降序排列."""
assets = self._make_assets()
result = _semantic_match_fallback("海边日落风景视频", assets)
self.assertEqual(len(result), 3)
# 第一个应该是海边日落
self.assertEqual(result[0]["id"], "a1")
self.assertGreater(result[0]["match_score"], result[2]["match_score"])
def test_fallback_each_has_match_score(self):
"""每个素材都有 match_score."""
assets = self._make_assets()
result = _semantic_match_fallback("测试", assets)
for item in result:
self.assertIn("match_score", item)
self.assertGreaterEqual(item["match_score"], 0.0)
self.assertLessEqual(item["match_score"], 1.0)
self.assertIn("match_reason", item)
def test_fallback_unrelated_desc_low_scores(self):
"""完全不相关的描述得分低."""
assets = self._make_assets()
result = _semantic_match_fallback("篮球比赛运动", assets)
# 所有素材得分都应该较低
for item in result:
self.assertLess(item["match_score"], 0.8)
def test_fallback_empty_keywords_default_score(self):
"""无有效关键词时给默认分."""
assets = self._make_assets()
result = _semantic_match_fallback("a", assets) # 单字符无有效关键词
for item in result:
self.assertEqual(item["match_score"], 0.5)
self.assertEqual(item["match_reason"], "fallback_default")
def test_fallback_name_match_higher(self):
"""名称命中得分更高."""
assets = [
{"id": "a1", "name": "美食探店vlog", "tags": [], "description": ""},
{"id": "a2", "name": "风景视频", "tags": ["美食"], "description": ""},
]
result = _semantic_match_fallback("美食", assets)
# a1名称含美食,a2标签含美食,名称命中应有额外加分
self.assertEqual(result[0]["id"], "a1")
self.assertGreater(result[0]["match_score"], result[1]["match_score"])
class TestSemanticMatchParsing(unittest.TestCase):
"""语义匹配返回解析测试."""
def test_parse_dict_format(self):
"""解析 {id: score} 格式."""
content = json.dumps({"asset1": 0.85, "asset2": 0.62, "asset3": 0.3})
result = _parse_semantic_match_response(content, ["asset1", "asset2", "asset3"])
self.assertIsNotNone(result)
self.assertEqual(len(result), 3)
self.assertAlmostEqual(result["asset1"], 0.85)
def test_parse_matches_list_format(self):
"""解析 {matches: [...]} 格式."""
content = json.dumps(
{
"matches": [
{"asset_id": "a1", "score": 0.9},
{"asset_id": "a2", "score": 0.7},
]
}
)
result = _parse_semantic_match_response(content, ["a1", "a2"])
self.assertIsNotNone(result)
self.assertAlmostEqual(result["a1"], 0.9)
self.assertAlmostEqual(result["a2"], 0.7)
def test_parse_array_format(self):
"""解析数组格式."""
content = json.dumps(
[
{"id": "x1", "score": 0.5},
{"id": "x2", "score": 0.88},
]
)
result = _parse_semantic_match_response(content, ["x1", "x2"])
self.assertIsNotNone(result)
self.assertAlmostEqual(result["x1"], 0.5)
def test_parse_score_clamped(self):
"""分数被限制在0-1."""
content = json.dumps({"a1": 1.5, "a2": -0.2})
result = _parse_semantic_match_response(content, ["a1", "a2"])
self.assertIsNotNone(result)
self.assertAlmostEqual(result["a1"], 1.0)
self.assertAlmostEqual(result["a2"], 0.0)
def test_parse_markdown_code_block(self):
"""解析markdown代码块."""
content = '```json\n{"a1": 0.7}\n```'
result = _parse_semantic_match_response(content, ["a1", "a2"])
# 只有1个素材评分,少于一半(需要至少1个,max(1, 2//2)=1
self.assertIsNotNone(result)
self.assertAlmostEqual(result["a1"], 0.7)
def test_parse_empty_returns_none(self):
"""空内容返回None."""
result = _parse_semantic_match_response("", ["a1"])
self.assertIsNone(result)
def test_parse_invalid_json_returns_none(self):
"""无效JSON返回None."""
result = _parse_semantic_match_response("不是json", ["a1", "a2", "a3"])
self.assertIsNone(result)
class TestSemanticMatchAssets(unittest.TestCase):
"""semantic_match_assets 集成测试."""
def _make_assets(self):
return [
{"id": "a1", "name": "海边日落", "tags": ["风景"], "description": ""},
{"id": "a2", "name": "城市夜景", "tags": ["城市"], "description": ""},
{"id": "a3", "name": "美食制作", "tags": ["美食"], "description": ""},
]
def test_fallback_mode_without_api_key(self):
"""无API Key时走降级."""
mock_client = MagicMock()
mock_client.is_available = False
with patch("app.services.ai_service.get_doubao_client", return_value=mock_client):
result = semantic_match_assets("海边", self._make_assets())
self.assertEqual(result["source"], "fallback")
self.assertEqual(result["total"], 3)
self.assertEqual(len(result["matches"]), 3)
def test_empty_assets(self):
"""空素材列表."""
result = semantic_match_assets("test", [])
self.assertEqual(result["total"], 0)
self.assertEqual(len(result["matches"]), 0)
def test_top_k_limit(self):
"""top_k 限制返回数量."""
mock_client = MagicMock()
mock_client.is_available = False
with patch("app.services.ai_service.get_doubao_client", return_value=mock_client):
result = semantic_match_assets("测试", self._make_assets(), top_k=2)
self.assertEqual(len(result["matches"]), 2)
def test_with_doubao_success(self):
"""豆包调用成功路径."""
mock_client = MagicMock()
mock_client.is_available = True
mock_client.chat_completion = MagicMock(return_value=json.dumps({"a1": 0.9, "a2": 0.5, "a3": 0.2}))
with patch("app.services.ai_service.get_doubao_client", return_value=mock_client):
result = semantic_match_assets("风景视频", self._make_assets())
self.assertEqual(result["source"], "doubao")
self.assertEqual(len(result["matches"]), 3)
# 按分数降序,a1最高
self.assertEqual(result["matches"][0]["id"], "a1")
self.assertAlmostEqual(result["matches"][0]["match_score"], 0.9)
def test_with_doubao_failure_fallback(self):
"""豆包调用失败降级."""
mock_client = MagicMock()
mock_client.is_available = True
mock_client.chat_completion = MagicMock(return_value=None)
with patch("app.services.ai_service.get_doubao_client", return_value=mock_client):
result = semantic_match_assets("测试", self._make_assets())
self.assertEqual(result["source"], "fallback")
def test_each_match_has_required_fields(self):
"""每个匹配结果都有必要字段."""
mock_client = MagicMock()
mock_client.is_available = False
with patch("app.services.ai_service.get_doubao_client", return_value=mock_client):
result = semantic_match_assets("测试", self._make_assets())
for item in result["matches"]:
self.assertIn("id", item)
self.assertIn("match_score", item)
self.assertIn("match_reason", item)
if __name__ == "__main__":
unittest.main()