mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-08-29 03:56:43 +08:00
- 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 清单校验排除下划线内部目录
198 lines
6.8 KiB
Python
198 lines
6.8 KiB
Python
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)
|