Files
EvoScientist/tests/test_channel_command_ui.py
T
dinos 690b903f85 test: standardize async tests on pytest-asyncio auto mode (#338)
* chore: add pytest-asyncio in auto mode

* test: migrate channel and stream tests to native async

Convert run_async() wrapper tests to plain 'async def test_*' under
pytest-asyncio auto mode. collect_events() in stream_v3_fakes becomes a
coroutine awaited at every call site.

* test: migrate command and model/middleware tests to native async

Convert run_async() wrappers (import, alias, and fixture forms) to plain
'async def test_*'. Multi-call tests merge onto one loop as sequential
awaits; none asserted on loop identity.

* test: migrate TUI, notifier, gateway, and session tests to native async

TUI/notifier/gateway files convert run_async wrappers to plain async
tests. test_sessions.py's unittest.TestCase classes move to
unittest.IsolatedAsyncioTestCase (pytest-asyncio does not await async
methods on plain TestCase; converting blindly would have made ~70 tests
silently vacuous). Its setUpClass keeps a one-shot asyncio.run() since
IsolatedAsyncioTestCase has no async class-level hook. TestLoadingWidget
in test_tui_widgets.py drops its TestCase base for the same reason.

* test: replace direct asyncio.run() calls with native async tests

Convert tests that called asyncio.run() (directly or via a local _run
helper) to plain 'async def test_*'; delete the local helpers.

* test: drop undeclared anyio markers and delete run_async helper

The @pytest.mark.anyio tests relied on anyio being a transitive dep of
httpx; auto-mode pytest-asyncio collects them natively. run_async() and
its fixture are unreferenced after the migration, so remove them —
pytest-asyncio's per-test loop teardown covers the pending-task
cancellation the helper existed for (verified: full suite runs with no
'Event loop is closed' errors or destroyed-task warnings).
2026-07-08 18:37:48 +00:00

141 lines
4.4 KiB
Python

from __future__ import annotations
import asyncio
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
import pytest
from EvoScientist.commands.channel_ui import ChannelCommandUI
from EvoScientist.gateway import ThreadStore
from tests.fakes import FakeGraphGateway, FakeThreadStore
def _make_ui(*, thread_store: ThreadStore, callback=None, bus_ref=None):
captured: list[str] = []
ui = ChannelCommandUI(
SimpleNamespace(
channel_type="fake",
chat_id="chat-1",
message_id="msg-1",
metadata={},
bus_ref=bus_ref,
channel_ref=None,
),
append_system_callback=lambda text, style="dim": captured.append(text),
handle_session_resume_callback=callback,
graph_gateway=FakeGraphGateway(thread_store=thread_store),
)
return ui, captured
async def _run_resume(ui, thread_id: str, workspace_dir: str):
loop = asyncio.get_running_loop()
scheduled: list[asyncio.Task] = []
def _schedule(coro, _loop):
task = loop.create_task(coro)
scheduled.append(task)
return task
with (
patch("EvoScientist.cli.channel._bus_loop", new=loop),
patch(
"EvoScientist.commands.channel_ui.asyncio.run_coroutine_threadsafe",
side_effect=_schedule,
),
):
await ui.handle_session_resume(thread_id, workspace_dir)
if scheduled:
await asyncio.gather(*scheduled)
def _sent_text(bus_ref) -> str:
return "\n".join(
call.args[0].content for call in bus_ref.publish_outbound.await_args_list
)
async def test_handle_session_resume_sends_history_back_to_channel_without_local_duplicate():
callback = AsyncMock()
bus_ref = SimpleNamespace(publish_outbound=AsyncMock())
messages = [
SimpleNamespace(type="human", content="How does this work?"),
SimpleNamespace(type="ai", content="Here is the saved answer."),
]
thread_store = FakeThreadStore(messages=messages)
ui, captured = _make_ui(
callback=callback,
bus_ref=bus_ref,
thread_store=thread_store,
)
await _run_resume(ui, "thread-42", "/workspace")
callback.assert_awaited_once_with("thread-42", "/workspace")
assert thread_store.calls == [("get_thread_messages", "thread-42")]
assert captured == []
text = _sent_text(bus_ref)
assert "Resumed session: thread-42" in text
assert "Conversation history:" in text
assert "User: How does this work?" in text
assert "EvoScientist: Here is the saved answer." in text
async def test_handle_session_resume_propagates_callback_abort_without_history():
callback = AsyncMock(side_effect=RuntimeError("workspace conflict"))
bus_ref = SimpleNamespace(publish_outbound=AsyncMock())
thread_store = FakeThreadStore()
ui, captured = _make_ui(
callback=callback,
bus_ref=bus_ref,
thread_store=thread_store,
)
with pytest.raises(RuntimeError, match="workspace conflict"):
await _run_resume(ui, "thread-42", "/workspace")
callback.assert_awaited_once_with("thread-42", "/workspace")
assert thread_store.calls == []
bus_ref.publish_outbound.assert_not_awaited()
assert captured == []
async def test_handle_session_resume_reports_history_load_error():
callback = AsyncMock()
bus_ref = SimpleNamespace(publish_outbound=AsyncMock())
ui, captured = _make_ui(
callback=callback,
bus_ref=bus_ref,
thread_store=FakeThreadStore(
errors={"get_thread_messages": RuntimeError("db locked")}
),
)
await _run_resume(ui, "thread-42", "/workspace")
callback.assert_awaited_once_with("thread-42", "/workspace")
assert captured == []
text = _sent_text(bus_ref)
assert "Resumed session: thread-42" in text
assert "history unavailable: db locked" in text
async def test_handle_session_resume_distinguishes_non_displayable_messages():
bus_ref = SimpleNamespace(publish_outbound=AsyncMock())
ui, captured = _make_ui(
bus_ref=bus_ref,
thread_store=FakeThreadStore(
messages=[SimpleNamespace(type="tool", content="hidden")]
),
)
await _run_resume(ui, "thread-42", "/workspace")
assert captured == [
"Resumed session: thread-42\nNo displayable messages in this session."
]
text = _sent_text(bus_ref)
assert "No displayable messages in this session." in text