"""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)第一个标题\n(2)第二个标题" 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