Files
xiaoxia-saas/tests/unit/test_1970_narrative_match.py
T
xiaoxia 4fa3e4eb92
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 1s
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 5s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 40s
CI/CD Pipeline / Build Staging API Image (push) Successful in 45s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 2m21s
CI/CD Pipeline / Validate - Style (push) Successful in 3m10s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 3m32s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 4m34s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 38s
CI/CD Pipeline / Integration Tests (push) Successful in 5m34s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m25s
CI/CD Pipeline / Validate - Security (push) Successful in 6m52s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 2m56s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 3m6s
CI/CD Pipeline / Unit Tests (push) Successful in 9m14s
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 / CI Gate (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
feat(#1970): 新 API 字段 + 叙事模式 PR3 - assembly_mode/script_id/tts_*/video_ratio (#1976)
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-09-18 07:30:43 +08:00

168 lines
6.6 KiB
Python
Raw 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.
"""#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"])