mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-06 16:07:01 +08:00
refactor: own shutdown lifecycle boundaries
This commit is contained in:
@@ -1,6 +1,8 @@
|
||||
from unittest.mock import Mock, patch
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from app.chain import transfer as transfer_module
|
||||
from app.chain.transfer import TransferChain
|
||||
from app.application.transfer import (
|
||||
@@ -59,6 +61,26 @@ class _Loop:
|
||||
return False
|
||||
|
||||
|
||||
class _DeferredLoop(_Loop):
|
||||
"""延迟执行线程安全回调,用于覆盖关闭与入环之间的竞态。"""
|
||||
|
||||
def __init__(self):
|
||||
"""初始化延迟回调和定时器清单。"""
|
||||
super().__init__()
|
||||
self.soon_callbacks = []
|
||||
|
||||
def call_soon_threadsafe(self, callback, *args):
|
||||
"""保存线程安全回调,直到测试显式执行。"""
|
||||
self.soon_callbacks.append((callback, args))
|
||||
|
||||
def run_soon_callbacks(self):
|
||||
"""执行并清空已保存的线程安全回调。"""
|
||||
callbacks = list(self.soon_callbacks)
|
||||
self.soon_callbacks.clear()
|
||||
for callback, args in callbacks:
|
||||
callback(*args)
|
||||
|
||||
|
||||
def _task(*, episode: int, download_hash: str = "hash-1") -> TransferTask:
|
||||
"""构造同一媒体不同剧集的整理任务。"""
|
||||
return TransferTask(
|
||||
@@ -122,6 +144,116 @@ def test_aggregator_debounces_same_group_and_flushes_once():
|
||||
callback.assert_called_once_with(notices)
|
||||
|
||||
|
||||
def test_aggregator_old_timer_cannot_flush_before_renewal_is_armed():
|
||||
"""新通知已接收时,旧 timer 不得抢在事件循环重置静默窗前发送。"""
|
||||
loop = _DeferredLoop()
|
||||
aggregator = TransferFailureNotificationAggregator()
|
||||
callback = Mock()
|
||||
first = TransferFailureNotification(
|
||||
"测试剧 (2026)", "S01E01", "原因A", 1, None, "tester"
|
||||
)
|
||||
second = TransferFailureNotification(
|
||||
"测试剧 (2026)", "S01E02", "原因B", 2, None, "tester"
|
||||
)
|
||||
|
||||
aggregator.schedule(
|
||||
group_key="media:test",
|
||||
notification=first,
|
||||
callback=callback,
|
||||
loop=loop,
|
||||
)
|
||||
loop.run_soon_callbacks()
|
||||
old_timer = loop.timers[0]
|
||||
|
||||
aggregator.schedule(
|
||||
group_key="media:test",
|
||||
notification=second,
|
||||
callback=callback,
|
||||
loop=loop,
|
||||
)
|
||||
old_timer.callback(*old_timer.args)
|
||||
callback.assert_not_called()
|
||||
|
||||
loop.run_soon_callbacks()
|
||||
renewed_timer = loop.timers[1]
|
||||
assert old_timer.cancelled is True
|
||||
renewed_timer.callback(*renewed_timer.args)
|
||||
callback.assert_called_once_with([first, second])
|
||||
|
||||
|
||||
def test_aggregator_close_flushes_accepted_notification_before_timer_is_armed():
|
||||
"""关闭应发送已接收但尚未进入事件循环的通知,且延迟回调不能重新建 timer。"""
|
||||
loop = _DeferredLoop()
|
||||
aggregator = TransferFailureNotificationAggregator()
|
||||
callback = Mock()
|
||||
notice = TransferFailureNotification(
|
||||
"测试剧 (2026)", "S01E01", "原因A", 1, None, "tester"
|
||||
)
|
||||
|
||||
aggregator.schedule(
|
||||
group_key="media:test",
|
||||
notification=notice,
|
||||
callback=callback,
|
||||
loop=loop,
|
||||
)
|
||||
aggregator.close()
|
||||
aggregator.close()
|
||||
loop.run_soon_callbacks()
|
||||
|
||||
callback.assert_called_once_with([notice])
|
||||
assert loop.timers == []
|
||||
|
||||
|
||||
def test_aggregator_close_cancels_timer_and_rejects_new_notification():
|
||||
"""关闭应取消已建 timer,并让调用方明确感知后续投递被拒绝。"""
|
||||
loop = _Loop()
|
||||
aggregator = TransferFailureNotificationAggregator()
|
||||
callback = Mock()
|
||||
notice = TransferFailureNotification(
|
||||
"测试剧 (2026)", "S01E01", "原因A", 1, None, "tester"
|
||||
)
|
||||
aggregator.schedule(
|
||||
group_key="media:test",
|
||||
notification=notice,
|
||||
callback=callback,
|
||||
loop=loop,
|
||||
)
|
||||
|
||||
aggregator.close()
|
||||
|
||||
assert loop.timers[0].cancelled is True
|
||||
callback.assert_called_once_with([notice])
|
||||
with pytest.raises(RuntimeError, match="正在关闭"):
|
||||
aggregator.schedule(
|
||||
group_key="media:test",
|
||||
notification=notice,
|
||||
callback=callback,
|
||||
loop=loop,
|
||||
)
|
||||
|
||||
|
||||
def test_aggregator_close_observes_flush_callback_error():
|
||||
"""关闭阶段同步刷新失败时应记录异常而不是让通知静默丢失。"""
|
||||
loop = _DeferredLoop()
|
||||
aggregator = TransferFailureNotificationAggregator()
|
||||
callback = Mock(side_effect=RuntimeError("send failed"))
|
||||
notice = TransferFailureNotification(
|
||||
"测试剧 (2026)", "S01E01", "原因A", 1, None, "tester"
|
||||
)
|
||||
aggregator.schedule(
|
||||
group_key="media:test",
|
||||
notification=notice,
|
||||
callback=callback,
|
||||
loop=loop,
|
||||
)
|
||||
|
||||
with patch("app.application.transfer.logger.error") as log_error:
|
||||
aggregator.close()
|
||||
|
||||
callback.assert_called_once_with([notice])
|
||||
log_error.assert_called_once()
|
||||
|
||||
|
||||
def test_aggregated_message_contains_count_reason_stats_and_batch_entry():
|
||||
"""聚合消息应给出失败数、原因统计、历史 ID 和批量处理入口。"""
|
||||
chain = object.__new__(TransferChain)
|
||||
|
||||
Reference in New Issue
Block a user