Files
MoviePilot/tests/test_module_invocation_dispatcher.py
2026-08-24 02:02:01 +08:00

571 lines
18 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 __future__ import annotations
from collections.abc import Callable
from unittest.mock import Mock
import pytest
from app.runtime.extensions.module.dispatcher import ModuleInvocationDispatcher
class _PluginCatalog:
"""提供固定插件方法表的内存目录。"""
def __init__(self, modules: dict) -> None:
"""保存测试提供的插件模块快照。"""
self.modules = modules
def get_plugin_modules(self) -> dict:
"""返回当前插件模块快照。"""
return self.modules
class _ModuleCatalog:
"""提供固定宿主模块序列的内存目录。"""
def __init__(self, modules: list) -> None:
"""保存测试提供的宿主模块。"""
self.modules = modules
def get_running_modules(self, _method: str) -> list:
"""返回所有测试模块,由调度器负责优先级排序。"""
return list(self.modules)
class _Module:
"""实现可配置优先级和调用函数的测试宿主模块。"""
def __init__(self, name: str, priority: int, func: Callable) -> None:
"""保存展示名、优先级和测试调用函数。"""
self._name = name
self._priority = priority
self._func = func
def get_name(self) -> str:
"""返回测试模块展示名。"""
return self._name
def get_priority(self) -> int:
"""返回调度优先级。"""
return self._priority
def execute(self, *args, **kwargs):
"""把模块调用转发到测试函数。"""
return self._func(*args, **kwargs)
def _dispatcher(
*,
plugins: dict | None = None,
modules: list | None = None,
async_runner: Callable | None = None,
) -> tuple[ModuleInvocationDispatcher, Mock, Mock, Mock]:
"""构造完全内存化的调度器及错误策略替身。"""
plugin_error = Mock()
system_error = Mock()
rate_error = Mock()
async def default_runner(func, *args, **kwargs):
"""在测试事件循环中直接运行同步函数。"""
return func(*args, **kwargs)
dispatcher = ModuleInvocationDispatcher(
module_catalog=_ModuleCatalog(modules or []),
plugin_catalog=_PluginCatalog(plugins or {}),
plugin_error_handler=plugin_error,
system_error_handler=system_error,
rate_limit_handler=rate_error,
async_function_runner=async_runner or default_runner,
)
return dispatcher, plugin_error, system_error, rate_error
def test_plugin_scalar_short_circuits_system_modules() -> None:
"""插件返回非空标量时不得继续执行宿主模块。"""
system_call = Mock(return_value="system")
dispatcher, _, _, _ = _dispatcher(
plugins={("P1", "插件一"): {"execute": lambda: "plugin"}},
modules=[_Module("系统", 10, system_call)],
)
assert dispatcher.dispatch("execute") == "plugin"
system_call.assert_not_called()
def test_fan_out_contract_runs_every_provider_and_ignores_results() -> None:
"""副作用广播应执行全部插件和宿主 provider并稳定返回 None。"""
calls = []
def record(name: str, result):
"""生成记录调用顺序并返回测试哨兵的 provider。"""
return lambda: calls.append(name) or result
system_20 = _Module("系统二", 20, record("system-20", "ignored-system"))
system_10 = _Module("系统一", 10, record("system-10", None))
setattr(system_20, "clear_cache", system_20.execute)
setattr(system_10, "clear_cache", system_10.execute)
dispatcher, _, _, _ = _dispatcher(
plugins={
("P1", "插件一"): {"clear_cache": record("plugin-1", "ignored-plugin")},
("P2", "插件二"): {"clear_cache": record("plugin-2", None)},
},
modules=[system_20, system_10],
)
assert dispatcher.dispatch("clear_cache") is None
assert calls == ["plugin-1", "plugin-2", "system-10", "system-20"]
@pytest.mark.asyncio
async def test_async_fan_out_contract_matches_sync_execution() -> None:
"""异步广播也应忽略返回值并执行全部同步或异步 provider。"""
calls = []
async def plugin_call():
"""记录异步插件调用并返回应被忽略的哨兵。"""
calls.append("plugin")
return "ignored-plugin"
def system_call():
"""记录同步宿主调用并返回应被忽略的哨兵。"""
calls.append("system")
return "ignored-system"
module = _Module("系统", 10, system_call)
setattr(module, "clear_cache", module.execute)
dispatcher, _, _, _ = _dispatcher(
plugins={("P1", "插件一"): {"clear_cache": plugin_call}},
modules=[module],
)
assert await dispatcher.async_dispatch("clear_cache") is None
assert calls == ["plugin", "system"]
def test_list_results_merge_in_plugin_then_priority_order() -> None:
"""列表结果应先按插件顺序合并,再按宿主优先级继续合并。"""
calls = []
def result(value: str) -> Callable:
"""生成记录调用顺序并返回单项列表的模块函数。"""
return lambda: calls.append(value) or [value]
dispatcher, _, _, _ = _dispatcher(
plugins={
("P1", "插件一"): {"execute": result("plugin-1")},
("P2", "插件二"): {"execute": result("plugin-2")},
},
modules=[
_Module("慢模块", 20, result("system-20")),
_Module("快模块", 10, result("system-10")),
],
)
assert dispatcher.dispatch("execute") == [
"plugin-1",
"plugin-2",
"system-10",
"system-20",
]
assert calls == ["plugin-1", "plugin-2", "system-10", "system-20"]
def test_system_signature_relay_passes_previous_result() -> None:
"""单参数宿主方法应接收上一模块的非列表结果。"""
class FirstModule:
"""产生首个字典结果的测试模块。"""
@staticmethod
def get_name() -> str:
"""返回测试模块名。"""
return "第一步"
@staticmethod
def get_priority() -> int:
"""返回第一优先级。"""
return 10
@staticmethod
def execute() -> dict:
"""产生首个模块结果。"""
return {"value": 1}
class SecondModule:
"""消费上一结果的测试模块。"""
@staticmethod
def get_name() -> str:
"""返回测试模块名。"""
return "第二步"
@staticmethod
def get_priority() -> int:
"""返回第二优先级。"""
return 20
@staticmethod
def execute(previous: dict) -> dict:
"""接收上一模块结果并生成下一结果。"""
return {"value": previous["value"] + 1}
dispatcher, _, _, _ = _dispatcher(
modules=[SecondModule(), FirstModule()]
)
assert dispatcher.dispatch("execute") == {"value": 2}
def test_explicit_pipeline_contract_relays_previous_result() -> None:
"""图片补全契约应按优先级把上一 provider 结果交给下一 provider。"""
class ImageModule:
"""在统一媒体对象上记录当前图片 provider。"""
def __init__(self, name: str, priority: int) -> None:
"""保存 provider 名称和优先级。"""
self._name = name
self._priority = priority
def get_name(self) -> str:
"""返回测试模块名。"""
return self._name
def get_priority(self) -> int:
"""返回测试优先级。"""
return self._priority
def obtain_images(self, mediainfo: dict) -> dict:
"""追加当前 provider 名称并返回同一媒体结果。"""
return {
**mediainfo,
"providers": [*mediainfo.get("providers", []), self._name],
}
dispatcher, _, _, _ = _dispatcher(
modules=[
ImageModule("fanart", 20),
ImageModule("tmdb", 10),
]
)
assert dispatcher.dispatch("obtain_images", mediainfo={}) == {
"providers": ["tmdb", "fanart"]
}
def test_first_non_empty_contract_stops_legacy_signature_relay() -> None:
"""显式首个非空契约不得再把结果交给后续宿主 provider 改写。"""
class FirstModule:
"""返回首个识别结果的宿主模块。"""
@staticmethod
def get_name() -> str:
"""返回测试模块名。"""
return "第一识别源"
@staticmethod
def get_priority() -> int:
"""返回第一优先级。"""
return 10
@staticmethod
def recognize_media() -> str:
"""返回首个非空识别结果。"""
return "first"
class RelayCompatibleModule:
"""模拟可接受上一结果的旧式宿主模块。"""
@staticmethod
def get_name() -> str:
"""返回测试模块名。"""
return "旧式接力源"
@staticmethod
def get_priority() -> int:
"""返回第二优先级。"""
return 20
@staticmethod
def recognize_media(previous: str) -> str:
"""若被调用则改写上一结果。"""
return f"relayed:{previous}"
dispatcher, _, _, _ = _dispatcher(
modules=[RelayCompatibleModule(), FirstModule()]
)
assert dispatcher.dispatch("recognize_media") == "first"
def test_ordered_list_contract_bypasses_legacy_signature_relay() -> None:
"""显式列表聚合契约应按原参数调用并保留 provider 顺序。"""
class SearchModule:
"""区分原参数调用与旧式结果接力的搜索模块。"""
@staticmethod
def get_name() -> str:
"""返回测试模块名。"""
return "系统搜索源"
@staticmethod
def get_priority() -> int:
"""返回稳定优先级。"""
return 10
@staticmethod
def search_medias(previous: list | None = None) -> list[str]:
"""原参数调用返回系统结果,接力调用返回可检测哨兵。"""
return ["relayed"] if previous is not None else ["system"]
dispatcher, _, _, _ = _dispatcher(
plugins={
("P1", "插件一"): {"search_medias": lambda: ["plugin"]},
},
modules=[SearchModule()],
)
assert dispatcher.dispatch("search_medias") == ["plugin", "system"]
def test_ordered_mapping_contract_merges_system_downloader_results() -> None:
"""未指定下载器时应按宿主优先级合并各 provider 的 Tracker 映射。"""
class TrackerModule:
"""返回单个下载器 Tracker 映射的测试模块。"""
def __init__(self, name: str, priority: int) -> None:
"""保存下载器名称和 provider 优先级。"""
self._name = name
self._priority = priority
def get_name(self) -> str:
"""返回测试模块名。"""
return self._name
def get_priority(self) -> int:
"""返回测试优先级。"""
return self._priority
def get_torrent_trackers(
self,
hash_string: str,
downloader: str | None = None,
) -> dict[str, list[str]]:
"""返回当前测试下载器的 Tracker 映射。"""
assert hash_string == "hash"
assert downloader is None
return {self._name: [f"https://{self._name}.test/announce"]}
dispatcher, _, _, _ = _dispatcher(
modules=[
TrackerModule("transmission", 20),
TrackerModule("qbittorrent", 10),
]
)
assert dispatcher.dispatch(
"get_torrent_trackers",
hash_string="hash",
downloader=None,
) == {
"qbittorrent": ["https://qbittorrent.test/announce"],
"transmission": ["https://transmission.test/announce"],
}
def test_plugin_mapping_keeps_existing_host_short_circuit() -> None:
"""插件返回 Tracker 映射后仍应保持插件优先,不再调用宿主 provider。"""
system_call = Mock(return_value={"system": ["https://system.test"]})
module = _Module("系统", 10, system_call)
setattr(module, "get_torrent_trackers", module.execute)
dispatcher, _, _, _ = _dispatcher(
plugins={
("P1", "插件一"): {
"get_torrent_trackers": lambda **_kwargs: {
"plugin": ["https://plugin.test"]
}
},
},
modules=[module],
)
assert dispatcher.dispatch(
"get_torrent_trackers",
hash_string="hash",
downloader=None,
) == {"plugin": ["https://plugin.test"]}
system_call.assert_not_called()
def test_module_exception_uses_error_policy_and_continues() -> None:
"""普通异常应交给错误策略,后续空结果模块仍可继续运行。"""
def broken():
"""模拟模块执行失败。"""
raise RuntimeError("broken")
dispatcher, _, system_error, _ = _dispatcher(
modules=[
_Module("失败模块", 10, broken),
_Module("后续模块", 20, lambda: "ok"),
],
)
assert dispatcher.dispatch("execute") == "ok"
system_error.assert_called_once()
@pytest.mark.asyncio
async def test_async_dispatch_awaits_coroutines_and_offloads_sync_functions() -> None:
"""异步路径应直接等待协程,并通过注入执行器运行同步方法。"""
offloaded = []
async def async_runner(func, *args, **kwargs):
"""记录被移出事件循环的同步函数。"""
offloaded.append(func)
return func(*args, **kwargs)
async def plugin_call():
"""返回插件列表结果。"""
return ["plugin"]
sync_module = _Module("同步模块", 10, lambda: ["system"])
dispatcher, _, _, _ = _dispatcher(
plugins={("P1", "插件一"): {"execute": plugin_call}},
modules=[sync_module],
async_runner=async_runner,
)
assert await dispatcher.async_dispatch("execute") == ["plugin", "system"]
assert offloaded == [sync_module.execute]
@pytest.mark.asyncio
async def test_async_ordered_list_contract_uses_same_aggregation_policy() -> None:
"""异步 dispatcher 应与同步路径共享显式列表聚合语义。"""
class SearchModule:
"""提供异步路径下可识别调用方式的同步 provider。"""
@staticmethod
def get_name() -> str:
"""返回测试模块名。"""
return "异步系统搜索源"
@staticmethod
def get_priority() -> int:
"""返回稳定优先级。"""
return 10
@staticmethod
def search_medias(previous: list | None = None) -> list[str]:
"""原参数调用返回系统结果,接力调用返回可检测哨兵。"""
return ["relayed"] if previous is not None else ["system"]
async def plugin_search() -> list[str]:
"""返回插件搜索结果。"""
return ["plugin"]
dispatcher, _, _, _ = _dispatcher(
plugins={
("P1", "插件一"): {"search_medias": plugin_search},
},
modules=[SearchModule()],
)
assert await dispatcher.async_dispatch("search_medias") == [
"plugin",
"system",
]
def test_plugin_non_mapping_module_decl_is_reported_and_skipped() -> None:
"""插件把方法表声明成 list 时走错误策略,且不影响后续健康插件。"""
dispatcher, plugin_error, _, _ = _dispatcher(
plugins={
("Bad", "坏插件"): ["not-a-mapping"],
("Good", "好插件"): {"execute": lambda: "ok"},
},
)
assert dispatcher.dispatch("execute") == "ok"
plugin_error.assert_called_once()
def test_unknown_plugin_method_records_legacy_abi_hit(monkeypatch) -> None:
"""未知第三方方法继续执行,同时记录可迁移的 legacy ABI 来源。"""
hits = []
monkeypatch.setattr(
"app.runtime.extensions.module.dispatcher.record_metric",
lambda name, **labels: hits.append((name, labels)),
)
dispatcher, _, _, _ = _dispatcher(
plugins={("P1", "插件一"): {"third_party_custom": lambda: "ok"}},
)
assert dispatcher.dispatch("third_party_custom") == "ok"
assert hits == [
(
"module.contract.legacy_hit",
{
"method": "third_party_custom",
"caller_type": "plugin",
"abi_source": "third_party_plugin",
},
)
]
def test_unknown_host_method_records_legacy_abi_hit(monkeypatch) -> None:
"""宿主临时新增而未登记的方法保持执行并留下迁移信号。"""
hits = []
monkeypatch.setattr(
"app.runtime.extensions.module.dispatcher.record_metric",
lambda name, **labels: hits.append((name, labels)),
)
class LegacyModule:
"""提供未进入清单的宿主兼容方法。"""
@staticmethod
def get_name() -> str:
"""返回测试模块名称。"""
return "旧模块"
@staticmethod
def get_priority() -> int:
"""返回稳定测试优先级。"""
return 1
@staticmethod
def third_party_host() -> str:
"""返回兼容方法结果。"""
return "ok"
dispatcher, _, _, _ = _dispatcher(modules=[LegacyModule()])
assert dispatcher.dispatch("third_party_host") == "ok"
assert hits == [
(
"module.contract.legacy_hit",
{
"method": "third_party_host",
"caller_type": "system",
"abi_source": "host_module",
},
)
]
@pytest.mark.asyncio
async def test_async_plugin_non_mapping_module_decl_is_reported_and_skipped() -> None:
"""异步路径下坏插件同样被隔离,嵌套补丁场景不再冒泡击穿调度。"""
dispatcher, plugin_error, _, _ = _dispatcher(
plugins={
("Bad", "坏插件"): ["not-a-mapping"],
("Good", "好插件"): {"execute": lambda: "ok"},
},
)
assert await dispatcher.async_dispatch("execute") == "ok"
plugin_error.assert_called_once()