Files
MoviePilot/tests/test_plugin_catalog_runtime.py
Aqr-K d44480d739 Merge pull request #6666 from Aqr-K/feat/plugin-instance-target-enablement
feat(plugin): 插件实例默认调用目标与启停,未指定实例时不再兜底取第一个
2026-09-13 16:37:27 +08:00

279 lines
9.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
import pytest
from app.runtime import log as log_module
from app.runtime.extensions.plugin.catalog import PluginCatalogFacade
from app.runtime.log import set_plugin_instance_log_level
from app.schemas.plugin import PluginInstance, PluginRuntimeStatus
from app.schemas.types import SystemConfigKey
@pytest.fixture(name="_isolated_log_overrides")
def fixture_isolated_log_overrides(monkeypatch):
"""隔离进程内日志等级覆盖缓存,避免与其他用例的实例 ID 相互污染。"""
monkeypatch.setattr(log_module, "_plugin_level_overrides", {})
def _facade(**overrides):
"""按测试所需覆盖最小可用回调集合,构造一个 PluginCatalogFacade。"""
defaults = dict(
classes=lambda: {},
running=lambda: {},
storage=lambda: SimpleNamespace(
read=lambda _key: None,
write=lambda _key, _value: None,
),
system=lambda: SimpleNamespace(),
market_catalog=lambda: None,
market_loader=lambda *_args, **_kwargs: [],
async_market_loader=lambda *_args, **_kwargs: [],
map_plugin=lambda **_kwargs: None,
auth_checker=lambda **_kwargs: True,
plugin_attr=lambda _plugin_id, _attr: None,
plugin_instance=lambda _plugin_id: None,
plugin_instances=lambda: {},
host_instances=lambda: {},
runtime_status=lambda _plugin_id: None,
log=SimpleNamespace(error=lambda *_args: None, info=lambda *_args: None),
)
defaults.update(overrides)
return PluginCatalogFacade(**defaults)
@pytest.mark.asyncio
async def test_async_online_normalizes_market_configuration(monkeypatch) -> None:
"""目录入口与候选库存使用相同的规范化市场列表。"""
from app.runtime.extensions.plugin import catalog as catalog_module
requested_markets: list[str] = []
class FakeMarketCatalog:
async def async_collect(self, **kwargs):
requested_markets.extend(kwargs["markets"])
return []
monkeypatch.setattr(
catalog_module,
"get_runtime_setting",
lambda key: {
"PLUGIN_MARKET": (
" https://github.com/Example/Plugins.git/ ,"
"https://github.com/example/plugins"
),
"VERSION_FLAG": "v3",
}[key],
)
facade = PluginCatalogFacade(
classes=lambda: {},
running=lambda: {},
storage=lambda: SimpleNamespace(read=lambda _key: []),
system=lambda: SimpleNamespace(
compatible_flags=lambda _flag: [],
),
market_catalog=lambda: FakeMarketCatalog(),
market_loader=lambda *_args, **_kwargs: [],
async_market_loader=lambda *_args, **_kwargs: [],
map_plugin=lambda **_kwargs: None,
auth_checker=lambda **_kwargs: True,
plugin_attr=lambda _plugin_id, _attr: None,
plugin_instance=lambda _plugin_id: None,
plugin_instances=lambda: {},
host_instances=lambda: {},
runtime_status=lambda _plugin_id: None,
log=SimpleNamespace(info=lambda *_args: None),
)
assert await facade.async_online() == []
assert requested_markets == ["https://github.com/Example/Plugins"]
def test_installed_catalog_keeps_plugins_that_are_not_loaded():
"""已安装清单中的插件即使缺依赖或源码也必须保留可观察卡片。"""
class ActivePlugin:
plugin_name = "已运行插件"
plugin_version = "1.0.0"
plugin_order = 0
active_instance = SimpleNamespace(get_state=lambda: True)
statuses = {
"ActivePlugin": PluginRuntimeStatus.ACTIVE,
"DependencyPending": PluginRuntimeStatus.DEPENDENCY_PENDING,
"SourceMissing": PluginRuntimeStatus.SOURCE_MISSING,
}
facade = PluginCatalogFacade(
classes=lambda: {"ActivePlugin": ActivePlugin},
running=lambda: {"ActivePlugin": active_instance},
storage=lambda: SimpleNamespace(
read=lambda key: [
"ActivePlugin",
"DependencyPending",
"SourceMissing",
] if key is SystemConfigKey.UserInstalledPlugins else None,
),
system=lambda: SimpleNamespace(),
market_catalog=lambda: None,
market_loader=lambda *_args, **_kwargs: [],
async_market_loader=lambda *_args, **_kwargs: [],
map_plugin=lambda **_kwargs: None,
auth_checker=lambda **_kwargs: True,
plugin_attr=lambda _plugin_id, _attr: None,
plugin_instance=lambda _plugin_id: None,
plugin_instances=lambda: {},
host_instances=lambda: {},
runtime_status=statuses.get,
log=SimpleNamespace(error=lambda *_args: None, info=lambda *_args: None),
)
plugins = facade.installed()
assert [plugin.id for plugin in plugins] == [
"ActivePlugin",
"DependencyPending",
"SourceMissing",
]
assert [plugin.runtime_status for plugin in plugins] == [
PluginRuntimeStatus.ACTIVE,
PluginRuntimeStatus.DEPENDENCY_PENDING,
PluginRuntimeStatus.SOURCE_MISSING,
]
assert plugins[1].plugin_name == "DependencyPending"
assert plugins[2].installed is True
def test_local_repository_failure_does_not_break_catalog_projection():
"""本地仓索引读取失败只影响本地候选展示,不能拖垮整个插件目录。"""
warnings: list[str] = []
def local_candidates():
raise RuntimeError("invalid local package")
facade = PluginCatalogFacade(
classes=lambda: {},
running=lambda: {},
storage=lambda: SimpleNamespace(read=lambda _key: []),
system=lambda: SimpleNamespace(
local_candidates=local_candidates,
),
market_catalog=lambda: None,
market_loader=lambda *_args, **_kwargs: [],
async_market_loader=lambda *_args, **_kwargs: [],
map_plugin=lambda **_kwargs: None,
auth_checker=lambda **_kwargs: True,
plugin_attr=lambda _plugin_id, _attr: None,
plugin_instance=lambda _plugin_id: None,
plugin_instances=lambda: {},
host_instances=lambda: {},
runtime_status=lambda _plugin_id: None,
log=SimpleNamespace(
error=lambda *_args: None,
info=lambda *_args: None,
warning=warnings.append,
),
)
assert facade.local_repository() == []
assert warnings == [
"读取本地插件仓候选失败已跳过本地目录展示invalid local package"
]
with pytest.raises(RuntimeError, match="invalid local package"):
facade.local_repository(raise_errors=True)
def test_local_projects_effective_log_level_for_virtual_instance(
_isolated_log_overrides,
):
"""分身设了日志等级覆盖时,生效等级要如实投影到它的卡片上。"""
set_plugin_instance_log_level("CatalogOverlayVirtual", "DEBUG")
class DemoPlugin:
plugin_name = "Demo"
plugin_order = 0
instance = PluginInstance(
instance_id="CatalogOverlayVirtual",
source_plugin_id="DemoPlugin",
)
facade = _facade(
classes=lambda: {"CatalogOverlayVirtual": DemoPlugin},
plugin_instance=lambda plugin_id: (
instance if plugin_id == "CatalogOverlayVirtual" else None
),
plugin_instances=lambda: {"CatalogOverlayVirtual": instance},
)
plugin = facade.local()[0]
assert plugin.log_level_effective == "DEBUG"
def test_local_reports_no_log_override_when_instance_follows_global_level(
_isolated_log_overrides,
):
"""没有覆盖的分身实例不应带任何日志等级徽标信息。"""
class DemoPlugin:
plugin_name = "Demo"
plugin_order = 0
instance = PluginInstance(
instance_id="CatalogOverlayDefaultVirtual",
source_plugin_id="DemoPlugin",
)
facade = _facade(
classes=lambda: {"CatalogOverlayDefaultVirtual": DemoPlugin},
plugin_instance=lambda plugin_id: (
instance if plugin_id == "CatalogOverlayDefaultVirtual" else None
),
plugin_instances=lambda: {"CatalogOverlayDefaultVirtual": instance},
)
plugin = facade.local()[0]
assert plugin.log_level_effective is None
def test_local_projects_effective_log_level_for_physical_plugin(
_isolated_log_overrides,
):
"""物理插件本体的覆盖同样要投影出来,实例 ID 就是插件 ID。
``is_instance``、``instance_mode`` 只看分身记录,不受日志等级覆盖影响,
现有语义保持不变。
"""
set_plugin_instance_log_level("CatalogOverlayHost", "WARNING")
class DemoPlugin:
plugin_name = "Demo"
plugin_order = 0
facade = _facade(classes=lambda: {"CatalogOverlayHost": DemoPlugin})
plugin = facade.local()[0]
assert plugin.is_instance is False
assert plugin.instance_mode is None
assert plugin.log_level_effective == "WARNING"
def test_installed_placeholder_projects_effective_log_level(
_isolated_log_overrides,
):
"""未加载插件的占位卡片同样要投影生效日志等级。"""
set_plugin_instance_log_level("CatalogOverlayPlaceholder", "ERROR")
facade = _facade(
storage=lambda: SimpleNamespace(
read=lambda key: (
["CatalogOverlayPlaceholder"]
if key is SystemConfigKey.UserInstalledPlugins
else None
)
),
)
plugin = facade.installed()[0]
assert plugin.log_level_effective == "ERROR"