refactor: formaliza contratos de streaming e ciclo de vida dos provedores
This commit is contained in:
194
tests/test_provider_contracts.py
Normal file
194
tests/test_provider_contracts.py
Normal file
@@ -0,0 +1,194 @@
|
||||
"""Behavioral guarantees for streaming and provider cleanup."""
|
||||
|
||||
import asyncio
|
||||
from contextlib import aclosing
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
|
||||
from voice_assistant.agent.adapter import StreamingAgentAdapter
|
||||
from voice_assistant.agent.mock import MockAgentAdapter
|
||||
from voice_assistant.application.events import SpeechChunk
|
||||
from voice_assistant.application.voice_session import VoiceSession
|
||||
from voice_assistant.domain.session import SessionState
|
||||
from voice_assistant.stt.mock import MockSTTAdapter
|
||||
from voice_assistant.tts.adapter import TTSAdapter
|
||||
from voice_assistant.tts.mock import MockTTSAdapter
|
||||
from voice_assistant.turn_manager import TurnManager
|
||||
from voice_assistant.voice_pipeline import VoicePipeline
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def anyio_backend():
|
||||
return "asyncio"
|
||||
|
||||
|
||||
def make_pipeline(agent, tts):
|
||||
return VoicePipeline(
|
||||
session=SessionState(), turn_manager=TurnManager(), agent=agent, tts=tts,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_streaming_agent_collects_fragments_preserving_spaces():
|
||||
closed = asyncio.Event()
|
||||
|
||||
class Agent(StreamingAgentAdapter):
|
||||
async def stream(self, session_id, turn_id, text):
|
||||
try:
|
||||
yield " Olá"
|
||||
yield ", mundo! "
|
||||
finally:
|
||||
closed.set()
|
||||
|
||||
assert await Agent().process("session", 1, "Olá") == "Olá, mundo!"
|
||||
assert closed.is_set()
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_streaming_failure_propagates_and_releases_stream():
|
||||
closed = asyncio.Event()
|
||||
|
||||
class Agent(StreamingAgentAdapter):
|
||||
async def stream(self, session_id, turn_id, text):
|
||||
try:
|
||||
yield "Resposta parcial"
|
||||
raise RuntimeError("Provider failed")
|
||||
finally:
|
||||
closed.set()
|
||||
|
||||
with pytest.raises(RuntimeError, match="Provider failed"):
|
||||
await Agent().process("session", 1, "Olá")
|
||||
assert closed.is_set()
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_streaming_cancellation_releases_stream():
|
||||
started = asyncio.Event()
|
||||
closed = asyncio.Event()
|
||||
|
||||
class Agent(StreamingAgentAdapter):
|
||||
async def stream(self, session_id, turn_id, text):
|
||||
try:
|
||||
yield "Resposta parcial"
|
||||
started.set()
|
||||
await asyncio.Event().wait()
|
||||
finally:
|
||||
closed.set()
|
||||
|
||||
task = asyncio.create_task(Agent().process("session", 1, "Olá"))
|
||||
try:
|
||||
await asyncio.wait_for(started.wait(), timeout=1.0)
|
||||
task.cancel()
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await task
|
||||
assert closed.is_set()
|
||||
finally:
|
||||
if not task.done():
|
||||
task.cancel()
|
||||
await asyncio.gather(task, return_exceptions=True)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_stopping_synthesis_early_closes_provider_generator():
|
||||
closed = asyncio.Event()
|
||||
|
||||
class TTS(TTSAdapter):
|
||||
async def synthesize(self, text):
|
||||
try:
|
||||
yield b"audio"
|
||||
await asyncio.Event().wait()
|
||||
finally:
|
||||
closed.set()
|
||||
|
||||
pipeline = make_pipeline(MockAgentAdapter(), TTS())
|
||||
async with aclosing(pipeline.synthesize_response("Olá")) as stream:
|
||||
assert await anext(stream) == b"audio"
|
||||
assert closed.is_set()
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_interrupt_during_delivery_closes_nonstreaming_tts():
|
||||
delivery_started = asyncio.Event()
|
||||
synthesis_closed = asyncio.Event()
|
||||
stt = MockSTTAdapter()
|
||||
|
||||
class TTS(TTSAdapter):
|
||||
async def synthesize(self, text):
|
||||
try:
|
||||
yield b"audio"
|
||||
await asyncio.Event().wait()
|
||||
finally:
|
||||
synthesis_closed.set()
|
||||
|
||||
async def emit(event):
|
||||
if isinstance(event, SpeechChunk):
|
||||
delivery_started.set()
|
||||
await asyncio.Event().wait()
|
||||
|
||||
session = VoiceSession(
|
||||
pipeline=make_pipeline(MockAgentAdapter(), TTS()),
|
||||
create_stt=lambda: stt, emit=emit,
|
||||
)
|
||||
try:
|
||||
turn_id = await session.start_turn()
|
||||
await stt.emit_final("Olá")
|
||||
await asyncio.wait_for(delivery_started.wait(), timeout=1.0)
|
||||
await asyncio.wait_for(session.interrupt(turn_id), timeout=1.0)
|
||||
assert synthesis_closed.is_set()
|
||||
assert session.active_turn_id is None
|
||||
finally:
|
||||
await session.aclose()
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_agent_cleanup_failure_does_not_prevent_tts_cleanup():
|
||||
agent = MockAgentAdapter()
|
||||
tts = MockTTSAdapter()
|
||||
agent.aclose = AsyncMock(side_effect=RuntimeError("Agent close failed"))
|
||||
tts.aclose = AsyncMock()
|
||||
|
||||
async def emit(event):
|
||||
pass
|
||||
|
||||
session = VoiceSession(
|
||||
pipeline=make_pipeline(agent, tts), create_stt=MockSTTAdapter, emit=emit,
|
||||
)
|
||||
await session.aclose()
|
||||
agent.aclose.assert_awaited_once()
|
||||
tts.aclose.assert_awaited_once()
|
||||
assert session.closed
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@pytest.mark.parametrize("phase", ["tts", "turn_manager"])
|
||||
@pytest.mark.parametrize("agent_close_fails", [False, True])
|
||||
async def test_partial_composition_closes_created_providers(
|
||||
monkeypatch, phase, agent_close_fails,
|
||||
):
|
||||
from voice_assistant.audio_gateway.app import main
|
||||
|
||||
agent = MockAgentAdapter()
|
||||
tts = MockTTSAdapter()
|
||||
agent.aclose = AsyncMock(
|
||||
side_effect=RuntimeError("Agent close failed") if agent_close_fails else None,
|
||||
)
|
||||
tts.aclose = AsyncMock()
|
||||
|
||||
def fail(*args, **kwargs):
|
||||
raise RuntimeError("Composition failed")
|
||||
|
||||
monkeypatch.setattr(main, "create_agent_adapter", lambda **kwargs: agent)
|
||||
monkeypatch.setattr(
|
||||
main, "create_tts_adapter", fail if phase == "tts" else lambda **kwargs: tts,
|
||||
)
|
||||
monkeypatch.setattr(main, "create_turn_manager", fail)
|
||||
|
||||
with pytest.raises(RuntimeError, match="Composition failed"):
|
||||
await main.create_voice_session(AsyncMock(), None)
|
||||
|
||||
agent.aclose.assert_awaited_once()
|
||||
if phase == "tts":
|
||||
tts.aclose.assert_not_awaited()
|
||||
else:
|
||||
tts.aclose.assert_awaited_once()
|
||||
Reference in New Issue
Block a user