Files
roboco/tests/unit/services/test_transcription_flush.py
T

113 lines
3.7 KiB
Python
Raw Normal View History

2026-06-30 08:08:35 +02:00
"""#96/#97: the periodic flush must (a) actually remove a ready buffer after
notifying callbacks (``get_ready_buffers`` only peeks — without a flush the
buffer map grows unbounded), and (b) offload each sync callback to a thread so
a slow callback can't block the flush task / event loop.
"""
from __future__ import annotations
import threading
from uuid import UUID, uuid4
import pytest
from roboco.models.transcription import TranscriptionConfig
from roboco.services.transcription import TranscriptionService
def _svc() -> TranscriptionService:
# Tiny intervals so a single flush tick is fast and deterministic.
return TranscriptionService(
TranscriptionConfig(
min_chars_for_extraction=2,
idle_threshold_seconds=0.0,
flush_interval_seconds=0.01,
)
)
def _ready_buffer(svc: TranscriptionService, content: str) -> tuple[UUID, UUID]:
"""Create a buffer for a fresh agent/session, append content, mark it
complete (ready for extraction). Returns the (agent_id, session_id)."""
agent_id, session_id, channel_id = uuid4(), uuid4(), uuid4()
buffer = svc.get_buffer(
agent_id=agent_id, session_id=session_id, channel_id=channel_id
)
buffer.append(content)
buffer.is_complete = True
return agent_id, session_id
@pytest.mark.asyncio
async def test_periodic_flush_removes_ready_buffer() -> None:
"""#96: after a flush tick, a ready buffer is popped from ``_buffers``
(not re-yielded forever). Without the flush call the map grew unbounded."""
svc = _svc()
agent_id, session_id = _ready_buffer(svc, "some content")
assert agent_id in svc._buffers
await svc._flush_ready_buffers()
# The ready buffer was flushed (removed), not left to accumulate.
assert agent_id not in svc._buffers or session_id not in svc._buffers[agent_id]
@pytest.mark.asyncio
async def test_periodic_flush_invokes_callbacks() -> None:
"""#96: callbacks are still notified on the flush tick."""
svc = _svc()
_ready_buffer(svc, "content for callback")
seen: list[object] = []
svc.register_callback(seen.append)
await svc._flush_ready_buffers()
assert len(seen) == 1
@pytest.mark.asyncio
async def test_periodic_flush_offloads_sync_callback_to_thread() -> None:
"""#97: a sync callback runs off the event loop (in a worker thread) so a
slow callback can't block the flush task. Assert the callback executes in a
thread that is NOT the running event loop's main thread."""
svc = _svc()
_ready_buffer(svc, "content for offload")
callback_thread: list[object] = []
def _slow_callback(_b: object) -> None:
# Record the thread id; a sleep here would block the loop if not offloaded.
callback_thread.append(threading.get_ident())
svc.register_callback(_slow_callback)
await svc._flush_ready_buffers()
assert len(callback_thread) == 1
# The callback ran in a worker thread, not on the event loop's main thread.
assert callback_thread[0] != threading.get_ident()
@pytest.mark.asyncio
async def test_periodic_flush_callback_error_does_not_abort_flush() -> None:
"""A failing callback must not abort the flush (the buffer is still removed
and a sibling callback still runs)."""
svc = _svc()
agent_id, session_id = _ready_buffer(svc, "content with bad callback")
sibling_seen: list[object] = []
def _bad(_b: object) -> None:
raise RuntimeError("boom")
def _good(b: object) -> None:
sibling_seen.append(b)
svc.register_callback(_bad)
svc.register_callback(_good)
await svc._flush_ready_buffers() # must not raise
assert len(sibling_seen) == 1
assert agent_id not in svc._buffers or session_id not in svc._buffers[agent_id]