mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-08-29 03:56:43 +08:00
203 lines
7.4 KiB
Python
203 lines
7.4 KiB
Python
"""虚拟插件实例的持久化、加载和创建行为测试。"""
|
|
|
|
import sys
|
|
from types import ModuleType, SimpleNamespace
|
|
|
|
from app.runtime.extensions.plugin.clone import PluginCloneService
|
|
from app.runtime.extensions.plugin.loader import PluginLoader
|
|
from app.runtime.extensions.plugin.storage import PluginInstanceStore, PluginStorage
|
|
from app.schemas.plugin import PluginInstance, PluginRuntimeStatus
|
|
from app.schemas.types import SystemConfigKey
|
|
|
|
|
|
def _logger() -> SimpleNamespace:
|
|
"""提供加载器测试所需的最小日志对象。"""
|
|
return SimpleNamespace(
|
|
debug=lambda *_args: None,
|
|
info=lambda *_args: None,
|
|
warning=lambda *_args: None,
|
|
error=lambda *_args: None,
|
|
)
|
|
|
|
|
|
def test_instance_store_keeps_virtual_instances_out_of_installed_list():
|
|
"""虚拟实例使用独立配置键,不改写存量物理插件安装清单。"""
|
|
values = {SystemConfigKey.UserInstalledPlugins: ["DemoPlugin"]}
|
|
storage = PluginStorage(
|
|
read=values.get,
|
|
write=lambda key, value: values.__setitem__(key, value),
|
|
)
|
|
store = PluginInstanceStore(storage=lambda: storage)
|
|
|
|
instance = PluginInstance(
|
|
instance_id="DemoPluginWork",
|
|
source_plugin_id="DemoPlugin",
|
|
plugin_name="工作实例",
|
|
)
|
|
store.save(instance)
|
|
|
|
assert store.get("DemoPluginWork") == instance
|
|
assert store.for_source("DemoPlugin") == [instance]
|
|
assert values[SystemConfigKey.UserInstalledPlugins] == ["DemoPlugin"]
|
|
assert store.delete("DemoPluginWork") is True
|
|
assert store.all() == {}
|
|
|
|
|
|
def test_loader_executes_each_instance_in_an_isolated_module_namespace(
|
|
tmp_path,
|
|
monkeypatch,
|
|
):
|
|
"""两个实例共享磁盘源码,但模块全局状态、类身份和相对导入互相隔离。"""
|
|
source_dir = tmp_path / "demoplugin"
|
|
source_dir.mkdir()
|
|
(source_dir / "state.py").write_text("state = []\n", encoding="utf-8")
|
|
(source_dir / "__init__.py").write_text(
|
|
"from app.plugins.demoplugin.state import state\n"
|
|
"class DemoPlugin:\n"
|
|
" plugin_name = 'Demo'\n"
|
|
" plugin_desc = 'Source'\n"
|
|
" plugin_icon = 'source.svg'\n"
|
|
" plugin_config_prefix = 'demo_'\n"
|
|
" def init_plugin(self, _config):\n"
|
|
" state.append(self.__class__.__name__)\n",
|
|
encoding="utf-8",
|
|
)
|
|
loader = PluginLoader(
|
|
plugins_root=tmp_path,
|
|
import_preparer=lambda **_kwargs: None,
|
|
import_scanner=lambda **_kwargs: None,
|
|
log=_logger(),
|
|
)
|
|
validator = lambda candidate: hasattr(candidate, "init_plugin")
|
|
import app.plugins as plugin_package
|
|
|
|
source_module = ModuleType("app.plugins.demoplugin")
|
|
source_module.__path__ = [str(source_dir)]
|
|
monkeypatch.setitem(sys.modules, "app.plugins.demoplugin", source_module)
|
|
monkeypatch.setattr(plugin_package, "demoplugin", source_module, raising=False)
|
|
|
|
work_class = loader.load_instance(
|
|
PluginInstance(
|
|
instance_id="DemoPluginWork",
|
|
source_plugin_id="DemoPlugin",
|
|
plugin_name="工作实例",
|
|
),
|
|
validator,
|
|
)[0]
|
|
home_class = loader.load_instance(
|
|
PluginInstance(
|
|
instance_id="DemoPluginHome",
|
|
source_plugin_id="DemoPlugin",
|
|
plugin_desc="家庭实例",
|
|
),
|
|
validator,
|
|
)[0]
|
|
work_class().init_plugin({})
|
|
home_class().init_plugin({})
|
|
|
|
assert work_class.__name__ == "DemoPluginWork"
|
|
assert home_class.__name__ == "DemoPluginHome"
|
|
assert work_class.__qualname__ == "DemoPlugin"
|
|
assert work_class.__module__ == "app.plugins.demopluginwork"
|
|
assert home_class.__module__ == "app.plugins.demopluginhome"
|
|
assert work_class.plugin_source_id == "DemoPlugin"
|
|
assert work_class.plugin_name == "工作实例"
|
|
assert work_class.plugin_config_prefix == "demopluginwork_"
|
|
assert work_class.__dict__["init_plugin"].__globals__["state"] == [
|
|
"DemoPluginWork"
|
|
]
|
|
assert home_class.__dict__["init_plugin"].__globals__["state"] == [
|
|
"DemoPluginHome"
|
|
]
|
|
assert sys.modules["app.plugins.demoplugin"] is source_module
|
|
assert plugin_package.demoplugin is source_module
|
|
|
|
|
|
def test_clone_service_persists_descriptor_without_copying_source_package():
|
|
"""创建分身只写实例描述和隔离配置,并始终跟随源插件版本。"""
|
|
instances: dict[str, PluginInstance] = {}
|
|
configs = {"DemoPlugin": {"enable": True, "token": "secret"}}
|
|
reloaded: list[str] = []
|
|
removed: list[str] = []
|
|
|
|
class DemoPlugin:
|
|
"""提供创建用例需要的最小源插件类。"""
|
|
|
|
service = PluginCloneService(
|
|
plugin_class=lambda plugin_id: DemoPlugin if plugin_id == "DemoPlugin" else None,
|
|
plugin_exists=lambda plugin_id: plugin_id in instances,
|
|
source_plugin_id=lambda plugin_id: plugin_id,
|
|
save_instance=lambda instance: instances.__setitem__(
|
|
instance.instance_id,
|
|
instance,
|
|
),
|
|
delete_instance=lambda plugin_id: instances.pop(plugin_id, None) is not None,
|
|
read_config=lambda plugin_id: configs.get(plugin_id, {}),
|
|
save_config=lambda plugin_id, config: not configs.__setitem__(plugin_id, config),
|
|
delete_config=lambda plugin_id: configs.pop(plugin_id, None) is not None,
|
|
reload_plugin=lambda plugin_id: (
|
|
reloaded.append(plugin_id) or PluginRuntimeStatus.ACTIVE
|
|
),
|
|
remove_plugin=removed.append,
|
|
log=_logger(),
|
|
)
|
|
|
|
success, clone_id = service.clone(
|
|
plugin_id="DemoPlugin",
|
|
suffix="Work",
|
|
name="工作实例",
|
|
description="独立配置",
|
|
version="9.9.9",
|
|
)
|
|
|
|
assert success is True
|
|
assert clone_id == "DemoPluginwork"
|
|
assert instances[clone_id].source_plugin_id == "DemoPlugin"
|
|
assert configs[clone_id] == {
|
|
"enable": False,
|
|
"enabled": False,
|
|
"token": "secret",
|
|
}
|
|
assert reloaded == [clone_id]
|
|
assert removed == []
|
|
|
|
|
|
def test_clone_service_rolls_back_descriptor_and_config_after_load_failure():
|
|
"""实例首次加载失败时不留下不可见描述或孤立配置。"""
|
|
instances: dict[str, PluginInstance] = {}
|
|
configs = {"DemoPlugin": {"enabled": True}}
|
|
removed: list[str] = []
|
|
|
|
class DemoPlugin:
|
|
"""提供失败回滚用例需要的最小源插件类。"""
|
|
|
|
service = PluginCloneService(
|
|
plugin_class=lambda _plugin_id: DemoPlugin,
|
|
plugin_exists=lambda plugin_id: plugin_id in instances,
|
|
source_plugin_id=lambda plugin_id: plugin_id,
|
|
save_instance=lambda instance: instances.__setitem__(
|
|
instance.instance_id,
|
|
instance,
|
|
),
|
|
delete_instance=lambda plugin_id: instances.pop(plugin_id, None) is not None,
|
|
read_config=lambda plugin_id: configs.get(plugin_id, {}),
|
|
save_config=lambda plugin_id, config: not configs.__setitem__(plugin_id, config),
|
|
delete_config=lambda plugin_id: configs.pop(plugin_id, None) is not None,
|
|
reload_plugin=lambda _plugin_id: PluginRuntimeStatus.LOAD_FAILED,
|
|
remove_plugin=removed.append,
|
|
log=_logger(),
|
|
)
|
|
|
|
success, message = service.clone(
|
|
plugin_id="DemoPlugin",
|
|
suffix="Broken",
|
|
name="失败实例",
|
|
description="",
|
|
)
|
|
|
|
assert success is False
|
|
assert "加载失败" in message
|
|
assert instances == {}
|
|
assert configs == {"DemoPlugin": {"enabled": True}}
|
|
assert removed == ["DemoPluginbroken"]
|