mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-05 07:27:15 +08:00
fix: retain discord typing task owners
This commit is contained in:
@@ -1,6 +1,9 @@
|
||||
import asyncio
|
||||
import threading
|
||||
from typing import Optional
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
|
||||
from app.modules.discord.discord import Discord
|
||||
|
||||
@@ -40,6 +43,20 @@ class _YieldingCloseDiscordClientStub(_DiscordClientStub):
|
||||
self.closed.set()
|
||||
|
||||
|
||||
class _BlockingTypingChannelStub:
|
||||
"""模拟阻塞中的 Discord typing 请求。"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""初始化请求进入与释放屏障。"""
|
||||
self.entered = asyncio.Event()
|
||||
self.release = asyncio.Event()
|
||||
|
||||
async def trigger_typing(self) -> None:
|
||||
"""阻塞请求,直到测试释放或 owner 被取消。"""
|
||||
self.entered.set()
|
||||
await self.release.wait()
|
||||
|
||||
|
||||
def _discord(client: _DiscordClientStub) -> Discord:
|
||||
"""构造只包含线程与事件循环生命周期状态的 Discord 实例。"""
|
||||
instance = Discord.__new__(Discord)
|
||||
@@ -51,6 +68,24 @@ def _discord(client: _DiscordClientStub) -> Discord:
|
||||
instance._ready_event = threading.Event()
|
||||
instance._typing_tasks = {}
|
||||
instance._typing_stop_events = {}
|
||||
instance._typing_lifecycle_lock = asyncio.Lock()
|
||||
instance._typing_accepting = True
|
||||
instance._typing_stop_timeout_seconds = 0.01
|
||||
return instance
|
||||
|
||||
|
||||
def _typing_discord() -> Discord:
|
||||
"""构造绑定当前测试循环且不连接外部服务的 typing client。"""
|
||||
instance = Discord.__new__(Discord)
|
||||
instance._loop = asyncio.get_running_loop()
|
||||
instance._typing_tasks = {}
|
||||
instance._typing_stop_events = {}
|
||||
instance._typing_lifecycle_lock = asyncio.Lock()
|
||||
instance._typing_accepting = True
|
||||
instance._typing_interval_seconds = 0.01
|
||||
instance._typing_initial_delay_seconds = 0
|
||||
instance._typing_max_duration_seconds = 1
|
||||
instance._typing_stop_timeout_seconds = 0.01
|
||||
return instance
|
||||
|
||||
|
||||
@@ -129,3 +164,92 @@ def test_stop_during_thread_bootstrap_preserves_runner_cleanup(monkeypatch) -> N
|
||||
finally:
|
||||
release_runner.set()
|
||||
_cleanup(instance)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_short_typing_task_can_stop_before_first_trigger(monkeypatch) -> None:
|
||||
"""短响应在首发前结束时,不应留下 Discord 客户端 typing 状态。"""
|
||||
discord_client = _typing_discord()
|
||||
channel = AsyncMock()
|
||||
channel.trigger_typing = AsyncMock()
|
||||
monkeypatch.setattr(
|
||||
discord_client,
|
||||
"_resolve_channel",
|
||||
AsyncMock(return_value=channel),
|
||||
)
|
||||
|
||||
started = await discord_client._start_typing_task(
|
||||
typing_key="chat:30003",
|
||||
chat_id="30003",
|
||||
max_duration_seconds=1,
|
||||
initial_delay_seconds=0.05,
|
||||
)
|
||||
stopped = await discord_client._stop_typing_task("chat:30003")
|
||||
await asyncio.sleep(0.08)
|
||||
|
||||
assert started
|
||||
assert stopped
|
||||
channel.trigger_typing.assert_not_called()
|
||||
assert "chat:30003" not in discord_client._typing_tasks
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_typing_stop_retains_blocked_owner_until_terminal(monkeypatch) -> None:
|
||||
"""typing 请求阻塞超过预算时,不得删除或覆盖仍运行的 task owner。"""
|
||||
discord_client = _typing_discord()
|
||||
channel = _BlockingTypingChannelStub()
|
||||
resolve_channel = AsyncMock(return_value=channel)
|
||||
monkeypatch.setattr(discord_client, "_resolve_channel", resolve_channel)
|
||||
|
||||
try:
|
||||
assert await discord_client._start_typing_task(
|
||||
typing_key="chat:blocked",
|
||||
chat_id="blocked",
|
||||
)
|
||||
await asyncio.wait_for(channel.entered.wait(), timeout=1)
|
||||
owner = discord_client._typing_tasks["chat:blocked"]
|
||||
|
||||
assert await discord_client._stop_typing_task("chat:blocked")
|
||||
assert discord_client._typing_tasks["chat:blocked"] is owner
|
||||
assert not owner.done()
|
||||
assert not await discord_client._start_typing_task(
|
||||
typing_key="chat:blocked",
|
||||
chat_id="blocked",
|
||||
)
|
||||
assert discord_client._typing_tasks["chat:blocked"] is owner
|
||||
|
||||
channel.release.set()
|
||||
await asyncio.wait_for(owner, timeout=1)
|
||||
assert "chat:blocked" not in discord_client._typing_tasks
|
||||
finally:
|
||||
channel.release.set()
|
||||
await discord_client._stop_all_typing_tasks()
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_stop_all_seals_and_drains_typing_owners(monkeypatch) -> None:
|
||||
"""client shutdown 必须封住新增任务并取消、等待既有 owner 进入终态。"""
|
||||
discord_client = _typing_discord()
|
||||
channel = _BlockingTypingChannelStub()
|
||||
resolve_channel = AsyncMock(return_value=channel)
|
||||
monkeypatch.setattr(discord_client, "_resolve_channel", resolve_channel)
|
||||
|
||||
assert await discord_client._start_typing_task(
|
||||
typing_key="chat:shutdown",
|
||||
chat_id="shutdown",
|
||||
)
|
||||
await asyncio.wait_for(channel.entered.wait(), timeout=1)
|
||||
owner = discord_client._typing_tasks["chat:shutdown"]
|
||||
|
||||
await discord_client._stop_all_typing_tasks()
|
||||
|
||||
assert owner.done()
|
||||
assert owner.cancelled()
|
||||
assert discord_client._typing_tasks == {}
|
||||
assert discord_client._typing_stop_events == {}
|
||||
resolve_channel.reset_mock()
|
||||
assert not await discord_client._start_typing_task(
|
||||
typing_key="chat:after-stop",
|
||||
chat_id="after-stop",
|
||||
)
|
||||
resolve_channel.assert_not_awaited()
|
||||
|
||||
Reference in New Issue
Block a user