Files
MoviePilot/tests/test_subscription_classification_reference.py

240 lines
8.3 KiB
Python

"""订阅稳定分类引用的写入、兼容与运行时覆盖契约测试。"""
from collections.abc import Iterator
import pytest
from app.application.classification.execution import ClassificationExecutionService
from app.application.classification.reference import (
ClassificationCategoryResolver,
configure_classification_category_resolver,
normalize_classification_reference_payload,
reset_classification_category_resolver,
subscription_classification_override,
)
from app.domain.context import MediaInfo
from app.schemas.category import (
CategoryConfig,
ClassificationCategory,
ClassificationPolicy,
)
from app.schemas.types import MediaType
class _MutableClassificationRuntime:
"""为订阅引用测试提供可切换 revision 的活动策略。"""
def __init__(self, policy: ClassificationPolicy) -> None:
"""保存当前活动策略。"""
self.policy = policy
def active_policy(self) -> ClassificationPolicy:
"""返回当前活动策略。"""
return self.policy
def legacy_config(self) -> CategoryConfig:
"""返回空 legacy 配置,确保测试只观察新分类体系。"""
return CategoryConfig()
def _policy(
*,
revision: int = 7,
automatic_path: list[str] | None = None,
) -> ClassificationPolicy:
"""构造同时包含有效、禁用和跨媒体类型分类的测试策略。"""
return ClassificationPolicy(
revision=revision,
categories=[
ClassificationCategory(
id="movie.auto",
media_type="电影",
name="自动电影",
path=automatic_path or ["电影", "自动"],
),
ClassificationCategory(
id="movie.favorite",
media_type="电影",
name="收藏电影",
path=["收藏", "当前路径"],
),
ClassificationCategory(
id="movie.disabled",
media_type="电影",
name="禁用电影",
path=["电影", "禁用"],
enabled=False,
),
ClassificationCategory(
id="tv.favorite",
media_type="电视剧",
name="收藏剧集",
path=["剧集", "收藏"],
),
],
fallbacks={"电影": "movie.auto"},
)
@pytest.fixture
def classification_runtime() -> Iterator[_MutableClassificationRuntime]:
"""装配测试策略解析器,并在用例结束后恢复全局状态。"""
runtime = _MutableClassificationRuntime(_policy())
previous = configure_classification_category_resolver(
ClassificationCategoryResolver(runtime)
)
try:
yield runtime
finally:
reset_classification_category_resolver(previous)
def _movie() -> MediaInfo:
"""构造具有完整稳定身份、可执行自动分类的电影。"""
return MediaInfo(
media_source="douban",
media_id="1291561",
type=MediaType.MOVIE,
title="测试电影",
)
def test_subscription_reference_prefers_id_and_refreshes_current_path(
classification_runtime: _MutableClassificationRuntime,
) -> None:
"""稳定 ID 必须覆盖客户端旧路径,并保存当前策略中的路径快照。"""
normalized = normalize_classification_reference_payload(
{
"media_category_id": "movie.favorite",
"media_category": "收藏/旧路径",
},
media_type=MediaType.MOVIE,
)
assert classification_runtime.policy.revision == 7
assert normalized["media_category_id"] == "movie.favorite"
assert normalized["media_category"] == "收藏/当前路径"
def test_subscription_reference_accepts_legacy_path_only(
classification_runtime: _MutableClassificationRuntime,
) -> None:
"""旧订阅只有 path 时仍应可写入并形成无稳定 ID 的人工覆盖。"""
normalized = normalize_classification_reference_payload(
{"media_category": "旧目录/电影"},
media_type=MediaType.MOVIE,
)
override = subscription_classification_override(
category_id=normalized["media_category_id"],
path_snapshot=normalized["media_category"],
media_type=MediaType.MOVIE,
)
assert classification_runtime.policy.revision == 7
assert normalized["media_category_id"] is None
assert normalized["media_category"] == "旧目录/电影"
assert override is not None
assert override.category_id is None
assert override.category_path == ["旧目录", "电影"]
assert override.source == "subscription"
def test_subscription_reference_explicit_null_clears_id_and_path(
classification_runtime: _MutableClassificationRuntime,
) -> None:
"""PATCH 显式清空稳定 ID 时不得遗留旧路径人工覆盖。"""
normalized = normalize_classification_reference_payload(
{
"media_category_id": None,
"media_category": "不得残留/旧路径",
},
media_type=MediaType.MOVIE,
)
assert classification_runtime.policy.revision == 7
assert normalized["media_category_id"] is None
assert normalized["media_category"] is None
def test_subscription_without_override_reclassifies_with_current_policy(
classification_runtime: _MutableClassificationRuntime,
) -> None:
"""未设置人工分类的订阅不得锁定创建时路径,应跟随当前策略自动分类。"""
service = ClassificationExecutionService(classification_runtime)
created = service.finalize(_movie())
classification_runtime.policy = _policy(
revision=8,
automatic_path=["电影", "新版自动"],
)
override = subscription_classification_override(
category_id=None,
path_snapshot=None,
media_type=MediaType.MOVIE,
)
refreshed = service.finalize(
created,
effective_override=override,
refresh=True,
)
assert override is None
assert created.library_category == "电影/自动"
assert refreshed.classification is not None
assert refreshed.classification.policy_revision == 8
assert refreshed.classification.effective is not None
assert refreshed.classification.effective.category_id == "movie.auto"
assert refreshed.library_category == "电影/新版自动"
def test_subscription_override_survives_auxiliary_refresh(
classification_runtime: _MutableClassificationRuntime,
) -> None:
"""订阅人工覆盖装配后,辅助信息触发的再次分类不得覆盖 effective。"""
service = ClassificationExecutionService(classification_runtime)
override = subscription_classification_override(
category_id="movie.favorite",
path_snapshot="收藏/旧路径",
media_type=MediaType.MOVIE,
)
overridden = service.finalize(_movie(), effective_override=override)
overridden.vote_average = 9.5
refreshed = service.finalize(overridden, refresh=True)
assert override is not None
assert refreshed.classification is not None
assert refreshed.classification.recommended is not None
assert refreshed.classification.recommended.category_id == "movie.auto"
assert refreshed.classification.effective is not None
assert refreshed.classification.effective.category_id == "movie.favorite"
assert refreshed.classification.effective.category_path == ["收藏", "当前路径"]
assert refreshed.classification.effective.source == "subscription"
assert refreshed.library_category == "收藏/当前路径"
@pytest.mark.parametrize(
("category_id", "message"),
[
("movie.missing", "不存在"),
("movie.disabled", "禁用"),
("tv.favorite", "不一致"),
],
)
def test_new_subscription_rejects_invalid_category_id_even_with_legacy_path(
classification_runtime: _MutableClassificationRuntime,
category_id: str,
message: str,
) -> None:
"""新写入的无效稳定 ID 不得借合法旧路径降级为可接受引用。"""
assert classification_runtime.policy.revision == 7
with pytest.raises(ValueError, match=message):
normalize_classification_reference_payload(
{
"media_category_id": category_id,
"media_category": "兼容/旧路径",
},
media_type=MediaType.MOVIE,
)