mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-08-28 19:47:41 +08:00
571 lines
18 KiB
Python
571 lines
18 KiB
Python
"""模块调用调度器的同步、异步协议回归测试。"""
|
|
|
|
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()
|