"""#1970 PR3 叙事模式文案标签匹配纯函数测试。""" from __future__ import annotations import random from dataclasses import dataclass, field import pytest from packages.domain.narrative_match import ( build_asset_tag_name_index, match_assets_by_script_tags, normalize_tag, pick_narrative_assets, ) @dataclass class FakeAsset: id: str tag_ids: list[str] = field(default_factory=list) tags: list[str] = field(default_factory=list) status: str = "ready" file_type: str = "video" duration: float = 10.0 quality_score: float | None = None created_at: object = None metadata: dict = field(default_factory=dict) # ── normalize_tag ────────────────────────────────────────────────────────── class TestNormalizeTag: def test_strip_and_lower(self): assert normalize_tag(" 带货 ") == "带货" assert normalize_tag("Factory") == "factory" def test_none_and_non_string(self): assert normalize_tag(None) == "" assert normalize_tag(123) == "123" def test_short_tag_filtered_by_normalize_set(self): # 单字噪声标签不参与匹配(_normalize_tags 层过滤) from packages.domain.narrative_match import _normalize_tags assert _normalize_tags(["的", " a ", "工厂"]) == {"工厂"} # ── match_assets_by_script_tags ──────────────────────────────────────────── class TestMatchSplit: def test_split_by_tag_names(self): assets = [ FakeAsset("a1", tags=["工厂"]), FakeAsset("a2", tags=["旅游"]), FakeAsset("a3", tags=["工厂", "车间"]), ] matched, unmatched = match_assets_by_script_tags(assets, script_tags=["工厂"]) assert [a.id for a in matched] == ["a1", "a3"] assert [a.id for a in unmatched] == ["a2"] def test_case_insensitive(self): assets = [FakeAsset("a1", tags=["Factory"])] matched, unmatched = match_assets_by_script_tags(assets, script_tags=["FACTORY"]) assert [a.id for a in matched] == ["a1"] assert unmatched == [] def test_tag_ids_via_name_index(self): assets = [FakeAsset("a1", tag_ids=["t1"]), FakeAsset("a2", tag_ids=["t2"])] index = {"a1": ["测评"], "a2": ["vlog"]} matched, unmatched = match_assets_by_script_tags(assets, script_tags=["测评"], tag_names_by_id=index) assert [a.id for a in matched] == ["a1"] assert [a.id for a in unmatched] == ["a2"] def test_empty_script_tags_degrades_all_unmatched(self): assets = [FakeAsset("a1", tags=["工厂"])] matched, unmatched = match_assets_by_script_tags(assets, script_tags=[]) assert matched == [] assert [a.id for a in unmatched] == ["a1"] def test_no_match_degrades(self): assets = [FakeAsset("a1", tags=["工厂"]), FakeAsset("a2", tags=["车间"])] matched, unmatched = match_assets_by_script_tags(assets, script_tags=["美食"]) assert matched == [] assert {a.id for a in unmatched} == {"a1", "a2"} def test_order_preserved(self): assets = [FakeAsset(f"a{i}", tags=["x" if i % 2 else "工厂"]) for i in range(6)] matched, _ = match_assets_by_script_tags(assets, script_tags=["工厂"]) assert [a.id for a in matched] == ["a0", "a2", "a4"] def test_build_index_ignores_blank(self): # 空白/None/单字符噪声标签均不参与匹配 idx = build_asset_tag_name_index({"a1": [" 工厂 ", "", None, "A"]}) assert idx == {"a1": {"工厂"}} # ── pick_narrative_assets ────────────────────────────────────────────────── class TestPickNarrativeAssets: def _assets(self): # smart_match 需要 created_at(None 走 recency 兜底) import datetime as dt old = dt.datetime(2020, 1, 1, tzinfo=dt.UTC) return [ FakeAsset("match1", tags=["工厂"], created_at=old), FakeAsset("nomatch1", tags=["旅游"], created_at=old), FakeAsset("match2", tags=["工厂"], created_at=old), FakeAsset("nomatch2", tags=["美食"], created_at=old), ] def test_matched_pool_prioritized(self): picked = pick_narrative_assets(self._assets(), script_tags=["工厂"], limit=2, rng=random.Random(0)) assert {a.id for a in picked} <= {"match1", "match2"} assert all(a.id.startswith("match") for a in picked) def test_fallback_fills_from_unmatched(self): picked = pick_narrative_assets(self._assets(), script_tags=["工厂"], limit=4, rng=random.Random(0)) ids = {a.id for a in picked} assert ids == {"match1", "match2", "nomatch1", "nomatch2"} # 命中池排在前面 assert picked[0].id.startswith("match") assert picked[1].id.startswith("match") def test_no_tag_match_equals_random_selection(self): assets = self._assets() picked = pick_narrative_assets(assets, script_tags=["不存在"], limit=3, rng=random.Random(42)) assert len(picked) == 3 def test_empty_tags_selects_all_pool(self): assets = self._assets() picked = pick_narrative_assets(assets, script_tags=[], limit=None, rng=random.Random(1)) assert len(picked) == 4 def test_limit_none_returns_all_with_matched_first(self): picked = pick_narrative_assets(self._assets(), script_tags=["工厂"], limit=None, rng=random.Random(1)) assert len(picked) == 4 assert {a.id for a in picked[:2]} == {"match1", "match2"} def test_tag_ids_index_path(self): assets = [FakeAsset("a1", tag_ids=["t1"]), FakeAsset("a2", tag_ids=["t2"])] # 补 created_at import datetime as dt for a in assets: a.created_at = dt.datetime(2020, 1, 1, tzinfo=dt.UTC) picked = pick_narrative_assets( assets, script_tags=["教程"], tag_names_by_id={"a1": ["教程"], "a2": ["旅游"]}, limit=1, rng=random.Random(0), ) assert [a.id for a in picked] == ["a1"] def test_deterministic_with_seed(self): r1 = pick_narrative_assets(self._assets(), script_tags=["工厂"], limit=4, rng=random.Random(7)) r2 = pick_narrative_assets(self._assets(), script_tags=["工厂"], limit=4, rng=random.Random(7)) assert [a.id for a in r1] == [a.id for a in r2] if __name__ == "__main__": pytest.main([__file__, "-q"])