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

665 lines
22 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.
"""
插件媒体来源分类声明、事实过滤和动态 provider 契约测试。
"""
from __future__ import annotations
from copy import deepcopy
from types import SimpleNamespace
from unittest.mock import Mock
import pytest
from app.application.classification.configuration import (
ClassificationPolicyConfigurationService,
)
from app.application.classification.execution import ClassificationExecutionService
from app.domain.classification.facts import build_classification_facts
from app.domain.context import MediaInfo
from app.runtime.extensions.plugin.classification import (
PluginClassificationDeclarationError,
PluginClassificationRegistry,
)
from app.schemas.category import (
CategoryConfig,
ClassificationEnrichmentRequest,
ClassificationEnrichmentResponse,
ClassificationFieldDefinition,
ClassificationPolicy,
)
from app.schemas.event import MediaSourceInfo
from app.schemas.types import MediaSource, MediaType
from app.sdk.classification import (
MEDIA_SOURCE_CLASSIFICATION_PROTOCOL_VERSION,
classify_media,
)
from app.sdk.classification import (
ClassificationEnrichmentRequest as PublicClassificationEnrichmentRequest,
)
from app.sdk.classification import (
ClassificationEnrichmentResponse as PublicClassificationEnrichmentResponse,
)
from app.sdk.classification import (
ClassificationFieldDefinition as PublicClassificationFieldDefinition,
)
from app.sdk.classification import (
MediaSourceInfo as PublicMediaSourceInfo,
)
PLUGIN_ID = "classificationexample"
OTHER_PLUGIN_ID = "classificationother"
SOURCE_ID = "example.source"
FIELD_PREFIX = f"extensions.{SOURCE_ID}."
class _Runtime:
"""
向分类执行服务提供固定活动策略和空 legacy 配置。
"""
def __init__(self, policy: ClassificationPolicy) -> None:
"""
保存与调用方隔离的活动策略。
"""
self._policy = policy.model_copy(deep=True)
def active_policy(self) -> ClassificationPolicy:
"""
返回活动策略的深拷贝。
"""
return self._policy.model_copy(deep=True)
@staticmethod
def legacy_config() -> CategoryConfig:
"""
返回不包含旧分类规则的配置。
"""
return CategoryConfig()
def _field(
local_id: str,
*,
value_type: str = "string",
operators: list[str] | None = None,
media_types: list[str] | None = None,
options: list[object] | None = None,
allow_custom_values: bool = True,
source_id: str = SOURCE_ID,
) -> dict[str, object]:
"""
构造一个插件来源扩展字段声明。
"""
default_operators = {
"string": ["equals", "exists"],
"enum": ["equals", "in"],
"integer": ["equals", "gte"],
"number": ["equals", "gte"],
"year": ["equals", "between"],
"string_list": ["contains_any", "contains_all"],
"boolean": ["is_true", "is_false"],
}
return {
"id": f"extensions.{source_id}.{local_id}",
"label": local_id.replace("_", " "),
"value_type": value_type,
"operators": operators or default_operators[value_type],
"media_types": media_types or [],
"options": options or [],
"allow_custom_values": allow_custom_values,
}
def _source(
*,
source_id: str = SOURCE_ID,
media_types: list[str] | None = None,
fields: list[dict[str, object]] | None = None,
) -> dict[str, object]:
"""
构造一个插件媒体来源声明。
"""
return {
"name": "Example Source",
"media_source": source_id,
"media_types": media_types or ["电影"],
"classification_fields": fields or [_field("region_group")],
}
def _registry() -> PluginClassificationRegistry:
"""
构造使用隔离日志桩的插件分类注册表。
"""
return PluginClassificationRegistry(Mock())
def test_public_sdk_exposes_stable_protocol_version_and_schema_types() -> None:
"""
公开 SDK v2 应保留来源声明并增加富化请求响应模型。
"""
assert MEDIA_SOURCE_CLASSIFICATION_PROTOCOL_VERSION == 2
assert PublicClassificationFieldDefinition is ClassificationFieldDefinition
assert PublicMediaSourceInfo is MediaSourceInfo
assert PublicClassificationEnrichmentRequest is ClassificationEnrichmentRequest
assert PublicClassificationEnrichmentResponse is ClassificationEnrichmentResponse
def test_public_sdk_classify_media_returns_copy_before_runtime_composition(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""
宿主尚未装配时公开分类调用应返回隔离副本,而不是修改插件对象。
"""
media = MediaInfo(
media_source=MediaSource(SOURCE_ID),
media_id="native-1",
type=MediaType.MOVIE,
title="Example",
)
def unavailable_context() -> object:
"""
模拟插件在 lifespan 完成装配前调用 SDK。
"""
raise RuntimeError("runtime unavailable")
monkeypatch.setattr(
"app.sdk.classification.get_chain_runtime_context",
unavailable_context,
)
classified = classify_media(media)
assert classified is not media
assert classified.title == media.title
classified.title = "Changed"
assert media.title == "Example"
def test_public_sdk_classify_media_uses_composed_service(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""
宿主完成装配后公开分类调用应委托唯一分类执行服务。
"""
media = MediaInfo(
media_source=MediaSource(SOURCE_ID),
media_id="native-1",
type=MediaType.MOVIE,
title="Example",
)
classified = deepcopy(media)
classified.set_library_category("电影/扩展")
service = Mock()
service.finalize.return_value = classified
monkeypatch.setattr(
"app.sdk.classification.get_chain_runtime_context",
lambda: SimpleNamespace(classification_service=service),
)
result = classify_media(media)
assert result is classified
service.finalize.assert_called_once_with(media, refresh=True)
def _all_value_type_source() -> dict[str, object]:
"""
构造覆盖全部扩展事实值类型的有效来源声明。
"""
return _source(
fields=[
_field("region_group"),
_field(
"release_status",
value_type="enum",
options=["official", "bootleg"],
allow_custom_values=False,
),
_field(
"distribution_tier",
value_type="enum",
options=[1, 2],
allow_custom_values=False,
),
_field("rank", value_type="integer"),
_field("score", value_type="number"),
_field("release_year", value_type="year"),
_field("tags", value_type="string_list"),
_field("verified", value_type="boolean"),
]
)
def _media(
facts: dict[str, object],
*,
media_type: MediaType = MediaType.MOVIE,
) -> MediaInfo:
"""
构造携带完整扩展字段路径和稳定来源身份的媒体对象。
"""
return MediaInfo(
media_source=MediaSource(SOURCE_ID),
media_id="native-1",
type=media_type,
title="Example",
classification_facts=facts,
)
def _extension_policy() -> ClassificationPolicy:
"""
构造由插件扩展字段命中分类、否则回退的活动策略。
"""
return ClassificationPolicy.model_validate(
{
"revision": 7,
"categories": [
{
"id": "movie.extension",
"media_type": "电影",
"name": "扩展命中",
"path": ["扩展命中"],
},
{
"id": "movie.fallback",
"media_type": "电影",
"name": "电影兜底",
"path": ["电影兜底"],
},
],
"rules": [
{
"id": "rule.extension.region",
"name": "插件地区组",
"kind": "category",
"media_types": ["电影"],
"sources": [SOURCE_ID],
"when": {
"all": [
{
"field": f"{FIELD_PREFIX}region_group",
"operator": "equals",
"value": "east-asia",
}
]
},
"target": {"category_id": "movie.extension"},
}
],
"fallbacks": {"电影": "movie.fallback"},
}
)
def test_registry_accepts_valid_declaration_and_returns_deep_copies() -> None:
"""
有效声明应被规范化,输入和返回快照均不能污染注册表。
"""
registry = _registry()
declaration = _all_value_type_source()
registry.replace(PLUGIN_ID, [declaration])
declaration["name"] = "Mutated Source"
declaration_fields = declaration["classification_fields"]
assert isinstance(declaration_fields, list)
declaration_fields[0]["label"] = "Mutated Field"
first_sources = registry.sources()
first_fields = registry.fields()
first_sources[0]["name"] = "Changed Snapshot"
first_fields[0].label = "Changed Snapshot"
current_source = registry.sources()[0]
current_fields = registry.fields()
assert current_source["name"] == "Example Source"
assert current_source["plugin_id"] == PLUGIN_ID
assert current_fields[0].label == "region group"
assert current_fields[0].media_types == ["电影"]
assert current_fields[0].source_support == {SOURCE_ID: "extension"}
def test_registry_rejects_builtin_duplicate_and_cross_plugin_sources() -> None:
"""
插件不得覆盖内置来源、重复声明来源或占用其他插件的来源。
"""
registry = _registry()
with pytest.raises(PluginClassificationDeclarationError, match="内置媒体来源"):
registry.replace(PLUGIN_ID, [_source(source_id="themoviedb")])
with pytest.raises(PluginClassificationDeclarationError, match="重复声明"):
registry.replace(PLUGIN_ID, [_source(), _source()])
registry.replace(PLUGIN_ID, [_source()])
with pytest.raises(PluginClassificationDeclarationError, match="已由插件"):
registry.replace(OTHER_PLUGIN_ID, [_source()])
assert registry.sources(PLUGIN_ID)[0]["media_source"] == SOURCE_ID
assert registry.sources(OTHER_PLUGIN_ID) == []
@pytest.mark.parametrize(
"media_source",
["bilibili", "mangguodiscover", "migu", "tencentvideodiscover", "iqiyidiscover"],
)
def test_plugin_owned_sources_follow_registration_lifecycle(
media_source: str,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""
无内置模块的来源由插件注册,API 和分类字段随插件启停增删。
"""
from app.api.endpoints.media import _registered_media_sources
from app.application.classification.catalog import build_classification_field_catalog
from app.domain.classification.sources import BUILTIN_CLASSIFICATION_SOURCES
registry = _registry()
monkeypatch.setattr(
"app.application.plugin.runtime.get_plugin_manager",
lambda: SimpleNamespace(get_media_sources=registry.sources),
)
source_id = media_source
field_id = f"extensions.{source_id}.region_group"
baseline_sources = _registered_media_sources()
assert {source.media_source.value for source in baseline_sources} == set(
BUILTIN_CLASSIFICATION_SOURCES
)
assert media_source not in {source.media_source for source in baseline_sources}
assert all(
source_id not in field.source_support
for field in build_classification_field_catalog()
)
registry.replace(PLUGIN_ID, [_source(
source_id=source_id,
fields=[_field("region_group", source_id=source_id)],
)])
sources = _registered_media_sources()
assert [source for source in sources if source.media_source == media_source] == [
MediaSourceInfo.model_validate(registry.sources()[0])
]
fields = {field.id: field for field in build_classification_field_catalog(registry.fields())}
assert fields[field_id].source_support == {source_id: "extension"}
media = MediaInfo(
media_source=media_source,
media_id="native-1",
type=MediaType.MOVIE,
classification_facts={field_id: "亚洲"},
)
assert registry.facts(media) == {source_id: {"region_group": "亚洲"}}
registry.remove(PLUGIN_ID)
assert _registered_media_sources() == baseline_sources
assert field_id not in {
field.id for field in build_classification_field_catalog(registry.fields())
}
@pytest.mark.parametrize(
("field", "error_text"),
[
(_field("region_group", source_id="other.source"), "命名空间"),
(
_field("rank", value_type="integer", operators=["contains"]),
"不兼容的操作符",
),
(_field("music_only", media_types=["音乐"]), "媒体类型超出"),
],
)
def test_registry_rejects_invalid_namespace_operator_and_media_type(
field: dict[str, object],
error_text: str,
) -> None:
"""
字段命名空间、操作符和值适用媒体类型必须受来源声明约束。
"""
registry = _registry()
with pytest.raises(PluginClassificationDeclarationError, match=error_text):
registry.replace(PLUGIN_ID, [_source(fields=[field])])
assert registry.sources() == []
def test_registry_replacement_is_atomic_and_remove_is_idempotent() -> None:
"""
无效替换保留旧快照,有效替换完整覆盖,移除可重复调用。
"""
registry = _registry()
registry.replace(PLUGIN_ID, [_source()])
invalid = _source(fields=[_field("rank", value_type="integer", operators=["contains"])])
with pytest.raises(PluginClassificationDeclarationError):
registry.replace(PLUGIN_ID, [invalid])
assert [field.id for field in registry.fields()] == [f"{FIELD_PREFIX}region_group"]
registry.replace(PLUGIN_ID, [_source(fields=[_field("replacement")])])
assert [field.id for field in registry.fields()] == [f"{FIELD_PREFIX}replacement"]
registry.remove(PLUGIN_ID)
registry.remove(PLUGIN_ID)
assert registry.sources() == []
assert registry.fields() == ()
def test_registry_accepts_complete_extension_paths_for_all_value_types() -> None:
"""
全部协议值类型应从完整字段路径转换为来源内局部事实。
"""
registry = _registry()
registry.replace(PLUGIN_ID, [_all_value_type_source()])
media = _media(
{
f"{FIELD_PREFIX}region_group": "east-asia",
f"{FIELD_PREFIX}release_status": "official",
f"{FIELD_PREFIX}distribution_tier": 2,
f"{FIELD_PREFIX}rank": 3,
f"{FIELD_PREFIX}score": 8.5,
f"{FIELD_PREFIX}release_year": 2026,
f"{FIELD_PREFIX}tags": ["anime", "movie"],
f"{FIELD_PREFIX}verified": True,
}
)
accepted = registry.facts(media)
facts = build_classification_facts(media, extensions=accepted)
assert accepted == {
SOURCE_ID: {
"region_group": "east-asia",
"release_status": "official",
"distribution_tier": 2,
"rank": 3,
"score": 8.5,
"release_year": 2026,
"tags": ["anime", "movie"],
"verified": True,
}
}
assert facts.identity.media_source == SOURCE_ID
assert facts.identity.media_id == "native-1"
assert media.media_source == MediaSource(SOURCE_ID)
assert media.media_id == "native-1"
@pytest.mark.parametrize("invalid_value", [float("inf"), float("-inf"), float("nan")])
def test_registry_rejects_non_finite_json_numbers(invalid_value: float) -> None:
"""
扩展事实不得接受 JSON 无法稳定表达的非有限数值。
"""
registry = _registry()
registry.replace(PLUGIN_ID, [_all_value_type_source()])
accepted = registry.facts(_media({f"{FIELD_PREFIX}score": invalid_value}))
assert accepted == {}
@pytest.mark.parametrize(
("facts", "media_type", "expected"),
[
(
{
f"{FIELD_PREFIX}region_group": "east-asia",
f"{FIELD_PREFIX}unknown": "ignored",
},
MediaType.MOVIE,
{SOURCE_ID: {"region_group": "east-asia"}},
),
(
{
f"{FIELD_PREFIX}region_group": "east-asia",
"extensions.other.source.region_group": "ignored",
},
MediaType.MOVIE,
{SOURCE_ID: {"region_group": "east-asia"}},
),
(
{f"{FIELD_PREFIX}region_group": ["wrong"]},
MediaType.MOVIE,
{},
),
(
{f"{FIELD_PREFIX}region_group": "east-asia"},
MediaType.TV,
{},
),
],
)
def test_registry_ignores_unregistered_cross_source_and_type_mismatches(
facts: dict[str, object],
media_type: MediaType,
expected: dict[str, dict[str, object]],
) -> None:
"""
无效事实按字段忽略,媒体类型不符时拒绝整批且不改写身份。
"""
registry = _registry()
registry.replace(PLUGIN_ID, [_source()])
media = _media(facts, media_type=media_type)
accepted = registry.facts(media)
projected = build_classification_facts(media, extensions=accepted)
assert accepted == expected
assert projected.identity.media_source == SOURCE_ID
assert projected.identity.media_id == "native-1"
assert media.media_source == MediaSource(SOURCE_ID)
assert media.media_id == "native-1"
def test_configuration_service_reads_dynamic_fields_without_caching() -> None:
"""
动态 provider 的新增和撤销必须立即反映到配置服务字段目录。
"""
registry = _registry()
service = ClassificationPolicyConfigurationService(
Mock(),
extra_fields_provider=registry.fields,
)
assert service.extra_fields() == ()
registry.replace(PLUGIN_ID, [_source()])
assert [field.id for field in service.extra_fields()] == [f"{FIELD_PREFIX}region_group"]
registry.remove(PLUGIN_ID)
assert service.extra_fields() == ()
def test_policy_validation_immediately_rejects_removed_plugin_field() -> None:
"""
插件撤销后保留策略内容,但再次校验必须把旧扩展字段标记为不可用。
"""
registry = _registry()
registry.replace(PLUGIN_ID, [_source()])
service = ClassificationPolicyConfigurationService(
Mock(),
extra_fields_provider=registry.fields,
)
policy = _extension_policy()
before = service.validate(policy)
assert not any(issue.code == "unknown_field" for issue in before.issues)
registry.remove(PLUGIN_ID)
validation = service.validate(policy)
assert any(issue.code == "unknown_field" for issue in validation.issues)
def test_execution_service_uses_registry_provider_and_revokes_old_facts() -> None:
"""
执行服务只使用当前登记事实,来源撤销后同一旧对象必须回退。
"""
registry = _registry()
registry.replace(PLUGIN_ID, [_source()])
service = ClassificationExecutionService(
_Runtime(_extension_policy()),
extension_facts_provider=registry.facts,
)
media = _media({f"{FIELD_PREFIX}region_group": "east-asia"})
classified = service.finalize(media)
registry.remove(PLUGIN_ID)
revoked = service.finalize(media)
assert classified.media_source == MediaSource(SOURCE_ID)
assert classified.media_id == "native-1"
assert classified.library_category == "扩展命中"
assert classified.classification is not None
assert classified.classification.effective.category_id == "movie.extension"
assert revoked.media_source == MediaSource(SOURCE_ID)
assert revoked.media_id == "native-1"
assert revoked.library_category == "电影兜底"
assert revoked.classification is not None
assert revoked.classification.effective.category_id == "movie.fallback"
assert media.library_category == ""
def test_extension_facts_survive_cache_roundtrip_with_identical_result() -> None:
"""
插件事实经媒体缓存字典往返后应得到相同分类且保持来源身份。
"""
registry = _registry()
registry.replace(PLUGIN_ID, [_source()])
service = ClassificationExecutionService(
_Runtime(_extension_policy()),
extension_facts_provider=registry.facts,
)
source = _media({f"{FIELD_PREFIX}region_group": "east-asia"})
restored = MediaInfo()
restored.from_dict(source.to_dict())
direct = service.finalize(source)
cached = service.finalize(restored)
assert cached.classification == direct.classification
assert cached.library_category == direct.library_category == "扩展命中"
assert cached.classification_facts == source.classification_facts
assert cached.media_source == source.media_source == MediaSource(SOURCE_ID)
assert cached.media_id == source.media_id == "native-1"
def test_execution_service_isolates_extension_provider_failure() -> None:
"""
扩展事实端口异常不能导致识别失败,结果按缺失字段使用策略兜底。
"""
def fail_provider(_media):
"""
模拟插件运行时在事实读取期间失效。
"""
raise RuntimeError("registry unavailable")
service = ClassificationExecutionService(
_Runtime(_extension_policy()),
extension_facts_provider=fail_provider,
)
media = _media({f"{FIELD_PREFIX}region_group": "east-asia"})
classified = service.finalize(media)
assert classified.media_source == MediaSource(SOURCE_ID)
assert classified.media_id == "native-1"
assert classified.library_category == "电影兜底"