refactor: own shutdown lifecycle boundaries

This commit is contained in:
jxxghp
2026-08-23 20:20:26 +08:00
parent 59f020f226
commit 7f09927c47
59 changed files with 6393 additions and 958 deletions
@@ -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)