Files
archived-MoviePilot/tests/test_coalesce.py

202 lines
7.2 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.
"""
`EventCoalescer` 基础设施单元测试。
测试策略:用极短窗口(默认 0.05s)驱动真实事件循环触发 flush避免引入
对时间 mock 的复杂度;同时通过 `asyncio.sleep` 让出控制权以保证 flush
回调被调度执行。
"""
import asyncio
from typing import List
from unittest import IsolatedAsyncioTestCase
from app.utils.coalesce import (
CoalesceDecision,
CoalesceSummary,
EventCoalescer,
)
# 窗口尽量短,但要大于事件循环单次 tick 的开销,避免 flush 在 record 仍持锁时触发
_TEST_WINDOW = 0.05
# 等待窗口到期 + flush 任务完成所需的额外余量
_TEST_WAIT = _TEST_WINDOW * 4
class EventCoalescerTest(IsolatedAsyncioTestCase):
"""
覆盖 EventCoalescer 的核心契约:首条 EMIT、窗口内 SUPPRESS、count>1
时 flush 摘要、不同 key 互不影响、close() 立即 flush、on_flush 异常
被吞、同步/async on_flush 都可用。
"""
async def test_first_record_returns_emit(self):
"""
某 key 在新窗口内的首次出现必须返回 EMIT确保调用方按原样输出。
"""
summaries: List[CoalesceSummary] = []
coalescer = EventCoalescer(_TEST_WINDOW, summaries.append)
decision = await coalescer.record(("host", "reason"), payload={"i": 1})
self.assertIs(decision, CoalesceDecision.EMIT)
await coalescer.close()
async def test_subsequent_same_key_records_are_suppressed(self):
"""
同一 key 在窗口内连续命中,第 2 次起返回 SUPPRESS。
"""
coalescer = EventCoalescer(_TEST_WINDOW, lambda _s: None)
await coalescer.record("k", payload="first")
for _ in range(3):
self.assertIs(
await coalescer.record("k", payload="ignored"),
CoalesceDecision.SUPPRESS,
)
await coalescer.close()
async def test_window_expiry_flushes_summary_when_count_gt_one(self):
"""
窗口到期且 count>1 时on_flush 收到包含 count、first_payload、window 的摘要。
"""
summaries: List[CoalesceSummary] = []
coalescer = EventCoalescer(_TEST_WINDOW, summaries.append, source="test")
key = ("h", "r")
await coalescer.record(key, payload={"url": "u1"})
await coalescer.record(key, payload={"url": "u2"})
await coalescer.record(key, payload={"url": "u3"})
await asyncio.sleep(_TEST_WAIT)
self.assertEqual(len(summaries), 1)
summary = summaries[0]
self.assertEqual(summary.key, key)
self.assertEqual(summary.count, 3)
self.assertEqual(summary.first_payload, {"url": "u1"})
self.assertEqual(summary.window_seconds, _TEST_WINDOW)
async def test_window_expiry_does_not_flush_when_count_is_one(self):
"""
窗口内只出现一次时,首条 EMIT 已表达完整事件,不再补发聚合摘要。
"""
summaries: List[CoalesceSummary] = []
coalescer = EventCoalescer(_TEST_WINDOW, summaries.append)
await coalescer.record("solo", payload=None)
await asyncio.sleep(_TEST_WAIT)
self.assertEqual(summaries, [])
async def test_different_keys_do_not_collapse(self):
"""
不同 key 各自独立计数与 flush互不吞并。
"""
summaries: List[CoalesceSummary] = []
coalescer = EventCoalescer(_TEST_WINDOW, summaries.append)
await coalescer.record("a", payload="a1")
await coalescer.record("b", payload="b1")
await coalescer.record("a", payload="a2")
await coalescer.record("b", payload="b2")
await coalescer.record("a", payload="a3")
await asyncio.sleep(_TEST_WAIT)
by_key = {s.key: s for s in summaries}
self.assertEqual(set(by_key.keys()), {"a", "b"})
self.assertEqual(by_key["a"].count, 3)
self.assertEqual(by_key["a"].first_payload, "a1")
self.assertEqual(by_key["b"].count, 2)
self.assertEqual(by_key["b"].first_payload, "b1")
async def test_new_window_after_flush_emits_again(self):
"""
窗口结束后下一条同 key 事件应被视为新窗口的首条,返回 EMIT。
"""
coalescer = EventCoalescer(_TEST_WINDOW, lambda _s: None)
await coalescer.record("k", payload=1)
await coalescer.record("k", payload=2)
await asyncio.sleep(_TEST_WAIT)
decision = await coalescer.record("k", payload=3)
self.assertIs(decision, CoalesceDecision.EMIT)
await coalescer.close()
async def test_close_flushes_pending_buckets_immediately(self):
"""
close() 必须取消未到期 timer 并立即触发 count>1 的 bucket flush
用于进程退出路径。
"""
# 使用一个足够长的窗口,确保自然到期不会先于 close 触发
summaries: List[CoalesceSummary] = []
coalescer = EventCoalescer(1.0, summaries.append)
await coalescer.record("k", payload="first")
await coalescer.record("k", payload="second")
await coalescer.close()
self.assertEqual(len(summaries), 1)
self.assertEqual(summaries[0].count, 2)
self.assertEqual(summaries[0].first_payload, "first")
async def test_close_does_not_emit_when_count_is_one(self):
"""
close() 与正常窗口到期一致count==1 时不输出摘要。
"""
summaries: List[CoalesceSummary] = []
coalescer = EventCoalescer(1.0, summaries.append)
await coalescer.record("k", payload="only")
await coalescer.close()
self.assertEqual(summaries, [])
async def test_async_on_flush_is_awaited(self):
"""
on_flush 为 async 函数时应被正确 await而不是被丢弃成协程对象。
"""
awaited: List[CoalesceSummary] = []
async def on_flush(summary: CoalesceSummary) -> None:
await asyncio.sleep(0)
awaited.append(summary)
coalescer = EventCoalescer(_TEST_WINDOW, on_flush)
await coalescer.record("k", payload="a")
await coalescer.record("k", payload="b")
await asyncio.sleep(_TEST_WAIT)
self.assertEqual(len(awaited), 1)
self.assertEqual(awaited[0].count, 2)
async def test_on_flush_exception_is_swallowed(self):
"""
on_flush 抛异常不能影响 coalescer 自身或上层调用方,仅 debug 记录。
"""
def on_flush(_summary: CoalesceSummary) -> None:
raise RuntimeError("boom")
coalescer = EventCoalescer(_TEST_WINDOW, on_flush)
await coalescer.record("k", payload="x")
await coalescer.record("k", payload="y")
await asyncio.sleep(_TEST_WAIT)
# 异常被吞,新窗口可以继续接受 record
self.assertIs(
await coalescer.record("k", payload="z"),
CoalesceDecision.EMIT,
)
await coalescer.close()
async def test_invalid_window_raises(self):
"""
非正数窗口值在构造期即拒绝,避免运行期出现 0 或负窗口的死循环 flush。
"""
with self.assertRaises(ValueError):
EventCoalescer(0, lambda _s: None)
with self.assertRaises(ValueError):
EventCoalescer(-1.0, lambda _s: None)