Files
MoviePilot/tests/test_plugin_projection.py
T

226 lines
7.4 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 types import SimpleNamespace
from app.runtime.extensions.plugin.projection import PluginProjection
class _Plugin(SimpleNamespace):
"""提供可配置插件 hook 的最小运行态插件替身。"""
def __init__(self, enabled=True, **hooks):
"""保存启用状态、插件名称和 hook 实现。"""
super().__init__(plugin_name=hooks.pop("plugin_name", "测试插件"), **hooks)
self._enabled = enabled
def get_state(self):
"""返回预设启用状态。"""
return self._enabled
def get_name(self):
"""返回插件展示名称。"""
return self.plugin_name
def test_projection_preserves_commands_and_api_adaptation():
"""命令补 pidAPI 补宿主路径与默认认证方式。"""
command = {"cmd": "/demo"}
api = {"path": "/items", "endpoint": object()}
plugin = _Plugin(
get_command=lambda: [command],
get_api=lambda: [api],
)
projection = PluginProjection({"Demo": plugin})
assert projection.commands() == [{"cmd": "/demo", "pid": "Demo"}]
assert projection.apis() == [{
"path": "/Demo/items",
"endpoint": api["endpoint"],
"auth": "apikey",
}]
assert api == {"path": "/items", "endpoint": api["endpoint"]}
def test_projection_filters_disabled_stateful_hooks_but_keeps_api_contract():
"""禁用插件不暴露命令/服务/模块/动作,API 保持历史上的独立注册语义。"""
plugin = _Plugin(
enabled=False,
get_command=lambda: [{"cmd": "/demo"}],
get_api=lambda: [{"path": "/items"}],
get_service=lambda: [{"id": "job"}],
get_module=lambda: {"recognize": object()},
get_actions=lambda: [{"id": "action"}],
)
projection = PluginProjection({"Demo": plugin})
assert projection.commands() == []
assert projection.services() == []
assert projection.modules() == {}
assert projection.actions() == []
assert projection.apis() == [{"path": "/Demo/items", "auth": "apikey"}]
def test_projection_preserves_services_modules_actions_and_pid_filter():
"""指定 pid 时只投影目标插件,并保持各 hook 的原始结构。"""
demo = _Plugin(
get_service=lambda: [{"id": "job"}],
get_module=lambda: {"recognize": "handler"},
get_actions=lambda: [{"id": "action"}],
)
other = _Plugin(get_service=lambda: [{"id": "other"}])
projection = PluginProjection({"Demo": demo, "Other": other})
assert projection.services("Demo") == [{"id": "job"}]
assert projection.modules("Demo") == {
("Demo", "测试插件"): {"recognize": "handler"}
}
assert projection.actions("Demo") == [{
"plugin_id": "Demo",
"plugin_name": "测试插件",
"actions": [{"id": "action"}],
}]
def test_projection_modules_skips_none_and_non_mapping_declarations():
"""get_module 未返回映射的插件被跳过并记日志,不污染整体模块投影。"""
errors = []
log = SimpleNamespace(error=lambda message: errors.append(message))
projection = PluginProjection(
{
"NoDecl": _Plugin(get_module=lambda: None),
"ListDecl": _Plugin(get_module=lambda: ["not-a-mapping"]),
"Healthy": _Plugin(get_module=lambda: {"recognize": "handler"}),
},
log=log,
)
assert projection.modules() == {("Healthy", "测试插件"): {"recognize": "handler"}}
assert any("ListDecl" in message for message in errors)
def test_projection_collects_enabled_media_source_declarations():
"""只投影启用插件的媒体来源声明,并附带插件 ID 便于诊断。"""
demo = _Plugin(
get_media_source=lambda: [{
"name": "Acme Video",
"media_source": "acme.video",
"media_types": ["电影", "电视剧"],
}],
)
disabled = _Plugin(
enabled=False,
get_media_source=lambda: [{"name": "Disabled", "media_source": "disabled"}],
)
projection = PluginProjection({"Demo": demo, "Disabled": disabled})
assert projection.media_sources() == [{
"name": "Acme Video",
"media_source": "acme.video",
"media_types": ["电影", "电视剧"],
"plugin_id": "Demo",
}]
def test_projection_isolates_one_plugin_hook_failure():
"""单个插件 hook 失败只记日志,不阻断其他插件投影。"""
errors = []
log = SimpleNamespace(error=lambda message: errors.append(message))
def fail():
"""模拟插件 hook 抛出异常。"""
raise RuntimeError("broken")
projection = PluginProjection(
{
"Broken": _Plugin(get_service=fail),
"Healthy": _Plugin(get_service=lambda: [{"id": "healthy"}]),
},
log=log,
)
assert projection.services() == [{"id": "healthy"}]
assert errors and "Broken" in errors[0]
def test_projection_builds_federation_and_auth_provider_entries():
"""联邦远程入口和插件认证入口保持既有字段与默认值。"""
plugin = _Plugin(
get_render_mode=lambda: ("vue", "dist/assets"),
get_auth_providers=lambda: [{"id": "demo-login"}],
)
projection = PluginProjection(
{"Demo": plugin},
remote_entry_factory=lambda plugin_id, path: f"/{plugin_id}/{path}",
)
assert projection.remotes() == [{
"id": "Demo",
"url": "/Demo/dist/assets",
"name": "测试插件",
}]
assert projection.auth_providers() == [{
"id": "demo-login",
"type": "plugin",
"plugin_id": "Demo",
"name": "测试插件",
"enabled": True,
"component": "AuthPage",
"remote": {
"id": "Demo",
"url": "/Demo/dist/assets",
"name": "测试插件",
},
}]
def test_projection_exposes_source_identity_for_virtual_frontend_instance():
"""虚拟实例的联邦入口保留实例 URL,并补充共享源码身份。"""
plugin = _Plugin(
plugin_source_id="Demo",
get_render_mode=lambda: ("vue", "dist/assets"),
get_auth_providers=lambda: [{"id": "demo-login"}],
)
projection = PluginProjection(
{"DemoWork": plugin},
remote_entry_factory=lambda plugin_id, path: f"/{plugin_id}/{path}",
)
assert projection.remotes()[0] == {
"id": "DemoWork",
"url": "/DemoWork/dist/assets",
"name": "测试插件",
"source_plugin_id": "Demo",
}
assert projection.auth_providers()[0]["remote"]["source_plugin_id"] == "Demo"
def test_projection_normalizes_sidebar_and_dashboard_metadata():
"""侧栏和仪表板元数据在投影层完成校验、排序与兼容默认值。"""
plugin = _Plugin(
get_render_mode=lambda: ("vue", "dist"),
get_sidebar_nav=lambda: [{
"key": "settings",
"section": "invalid",
"permission": "invalid",
"order": "3",
}],
get_dashboard=lambda: ({}, {}, []),
get_dashboard_meta=lambda: [{"name": "状态", "key": "status"}],
)
projection = PluginProjection({"Demo": plugin})
assert projection.sidebar() == [{
"plugin_id": "Demo",
"nav_key": "settings",
"title": "测试插件",
"icon": "mdi-puzzle",
"section": "system",
"permission": None,
"order": 3,
}]
assert projection.dashboard_metadata() == [{
"id": "Demo",
"name": "状态",
"key": "status",
}]