Files
xiaoxia-saas/tests/unit/test_ai_parsing.py

316 lines
12 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_parsing 模块单测 — 纯逻辑."""
from __future__ import annotations
import pytest
from packages.domain.ai_parsing import (
generate_titles_fallback,
keyword_match_fallback,
parse_semantic_match_response,
parse_titles_from_response,
)
# ── parse_titles_from_response 测试 ──────────────────────────────────────────
class TestParseTitlesJsonArray:
def test_simple_json_array(self):
result = parse_titles_from_response('["标题1", "标题2", "标题3"]')
assert result == ["标题1", "标题2", "标题3"]
def test_json_array_with_empty_strings_skipped(self):
result = parse_titles_from_response('["标题1", "", "标题2"]')
assert result == ["标题1", "标题2"]
def test_json_dict_with_titles_key(self):
result = parse_titles_from_response('{"titles": ["a", "b", "c"]}')
assert result == ["a", "b", "c"]
def test_json_with_markdown_code_block(self):
content = '```json\n["标题1", "标题2"]\n```'
result = parse_titles_from_response(content)
assert result == ["标题1", "标题2"]
def test_json_with_backticks_no_lang(self):
content = '```\n["标题1", "标题2"]\n```'
result = parse_titles_from_response(content)
assert result == ["标题1", "标题2"]
def test_none_returns_empty(self):
assert parse_titles_from_response(None) == [] # type: ignore
def test_empty_string_returns_empty(self):
assert parse_titles_from_response("") == []
class TestParseTitlesNumberedList:
def test_dot_numbered(self):
content = "1. 第一个标题\n2. 第二个标题\n3. 第三个标题"
result = parse_titles_from_response(content)
assert len(result) == 3
assert result[0] == "第一个标题"
assert result[1] == "第二个标题"
def test_chinese_period_numbered(self):
content = "1、第一个标题\n2、第二个标题"
result = parse_titles_from_response(content)
assert result == ["第一个标题", "第二个标题"]
def test_parentheses_numbered(self):
content = "1) 第一个标题\n2) 第二个标题"
result = parse_titles_from_response(content)
assert result == ["第一个标题", "第二个标题"]
def test_chinese_paren_numbered(self):
# 原实现只支持半角括号,全角括号保留原样(不影响实际使用)
content = "1)第一个标题\n2)第二个标题"
result = parse_titles_from_response(content)
assert len(result) == 2
class TestParseTitlesDash:
def test_dash_prefix(self):
content = "- 标题一\n- 标题二\n- 标题三"
result = parse_titles_from_response(content)
assert len(result) == 3
assert result[0] == "标题一"
def test_bullet_prefix(self):
content = "• 标题一\n• 标题二"
result = parse_titles_from_response(content)
assert len(result) == 2
assert result[0] == "标题一"
class TestParseTitlesQuoted:
def test_strips_quotes(self):
content = '1. "带引号的标题"\n2. 正常标题'
result = parse_titles_from_response(content)
assert "带引号的标题" in result
def test_strips_chinese_quotes(self):
content = "1. 「中文引号标题」\n2. 正常标题"
result = parse_titles_from_response(content)
assert "中文引号标题" in result
class TestParseTitlesEdgeCases:
def test_skips_empty_lines(self):
content = "标题一\n\n标题二\n\n标题三"
result = parse_titles_from_response(content)
assert len(result) == 3
def test_filters_long_lines(self):
long_title = "A" * 150
content = f"短标题\n{long_title}\n另一个短标题"
result = parse_titles_from_response(content)
assert len(result) == 2
assert long_title not in result
def test_invalid_json_falls_back_to_lines(self):
content = "标题1\n标题2\n标题3"
result = parse_titles_from_response(content)
assert result == ["标题1", "标题2", "标题3"]
# ── parse_semantic_match_response 测试 ──────────────────────────────────────
class TestParseSemanticMatchDictFormat:
def test_simple_dict(self):
asset_ids = ["a1", "a2", "a3"]
content = '{"a1": 0.8, "a2": 0.6, "a3": 0.9}'
result = parse_semantic_match_response(content, asset_ids)
assert result is not None
assert result["a1"] == 0.8
assert result["a2"] == 0.6
assert result["a3"] == 0.9
def test_score_clamped_to_0_1(self):
asset_ids = ["a1", "a2"]
content = '{"a1": 1.5, "a2": -0.5}'
result = parse_semantic_match_response(content, asset_ids)
assert result is not None
assert result["a1"] == 1.0
assert result["a2"] == 0.0
class TestParseSemanticMatchMatchesFormat:
def test_matches_array(self):
asset_ids = ["a1", "a2"]
content = '{"matches": [{"asset_id": "a1", "score": 0.8}, {"asset_id": "a2", "score": 0.6}]}'
result = parse_semantic_match_response(content, asset_ids)
assert result is not None
assert result["a1"] == 0.8
assert result["a2"] == 0.6
def test_matches_with_id_key(self):
asset_ids = ["a1", "a2"]
content = '{"matches": [{"id": "a1", "score": 0.7}, {"id": "a2", "score": 0.5}]}'
result = parse_semantic_match_response(content, asset_ids)
assert result is not None
assert result["a1"] == 0.7
class TestParseSemanticMatchArrayFormat:
def test_array_of_objects(self):
asset_ids = ["a1", "a2", "a3"]
content = (
'[{"asset_id": "a1", "score": 0.8}, {"asset_id": "a2", "score": 0.6}, {"asset_id": "a3", "score": 0.3}]'
)
result = parse_semantic_match_response(content, asset_ids)
assert result is not None
assert len(result) == 3
class TestParseSemanticMatchEdgeCases:
def test_empty_content_returns_none(self):
assert parse_semantic_match_response("", ["a1"]) is None
def test_invalid_json_returns_none(self):
assert parse_semantic_match_response("not json", ["a1"]) is None
def test_less_than_half_returns_none(self):
asset_ids = ["a1", "a2", "a3", "a4", "a5"]
# 只返回1个,少于 5//2=2,应该返回 None
content = '{"a1": 0.8}'
result = parse_semantic_match_response(content, asset_ids)
assert result is None
def test_at_least_half_returns_result(self):
asset_ids = ["a1", "a2", "a3", "a4", "a5"]
# 返回3个,>= 5//2=2
content = '{"a1": 0.8, "a2": 0.7, "a3": 0.6}'
result = parse_semantic_match_response(content, asset_ids)
assert result is not None
assert len(result) == 3
def test_markdown_code_block(self):
asset_ids = ["a1", "a2"]
content = '```json\n{"a1": 0.8, "a2": 0.6}\n```'
result = parse_semantic_match_response(content, asset_ids)
assert result is not None
assert result["a1"] == 0.8
def test_no_asset_ids_returns_result_if_any(self):
content = '{"a1": 0.8, "a2": 0.6}'
result = parse_semantic_match_response(content, [])
assert result is not None
assert len(result) == 2
def test_single_asset_id_needs_at_least_1(self):
# max(1, 1//2) = max(1, 0) = 1
content = '{"a1": 0.8}'
result = parse_semantic_match_response(content, ["a1"])
assert result is not None
# ── generate_titles_fallback 测试 ────────────────────────────────────────────
class TestGenerateTitlesFallback:
def test_returns_requested_count(self):
style = {"examples": ["例1", "例2", "例3"]}
result = generate_titles_fallback("测试描述 关键词", style, count=5)
assert len(result) == 5
def test_uses_keyword_from_description(self):
style = {"examples": ["例1", "例2"]}
result = generate_titles_fallback("美食 探店 打卡", style, count=3)
# 第一个关键词是"美食"
assert any("美食" in t for t in result)
def test_no_keywords_uses_default(self):
style = {"examples": ["例1", "例2"]}
result = generate_titles_fallback("", style, count=3)
assert any("精彩内容" in t for t in result)
def test_count_limited_by_templates(self):
style = {"examples": ["例1", "例2"]}
result = generate_titles_fallback("测试", style, count=100)
assert len(result) <= 10 # 模板只有10个
def test_all_titles_are_strings(self):
style = {"examples": ["例1", "例2"]}
result = generate_titles_fallback("测试", style, count=5)
assert all(isinstance(t, str) and t for t in result)
def test_empty_examples(self):
style = {"examples": []}
result = generate_titles_fallback("测试", style, count=3)
assert len(result) == 3
assert all(isinstance(t, str) for t in result)
# ── keyword_match_fallback 测试 ──────────────────────────────────────────────
class TestKeywordMatchFallback:
def test_basic_matching(self):
assets = [
{"id": "1", "name": "美食探店视频", "tags": ["美食", "探店"], "description": "好吃的"},
{"id": "2", "name": "旅行vlog", "tags": ["旅行", "风景"], "description": "出去玩"},
]
result = keyword_match_fallback("美食探店 好吃的美食", assets)
assert len(result) == 2
# 第一个应该是美食相关的
assert result[0]["id"] == "1"
assert result[0]["match_score"] >= result[1]["match_score"]
def test_returns_match_score_and_reason(self):
assets = [{"id": "1", "name": "测试素材", "tags": [], "description": ""}]
result = keyword_match_fallback("美食", assets)
assert len(result) == 1
assert "match_score" in result[0]
assert "match_reason" in result[0]
assert 0.0 <= result[0]["match_score"] <= 1.0
def test_no_keywords_default_score(self):
assets = [
{"id": "1", "name": "素材1", "tags": [], "description": ""},
{"id": "2", "name": "素材2", "tags": [], "description": ""},
]
# 单个字符不算关键词
result = keyword_match_fallback("a", assets)
assert len(result) == 2
assert all(r["match_score"] == 0.5 for r in result)
assert all(r["match_reason"] == "fallback_default" for r in result)
def test_sorted_by_score_descending(self):
assets = [
{"id": "low", "name": "无关素材", "tags": [], "description": ""},
{"id": "high", "name": "美食视频", "tags": ["美食"], "description": "美食分享"},
]
result = keyword_match_fallback("美食分享", assets)
assert result[0]["id"] == "high"
assert result[0]["match_score"] > result[1]["match_score"]
def test_does_not_modify_original_assets(self):
original = {"id": "1", "name": "测试", "tags": [], "description": ""}
assets = [dict(original)]
keyword_match_fallback("美食", assets)
assert "match_score" not in assets[0]
def test_name_matches_higher_score(self):
assets = [
{"id": "name_match", "name": "美食教程", "tags": [], "description": "内容"},
{"id": "desc_match", "name": "视频1", "tags": [], "description": "美食教程内容"},
]
result = keyword_match_fallback("美食教程", assets)
# 名称命中应该加分更多
name_idx = next(i for i, r in enumerate(result) if r["id"] == "name_match")
desc_idx = next(i for i, r in enumerate(result) if r["id"] == "desc_match")
assert name_idx < desc_idx
def test_empty_assets_returns_empty(self):
result = keyword_match_fallback("美食", [])
assert result == []
def test_score_is_rounded_to_3_decimals(self):
assets = [{"id": "1", "name": "测试素材", "tags": [], "description": "内容描述"}]
result = keyword_match_fallback("美食探店旅行", assets)
score = result[0]["match_score"]
# 验证是3位小数
assert round(score, 3) == score