WechatOnCloud/bridge/tests/test_ui_action_scheduler.py

252 lines
8.8 KiB
Python
Raw Normal View History

"""UIActionScheduler 单元测试。
覆盖
- 向后兼容 enqueue
- 优先级排序CRITICAL 优先于 NORMAL
- drain空队列 / pending / 超时
- 队列满抛 RATE_LIMITED
- 等待超时抛 TIMEOUT
- metrics 准确性
- fast-fail 机制mark_wechat_dead 后非 CRITICAL WECHAT_NOT_READY
- CRITICAL 任务在 fast-fail 窗口内正常执行
- CRITICAL 任务自动 delay_ms=0
"""
from __future__ import annotations
import asyncio
import os
import sys
import time
import pytest
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from woc_bridge.messaging.ui_action_scheduler import UIActionScheduler, Priority
from woc_bridge.models import BridgeError
pytestmark = pytest.mark.asyncio
async def test_backward_compat_enqueue():
"""向后兼容 enqueue以 NORMAL 优先级入队并返回结果。"""
scheduler = UIActionScheduler(send_delay_ms=10, max_calls_per_sec=100)
await scheduler.start()
try:
result = await scheduler.enqueue(lambda: asyncio.sleep(0.01, result="ok"))
assert result == "ok"
metrics = scheduler.get_metrics()
assert metrics["executed_total"] == 1
assert metrics["executed_by_priority"]["NORMAL"] == 1
finally:
await scheduler.stop()
async def test_priority_ordering():
"""CRITICAL 任务应先于后入队的 NORMAL 任务开始执行。"""
scheduler = UIActionScheduler(send_delay_ms=0, max_calls_per_sec=100)
await scheduler.start()
try:
start_times: list[tuple[str, str]] = []
async def _track(result: str, delay: float = 0.01) -> str:
start_times.append(("start", result))
await asyncio.sleep(delay)
return result
# 第一个 NORMAL 立即开始执行
normal_task = asyncio.create_task(
scheduler.enqueue(lambda: _track("normal", 0.1))
)
await asyncio.sleep(0.02) # 等第一个开始
critical_task = asyncio.create_task(
scheduler.execute(
lambda: _track("critical", 0.01), priority=Priority.CRITICAL
)
)
normal2_task = asyncio.create_task(
scheduler.execute(
lambda: _track("normal2", 0.01), priority=Priority.NORMAL
)
)
critical_result = await critical_task
normal2_result = await normal2_task
normal_result = await normal_task
assert critical_result == "critical"
assert normal2_result == "normal2"
assert normal_result == "normal"
# CRITICAL 应在 normal2 之前开始
critical_idx = next(i for i, (_, r) in enumerate(start_times) if r == "critical")
normal2_idx = next(i for i, (_, r) in enumerate(start_times) if r == "normal2")
assert critical_idx < normal2_idx, (
f"CRITICAL 应在 NORMAL2 之前执行,实际顺序: {start_times}"
)
finally:
await scheduler.stop()
async def test_drain_empty_queue():
"""空队列 drain 应立即返回 True。"""
scheduler = UIActionScheduler()
result = await scheduler.drain(timeout=1.0)
assert result is True
async def test_drain_with_pending():
"""有 pending 任务的队列 drain 应等待任务完成。"""
scheduler = UIActionScheduler(send_delay_ms=10)
await scheduler.start()
try:
await scheduler.enqueue(lambda: asyncio.sleep(0.05, result="ok"))
result = await scheduler.drain(timeout=2.0)
assert result is True
await asyncio.sleep(0.01) # 让 worker 完全进入 idle
finally:
await scheduler.stop()
async def test_drain_timeout():
"""长任务未完成时 drain 应超时返回 False。"""
scheduler = UIActionScheduler(send_delay_ms=0)
await scheduler.start()
try:
# 入队长任务但不 await避免阻塞测试
asyncio.create_task(
scheduler.enqueue(lambda: asyncio.sleep(1.0, result="slow"))
)
await asyncio.sleep(0.05) # 让任务开始执行
result = await scheduler.drain(timeout=0.1)
assert result is False
finally:
await scheduler.stop()
async def test_queue_full_rate_limited():
"""队列满时入队应抛 RATE_LIMITED。"""
scheduler = UIActionScheduler(send_delay_ms=1000, max_queue_size=2)
await scheduler.start()
try:
# 第一个任务开始执行worker 取走后队列腾出空位)
asyncio.create_task(scheduler.enqueue(lambda: asyncio.sleep(0.5)))
await asyncio.sleep(0.05) # 让 worker 取走第一个任务并开始执行
# 再入队 2 个排队任务队列满2 个 pending
asyncio.create_task(scheduler.enqueue(lambda: asyncio.sleep(0.01)))
asyncio.create_task(scheduler.enqueue(lambda: asyncio.sleep(0.01)))
await asyncio.sleep(0.02) # 确保两个任务都入队
# 第 3 个排队任务应被拒绝(队列已满)
with pytest.raises(BridgeError) as exc_info:
await scheduler.enqueue(lambda: asyncio.sleep(0.01))
assert exc_info.value.code == "RATE_LIMITED"
finally:
await scheduler.stop()
async def test_wait_timeout():
"""等待结果超时应抛 TIMEOUT。"""
scheduler = UIActionScheduler(send_delay_ms=1000, max_calls_per_sec=100)
await scheduler.start()
try:
# 第一个任务慢(执行 + 默认延时会让第二个等待很久)
asyncio.create_task(scheduler.enqueue(lambda: asyncio.sleep(0.5)))
await asyncio.sleep(0.02) # 让第一个开始
with pytest.raises(BridgeError) as exc_info:
await scheduler.enqueue(
lambda: asyncio.sleep(0.01), wait_timeout_ms=100
)
assert exc_info.value.code == "TIMEOUT"
finally:
await scheduler.stop()
async def test_metrics_accuracy():
"""metrics 应准确统计各优先级执行次数。"""
scheduler = UIActionScheduler(send_delay_ms=0, max_calls_per_sec=100)
await scheduler.start()
try:
async def _ret(v):
return v
await scheduler.execute(lambda: _ret("a"), priority=Priority.CRITICAL)
await scheduler.execute(lambda: _ret("b"), priority=Priority.HIGH)
await scheduler.execute(lambda: _ret("c"), priority=Priority.NORMAL)
await scheduler.execute(lambda: _ret("d"), priority=Priority.LOW)
metrics = scheduler.get_metrics()
assert metrics["executed_total"] == 4
assert metrics["executed_by_priority"]["CRITICAL"] == 1
assert metrics["executed_by_priority"]["HIGH"] == 1
assert metrics["executed_by_priority"]["NORMAL"] == 1
assert metrics["executed_by_priority"]["LOW"] == 1
assert metrics["pending_count"] == 0
finally:
await scheduler.stop()
async def test_fast_fail_wechat_dead():
"""mark_wechat_dead 后非 CRITICAL 任务应 fast-fail 抛 WECHAT_NOT_READY。"""
scheduler = UIActionScheduler(send_delay_ms=0, max_calls_per_sec=100)
await scheduler.start()
try:
scheduler.mark_wechat_dead(2.0) # 启动 2 秒 fast-fail 窗口
with pytest.raises(BridgeError) as exc_info:
await scheduler.execute(
lambda: asyncio.sleep(0.01, result="ok"),
priority=Priority.NORMAL,
)
assert exc_info.value.code == "WECHAT_NOT_READY"
metrics = scheduler.get_metrics()
assert metrics["fast_fail_total"] == 1
finally:
await scheduler.stop()
async def test_critical_bypasses_fast_fail():
"""CRITICAL 任务在 fast-fail 窗口内应正常执行。"""
scheduler = UIActionScheduler(send_delay_ms=0, max_calls_per_sec=100)
await scheduler.start()
try:
scheduler.mark_wechat_dead(2.0) # 启动 fast-fail 窗口
result = await scheduler.execute(
lambda: asyncio.sleep(0.01, result="critical_ok"),
priority=Priority.CRITICAL,
)
assert result == "critical_ok"
metrics = scheduler.get_metrics()
assert metrics["executed_total"] == 1
assert metrics["executed_by_priority"]["CRITICAL"] == 1
assert metrics["fast_fail_total"] == 0
finally:
await scheduler.stop()
async def test_critical_auto_delay_zero():
"""CRITICAL 任务应自动 delay_ms=0不受默认 send_delay_ms 影响。"""
scheduler = UIActionScheduler(send_delay_ms=5000, max_calls_per_sec=100)
await scheduler.start()
try:
async def _ret(v):
return v
t0 = time.monotonic()
await scheduler.execute(lambda: _ret("first"), priority=Priority.CRITICAL)
await scheduler.execute(lambda: _ret("second"), priority=Priority.CRITICAL)
elapsed = time.monotonic() - t0
# 若 delay_ms=5000 生效,第二个任务会等 5s。自动 delay=0 后应在 1s 内完成
assert elapsed < 1.5, f"CRITICAL 自动 delay=0 失效,耗时 {elapsed:.2f}s"
finally:
await scheduler.stop()