Files
MoviePilot/tests/test_capability_registry.py
jxxghp 7e851dbfa7 refactor(chain): 处理链功能域 mixin 化,清理未使用导入并根治兼容层循环导入
- ChainBase 拆分为 RecognitionMixin/MessageProcessingMixin/NotificationMixin
- TransferChain 拆分为 7 个功能 mixin(_mixins.py),SubscribeChain 音乐订阅域拆出 _music.py
- 斜杠命令交互四件套收敛为 InteractionChainMixin 委托,会话管理器移至 application 层,chain 层不再 re-export
- 模块基础类收敛到 app/modules/_base(notification/mediaserver 语义重命名)
- 清理 app/chain/__init__.py 24 个未使用导入,修正 49 处测试 patch 目标到实际命名空间
- 兼容层 legacy 符号不再并入 __all__,根治 schemas 初始化反向拉起 application.transfer 的循环导入
- 修复 bangumi 集数为字符串时 set_bangumi_info 抛 TypeError
- 新增重复代码等架构门禁测试;capability 清单校验排除下划线内部目录
2026-08-16 16:30:16 +08:00

198 lines
6.8 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.

from __future__ import annotations
import sys
from pathlib import Path
import pytest
from app.runtime.capabilities.errors import CapabilityManifestError
from app.runtime.capabilities.model import (
ActivationPolicy,
SelectorSchema,
)
from app.runtime.capabilities.registry import CapabilityRegistry
_BASE_MANIFEST = """
schema_version = 1
id = "sample.capability"
kind = "sample"
entrypoint = "sample_implementation:SampleCapability"
depends_on = []
[metadata]
name = "Sample capability"
priority = 10
[activation]
policy = "when_configured"
watch = ["sample.config"]
[activation.selector]
kind = "configured"
key = "sample.config"
enabled = true
"""
def _write_manifest(root: Path, content: str = _BASE_MANIFEST, name: str = "sample") -> Path:
manifest_dir = root / name
manifest_dir.mkdir(parents=True)
manifest_path = manifest_dir / "capability.toml"
manifest_path.write_text(content.strip() + "\n", encoding="utf-8")
return manifest_path
def _discover(root: Path) -> CapabilityRegistry:
return CapabilityRegistry.discover(
roots=[root],
kinds={"sample"},
selector_schemas={
"configured": SelectorSchema(
required_fields=frozenset({"key", "enabled"}),
)
},
)
def test_discovery_reads_toml_without_importing_entrypoint(tmp_path: Path) -> None:
"""能力发现只能读取声明,不能执行 entrypoint 对应的 Python 模块。"""
_write_manifest(tmp_path)
(tmp_path / "sample_implementation.py").write_text(
"raise AssertionError('entrypoint must not be imported during discovery')\n",
encoding="utf-8",
)
sys.modules.pop("sample_implementation", None)
registry = _discover(tmp_path)
spec = registry.get_spec("sample.capability")
assert spec is not None
assert spec.activation is ActivationPolicy.WHEN_CONFIGURED
assert spec.selector is not None
assert spec.selector.kind == "configured"
assert spec.selector.config == {"key": "sample.config", "enabled": True}
assert spec.watch == ("sample.config",)
assert spec.depends_on == ()
assert "sample_implementation" not in sys.modules
def test_discovered_specs_are_recursively_immutable(tmp_path: Path) -> None:
"""Registry 暴露的声明及嵌套 metadata/selector 都不能被调用方改写。"""
_write_manifest(tmp_path)
spec = _discover(tmp_path).require_spec("sample.capability")
with pytest.raises(TypeError):
spec.metadata["name"] = "changed"
with pytest.raises(TypeError):
spec.selector.config["key"] = "changed" # type: ignore[union-attr]
with pytest.raises(AttributeError):
spec.watch.append("changed") # type: ignore[attr-defined]
@pytest.mark.parametrize(
("replacement", "match"),
[
("schema_version = 1", "缺少字段"),
(_BASE_MANIFEST.replace("schema_version = 1", "schema_version = 2"), "schema_version"),
(_BASE_MANIFEST.replace("schema_version = 1", "schema_version = 1.0"), "schema_version"),
(_BASE_MANIFEST.replace('id = "sample.capability"', 'id = "bad id"'), "id"),
(_BASE_MANIFEST.replace('kind = "sample"', 'kind = "unknown"'), "kind"),
(
_BASE_MANIFEST.replace(
'entrypoint = "sample_implementation:SampleCapability"',
'entrypoint = "sample_implementation.SampleCapability"',
),
"entrypoint",
),
(_BASE_MANIFEST.replace('kind = "configured"', 'kind = "unknown"'), "selector"),
(_BASE_MANIFEST.replace('enabled = true', 'extra = true'), "selector"),
(_BASE_MANIFEST.replace("depends_on = []", 'depends_on = ["other"]'), "depends_on"),
(_BASE_MANIFEST.replace("watch =", "unknown_field = true\nwatch ="), "activation"),
],
)
def test_registry_fails_closed_for_invalid_manifest(
tmp_path: Path,
replacement: str,
match: str,
) -> None:
"""未知或不完整声明必须阻止 Registry 构建,不能静默丢失能力。"""
_write_manifest(tmp_path, replacement)
with pytest.raises(CapabilityManifestError, match=match):
_discover(tmp_path)
def test_selector_presence_must_match_activation_policy(tmp_path: Path) -> None:
"""只有 when_configured 声明可以且必须携带配置 selector。"""
bootstrap = _BASE_MANIFEST.replace(
'policy = "when_configured"', 'policy = "bootstrap"'
)
_write_manifest(tmp_path, bootstrap)
with pytest.raises(CapabilityManifestError, match="selector"):
_discover(tmp_path)
def test_registry_rejects_duplicate_ids_across_roots(tmp_path: Path) -> None:
"""多个声明根出现相同 capability ID 时必须 fail closed。"""
first_root = tmp_path / "first"
second_root = tmp_path / "second"
_write_manifest(first_root)
_write_manifest(second_root)
with pytest.raises(CapabilityManifestError, match="重复"):
CapabilityRegistry.discover(
roots=[first_root, second_root],
kinds={"sample"},
selector_schemas={
"configured": SelectorSchema(
required_fields=frozenset({"key", "enabled"}),
)
},
)
def test_registry_rejects_root_without_manifest(tmp_path: Path) -> None:
"""注册了声明根却没有任何 manifest 时应直接失败。"""
with pytest.raises(CapabilityManifestError, match="capability.toml"):
_discover(tmp_path)
def test_current_host_module_manifests_follow_the_strict_nested_schema() -> None:
"""仓内 Host Module 声明必须全部通过同一套嵌套 schema。"""
modules_root = Path(__file__).parents[1] / "app" / "modules"
imported_before = set(sys.modules)
registry = CapabilityRegistry.discover(
roots=[modules_root],
kinds={"host_module"},
selector_schemas={
"system_config_item": SelectorSchema(
required_fields=frozenset({
"key",
"match_field",
"match_value",
"enabled_field",
}),
),
"setting_truthy": SelectorSchema(
required_fields=frozenset({"key"}),
),
},
)
specs = registry.list_specs()
declared_directories = {spec.source.parent for spec in specs}
module_directories = {
path
for path in modules_root.iterdir()
if path.is_dir() and (path / "__init__.py").is_file()
# 下划线前缀目录是内部基础包(如 _base不是 host module不参与清单校验
and not path.name.startswith("_")
}
entrypoint_modules = {spec.entrypoint.split(":", maxsplit=1)[0] for spec in specs}
assert declared_directories == module_directories
assert all(spec.kind == "host_module" for spec in specs)
assert not ((set(sys.modules) - imported_before) & entrypoint_modules)