Files
MoviePilot/tests/test_media_classification_execution.py
DDSRem 2de976f6b3 Merge pull request #7050 from DDSRem-Dev/codex-comment-migration
docs: 统一全项目中文注释和结构化契约说明
2026-10-17 19:26:29 +08:00

841 lines
27 KiB
Python
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.
"""
完整识别结果分类执行与 Chain 收口验收测试。
"""
from __future__ import annotations
import asyncio
from copy import deepcopy
from typing import Optional
from unittest.mock import AsyncMock, Mock, patch
from app.application.classification.analysis import build_classification_facts_from_media
from app.application.classification.execution import (
ClassificationExecutionService,
evaluate_classification_facts,
)
from app.application.classification.legacy import migrate_legacy_category_config
from app.chain.base import ChainBase
from app.domain.context import MediaInfo, MusicArtistInfo, MusicInfo
from app.domain.metainfo import MetaInfo
from app.schemas.category import (
CategoryConfig,
CategoryRule,
ClassificationFacts,
ClassificationPolicy,
ClassificationResult,
ClassificationSelection,
)
from app.schemas.types import MediaSource, MediaType
class _Runtime:
"""
提供可变活动策略与隔离 legacy 快照的测试端口。
"""
def __init__(
self,
policy: ClassificationPolicy | None,
legacy: CategoryConfig | None = None,
) -> None:
"""
保存测试需要的当前策略和旧配置。
"""
self.policy = policy
self.legacy = legacy or CategoryConfig()
def active_policy(self) -> ClassificationPolicy | None:
"""
返回活动策略副本。
"""
return self.policy.model_copy(deep=True) if self.policy else None
def legacy_config(self) -> CategoryConfig:
"""
返回 legacy 配置副本。
"""
return self.legacy.model_copy(deep=True)
def _policy(*, revision: int = 7, movie_category: str = "movie.jp") -> ClassificationPolicy:
"""
构造同时覆盖影视、音乐专辑和艺术家的分类策略。
"""
return ClassificationPolicy.model_validate(
{
"schema_version": 2,
"revision": revision,
"categories": [
{
"id": movie_category,
"media_type": "电影",
"name": "日本动画",
"path": ["动画", "日本"],
},
{
"id": "movie.other",
"media_type": "电影",
"name": "其它电影",
"path": ["其它电影"],
},
{
"id": "tv.other",
"media_type": "电视剧",
"name": "其它剧集",
"path": ["其它剧集"],
},
{
"id": "music.live",
"media_type": "音乐",
"name": "现场音乐",
"path": ["音乐", "现场"],
},
{
"id": "music.artist",
"media_type": "音乐",
"name": "艺术家",
"path": ["音乐", "艺术家"],
},
{
"id": "music.other",
"media_type": "音乐",
"name": "其它音乐",
"path": ["其它音乐"],
},
],
"rules": [
{
"id": "rule.movie.jp",
"name": "日本动画",
"kind": "category",
"media_types": ["电影"],
"when": {
"all": [
{
"field": "media.genre_keys",
"operator": "contains_any",
"value": ["animation"],
},
{
"field": "media.countries",
"operator": "contains_any",
"value": ["JP"],
},
]
},
"target": {"category_id": movie_category},
},
{
"id": "rule.music.live",
"name": "现场音乐",
"kind": "category",
"media_types": ["音乐"],
"when": {
"all": [
{
"field": "music.secondary_types",
"operator": "contains_any",
"value": ["Live"],
}
]
},
"target": {"category_id": "music.live"},
},
{
"id": "rule.music.artist",
"name": "艺术家",
"kind": "category",
"media_types": ["音乐"],
"when": {
"all": [
{
"field": "music.entity_type",
"operator": "equals",
"value": "artist",
}
]
},
"target": {"category_id": "music.artist"},
},
],
"fallbacks": {
"电影": "movie.other",
"电视剧": "tv.other",
"音乐": "music.other",
},
}
)
def test_execution_classifies_copy_and_preserves_source_identity() -> None:
"""
完整来源对象应被复制分类,主身份和来源对象不得被改写。
"""
source = MediaInfo(
media_source="douban",
media_id="1291561",
type=MediaType.MOVIE,
title="千与千寻",
origin_country=["JP"],
genres=[{"name": "动画"}],
library_category="旧缓存分类",
classification=ClassificationResult(
policy_revision=1,
state="complete",
),
)
finalized = ClassificationExecutionService(_Runtime(_policy())).finalize(source)
assert finalized is not source
assert finalized.media_source == MediaSource.Douban
assert finalized.media_id == "1291561"
assert finalized.library_category == "动画/日本"
assert finalized.classification is not None
assert finalized.classification.policy_revision == 7
assert finalized.classification.effective.category_id == "movie.jp"
assert source.library_category == "旧缓存分类"
assert source.classification.policy_revision == 1
def test_legacy_tmdb_rules_use_non_tmdb_standard_facts() -> None:
"""
旧 TMDB 分类规则应使用豆瓣等来源已有的标准国家事实。
"""
migration = migrate_legacy_category_config(
{
"movie": {},
"tv": {
"国产剧": {"origin_country": "CN,TW,HK"},
"未分类": None,
},
}
)
source = MediaInfo(
media_source=MediaSource.Douban,
media_id="35593344",
type=MediaType.TV,
title="测试剧",
production_countries=[{"name": "中国大陆"}],
)
finalized = ClassificationExecutionService(
_Runtime(migration.policy)
).finalize(source)
assert finalized.media_source == MediaSource.Douban
assert finalized.library_category == "国产剧"
assert finalized.classification is not None
assert finalized.classification.effective.category_id == next(
category.id
for category in migration.policy.categories
if category.name == "国产剧"
)
def test_legacy_compatibility_preserves_primary_source_provenance() -> None:
"""
旧 TMDB 规则兼容求值不得把非 TMDB 主身份伪装成 TMDB。
"""
migration = migrate_legacy_category_config(
{"movie": {"华语电影": {"original_language": "zh"}}, "tv": {}}
)
media = MediaInfo(
media_source=MediaSource.Douban,
media_id="douban-1",
type=MediaType.MOVIE,
original_language="zh",
)
facts = build_classification_facts_from_media(media)
evaluation = evaluate_classification_facts(migration.policy, facts)
assert evaluation.facts.identity.media_source == MediaSource.Douban.value
assert all(
warning.source == MediaSource.Douban.value for warning in evaluation.warnings
)
def test_non_tmdb_tmdb_info_does_not_override_primary_standard_facts() -> None:
"""
非 TMDB 媒体携带的旧 tmdb_info 不得覆盖主来源标准事实。
"""
migration = migrate_legacy_category_config(
{
"movie": {
"华语电影": {"original_language": "zh"},
"外语电影": None,
},
"tv": {},
}
)
media = MediaInfo(
media_source=MediaSource.Douban,
media_id="douban-1",
type=MediaType.MOVIE,
original_language="zh",
tmdb_info={"original_language": "en"},
)
finalized = ClassificationExecutionService(_Runtime(migration.policy)).finalize(media)
assert finalized.library_category == "华语电影"
def test_execution_builds_complete_facts_without_mutating_media() -> None:
"""
影响分析事实入口应复用插件字段构造,并保持原媒体对象不变。
"""
source = MediaInfo(
media_source=MediaSource.Douban,
media_id="native-1",
type=MediaType.MOVIE,
title="Example",
genres=[{"name": "动画"}],
origin_country=["JP"],
)
service = ClassificationExecutionService(
_Runtime(_policy()),
extension_facts_provider=lambda media: {
"example.source": {"region_group": "east-asia"}
},
)
facts = asyncio.run(service.async_build_facts(source))
assert facts is not None
assert facts.identity.media_id == "native-1"
assert facts.media.genre_keys == ["animation"]
assert facts.media.countries == ["JP"]
assert facts.extensions["example.source"]["region_group"] == "east-asia"
assert source.classification is None
def test_execution_reclassifies_cached_result_after_policy_revision_changes() -> None:
"""
缓存对象携带旧 revision 时必须按当前策略重新分类。
"""
runtime = _Runtime(_policy(revision=7))
service = ClassificationExecutionService(runtime)
source = MediaInfo(
media_source="douban",
media_id="1",
type=MediaType.MOVIE,
origin_country=["JP"],
genres=[{"name": "动画"}],
)
first = service.finalize(source)
runtime.policy = _policy(revision=8, movie_category="movie.other")
second = service.finalize(first)
assert second.classification is not None
assert second.classification.policy_revision == 8
assert second.classification.effective.category_id == "movie.other"
assert second.library_category == "其它电影"
assert first.classification.policy_revision == 7
def test_execution_refreshes_same_revision_after_auxiliary_facts_change() -> None:
"""
来源缓存恢复后即使 revision 相同,也必须按当前完整事实重新求值。
"""
service = ClassificationExecutionService(_Runtime(_policy()))
source = MediaInfo(
media_source="anilist",
media_id="1",
type=MediaType.MOVIE,
)
first = service.finalize(source)
first.genre_ids = [16]
first.origin_country = ["JP"]
reclassified = service.finalize(first)
refreshed = service.finalize(first, refresh=True)
assert reclassified.library_category == "动画/日本"
assert refreshed.library_category == "动画/日本"
assert refreshed.classification is not None
assert refreshed.classification.effective.rule_id == "rule.movie.jp"
def test_execution_preserves_automatic_category_when_supplement_drops_facts() -> None:
"""
裁剪过字段的媒体再次分类时,同策略已命中的自动分类不得被兜底覆盖。
"""
migration = migrate_legacy_category_config(
{
"movie": {},
"tv": {
"国产剧": {"origin_country": "CN,TW,HK"},
"未分类": None,
},
}
)
domestic = next(
category.id
for category in migration.policy.categories
if category.name == "国产剧"
)
service = ClassificationExecutionService(_Runtime(migration.policy))
source = MediaInfo(
media_source=MediaSource.Douban,
media_id="35593344",
type=MediaType.TV,
title="测试剧",
production_countries=[{"name": "中国大陆"}],
)
classified = service.finalize(source)
assert classified.classification.effective.category_id == domestic
assert classified.classification.state == "complete"
classified.clear()
supplemented = service.finalize(classified)
assert supplemented.classification is not None
assert supplemented.classification.effective.category_id == domestic
assert supplemented.classification.effective.source == "automatic"
assert supplemented.library_category == "国产剧"
# 本次求值确实缺事实,推荐结果和状态仍如实记录,供预览解释差异。
assert supplemented.classification.state == "partial"
assert supplemented.classification.recommended.source == "fallback"
def test_execution_keeps_fallback_when_complete_facts_no_longer_match() -> None:
"""
事实读齐后不再命中规则时,兜底结果必须生效而不是复用旧自动分类。
"""
service = ClassificationExecutionService(_Runtime(_policy()))
source = MediaInfo(
media_source="douban",
media_id="1",
type=MediaType.MOVIE,
origin_country=["JP"],
genres=[{"name": "动画"}],
)
classified = service.finalize(source)
assert classified.classification.effective.category_id == "movie.jp"
classified.origin_country = ["US"]
reclassified = service.finalize(classified)
assert reclassified.classification is not None
assert reclassified.classification.state == "complete"
assert reclassified.classification.effective.category_id == "movie.other"
assert reclassified.classification.effective.source == "fallback"
assert reclassified.library_category == "其它电影"
def test_execution_applies_manual_effective_override_without_losing_recommendation() -> None:
"""
订阅或目录人工覆盖只替换 effective,仍保留自动推荐供 UI 解释。
"""
source = MediaInfo(
media_source="douban",
media_id="1",
type=MediaType.MOVIE,
origin_country=["JP"],
genres=[{"name": "动画"}],
)
override = ClassificationSelection(
category_path=["收藏", "家庭电影"],
source="subscription",
)
finalized = ClassificationExecutionService(_Runtime(_policy())).finalize(
source,
effective_override=override,
)
assert finalized.library_category == "收藏/家庭电影"
assert finalized.classification is not None
assert finalized.classification.recommended.category_id == "movie.jp"
assert finalized.classification.effective.source == "subscription"
assert finalized.classification.effective.category_path == ["收藏", "家庭电影"]
def test_execution_preserves_subscription_override_during_auxiliary_refresh() -> None:
"""
补充来源事实再次分类时必须保留已装配的订阅人工覆盖。
"""
service = ClassificationExecutionService(_Runtime(_policy()))
source = MediaInfo(
media_source="douban",
media_id="1",
type=MediaType.MOVIE,
origin_country=["JP"],
genres=[{"name": "动画"}],
)
overridden = service.finalize(
source,
effective_override=ClassificationSelection(
category_id="movie.manual",
category_path=["收藏", "家庭电影"],
source="subscription",
),
)
refreshed = service.finalize(overridden, refresh=True)
assert refreshed.classification.recommended.category_id == "movie.jp"
assert refreshed.classification.effective.category_id == "movie.manual"
assert refreshed.classification.effective.source == "subscription"
assert refreshed.library_category == "收藏/家庭电影"
class _StaticEnrichment:
"""
为同步和异步收口补充同一组国家事实。
"""
def __init__(self) -> None:
"""
创建空调用记录。
"""
self.calls: list[str] = []
@staticmethod
def _enrich(facts: ClassificationFacts) -> ClassificationFacts:
"""
复制事实并填入当前缺失的国家。
"""
enriched = facts.model_copy(deep=True)
enriched.media.countries = ["JP"]
return enriched
def enrich(
self,
policy: ClassificationPolicy,
facts: ClassificationFacts,
media: object,
) -> ClassificationFacts:
"""
记录同步调用并返回补充事实。
"""
del policy, media
self.calls.append("sync")
return self._enrich(facts)
async def async_enrich(
self,
policy: ClassificationPolicy,
facts: ClassificationFacts,
media: object,
) -> ClassificationFacts:
"""
记录异步调用并返回同一补充事实。
"""
del policy, media
self.calls.append("async")
return self._enrich(facts)
def test_execution_sync_and_async_apply_the_same_enriched_facts() -> None:
"""
同步和异步执行服务必须在同一求值边界消费补充事实。
"""
source = MediaInfo(
media_source=MediaSource.Douban,
media_id="1291561",
type=MediaType.MOVIE,
title="千与千寻",
genres=[{"name": "动画"}],
)
enrichment = _StaticEnrichment()
service = ClassificationExecutionService(
_Runtime(_policy()),
enrichment=enrichment,
)
sync_result = service.finalize(source)
async_result = asyncio.run(service.async_finalize(source))
assert enrichment.calls == ["sync", "async"]
assert sync_result.classification == async_result.classification
assert sync_result.library_category == async_result.library_category == "动画/日本"
assert sync_result.media_source == async_result.media_source == MediaSource.Douban
assert sync_result.media_id == async_result.media_id == "1291561"
assert source.library_category == ""
def test_offline_classification_keeps_policy_without_external_enrichment() -> None:
"""
离线结果仍遵守用户分类和手工覆盖,但同步异步都不调用外部补充。
"""
source = MusicInfo(title="晴天", artists=["周杰伦"], album="叶惠美")
enrichment = _StaticEnrichment()
service = ClassificationExecutionService(_Runtime(_policy()), enrichment=enrichment)
override = ClassificationSelection(
category_path=["收藏"], source="manual", category_id="music.other",
)
sync_result = service.finalize(source, allow_enrichment=False, effective_override=override)
async_result = asyncio.run(service.async_finalize(
source, allow_enrichment=False, effective_override=override,
))
assert enrichment.calls == []
assert sync_result.classification == async_result.classification
assert sync_result.classification.recommended.category_id == "music.other"
assert sync_result.library_category == async_result.library_category == "收藏"
assert source.media_id is None
assert source.library_category == ""
def test_music_metadata_category_is_never_promoted_without_a_rule() -> None:
"""
音乐来源描述分类只能形成事实,目录分类必须来自规则或兜底。
"""
music = MusicInfo(
media_source="musicbrainz",
media_id="recording-1",
title="Live Recording",
metadata_category="Album / Live",
secondary_types=["Live"],
)
finalized = ClassificationExecutionService(_Runtime(_policy())).finalize(music)
assert finalized.metadata_category == "Album / Live"
assert finalized.library_category == "音乐/现场"
assert finalized.classification.effective.rule_id == "rule.music.live"
assert music.library_category == ""
def test_artist_detail_uses_music_entity_facts() -> None:
"""
完整艺术家详情应支持 music.entity_type 分类并保留描述类型。
"""
artist = MusicArtistInfo(
media_source="musicbrainz",
media_id="artist-1",
name="Example Band",
artist_type="Group",
)
finalized = ClassificationExecutionService(_Runtime(_policy())).finalize(artist)
assert finalized.library_category == "音乐/艺术家"
assert finalized.metadata_category == "Group"
assert finalized.classification is not None
assert finalized.classification.effective.rule_id == "rule.music.artist"
def test_incomplete_identity_remains_not_evaluated() -> None:
"""
本地音乐兜底缺少远端身份时不得伪造已完成分类。
"""
music = MusicInfo(
title="Local Track",
metadata_category="Rock",
)
finalized = ClassificationExecutionService(_Runtime(_policy())).finalize(music)
assert finalized.classification is not None
assert finalized.classification.state == "not_evaluated"
assert finalized.classification.policy_revision == 7
assert finalized.library_category == ""
assert finalized.metadata_category == "Rock"
def test_active_legacy_policy_builds_tmdb_extension_facts_at_execution() -> None:
"""
迁移策略使用的 TMDB 动态字段应在收口点从受控原始详情构造。
"""
migration = migrate_legacy_category_config(
CategoryConfig(
movie={"特别电影": CategoryRule(custom_flag="yes")},
)
)
assert migration.valid is True
media = MediaInfo(
media_source="themoviedb",
media_id="10",
type=MediaType.MOVIE,
tmdb_info={"id": 10, "media_type": "movie", "custom_flag": "yes"},
)
finalized = ClassificationExecutionService(
_Runtime(migration.policy)
).finalize(media)
assert finalized.library_category == "特别电影"
assert finalized.classification is not None
assert finalized.classification.effective.rule_id is not None
def test_invalid_policy_uses_read_only_legacy_tmdb_fallback() -> None:
"""
策略不可用时只对 TMDB 恢复旧分类,并明确标记 invalid_policy。
"""
legacy = CategoryConfig(
movie={
"日本动画": CategoryRule(
genre_ids="16",
production_countries="JP",
)
}
)
source = MediaInfo(
media_source="themoviedb",
media_id="20",
type=MediaType.MOVIE,
tmdb_info={
"id": 20,
"media_type": "movie",
"genre_ids": [16],
"production_countries": [{"iso_3166_1": "JP"}],
},
)
finalized = ClassificationExecutionService(_Runtime(None, legacy)).finalize(source)
assert finalized.library_category == "日本动画"
assert finalized.classification is not None
assert finalized.classification.state == "invalid_policy"
assert finalized.classification.effective.source == "legacy"
class _OrderingClassificationService:
"""
记录共享上报与分类收口顺序的测试执行服务。
"""
def __init__(self, events: list[str]) -> None:
"""
保存共享顺序记录列表。
"""
self.events = events
self.calls: list[MediaInfo] = []
def finalize(
self,
media: MediaInfo,
*,
effective_override: ClassificationSelection | None = None,
refresh: bool = False,
) -> MediaInfo:
"""
复制候选并写入可观察分类。
"""
del effective_override, refresh
self.events.append("finalize")
self.calls.append(media)
result = deepcopy(media)
result.set_library_category("已分类")
return result
async def async_finalize(
self,
media: MediaInfo,
*,
effective_override: ClassificationSelection | None = None,
refresh: bool = False,
) -> MediaInfo:
"""
记录异步分类收口,并返回独立结果。
"""
del effective_override, refresh
self.events.append("async_finalize")
self.calls.append(media)
result = deepcopy(media)
result.set_library_category("已分类")
return result
def _keep_candidate(**kwargs: object) -> Optional[MediaInfo]:
"""
保持原生候选不变,隔离插件补充分支。
"""
value = kwargs.get("mediainfo")
return value if isinstance(value, MediaInfo) else None
async def _async_keep_candidate(**kwargs: object) -> Optional[MediaInfo]:
"""
异步保持原生候选不变。
"""
return _keep_candidate(**kwargs)
def test_sync_recognition_finalizes_after_share_report() -> None:
"""
同步完整识别应先完成共享上报,再通过应用服务分类返回副本。
"""
chain = ChainBase()
events: list[str] = []
classifier = _OrderingClassificationService(events)
chain.classification_service = classifier
candidate = MediaInfo(
media_source="themoviedb",
media_id="30",
type=MediaType.MOVIE,
title="Example",
)
share = Mock()
share.report_recognize_share.side_effect = lambda **_kwargs: events.append("report")
with patch.object(chain, "_run_native_media_recognize", return_value=candidate), patch.object(
chain,
"_supplement_media_recognize",
side_effect=_keep_candidate,
), patch("app.chain._recognition._recognition_share_snapshot", return_value=share):
result = chain.recognize_media(meta=MetaInfo("Example"), cache=False)
assert events == ["report", "finalize"]
assert classifier.calls == [candidate]
assert isinstance(result, MediaInfo)
assert result is not candidate
assert result.library_category == "已分类"
def test_async_recognition_uses_the_same_finalize_boundary() -> None:
"""
异步完整识别应调用同一应用服务的有界异步分类收口。
"""
chain = ChainBase()
events: list[str] = []
classifier = _OrderingClassificationService(events)
chain.classification_service = classifier
candidate = MediaInfo(
media_source="douban",
media_id="40",
type=MediaType.TV,
title="Example TV",
)
share = Mock()
share.async_report_recognize_share = AsyncMock(
side_effect=lambda **_kwargs: events.append("report")
)
async def exercise() -> MediaInfo | None:
"""
执行异步识别并返回分类后的媒体。
"""
with patch.object(
chain,
"_async_run_native_media_recognize",
AsyncMock(return_value=candidate),
), patch.object(
chain,
"_async_supplement_media_recognize",
side_effect=_async_keep_candidate,
), patch(
"app.chain._recognition._recognition_share_snapshot",
return_value=share,
):
return await chain.async_recognize_media(
meta=MetaInfo("Example TV"),
cache=False,
)
result = asyncio.run(exercise())
assert events == ["report", "async_finalize"]
assert classifier.calls == [candidate]
assert isinstance(result, MediaInfo)
assert result is not candidate
assert result.library_category == "已分类"