mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-08-29 03:56:43 +08:00
226 lines
7.4 KiB
Python
226 lines
7.4 KiB
Python
"""插件能力投影的隔离契约测试。"""
|
||
|
||
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():
|
||
"""命令补 pid,API 补宿主路径与默认认证方式。"""
|
||
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",
|
||
}]
|