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

509 lines
16 KiB
Python
Raw Permalink 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.
"""
ConfigReloadMixin 配置变更事件处理器解析的回归测试(Issue #6329)。
ConfigReloadMixin 动态生成的事件处理器,必须能被事件总线解析回实例方法,
不能因 __name__ 与类上方法名不一致而被静默跳过。
"""
import inspect
from unittest.mock import AsyncMock, Mock
import pytest
from app.adapters.cache.redis import AsyncRedisHelper, RedisHelper
from app.adapters.network.doh import DohHelper
from app.chain.scraping import ScrapingChain
from app.chain.transfer.facade import TransferChain
from app.foundation.singleton import Singleton, SingletonClass
from app.monitor.monitor import Monitor
from app.runtime.events import Event, EventHandlerBinding, eventmanager
from app.runtime.extensions.plugin.manager import PluginManager
from app.runtime.reload import ConfigReloadMixin
from app.runtime.state import SystemHelper
from app.scheduler.facade import Scheduler
from app.schemas.event import ConfigChangeEventData
from app.schemas.types import EventType
from app.startup.initializers.modules import (
configure_config_reload_event_handler_resolver,
configure_host_event_handler_resolver,
get_config_reload_handler_providers,
)
class _ReloadRecorder(ConfigReloadMixin):
"""
测试用同步重载子类,记录重载调用次数。
"""
CONFIG_WATCH = {"TEST_RELOAD_KEY"}
def __init__(self):
"""
初始化同步重载调用计数。
"""
self.reload_count = 0
def on_config_changed(self):
"""
记录同步重载调用,供键筛选断言使用。
"""
self.reload_count += 1
class _AsyncReloadRecorder(ConfigReloadMixin):
"""
测试用异步重载子类,记录重载调用次数。
"""
CONFIG_WATCH = {"TEST_RELOAD_KEY"}
def __init__(self):
"""
初始化异步重载调用计数。
"""
self.reload_count = 0
async def on_config_changed(self):
"""
记录异步重载调用,供等待执行的断言使用。
"""
self.reload_count += 1
@pytest.fixture(params=[_ReloadRecorder, _AsyncReloadRecorder])
def recorder(request, monkeypatch):
"""
将生成的处理器绑定到测试实例,并在测试后移除全局监听。
"""
recorder_cls = request.param
instance = recorder_cls()
def resolver(owner_class):
"""
只解析当前参数化用例的处理器实例。
"""
if owner_class is recorder_cls:
return EventHandlerBinding(instance=instance, owner_name=owner_class.__name__)
return None
# 隔离真实解析器,仅绑定测试实例
monkeypatch.setattr(
eventmanager,
"_EventManager__handler_instance_resolvers",
{"test_reload": resolver},
)
yield instance
# 移除类定义时注册的全局监听,避免影响其余测试
eventmanager.remove_event_listener(
EventType.ConfigChanged, recorder_cls.handle_config_changed
)
def _build_event(keys):
"""
构造携带指定配置键的 ConfigChanged 事件。
"""
return Event(EventType.ConfigChanged, ConfigChangeEventData(key=set(keys)))
def _configure_lifespan_resolvers() -> None:
"""
按启动组合顺序登记宿主和配置 owner resolver。
"""
configure_host_event_handler_resolver()
configure_config_reload_event_handler_resolver()
async def _dispatch(instance, event):
"""
按处理器类型走事件总线的同步或异步调用路径。
"""
handler = instance.__class__.handle_config_changed
if inspect.iscoroutinefunction(handler):
await eventmanager._EventManager__invoke_handler_by_type_async(handler, event)
else:
eventmanager._EventManager__invoke_handler_by_type_sync(handler, event)
def test_generated_handler_exposes_method_name(recorder):
"""
生成的处理器 __name__ 必须与类上的方法名一致,保证事件总线能解析到实例方法。
"""
handler = recorder.__class__.handle_config_changed
assert handler.__name__ == "handle_config_changed"
assert handler.__qualname__ == f"{recorder.__class__.__name__}.handle_config_changed"
@pytest.mark.asyncio
async def test_config_changed_event_triggers_reload(recorder):
"""
命中 CONFIG_WATCH 的配置变更事件必须触发子类重载逻辑。
"""
await _dispatch(recorder, _build_event({"TEST_RELOAD_KEY"}))
assert recorder.reload_count == 1
@pytest.mark.asyncio
async def test_unrelated_config_key_skips_reload(recorder):
"""
未监听的配置键变更不得触发重载。
"""
await _dispatch(recorder, _build_event({"OTHER_KEY"}))
assert recorder.reload_count == 0
@pytest.mark.asyncio
async def test_resolve_falls_back_to_qualname_when_name_mismatched(monkeypatch):
"""
处理器 __name__ 与类上方法名不一致时,事件总线应回退 __qualname__ 末段解析。
"""
instance = _ReloadRecorder()
# 包装器必须保留事件处理签名,当前用例只验证方法解析而不读取事件。
def wrapper(event): # pylint: disable=unused-argument
"""
模拟只同步了 __qualname__ 的动态处理器。
"""
instance.on_config_changed()
wrapper.__module__ = _ReloadRecorder.__module__
wrapper.__qualname__ = f"{_ReloadRecorder.__name__}.handle_config_changed"
def resolver(owner_class):
"""
把测试包装器解析到指定同步重载实例。
"""
if owner_class is _ReloadRecorder:
return EventHandlerBinding(instance=instance, owner_name=owner_class.__name__)
return None
monkeypatch.setattr(
eventmanager,
"_EventManager__handler_instance_resolvers",
{"test_reload": resolver},
)
await eventmanager._EventManager__invoke_handler_by_type_async(
wrapper, _build_event({"TEST_RELOAD_KEY"})
)
assert wrapper.__name__ == "wrapper"
assert instance.reload_count == 1
def test_externally_managed_reload_class_does_not_register_listener(monkeypatch):
"""
外部统一管理配置生命周期时,Mixin 保留重载能力但不重复绑定事件。
"""
registrations = []
monkeypatch.setattr(
eventmanager,
"add_event_listener",
lambda *args, **kwargs: registrations.append((args, kwargs)),
)
class _ExternallyManagedReloadRecorder(ConfigReloadMixin):
"""
模拟由外部生命周期唯一绑定事件的组件。
"""
CONFIG_RELOAD_MANAGED_EXTERNALLY = True
CONFIG_WATCH = {"TEST_RELOAD_KEY"}
def on_config_changed(self):
"""
保持空重载实现,验证类定义阶段不会注册监听。
"""
pass
assert registrations == []
assert "handle_config_changed" not in _ExternallyManagedReloadRecorder.__dict__
def test_config_reload_owner_is_skipped_without_lifecycle_resolver(monkeypatch):
"""
非模块配置 owner 缺少 resolver 时不得被事件总线临时构造。
"""
helper = object.__new__(DohHelper)
reload_config = Mock()
helper.on_config_changed = reload_config
monkeypatch.setattr(
eventmanager,
"_EventManager__handler_instance_resolvers",
{},
)
eventmanager._EventManager__invoke_handler_by_type_sync(
DohHelper.handle_config_changed,
_build_event({"DOH_ENABLE"}),
)
reload_config.assert_not_called()
assert (
"app.adapters.network.doh.DohHelper.handle_config_changed"
in eventmanager.unresolved_handler_bindings()
)
def test_host_config_reload_resolver_binds_current_doh_owner(monkeypatch):
"""
DoH 配置事件必须绑定到已由组合根物化的当前 Adapter。
"""
helper = object.__new__(DohHelper)
reload_config = Mock()
helper.on_config_changed = reload_config
singleton_key = (DohHelper, (), frozenset())
monkeypatch.setitem(Singleton._instances, singleton_key, helper)
monkeypatch.setattr(
eventmanager,
"_EventManager__handler_instance_resolvers",
{},
)
_configure_lifespan_resolvers()
eventmanager._EventManager__invoke_handler_by_type_sync(
DohHelper.handle_config_changed,
_build_event({"DOH_ENABLE"}),
)
reload_config.assert_called_once_with()
eventmanager._EventManager__invoke_handler_by_type_sync(
DohHelper.handle_config_changed,
_build_event({"OTHER_KEY"}),
)
reload_config.assert_called_once_with()
def test_host_resolver_binds_current_scraping_owner(monkeypatch):
"""
宿主 resolver 必须把同步重载处理器绑定到当前 Chain 单例。
"""
chain = object.__new__(ScrapingChain)
reload_config = Mock()
chain.on_config_changed = reload_config
singleton_key = (ScrapingChain, (), frozenset())
monkeypatch.setitem(Singleton._instances, singleton_key, chain)
monkeypatch.setattr(
eventmanager,
"_EventManager__handler_instance_resolvers",
{},
)
configure_host_event_handler_resolver()
eventmanager._EventManager__invoke_handler_by_type_sync(
ScrapingChain.handle_config_changed,
_build_event({"ScrapingSwitchs"}),
)
reload_config.assert_called_once_with()
@pytest.mark.asyncio
async def test_host_resolver_binds_current_scheduler_owner(monkeypatch):
"""
宿主 resolver 必须把异步重载处理器绑定到当前 Scheduler 单例。
"""
scheduler = object.__new__(Scheduler)
reload_config = AsyncMock()
scheduler.on_config_changed = reload_config
monkeypatch.setitem(SingletonClass._instances, Scheduler, scheduler)
monkeypatch.setattr(
eventmanager,
"_EventManager__handler_instance_resolvers",
{},
)
configure_host_event_handler_resolver()
await eventmanager._EventManager__invoke_handler_by_type_async(
Scheduler.handle_config_changed,
_build_event({"DEV"}),
)
reload_config.assert_awaited_once_with()
def test_config_reload_resolver_reads_latest_singleton_owner(monkeypatch, compose_cache_backend):
"""
resolver 每次读取当前单例,不能保留已经换代的 Redis 实例。
"""
compose_cache_backend("redis")
first = object.__new__(RedisHelper)
first_reload = Mock()
first.on_config_changed = first_reload
second = object.__new__(RedisHelper)
second_reload = Mock()
second.on_config_changed = second_reload
singleton_key = (RedisHelper, (), frozenset())
monkeypatch.setitem(Singleton._instances, singleton_key, first)
monkeypatch.setattr(
eventmanager,
"_EventManager__handler_instance_resolvers",
{},
)
_configure_lifespan_resolvers()
eventmanager._EventManager__invoke_handler_by_type_sync(
RedisHelper.handle_config_changed,
_build_event({"CACHE_BACKEND_URL"}),
)
monkeypatch.setitem(Singleton._instances, singleton_key, second)
eventmanager._EventManager__invoke_handler_by_type_sync(
RedisHelper.handle_config_changed,
_build_event({"CACHE_BACKEND_URL"}),
)
first_reload.assert_called_once_with()
second_reload.assert_called_once_with()
@pytest.mark.asyncio
async def test_config_reload_resolver_invokes_current_async_redis_owner(monkeypatch, compose_cache_backend):
"""
异步配置处理器必须绑定当前 Redis owner,并保留键筛选。
"""
compose_cache_backend("redis")
helper = object.__new__(AsyncRedisHelper)
reload_config = AsyncMock()
helper.on_config_changed = reload_config
singleton_key = (AsyncRedisHelper, (), frozenset())
monkeypatch.setitem(Singleton._instances, singleton_key, helper)
monkeypatch.setattr(
eventmanager,
"_EventManager__handler_instance_resolvers",
{},
)
_configure_lifespan_resolvers()
await eventmanager._EventManager__invoke_handler_by_type_async(
AsyncRedisHelper.handle_config_changed,
_build_event({"CACHE_REDIS_MAX_CONNECTIONS"}),
)
await eventmanager._EventManager__invoke_handler_by_type_async(
AsyncRedisHelper.handle_config_changed,
_build_event({"OTHER_KEY"}),
)
reload_config.assert_awaited_once_with()
def test_config_reload_resolver_does_not_materialize_lazy_transfer_owner(
monkeypatch,
):
"""
尚未创建的整理链只声明跳过,配置事件不得启动线程资源。
"""
singleton_key = (TransferChain, (), frozenset())
monkeypatch.delitem(Singleton._instances, singleton_key, raising=False)
monkeypatch.setattr(
eventmanager,
"_EventManager__handler_instance_resolvers",
{},
)
_configure_lifespan_resolvers()
eventmanager._EventManager__invoke_handler_by_type_sync(
TransferChain.handle_config_changed,
_build_event({"TRANSFER_THREADS"}),
)
assert TransferChain.get_existing_instance() is None
def test_plugin_manager_falls_through_plugin_resolver_to_current_owner(
monkeypatch,
):
"""
resolver 先注册时也应在插件 Runtime 创建后接管当前管理器。
"""
singleton_key = (PluginManager, (), frozenset())
monkeypatch.delitem(Singleton._instances, singleton_key, raising=False)
monkeypatch.setattr(
eventmanager,
"_EventManager__handler_instance_resolvers",
{},
)
configure_config_reload_event_handler_resolver()
manager = object.__new__(PluginManager)
reload_config = Mock()
manager.on_config_changed = reload_config
monkeypatch.setitem(Singleton._instances, singleton_key, manager)
plugin_resolver = Mock(return_value=None)
eventmanager.register_handler_instance_resolver(
"plugins",
plugin_resolver,
)
eventmanager._EventManager__invoke_handler_by_type_sync(
PluginManager.handle_config_changed,
_build_event({"PLUGIN_AUTO_RELOAD"}),
)
plugin_resolver.assert_not_called()
reload_config.assert_called_once_with()
def test_config_reload_resolver_registration_replaces_same_name(monkeypatch):
"""
重复装配只替换当前 lifespan resolver,不累积并行绑定。
"""
monkeypatch.setattr(
eventmanager,
"_EventManager__handler_instance_resolvers",
{},
)
configure_config_reload_event_handler_resolver()
first = eventmanager._EventManager__handler_instance_resolvers[
"config_reload"
]
configure_config_reload_event_handler_resolver()
assert set(eventmanager._EventManager__handler_instance_resolvers) == {
"config_reload"
}
assert (
eventmanager._EventManager__handler_instance_resolvers[
"config_reload"
]
is not first
)
@pytest.mark.parametrize(
("owner_class", "registry", "singleton_key"),
[
(
PluginManager,
Singleton._instances,
(PluginManager, (), frozenset()),
),
(Monitor, SingletonClass._instances, Monitor),
],
)
def test_config_reload_provider_returns_lifecycle_owned_instance(
monkeypatch,
owner_class,
registry,
singleton_key,
):
"""
管理器和监控器必须从各自生命周期注册表读取当前实例。
"""
instance = object.__new__(owner_class)
monkeypatch.setitem(registry, singleton_key, instance)
providers = get_config_reload_handler_providers()
assert providers[owner_class]() is instance
def test_system_reload_provider_keeps_one_lifespan_instance() -> None:
"""
无资源的系统配置处理器也由 resolver 闭包保持稳定 owner 身份。
"""
provider = get_config_reload_handler_providers()[SystemHelper]
assert provider() is provider()