Files
MoviePilot/tests/test_plugin_catalog_runtime.py
DDSRem 2de976f6b3 Merge pull request #7050 from DDSRem-Dev/codex-comment-migration
docs: 统一全项目中文注释和结构化契约说明
2026-10-17 19:26:29 +08:00

319 lines
10 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"