feat: establish voice assistant V1 baseline
This commit is contained in:
40
tests/test_agent.py
Normal file
40
tests/test_agent.py
Normal file
@@ -0,0 +1,40 @@
|
||||
import pytest
|
||||
|
||||
from voice_assistant.agent import MockAgentAdapter
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def text_mock_agent_process_text():
|
||||
agent = MackAgentAdapter()
|
||||
|
||||
response = await agent.process(
|
||||
session_id="session-1",
|
||||
turn_id=1,
|
||||
text="Qual o horário de funcionamento?",
|
||||
)
|
||||
|
||||
assert response == (
|
||||
"Resposta simulada para: "
|
||||
"Qual o horário de funcionamento?"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mock_agent_records_call():
|
||||
agent = MockAgentAdapter()
|
||||
|
||||
await agent.process(
|
||||
session_id="session-1",
|
||||
turn_id=7,
|
||||
text= "Olá",
|
||||
)
|
||||
|
||||
|
||||
assert agent.calls == [
|
||||
(
|
||||
"session-1",
|
||||
7,
|
||||
"Olá",
|
||||
)
|
||||
]
|
||||
|
||||
31
tests/test_audio_models.py
Normal file
31
tests/test_audio_models.py
Normal file
@@ -0,0 +1,31 @@
|
||||
"""
|
||||
Tests for the canonical Voice Assistant audio contract.
|
||||
|
||||
Purpose:
|
||||
Verify that the Voice Core exposes the expected provider-independent
|
||||
canonical audio format.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from voice_assistant.audio.models import (
|
||||
AudioFormat,
|
||||
CANONICAL_AUDIO_FORMAT,
|
||||
)
|
||||
|
||||
|
||||
def test_canonical_audio_format():
|
||||
assert CANONICAL_AUDIO_FORMAT.encoding == "pcm_s16le"
|
||||
assert CANONICAL_AUDIO_FORMAT.sample_rate == 16000
|
||||
assert CANONICAL_AUDIO_FORMAT.channels == 1
|
||||
|
||||
|
||||
def test_audio_format_rejects_unsupported_encoding():
|
||||
with pytest.raises(ValidationError):
|
||||
AudioFormat(
|
||||
encoding="mp3",
|
||||
sample_rate=16000,
|
||||
channels=1,
|
||||
)
|
||||
|
||||
288
tests/test_config.py
Normal file
288
tests/test_config.py
Normal file
@@ -0,0 +1,288 @@
|
||||
"""
|
||||
Typed configuration models for the Voice Assistant.
|
||||
|
||||
Purpose:
|
||||
Define and validate the configuration structures used by the
|
||||
application and dynamic provider loading system.
|
||||
|
||||
Responsibilities:
|
||||
- Define the application configuration for STT, Agent, and TTS.
|
||||
- Define the provider catalog structure.
|
||||
- Represent provider-specific options without coupling the core
|
||||
configuration model to a specific vendor or implementation.
|
||||
- Describe provider capabilities for discovery and future tooling.
|
||||
|
||||
Non-responsibilities:
|
||||
- Loading YAML files.
|
||||
- Reading environment variables or secrets.
|
||||
- Selecting or instantiating provider implementations.
|
||||
- Executing STT, Agent, or TTS functionality.
|
||||
|
||||
Architecture:
|
||||
YAML
|
||||
|
|
||||
v
|
||||
Config Loader
|
||||
|
|
||||
v
|
||||
AppConfig / ProviderCatalog
|
||||
|
|
||||
v
|
||||
Provider Loader
|
||||
|
|
||||
v
|
||||
STTAdapter / AgentAdapter / TTSAdapter
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
from voice_assistant.config.loader import load_config
|
||||
from voice_assistant.config.loader import (
|
||||
load_config,
|
||||
load_provider_catalog,
|
||||
)
|
||||
|
||||
|
||||
def test_load_development_config():
|
||||
config = load_config()
|
||||
|
||||
assert config.stt.provider == "nvidia"
|
||||
assert config.stt.options["server"] == "127.0.0.1:9000"
|
||||
assert config.stt.options["language_code"] == "pt-BR"
|
||||
assert config.stt.options["automatic_punctuation"] is True
|
||||
|
||||
assert config.tts.provider == "voxcpm"
|
||||
assert config.tts.options["server"] == "http://127.0.0.1:8000"
|
||||
assert config.tts.options["model"] == "openbmb/VoxCPM2"
|
||||
assert config.tts.options["voice"] == "default"
|
||||
|
||||
assert config.agent.provider == "mock"
|
||||
|
||||
|
||||
def test_load_config_missing_file(tmp_path):
|
||||
with pytest.raises(
|
||||
FileNotFoundError,
|
||||
match="Configuration file not found",
|
||||
):
|
||||
load_config(
|
||||
environment="development",
|
||||
config_dir=tmp_path,
|
||||
)
|
||||
|
||||
|
||||
def test_load_config_empty_file(tmp_path):
|
||||
config_file = tmp_path / "development.yaml"
|
||||
config_file.write_text(
|
||||
"",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
with pytest.raises(
|
||||
ValueError,
|
||||
match="Configuration file is empty",
|
||||
):
|
||||
load_config(
|
||||
environment="development",
|
||||
config_dir=tmp_path,
|
||||
)
|
||||
|
||||
|
||||
def test_load_config_root_must_be_mapping(tmp_path):
|
||||
config_file = tmp_path / "development.yaml"
|
||||
config_file.write_text(
|
||||
"- item1\n- item2\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
with pytest.raises(
|
||||
ValueError,
|
||||
match="Configuration root must be a mapping",
|
||||
):
|
||||
load_config(
|
||||
environment="development",
|
||||
config_dir=tmp_path,
|
||||
)
|
||||
|
||||
|
||||
def test_load_config_validates_required_fields(tmp_path):
|
||||
config_file = tmp_path / "development.yaml"
|
||||
|
||||
config_file.write_text(
|
||||
yaml.safe_dump(
|
||||
{
|
||||
"stt": {
|
||||
"provider": "nvidia",
|
||||
"server": "127.0.0.1:9000",
|
||||
"language": "pt-BR",
|
||||
},
|
||||
"tts": {
|
||||
"provider": "voxcpm",
|
||||
"server": "http://127.0.0.1:8000",
|
||||
"model": "openbmb/VoxCPM2",
|
||||
"voice": "default",
|
||||
},
|
||||
# agent intentionally missing
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
with pytest.raises(
|
||||
Exception,
|
||||
match="agent",
|
||||
):
|
||||
load_config(
|
||||
environment="development",
|
||||
config_dir=tmp_path,
|
||||
)
|
||||
|
||||
def test_load_provider_catalog():
|
||||
catalog = load_provider_catalog()
|
||||
|
||||
assert "nvidia" in catalog.providers["stt"]
|
||||
assert "voxcpm" in catalog.providers["tts"]
|
||||
assert "mock" in catalog.providers["agent"]
|
||||
|
||||
voxcpm = catalog.providers["tts"]["voxcpm"]
|
||||
|
||||
assert voxcpm.name == "VoxCPM"
|
||||
assert (
|
||||
voxcpm.implementation
|
||||
== "voice_assistant.tts.voxcpm:VoxCPMTTSAdapter"
|
||||
)
|
||||
assert voxcpm.capabilities == {
|
||||
"realtime": True,
|
||||
"streaming": True,
|
||||
}
|
||||
|
||||
|
||||
def test_load_provider_catalog_missing_file(tmp_path):
|
||||
with pytest.raises(
|
||||
FileNotFoundError,
|
||||
match="Provider catalog not found",
|
||||
):
|
||||
load_provider_catalog(
|
||||
config_dir=tmp_path,
|
||||
)
|
||||
|
||||
|
||||
def test_load_config_uses_app_env(
|
||||
tmp_path,
|
||||
monkeypatch,
|
||||
):
|
||||
development = tmp_path / "development.yaml"
|
||||
|
||||
development.write_text(
|
||||
"""
|
||||
stt:
|
||||
provider: nvidia
|
||||
options:
|
||||
server: "dev-asr"
|
||||
|
||||
tts:
|
||||
provider: voxcpm
|
||||
options:
|
||||
server: "dev-tts"
|
||||
model: "dev-model"
|
||||
voice: "default"
|
||||
|
||||
agent:
|
||||
provider: mock
|
||||
options: {}
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
monkeypatch.setenv(
|
||||
"APP_ENV",
|
||||
"development",
|
||||
)
|
||||
|
||||
config = load_config(
|
||||
config_dir=tmp_path,
|
||||
)
|
||||
|
||||
assert config.stt.options["server"] == "dev-asr"
|
||||
assert config.tts.options["server"] == "dev-tts"
|
||||
assert config.tts.options["model"] == "dev-model"
|
||||
|
||||
|
||||
def test_load_config_rejects_unknown_environment(
|
||||
tmp_path,
|
||||
):
|
||||
with pytest.raises(
|
||||
FileNotFoundError,
|
||||
match="Configuration file not found",
|
||||
):
|
||||
load_config(
|
||||
environment="does-not-exist",
|
||||
config_dir=tmp_path,
|
||||
)
|
||||
|
||||
def test_load_config_uses_app_env(
|
||||
tmp_path,
|
||||
monkeypatch,
|
||||
):
|
||||
config_file = tmp_path / "staging.yaml"
|
||||
|
||||
config_file.write_text(
|
||||
"""
|
||||
stt:
|
||||
provider: nvidia
|
||||
options:
|
||||
server: "staging-asr:9000"
|
||||
|
||||
tts:
|
||||
provider: voxcpm
|
||||
options:
|
||||
server: "http://staging-tts:8000"
|
||||
model: "staging-model"
|
||||
voice: "staging-voice"
|
||||
|
||||
agent:
|
||||
provider: mock
|
||||
options: {}
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
monkeypatch.setenv(
|
||||
"APP_ENV",
|
||||
"staging",
|
||||
)
|
||||
|
||||
config = load_config(
|
||||
config_dir=tmp_path,
|
||||
)
|
||||
|
||||
assert config.stt.provider == "nvidia"
|
||||
assert (
|
||||
config.stt.options["server"]
|
||||
== "staging-asr:9000"
|
||||
)
|
||||
|
||||
assert config.tts.provider == "voxcpm"
|
||||
assert (
|
||||
config.tts.options["server"]
|
||||
== "http://staging-tts:8000"
|
||||
)
|
||||
|
||||
assert config.tts.options["model"] == "staging-model"
|
||||
assert config.tts.options["voice"] == "staging-voice"
|
||||
|
||||
assert config.agent.provider == "mock"
|
||||
|
||||
|
||||
def test_load_config_rejects_unknown_environment(
|
||||
tmp_path,
|
||||
):
|
||||
with pytest.raises(
|
||||
FileNotFoundError,
|
||||
match="Configuration file not found",
|
||||
):
|
||||
load_config(
|
||||
environment="does-not-exist",
|
||||
config_dir=tmp_path,
|
||||
)
|
||||
|
||||
1701
tests/test_gateway.py
Normal file
1701
tests/test_gateway.py
Normal file
File diff suppressed because it is too large
Load Diff
0
tests/test_nvidia_stt.py
Normal file
0
tests/test_nvidia_stt.py
Normal file
71
tests/test_nvidia_stt_integration.py
Normal file
71
tests/test_nvidia_stt_integration.py
Normal file
@@ -0,0 +1,71 @@
|
||||
import asyncio
|
||||
import wave
|
||||
|
||||
from voice_assistant.stt.nvidia import NvidiaSTTAdapter
|
||||
|
||||
|
||||
AUDIO_FILE = (
|
||||
"/home/ubuntu/voice-assistant/audios/chafik_normalize.wav"
|
||||
)
|
||||
|
||||
CHUNK_FRAMES = 1600
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
stt = NvidiaSTTAdapter(
|
||||
server="127.0.0.1:9000",
|
||||
language_code="pt-BR",
|
||||
automatic_punctuation=True,
|
||||
)
|
||||
|
||||
print("Starting STT session...")
|
||||
|
||||
await stt.start_session()
|
||||
|
||||
print("STT session ready.")
|
||||
|
||||
async def send_audio() -> None:
|
||||
with wave.open(AUDIO_FILE, "rb") as wav:
|
||||
while True:
|
||||
audio = wav.readframes(CHUNK_FRAMES)
|
||||
|
||||
if not audio:
|
||||
break
|
||||
|
||||
await stt.send_audio(audio)
|
||||
|
||||
async def receive_events() -> None:
|
||||
async for event in stt.events():
|
||||
print(
|
||||
f"[{event.type.upper():7}] {event.text}"
|
||||
)
|
||||
|
||||
sender = asyncio.create_task(send_audio())
|
||||
receiver = asyncio.create_task(receive_events())
|
||||
|
||||
try:
|
||||
await sender
|
||||
print("Audio stream sent.")
|
||||
|
||||
await stt.stop_session()
|
||||
|
||||
await receiver
|
||||
|
||||
finally:
|
||||
if not sender.done():
|
||||
sender.cancel()
|
||||
|
||||
if not receiver.done():
|
||||
receiver.cancel()
|
||||
|
||||
await asyncio.gather(
|
||||
sender,
|
||||
receiver,
|
||||
return_exceptions=True,
|
||||
)
|
||||
|
||||
print("STT session finished.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
170
tests/test_protocol.py
Normal file
170
tests/test_protocol.py
Normal file
@@ -0,0 +1,170 @@
|
||||
"""
|
||||
Tests for the Voice Assistant WebSocket protocol.
|
||||
|
||||
Purpose:
|
||||
Verify that protocol message models enforce their declared
|
||||
event types and canonical audio contract.
|
||||
|
||||
Responsibilities:
|
||||
- Validate inbound protocol event discriminators.
|
||||
- Validate SessionStart audio configuration.
|
||||
- Reject messages whose type does not match their model.
|
||||
|
||||
Non-responsibilities:
|
||||
- Testing WebSocket transport behavior.
|
||||
- Testing session or turn lifecycle.
|
||||
- Testing STT, Agent, or TTS providers.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
from voice_assistant.audio.models import CANONICAL_AUDIO_FORMAT
|
||||
from voice_assistant.audio_gateway.app.protocol import (
|
||||
AgentResponse,
|
||||
AudioChunk,
|
||||
AudioStart,
|
||||
AudioStop,
|
||||
ErrorEvent,
|
||||
Interrupt,
|
||||
Interrupted,
|
||||
SessionEnd,
|
||||
SessionEnded,
|
||||
SessionReady,
|
||||
SessionStart,
|
||||
STTFinal,
|
||||
STTPartial,
|
||||
TurnEnded,
|
||||
TurnStarted,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("model", "expected_type"),
|
||||
[
|
||||
(SessionStart, "session.start"),
|
||||
(AudioStart, "audio.start"),
|
||||
(AudioChunk, "audio.chunk"),
|
||||
(AudioStop, "audio.stop"),
|
||||
(Interrupt, "interrupt"),
|
||||
(SessionEnd, "session.end"),
|
||||
],
|
||||
)
|
||||
def test_protocol_event_has_expected_type(
|
||||
model,
|
||||
expected_type,
|
||||
):
|
||||
payload = {
|
||||
"type": expected_type,
|
||||
}
|
||||
|
||||
if model in (AudioChunk, AudioStop, Interrupt):
|
||||
payload["turn_id"] = 1
|
||||
|
||||
if model is AudioChunk:
|
||||
payload["sequence_number"] = 0
|
||||
|
||||
event = model.model_validate(payload)
|
||||
|
||||
assert event.type == expected_type
|
||||
|
||||
|
||||
def test_protocol_rejects_wrong_event_type():
|
||||
with pytest.raises(ValidationError):
|
||||
AudioStart.model_validate(
|
||||
{
|
||||
"type": "audio.stop",
|
||||
}
|
||||
)
|
||||
|
||||
def test_session_ready_output():
|
||||
event = SessionReady(
|
||||
protocol_version="1.0",
|
||||
session_id="session-1",
|
||||
audio=CANONICAL_AUDIO_FORMAT,
|
||||
)
|
||||
|
||||
assert event.model_dump() == {
|
||||
"type": "session.ready",
|
||||
"protocol_version": "1.0",
|
||||
"session_id": "session-1",
|
||||
"audio": {
|
||||
"encoding": "pcm_s16le",
|
||||
"sample_rate": 16000,
|
||||
"channels": 1,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("model", "event_type"),
|
||||
[
|
||||
(TurnStarted, "turn.started"),
|
||||
(STTPartial, "stt.partial"),
|
||||
(STTFinal, "stt.final"),
|
||||
(AgentResponse, "agent.response"),
|
||||
(TurnEnded, "turn.ended"),
|
||||
(Interrupted, "interrupted"),
|
||||
],
|
||||
)
|
||||
def test_turn_output_event_types(
|
||||
model,
|
||||
event_type,
|
||||
):
|
||||
data = {
|
||||
"session_id": "session-1",
|
||||
"turn_id": 1,
|
||||
}
|
||||
|
||||
if model in (STTPartial, STTFinal, AgentResponse):
|
||||
data["text"] = "test"
|
||||
|
||||
event = model(**data)
|
||||
|
||||
assert event.type == event_type
|
||||
|
||||
|
||||
def test_session_ended_output():
|
||||
event = SessionEnded(
|
||||
session_id="session-1",
|
||||
)
|
||||
|
||||
assert event.type == "session.ended"
|
||||
assert event.session_id == "session-1"
|
||||
|
||||
def test_error_event():
|
||||
event = ErrorEvent(
|
||||
session_id="session-1",
|
||||
turn_id=1,
|
||||
code="TEST_ERROR",
|
||||
message="Something went wrong.",
|
||||
retryable=True,
|
||||
details={
|
||||
"reason": "test",
|
||||
},
|
||||
)
|
||||
|
||||
assert event.model_dump() == {
|
||||
"type": "error",
|
||||
"session_id": "session-1",
|
||||
"turn_id": 1,
|
||||
"code": "TEST_ERROR",
|
||||
"message": "Something went wrong.",
|
||||
"retryable": True,
|
||||
"details": {
|
||||
"reason": "test",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_error_event_defaults():
|
||||
event = ErrorEvent(
|
||||
code="TEST_ERROR",
|
||||
message="Something went wrong.",
|
||||
)
|
||||
|
||||
assert event.type == "error"
|
||||
assert event.session_id is None
|
||||
assert event.turn_id is None
|
||||
assert event.retryable is False
|
||||
assert event.details == {}
|
||||
|
||||
83
tests/test_provider_factories.py
Normal file
83
tests/test_provider_factories.py
Normal file
@@ -0,0 +1,83 @@
|
||||
"""
|
||||
Provider factory tests for the Voice Assistant.
|
||||
|
||||
Purpose:
|
||||
Verify that the provider composition layer resolves the configured
|
||||
providers dynamically and passes the configured options correctly.
|
||||
|
||||
Responsibilities:
|
||||
- Validate STT provider creation.
|
||||
- Validate Agent provider creation.
|
||||
- Validate TTS provider creation.
|
||||
- Verify provider-specific options are propagated to each adapter.
|
||||
|
||||
Non-responsibilities:
|
||||
- Testing provider internals.
|
||||
- Testing WebSocket behavior.
|
||||
- Testing external services.
|
||||
|
||||
Architecture:
|
||||
AppConfig + ProviderCatalog
|
||||
|
|
||||
v
|
||||
provider factories
|
||||
|
|
||||
v
|
||||
concrete Adapter instances
|
||||
"""
|
||||
|
||||
from voice_assistant.agent.mock import MockAgentAdapter
|
||||
from voice_assistant.config.loader import (
|
||||
load_config,
|
||||
load_provider_catalog,
|
||||
)
|
||||
from voice_assistant.providers import (
|
||||
create_agent_adapter,
|
||||
create_stt_adapter,
|
||||
create_tts_adapter,
|
||||
)
|
||||
from voice_assistant.stt.nvidia import NvidiaSTTAdapter
|
||||
from voice_assistant.tts.voxcpm import VoxCPMTTSAdapter
|
||||
|
||||
|
||||
def test_create_stt_adapter_from_config():
|
||||
config = load_config()
|
||||
catalog = load_provider_catalog()
|
||||
|
||||
stt = create_stt_adapter(
|
||||
config=config,
|
||||
catalog=catalog,
|
||||
)
|
||||
|
||||
assert isinstance(stt, NvidiaSTTAdapter)
|
||||
assert stt.server == "127.0.0.1:9000"
|
||||
assert stt.language_code == "pt-BR"
|
||||
assert stt.automatic_punctuation is True
|
||||
|
||||
|
||||
def test_create_agent_adapter_from_config():
|
||||
config = load_config()
|
||||
catalog = load_provider_catalog()
|
||||
|
||||
agent = create_agent_adapter(
|
||||
config=config,
|
||||
catalog=catalog,
|
||||
)
|
||||
|
||||
assert isinstance(agent, MockAgentAdapter)
|
||||
|
||||
|
||||
def test_create_tts_adapter_from_config():
|
||||
config = load_config()
|
||||
catalog = load_provider_catalog()
|
||||
|
||||
tts = create_tts_adapter(
|
||||
config=config,
|
||||
catalog=catalog,
|
||||
)
|
||||
|
||||
assert isinstance(tts, VoxCPMTTSAdapter)
|
||||
assert tts.server == "http://127.0.0.1:8000"
|
||||
assert tts.model == "openbmb/VoxCPM2"
|
||||
assert tts.voice == "default"
|
||||
|
||||
75
tests/test_providers.py
Normal file
75
tests/test_providers.py
Normal file
@@ -0,0 +1,75 @@
|
||||
import pytest
|
||||
|
||||
|
||||
from voice_assistant.agent.mock import MockAgentAdapter
|
||||
from voice_assistant.config.loader import load_provider_catalog
|
||||
from voice_assistant.tts.voxcpm import VoxCPMTTSAdapter
|
||||
from voice_assistant.config.models import ProviderCatalog
|
||||
from voice_assistant.providers import load_provider_class
|
||||
|
||||
|
||||
def test_provider_must_implement_component_contract():
|
||||
catalog = ProviderCatalog.model_validate(
|
||||
{
|
||||
"providers": {
|
||||
"tts": {
|
||||
"invalid": {
|
||||
"name": "Invalid TTS Provider",
|
||||
"implementation": (
|
||||
"voice_assistant.agent.mock:"
|
||||
"MockAgentAdapter"
|
||||
),
|
||||
"capabilities": {},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
with pytest.raises(
|
||||
TypeError,
|
||||
match="must implement TTSAdapter",
|
||||
):
|
||||
load_provider_class(
|
||||
catalog=catalog,
|
||||
component="tts",
|
||||
provider="invalid",
|
||||
)
|
||||
|
||||
|
||||
def test_load_tts_provider_class():
|
||||
catalog = load_provider_catalog()
|
||||
|
||||
provider_class = load_provider_class(
|
||||
catalog=catalog,
|
||||
component="tts",
|
||||
provider="voxcpm",
|
||||
)
|
||||
|
||||
assert provider_class is VoxCPMTTSAdapter
|
||||
|
||||
|
||||
def test_load_agent_provider_class():
|
||||
catalog = load_provider_catalog()
|
||||
|
||||
provider_class = load_provider_class(
|
||||
catalog=catalog,
|
||||
component="agent",
|
||||
provider="mock",
|
||||
)
|
||||
|
||||
assert provider_class is MockAgentAdapter
|
||||
|
||||
|
||||
def test_unknown_provider_fails():
|
||||
catalog = load_provider_catalog()
|
||||
|
||||
with pytest.raises(
|
||||
ValueError,
|
||||
match="is not registered",
|
||||
):
|
||||
load_provider_class(
|
||||
catalog=catalog,
|
||||
component="tts",
|
||||
provider="does-not-exist",
|
||||
)
|
||||
281
tests/test_session_turn_lifecycle.py
Normal file
281
tests/test_session_turn_lifecycle.py
Normal file
@@ -0,0 +1,281 @@
|
||||
"""
|
||||
Tests for SessionState and TurnManager lifecycle ownership.
|
||||
|
||||
Purpose:
|
||||
Define and verify the separation between session lifecycle state
|
||||
and semantic turn state.
|
||||
|
||||
Responsibilities:
|
||||
- Verify SessionState owns session and audio sequencing state.
|
||||
- Verify TurnManager owns semantic turn transitions.
|
||||
- Verify the expected lifecycle of a complete turn.
|
||||
- Verify interruption and invalid transitions.
|
||||
|
||||
Non-responsibilities:
|
||||
- Testing STT, Agent, or TTS providers.
|
||||
- Testing WebSocket transport.
|
||||
- Testing provider configuration.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from voice_assistant.audio_gateway.app.session import SessionState
|
||||
from voice_assistant.turn_manager import TurnManager, TurnState
|
||||
|
||||
|
||||
def test_session_creates_sequential_turn_ids():
|
||||
session = SessionState()
|
||||
|
||||
first_turn = session.start_turn()
|
||||
|
||||
assert first_turn == 1
|
||||
assert session.active_turn_id == 1
|
||||
assert session.expected_audio_sequence == 0
|
||||
|
||||
session.end_turn(first_turn)
|
||||
|
||||
second_turn = session.start_turn()
|
||||
|
||||
assert second_turn == 2
|
||||
assert session.active_turn_id == 2
|
||||
assert session.expected_audio_sequence == 0
|
||||
|
||||
|
||||
def test_session_owns_audio_sequence():
|
||||
session = SessionState()
|
||||
|
||||
turn_id = session.start_turn()
|
||||
|
||||
session.accept_audio_chunk(
|
||||
turn_id=turn_id,
|
||||
sequence_number=0,
|
||||
payload_size=320,
|
||||
)
|
||||
|
||||
assert session.expected_audio_sequence == 1
|
||||
|
||||
session.accept_audio_chunk(
|
||||
turn_id=turn_id,
|
||||
sequence_number=1,
|
||||
payload_size=320,
|
||||
)
|
||||
|
||||
assert session.expected_audio_sequence == 2
|
||||
|
||||
|
||||
def test_session_rejects_out_of_order_audio():
|
||||
session = SessionState()
|
||||
|
||||
turn_id = session.start_turn()
|
||||
|
||||
with pytest.raises(
|
||||
ValueError,
|
||||
match="Expected sequence_number=0",
|
||||
):
|
||||
session.accept_audio_chunk(
|
||||
turn_id=turn_id,
|
||||
sequence_number=1,
|
||||
payload_size=320,
|
||||
)
|
||||
|
||||
|
||||
def test_turn_manager_owns_semantic_turn_state():
|
||||
manager = TurnManager()
|
||||
|
||||
turn = manager.start(turn_id=1)
|
||||
|
||||
assert turn.turn_id == 1
|
||||
assert turn.state == TurnState.LISTENING
|
||||
|
||||
manager.on_partial(
|
||||
turn_id=1,
|
||||
text="Olá",
|
||||
)
|
||||
|
||||
assert turn.state == TurnState.LISTENING
|
||||
|
||||
manager.on_final(
|
||||
turn_id=1,
|
||||
text="Olá!",
|
||||
)
|
||||
|
||||
assert turn.state == TurnState.PROCESSING
|
||||
|
||||
manager.on_agent_response(turn_id=1)
|
||||
|
||||
assert turn.state == TurnState.SPEAKING
|
||||
|
||||
manager.on_tts_complete(turn_id=1)
|
||||
|
||||
assert turn.state == TurnState.COMPLETED
|
||||
|
||||
|
||||
def test_turn_manager_rejects_invalid_transition():
|
||||
manager = TurnManager()
|
||||
|
||||
manager.start(turn_id=1)
|
||||
|
||||
with pytest.raises(
|
||||
RuntimeError,
|
||||
match="Cannot start speaking",
|
||||
):
|
||||
manager.on_agent_response(turn_id=1)
|
||||
|
||||
|
||||
def test_turn_manager_interrupts_active_turn():
|
||||
manager = TurnManager()
|
||||
|
||||
manager.start(turn_id=1)
|
||||
|
||||
turn = manager.interrupt(turn_id=1)
|
||||
|
||||
assert turn.state == TurnState.INTERRUPTED
|
||||
assert manager.active_turn is turn
|
||||
|
||||
|
||||
def test_turn_manager_stop_requires_completed_state():
|
||||
manager = TurnManager()
|
||||
|
||||
manager.start(turn_id=1)
|
||||
|
||||
manager.on_final(
|
||||
turn_id=1,
|
||||
text="Olá!",
|
||||
)
|
||||
|
||||
with pytest.raises(
|
||||
RuntimeError,
|
||||
match="Cannot stop turn while turn is processing",
|
||||
):
|
||||
manager.stop(turn_id=1)
|
||||
|
||||
|
||||
def test_turn_manager_stop_allows_completed_turn():
|
||||
manager = TurnManager()
|
||||
|
||||
manager.start(turn_id=1)
|
||||
|
||||
manager.on_final(
|
||||
turn_id=1,
|
||||
text="Olá!",
|
||||
)
|
||||
|
||||
manager.on_agent_response(turn_id=1)
|
||||
|
||||
manager.on_tts_complete(turn_id=1)
|
||||
|
||||
turn = manager.stop(turn_id=1)
|
||||
|
||||
assert turn.state == TurnState.COMPLETED
|
||||
assert turn.final_text == "Olá!"
|
||||
|
||||
def test_session_and_turn_manager_complete_same_turn():
|
||||
session = SessionState()
|
||||
manager = TurnManager()
|
||||
|
||||
turn_id = session.start_turn()
|
||||
turn = manager.start(turn_id)
|
||||
|
||||
assert session.active_turn_id == turn_id
|
||||
assert turn.turn_id == turn_id
|
||||
assert turn.state == TurnState.LISTENING
|
||||
|
||||
manager.on_final(
|
||||
turn_id,
|
||||
"Olá!",
|
||||
)
|
||||
|
||||
manager.on_agent_response(turn_id)
|
||||
manager.on_tts_complete(turn_id)
|
||||
|
||||
completed_turn = manager.stop(turn_id)
|
||||
|
||||
assert completed_turn.state == TurnState.COMPLETED
|
||||
|
||||
manager.clear(turn_id)
|
||||
session.end_turn(turn_id)
|
||||
|
||||
assert manager.active_turn is None
|
||||
assert session.active_turn_id is None
|
||||
|
||||
def test_session_and_turn_manager_must_use_same_turn_id():
|
||||
session = SessionState()
|
||||
manager = TurnManager()
|
||||
|
||||
session_turn_id = session.start_turn()
|
||||
manager.start(1)
|
||||
|
||||
with pytest.raises(
|
||||
ValueError,
|
||||
match="Unexpected turn_id=2",
|
||||
):
|
||||
session.accept_audio_chunk(
|
||||
turn_id=2,
|
||||
sequence_number=0,
|
||||
payload_size=320,
|
||||
)
|
||||
|
||||
assert session.active_turn_id == session_turn_id
|
||||
assert manager.active_turn is not None
|
||||
assert manager.active_turn.turn_id == 1
|
||||
|
||||
|
||||
def test_session_ends_turn_after_turn_manager_completion():
|
||||
session = SessionState()
|
||||
manager = TurnManager()
|
||||
|
||||
turn_id = session.start_turn()
|
||||
manager.start(turn_id)
|
||||
|
||||
manager.on_final(
|
||||
turn_id,
|
||||
"Olá!",
|
||||
)
|
||||
|
||||
manager.on_agent_response(turn_id)
|
||||
manager.on_tts_complete(turn_id)
|
||||
|
||||
manager.stop(turn_id)
|
||||
manager.clear(turn_id)
|
||||
session.end_turn(turn_id)
|
||||
|
||||
assert session.active_turn_id is None
|
||||
|
||||
|
||||
def test_session_and_turn_manager_interrupt_same_turn():
|
||||
session = SessionState()
|
||||
manager = TurnManager()
|
||||
|
||||
turn_id = session.start_turn()
|
||||
manager.start(turn_id)
|
||||
|
||||
session.interrupt(turn_id)
|
||||
interrupted_turn = manager.interrupt(turn_id)
|
||||
|
||||
assert session.active_turn_id is None
|
||||
assert manager.active_turn is interrupted_turn
|
||||
assert interrupted_turn.turn_id == turn_id
|
||||
assert interrupted_turn.state == TurnState.INTERRUPTED
|
||||
|
||||
|
||||
def test_session_can_start_next_turn_after_interrupt():
|
||||
session = SessionState()
|
||||
manager = TurnManager()
|
||||
|
||||
first_turn_id = session.start_turn()
|
||||
manager.start(first_turn_id)
|
||||
|
||||
session.interrupt(first_turn_id)
|
||||
manager.interrupt(first_turn_id)
|
||||
manager.clear(first_turn_id)
|
||||
|
||||
second_turn_id = session.start_turn()
|
||||
manager.start(second_turn_id)
|
||||
|
||||
assert second_turn_id == 2
|
||||
assert session.active_turn_id == 2
|
||||
assert manager.active_turn is not None
|
||||
assert manager.active_turn.turn_id == 2
|
||||
assert manager.active_turn.state == TurnState.LISTENING
|
||||
|
||||
|
||||
307
tests/test_stt_abort.py
Normal file
307
tests/test_stt_abort.py
Normal file
@@ -0,0 +1,307 @@
|
||||
"""
|
||||
Tests for STT session abort behavior.
|
||||
|
||||
Purpose:
|
||||
Verify that STT implementations support interruption
|
||||
without delivering stale transcription results.
|
||||
|
||||
Responsibilities:
|
||||
- Validate immediate abort.
|
||||
- Verify pending transcript events are discarded.
|
||||
- Verify repeated abort calls are safe.
|
||||
- Verify audio is rejected after abort.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import asyncio
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
from voice_assistant.stt.nvidia import NvidiaSTTAdapter
|
||||
from voice_assistant.stt.models import TranscriptEvent
|
||||
|
||||
from voice_assistant.stt.mock import MockSTTAdapter
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mock_stt_abort_discards_pending_events():
|
||||
stt = MockSTTAdapter()
|
||||
|
||||
await stt.start_session()
|
||||
|
||||
await stt.emit_partial("Olá")
|
||||
await stt.emit_final("Olá, tudo bem?")
|
||||
|
||||
await stt.abort_session()
|
||||
|
||||
events = [
|
||||
event
|
||||
async for event in stt.events()
|
||||
]
|
||||
|
||||
assert events == []
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mock_stt_abort_rejects_audio():
|
||||
stt = MockSTTAdapter()
|
||||
|
||||
await stt.start_session()
|
||||
await stt.abort_session()
|
||||
|
||||
with pytest.raises(
|
||||
RuntimeError,
|
||||
match="STT session has not been started",
|
||||
):
|
||||
await stt.send_audio(b"\x00\x01")
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mock_stt_abort_is_idempotent():
|
||||
stt = MockSTTAdapter()
|
||||
|
||||
await stt.start_session()
|
||||
|
||||
await stt.abort_session()
|
||||
await stt.abort_session()
|
||||
|
||||
assert stt._started is False
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mock_stt_abort_unblocks_event_consumer():
|
||||
stt = MockSTTAdapter()
|
||||
|
||||
await stt.start_session()
|
||||
|
||||
async def consume():
|
||||
return [
|
||||
event
|
||||
async for event in stt.events()
|
||||
]
|
||||
|
||||
import asyncio
|
||||
|
||||
consumer = asyncio.create_task(consume())
|
||||
|
||||
# Allow the consumer to start waiting for events.
|
||||
await asyncio.sleep(0)
|
||||
|
||||
await stt.abort_session()
|
||||
|
||||
events = await asyncio.wait_for(
|
||||
consumer,
|
||||
timeout=1.0,
|
||||
)
|
||||
|
||||
assert events == []
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_nvidia_abort_without_started_session():
|
||||
stt = NvidiaSTTAdapter()
|
||||
|
||||
await stt.abort_session()
|
||||
await stt.abort_session()
|
||||
|
||||
assert stt._started is False
|
||||
assert stt._websocket is None
|
||||
assert stt._http is None
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_nvidia_abort_discards_pending_events():
|
||||
stt = NvidiaSTTAdapter()
|
||||
|
||||
stt._started = True
|
||||
|
||||
await stt._events.put(
|
||||
TranscriptEvent(
|
||||
type="partial",
|
||||
text="Transcrição antiga",
|
||||
)
|
||||
)
|
||||
|
||||
await stt.abort_session()
|
||||
|
||||
events = [
|
||||
event
|
||||
async for event in stt.events()
|
||||
]
|
||||
|
||||
assert events == []
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_nvidia_abort_unblocks_consumer():
|
||||
stt = NvidiaSTTAdapter()
|
||||
stt._started = True
|
||||
|
||||
async def consume():
|
||||
return [
|
||||
event
|
||||
async for event in stt.events()
|
||||
]
|
||||
|
||||
consumer = asyncio.create_task(consume())
|
||||
|
||||
await asyncio.sleep(0)
|
||||
|
||||
await stt.abort_session()
|
||||
|
||||
events = await asyncio.wait_for(
|
||||
consumer,
|
||||
timeout=1.0,
|
||||
)
|
||||
|
||||
assert events == []
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_nvidia_abort_closes_resources_without_done():
|
||||
stt = NvidiaSTTAdapter()
|
||||
|
||||
websocket = AsyncMock()
|
||||
http_client = AsyncMock()
|
||||
|
||||
stt._started = True
|
||||
stt._websocket = websocket
|
||||
stt._http = http_client
|
||||
|
||||
await stt.abort_session()
|
||||
|
||||
websocket.close.assert_awaited_once()
|
||||
websocket.send.assert_not_awaited()
|
||||
http_client.aclose.assert_awaited_once()
|
||||
|
||||
assert stt._websocket is None
|
||||
assert stt._http is None
|
||||
assert stt._started is False
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_nvidia_abort_cancels_receiver_task():
|
||||
stt = NvidiaSTTAdapter()
|
||||
stt._started = True
|
||||
|
||||
started = asyncio.Event()
|
||||
finished = asyncio.Event()
|
||||
|
||||
async def receiver():
|
||||
started.set()
|
||||
|
||||
try:
|
||||
await asyncio.Event().wait()
|
||||
finally:
|
||||
finished.set()
|
||||
|
||||
task = asyncio.create_task(receiver())
|
||||
stt._receiver_task = task
|
||||
|
||||
await started.wait()
|
||||
|
||||
await stt.abort_session()
|
||||
|
||||
assert task.done()
|
||||
assert task.cancelled()
|
||||
assert finished.is_set()
|
||||
assert stt._receiver_task is None
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_nvidia_concurrent_abort():
|
||||
"""
|
||||
Verify that concurrent abort requests produce a single
|
||||
end-of-stream marker and leave the adapter stopped.
|
||||
"""
|
||||
stt = NvidiaSTTAdapter()
|
||||
stt._started = True
|
||||
|
||||
await asyncio.gather(
|
||||
stt.abort_session(),
|
||||
stt.abort_session(),
|
||||
)
|
||||
|
||||
assert stt._started is False
|
||||
assert stt._receiver_task is None
|
||||
assert stt._websocket is None
|
||||
assert stt._http is None
|
||||
|
||||
assert stt._events.qsize() == 1
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_nvidia_abort_does_not_deliver_late_events():
|
||||
"""Queued transcripts must not survive an explicit abort."""
|
||||
stt = NvidiaSTTAdapter()
|
||||
stt._started = True
|
||||
|
||||
await stt._events.put(
|
||||
TranscriptEvent(
|
||||
type="partial",
|
||||
text="Resultado antigo",
|
||||
)
|
||||
)
|
||||
|
||||
await stt.abort_session()
|
||||
|
||||
events = [
|
||||
event
|
||||
async for event in stt.events()
|
||||
]
|
||||
|
||||
assert events == []
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_nvidia_concurrent_abort_closes_resources_once():
|
||||
"""
|
||||
Verify that concurrent abort requests do not close
|
||||
provider resources more than once.
|
||||
"""
|
||||
stt = NvidiaSTTAdapter()
|
||||
|
||||
websocket = AsyncMock()
|
||||
http_client = AsyncMock()
|
||||
|
||||
stt._started = True
|
||||
stt._websocket = websocket
|
||||
stt._http = http_client
|
||||
|
||||
# Force both abort operations to overlap during cleanup.
|
||||
close_started = asyncio.Event()
|
||||
allow_close = asyncio.Event()
|
||||
|
||||
async def slow_close():
|
||||
close_started.set()
|
||||
await allow_close.wait()
|
||||
|
||||
websocket.close.side_effect = slow_close
|
||||
|
||||
first_abort = asyncio.create_task(
|
||||
stt.abort_session()
|
||||
)
|
||||
|
||||
try:
|
||||
await asyncio.wait_for(
|
||||
close_started.wait(),
|
||||
timeout=1.0,
|
||||
)
|
||||
|
||||
second_abort = asyncio.create_task(
|
||||
stt.abort_session()
|
||||
)
|
||||
|
||||
# Give the second abort a chance to enter cleanup.
|
||||
await asyncio.sleep(0)
|
||||
|
||||
finally:
|
||||
allow_close.set()
|
||||
|
||||
await asyncio.gather(
|
||||
first_abort,
|
||||
second_abort,
|
||||
)
|
||||
|
||||
websocket.close.assert_awaited_once()
|
||||
http_client.aclose.assert_awaited_once()
|
||||
|
||||
assert stt._events.qsize() == 1
|
||||
39
tests/test_tts.py
Normal file
39
tests/test_tts.py
Normal file
@@ -0,0 +1,39 @@
|
||||
import pytest
|
||||
|
||||
from voice_assistant.stt.mock import MockSTTAdapter
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mock_stt_lifecycle():
|
||||
stt = MockSTTAdapter()
|
||||
|
||||
await stt.start_session()
|
||||
|
||||
await stt.send_audio(b"\x00\x00" * 160)
|
||||
|
||||
await stt.emit_partial("eu gostaria de saber")
|
||||
await stt.emit_final(
|
||||
"eu gostaria de saber o horário de funcionamento"
|
||||
)
|
||||
|
||||
await stt.stop_session()
|
||||
|
||||
events = [event async for event in stt.events()]
|
||||
|
||||
assert len(events) == 2
|
||||
|
||||
assert events[0].type == "partial"
|
||||
assert events[0].text == "eu gostaria de saber"
|
||||
|
||||
assert events[1].type == "final"
|
||||
assert events[1].text == (
|
||||
"eu gostaria de saber o horário de funcionamento"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mock_stt_rejects_audio_before_start():
|
||||
stt = MockSTTAdapter()
|
||||
|
||||
with pytest.raises(RuntimeError, match="has not been started"):
|
||||
await stt.send_audio(b"\x00\x00")
|
||||
32
tests/test_tts_adapter.py
Normal file
32
tests/test_tts_adapter.py
Normal file
@@ -0,0 +1,32 @@
|
||||
import pytest
|
||||
|
||||
from voice_assistant.tts import MockTTSAdapter
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mock_tts_synthesizes_audio_chunks():
|
||||
tts= MockTTSAdapter()
|
||||
|
||||
chunks = [
|
||||
chunk
|
||||
async for chunk in tts.synthesize("Olá, tudo bem?")
|
||||
]
|
||||
|
||||
assert chunks == [
|
||||
b"\x01\x02\x03\x04",
|
||||
b"\x05\x06\x07\x08",
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mock_tts_records_text():
|
||||
tts = MockTTSAdapter()
|
||||
|
||||
chunks = [
|
||||
chunk
|
||||
async for chunk in tts.synthesize("Olá")
|
||||
]
|
||||
|
||||
assert chunks
|
||||
assert tts.calls == ["Olá"], "A mensagem de retorno deve ser Olá"
|
||||
|
||||
194
tests/test_turn_manager.py
Normal file
194
tests/test_turn_manager.py
Normal file
@@ -0,0 +1,194 @@
|
||||
import pytest
|
||||
|
||||
from voice_assistant.turn_manager import (
|
||||
TurnManager,
|
||||
TurnState,
|
||||
)
|
||||
|
||||
|
||||
def test_start_turn():
|
||||
manager = TurnManager()
|
||||
|
||||
turn = manager.start(1)
|
||||
|
||||
assert turn.turn_id == 1
|
||||
assert turn.state == TurnState.LISTENING
|
||||
assert turn.partial_text == ""
|
||||
assert turn.final_text is None
|
||||
|
||||
|
||||
def test_partial_replaces_previous_partial():
|
||||
manager = TurnManager()
|
||||
|
||||
manager.start(1)
|
||||
|
||||
manager.on_partial(1, "Ô Alex")
|
||||
assert manager.active_turn is not None
|
||||
assert manager.active_turn.partial_text == "Ô Alex"
|
||||
|
||||
manager.on_partial(1, "Ô Alex tudo bem")
|
||||
assert manager.active_turn.partial_text == "Ô Alex tudo bem"
|
||||
|
||||
|
||||
def test_final_moves_turn_to_processing():
|
||||
manager = TurnManager()
|
||||
|
||||
manager.start(1)
|
||||
|
||||
manager.on_partial(1, "Ô Alex tudo bem")
|
||||
manager.on_final(1, "Ô Alex tudo bem?")
|
||||
|
||||
assert manager.active_turn is not None
|
||||
assert manager.active_turn.final_text == "Ô Alex tudo bem?"
|
||||
assert manager.active_turn.partial_text == "Ô Alex tudo bem?"
|
||||
assert manager.active_turn.state == TurnState.PROCESSING
|
||||
|
||||
|
||||
def test_cannot_start_second_turn():
|
||||
manager = TurnManager()
|
||||
|
||||
manager.start(1)
|
||||
|
||||
with pytest.raises(
|
||||
RuntimeError,
|
||||
match="A turn is already active",
|
||||
):
|
||||
manager.start(2)
|
||||
|
||||
|
||||
def test_cannot_stop_before_completed():
|
||||
manager = TurnManager()
|
||||
|
||||
manager.start(1)
|
||||
|
||||
with pytest.raises(
|
||||
RuntimeError,
|
||||
match="final transcript",
|
||||
):
|
||||
manager.stop(1)
|
||||
|
||||
|
||||
def test_interrupt():
|
||||
manager = TurnManager()
|
||||
|
||||
manager.start(1)
|
||||
manager.interrupt(1)
|
||||
|
||||
assert manager.active_turn is not None
|
||||
assert (
|
||||
manager.active_turn.state
|
||||
== TurnState.INTERRUPTED
|
||||
)
|
||||
|
||||
|
||||
def test_clear_turn():
|
||||
manager = TurnManager()
|
||||
|
||||
manager.start(1)
|
||||
manager.clear(1)
|
||||
|
||||
assert manager.active_turn is None
|
||||
|
||||
|
||||
def test_agent_response_moves_turn_to_speaking():
|
||||
manager = TurnManager()
|
||||
|
||||
manager.start(1)
|
||||
manager.on_final(
|
||||
1,
|
||||
"Qual o horário?",
|
||||
)
|
||||
|
||||
manager.on_agent_response(1)
|
||||
|
||||
assert manager.active_turn is not None
|
||||
assert (
|
||||
manager.active_turn.state
|
||||
== TurnState.SPEAKING
|
||||
)
|
||||
|
||||
|
||||
def test_tts_completion_moves_turn_to_completed():
|
||||
manager = TurnManager()
|
||||
|
||||
manager.start(1)
|
||||
|
||||
manager.on_final(
|
||||
1,
|
||||
"Qual o horário?",
|
||||
)
|
||||
|
||||
manager.on_agent_response(1)
|
||||
manager.on_tts_complete(1)
|
||||
|
||||
assert manager.active_turn is not None
|
||||
assert (
|
||||
manager.active_turn.state
|
||||
== TurnState.COMPLETED
|
||||
)
|
||||
|
||||
|
||||
def test_agent_response_requires_processing_state():
|
||||
manager = TurnManager()
|
||||
|
||||
manager.start(1)
|
||||
|
||||
with pytest.raises(
|
||||
RuntimeError,
|
||||
match="turn is listening",
|
||||
):
|
||||
manager.on_agent_response(1)
|
||||
|
||||
|
||||
def test_tts_completion_requires_speaking_state():
|
||||
manager = TurnManager()
|
||||
|
||||
manager.start(1)
|
||||
|
||||
with pytest.raises(
|
||||
RuntimeError,
|
||||
match="turn is listening",
|
||||
):
|
||||
manager.on_tts_complete(1)
|
||||
|
||||
|
||||
def test_cannot_stop_before_completed():
|
||||
manager = TurnManager()
|
||||
|
||||
manager.start(1)
|
||||
|
||||
with pytest.raises(
|
||||
RuntimeError,
|
||||
match="Cannot stop turn while turn is listening",
|
||||
):
|
||||
manager.stop(1)
|
||||
|
||||
|
||||
def test_clear_removes_completed_turn():
|
||||
manager = TurnManager()
|
||||
|
||||
manager.start(1)
|
||||
|
||||
manager.on_final(
|
||||
1,
|
||||
"Olá!",
|
||||
)
|
||||
|
||||
manager.on_agent_response(1)
|
||||
manager.on_tts_complete(1)
|
||||
|
||||
manager.clear(1)
|
||||
|
||||
assert manager.active_turn is None
|
||||
|
||||
|
||||
def test_clear_rejects_unexpected_turn_id():
|
||||
manager = TurnManager()
|
||||
|
||||
manager.start(1)
|
||||
|
||||
with pytest.raises(
|
||||
ValueError,
|
||||
match="Unexpected turn_id=2",
|
||||
):
|
||||
manager.clear(2)
|
||||
820
tests/test_voice_pipeline.py
Normal file
820
tests/test_voice_pipeline.py
Normal file
@@ -0,0 +1,820 @@
|
||||
"""
|
||||
Tests for the Voice Pipeline lifecycle orchestration.
|
||||
|
||||
Purpose:
|
||||
Verify that the VoicePipeline coordinates SessionState and
|
||||
TurnManager without coupling either component to transport details.
|
||||
|
||||
Responsibilities:
|
||||
- Validate turn creation.
|
||||
- Validate semantic turn completion.
|
||||
- Validate coordinated interruption.
|
||||
- Verify SessionState and TurnManager remain independently modeled.
|
||||
|
||||
Non-responsibilities:
|
||||
- Testing WebSocket transport.
|
||||
- Testing STT, Agent, or TTS providers.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import asyncio
|
||||
|
||||
from voice_assistant.audio_gateway.app.session import SessionState
|
||||
from voice_assistant.turn_manager import TurnManager, TurnState
|
||||
from voice_assistant.voice_pipeline import VoicePipeline
|
||||
from voice_assistant.agent.mock import MockAgentAdapter
|
||||
from voice_assistant.tts.mock import MockTTSAdapter
|
||||
|
||||
|
||||
def create_pipeline() -> tuple[
|
||||
VoicePipeline,
|
||||
SessionState,
|
||||
TurnManager,
|
||||
MockAgentAdapter,
|
||||
MockTTSAdapter,
|
||||
]:
|
||||
session = SessionState()
|
||||
turn_manager = TurnManager()
|
||||
agent = MockAgentAdapter()
|
||||
tts = MockTTSAdapter()
|
||||
|
||||
pipeline = VoicePipeline(
|
||||
session=session,
|
||||
turn_manager=turn_manager,
|
||||
agent=agent,
|
||||
tts=tts,
|
||||
)
|
||||
|
||||
return (
|
||||
pipeline,
|
||||
session,
|
||||
turn_manager,
|
||||
agent,
|
||||
tts,
|
||||
)
|
||||
|
||||
|
||||
def test_pipeline_starts_turn():
|
||||
pipeline, session, turn_manager, _, _ = create_pipeline()
|
||||
|
||||
turn = pipeline.start_turn()
|
||||
|
||||
assert turn.turn_id == 1
|
||||
assert turn.state == TurnState.LISTENING
|
||||
|
||||
assert session.active_turn_id == 1
|
||||
assert turn_manager.active_turn is turn
|
||||
|
||||
|
||||
def test_pipeline_completes_turn():
|
||||
pipeline, session, turn_manager, _, _ = create_pipeline()
|
||||
|
||||
turn = pipeline.start_turn()
|
||||
|
||||
turn_manager.on_final(
|
||||
turn.turn_id,
|
||||
"Olá!",
|
||||
)
|
||||
turn_manager.on_agent_response(turn.turn_id)
|
||||
turn_manager.on_tts_complete(turn.turn_id)
|
||||
|
||||
completed_turn = pipeline.complete_turn(
|
||||
turn.turn_id,
|
||||
)
|
||||
|
||||
assert completed_turn.state == TurnState.COMPLETED
|
||||
assert session.active_turn_id is None
|
||||
assert turn_manager.active_turn is None
|
||||
|
||||
|
||||
def test_pipeline_rejects_incomplete_turn():
|
||||
pipeline, session, turn_manager, _, _ = create_pipeline()
|
||||
|
||||
turn = pipeline.start_turn()
|
||||
|
||||
with pytest.raises(
|
||||
RuntimeError,
|
||||
match="Cannot stop turn while turn is listening",
|
||||
):
|
||||
pipeline.complete_turn(turn.turn_id)
|
||||
|
||||
# Failed completion must not destroy the active turn.
|
||||
assert session.active_turn_id == turn.turn_id
|
||||
assert turn_manager.active_turn is turn
|
||||
|
||||
|
||||
def test_pipeline_interrupts_turn():
|
||||
pipeline, session, turn_manager, _, _ = create_pipeline()
|
||||
|
||||
turn = pipeline.start_turn()
|
||||
|
||||
interrupted_turn = pipeline.interrupt_turn(
|
||||
turn.turn_id,
|
||||
)
|
||||
|
||||
assert interrupted_turn.state == TurnState.INTERRUPTED
|
||||
assert session.active_turn_id is None
|
||||
assert turn_manager.active_turn is None
|
||||
|
||||
|
||||
def test_pipeline_can_start_turn_after_interrupt():
|
||||
pipeline, session, turn_manager, _, _ = create_pipeline()
|
||||
|
||||
first_turn = pipeline.start_turn()
|
||||
|
||||
pipeline.interrupt_turn(
|
||||
first_turn.turn_id,
|
||||
)
|
||||
|
||||
second_turn = pipeline.start_turn()
|
||||
|
||||
assert second_turn.turn_id == 2
|
||||
assert second_turn.state == TurnState.LISTENING
|
||||
assert session.active_turn_id == 2
|
||||
assert turn_manager.active_turn is second_turn
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_pipeline_processes_transcript_through_agent():
|
||||
(
|
||||
pipeline,
|
||||
session,
|
||||
turn_manager,
|
||||
agent,
|
||||
_,
|
||||
) = create_pipeline()
|
||||
|
||||
turn = pipeline.start_turn()
|
||||
|
||||
turn_manager.on_final(
|
||||
turn.turn_id,
|
||||
"Olá!",
|
||||
)
|
||||
|
||||
result = await pipeline.process_transcript(
|
||||
session_id=session.session_id,
|
||||
turn_id=turn.turn_id,
|
||||
text="Olá!",
|
||||
)
|
||||
|
||||
assert result.response_text == (
|
||||
"Resposta simulada para: Olá!"
|
||||
)
|
||||
|
||||
assert agent.calls == [
|
||||
(
|
||||
session.session_id,
|
||||
turn.turn_id,
|
||||
"Olá!",
|
||||
)
|
||||
]
|
||||
|
||||
assert turn_manager.active_turn is not None
|
||||
assert (
|
||||
turn_manager.active_turn.state
|
||||
== TurnState.SPEAKING
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_pipeline_streams_tts_audio():
|
||||
(
|
||||
pipeline,
|
||||
session,
|
||||
turn_manager,
|
||||
_,
|
||||
tts,
|
||||
) = create_pipeline()
|
||||
|
||||
turn = pipeline.start_turn()
|
||||
|
||||
turn_manager.on_final(
|
||||
turn.turn_id,
|
||||
"Olá!",
|
||||
)
|
||||
|
||||
result = await pipeline.process_transcript(
|
||||
session_id=session.session_id,
|
||||
turn_id=turn.turn_id,
|
||||
text="Olá!",
|
||||
)
|
||||
|
||||
chunks = [
|
||||
chunk
|
||||
async for chunk in pipeline.synthesize_response(
|
||||
result.response_text,
|
||||
)
|
||||
]
|
||||
|
||||
assert chunks == [
|
||||
b"\x01\x02\x03\x04",
|
||||
b"\x05\x06\x07\x08",
|
||||
]
|
||||
|
||||
assert tts.calls == [
|
||||
"Resposta simulada para: Olá!",
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_pipeline_processes_complete_voice_turn():
|
||||
(
|
||||
pipeline,
|
||||
session,
|
||||
turn_manager,
|
||||
agent,
|
||||
tts,
|
||||
) = create_pipeline()
|
||||
|
||||
turn = pipeline.start_turn()
|
||||
|
||||
turn_manager.on_final(
|
||||
turn.turn_id,
|
||||
"Olá!",
|
||||
)
|
||||
|
||||
result = await pipeline.process_transcript(
|
||||
session_id=session.session_id,
|
||||
turn_id=turn.turn_id,
|
||||
text="Olá!",
|
||||
)
|
||||
|
||||
audio_chunks = [
|
||||
chunk
|
||||
async for chunk in pipeline.synthesize_response(
|
||||
result.response_text,
|
||||
)
|
||||
]
|
||||
|
||||
turn_manager.on_tts_complete(
|
||||
turn.turn_id,
|
||||
)
|
||||
|
||||
assert result.response_text == (
|
||||
"Resposta simulada para: Olá!"
|
||||
)
|
||||
|
||||
assert audio_chunks == [
|
||||
b"\x01\x02\x03\x04",
|
||||
b"\x05\x06\x07\x08",
|
||||
]
|
||||
|
||||
assert turn_manager.active_turn is not None
|
||||
assert (
|
||||
turn_manager.active_turn.state
|
||||
== TurnState.COMPLETED
|
||||
)
|
||||
|
||||
assert agent.calls == [
|
||||
(
|
||||
session.session_id,
|
||||
turn.turn_id,
|
||||
"Olá!",
|
||||
)
|
||||
]
|
||||
|
||||
assert tts.calls == [
|
||||
"Resposta simulada para: Olá!",
|
||||
]
|
||||
|
||||
|
||||
def test_pipeline_handles_partial_transcript():
|
||||
pipeline, _, _, _, _ = create_pipeline()
|
||||
|
||||
turn = pipeline.start_turn()
|
||||
|
||||
pipeline.on_partial(
|
||||
turn.turn_id,
|
||||
"Olá",
|
||||
)
|
||||
|
||||
assert turn.partial_text == "Olá"
|
||||
assert turn.state == TurnState.LISTENING
|
||||
|
||||
|
||||
def test_pipeline_handles_final_transcript():
|
||||
pipeline, _, _, _, _ = create_pipeline()
|
||||
|
||||
turn = pipeline.start_turn()
|
||||
|
||||
pipeline.on_final(
|
||||
turn.turn_id,
|
||||
"Olá!",
|
||||
)
|
||||
|
||||
assert turn.final_text == "Olá!"
|
||||
assert turn.partial_text == "Olá!"
|
||||
assert turn.state == TurnState.PROCESSING
|
||||
|
||||
|
||||
def test_pipeline_handles_tts_completion():
|
||||
pipeline, _, _, _, _ = create_pipeline()
|
||||
|
||||
turn = pipeline.start_turn()
|
||||
|
||||
pipeline.on_final(
|
||||
turn.turn_id,
|
||||
"Olá!",
|
||||
)
|
||||
|
||||
# The Agent transition is part of the normal semantic lifecycle.
|
||||
pipeline.on_agent_response(
|
||||
turn.turn_id,
|
||||
)
|
||||
|
||||
pipeline.on_tts_complete(
|
||||
turn.turn_id,
|
||||
)
|
||||
|
||||
assert turn.state == TurnState.COMPLETED
|
||||
|
||||
|
||||
def test_pipeline_closes_session_without_active_turn():
|
||||
pipeline, session, turn_manager, _, _ = create_pipeline()
|
||||
|
||||
pipeline.close_session()
|
||||
|
||||
assert session.closed is True
|
||||
assert session.active_turn_id is None
|
||||
assert turn_manager.active_turn is None
|
||||
|
||||
|
||||
def test_pipeline_closes_session_with_active_turn():
|
||||
pipeline, session, turn_manager, _, _ = create_pipeline()
|
||||
|
||||
turn = pipeline.start_turn()
|
||||
|
||||
pipeline.close_session()
|
||||
|
||||
assert session.closed is True
|
||||
assert session.active_turn_id is None
|
||||
assert turn_manager.active_turn is None
|
||||
assert turn.state == TurnState.INTERRUPTED
|
||||
|
||||
|
||||
def test_pipeline_cannot_start_turn_after_session_close():
|
||||
pipeline, session, turn_manager, _, _ = create_pipeline()
|
||||
|
||||
pipeline.close_session()
|
||||
|
||||
with pytest.raises(
|
||||
RuntimeError,
|
||||
match="Session is closed",
|
||||
):
|
||||
pipeline.start_turn()
|
||||
|
||||
|
||||
|
||||
def test_pipeline_interrupt_if_active():
|
||||
pipeline, session, turn_manager, _, _ = create_pipeline()
|
||||
|
||||
turn = pipeline.start_turn()
|
||||
|
||||
interrupted = pipeline.interrupt_if_active(
|
||||
turn.turn_id,
|
||||
)
|
||||
|
||||
assert interrupted is True
|
||||
assert session.active_turn_id is None
|
||||
assert turn_manager.active_turn is None
|
||||
assert turn.state == TurnState.INTERRUPTED
|
||||
|
||||
|
||||
def test_pipeline_interrupt_if_active_unknown_turn():
|
||||
pipeline, session, turn_manager, _, _ = create_pipeline()
|
||||
|
||||
turn = pipeline.start_turn()
|
||||
|
||||
interrupted = pipeline.interrupt_if_active(
|
||||
turn.turn_id + 1,
|
||||
)
|
||||
|
||||
assert interrupted is False
|
||||
assert session.active_turn_id == turn.turn_id
|
||||
assert turn_manager.active_turn is turn
|
||||
assert turn.state == TurnState.LISTENING
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_pipeline_registers_turn_task():
|
||||
pipeline, _, _, _, _ = create_pipeline()
|
||||
|
||||
pipeline.start_turn()
|
||||
|
||||
async def worker():
|
||||
await asyncio.sleep(0)
|
||||
|
||||
task = asyncio.create_task(worker())
|
||||
|
||||
pipeline.register_turn_task(task)
|
||||
|
||||
assert pipeline.turn_task is task
|
||||
|
||||
await task
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_pipeline_rejects_duplicate_running_task():
|
||||
pipeline, _, _, _, _ = create_pipeline()
|
||||
|
||||
pipeline.start_turn()
|
||||
|
||||
started = asyncio.Event()
|
||||
release = asyncio.Event()
|
||||
|
||||
async def worker():
|
||||
started.set()
|
||||
await release.wait()
|
||||
|
||||
task = asyncio.create_task(worker())
|
||||
|
||||
try:
|
||||
await started.wait()
|
||||
|
||||
pipeline.register_turn_task(task)
|
||||
|
||||
with pytest.raises(
|
||||
RuntimeError,
|
||||
match="A turn execution task is already active",
|
||||
):
|
||||
pipeline.register_turn_task(task)
|
||||
|
||||
finally:
|
||||
release.set()
|
||||
await task
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_pipeline_rejects_task_without_active_turn():
|
||||
pipeline, _, _, _, _ = create_pipeline()
|
||||
|
||||
async def worker():
|
||||
await asyncio.sleep(0)
|
||||
|
||||
task = asyncio.create_task(worker())
|
||||
|
||||
try:
|
||||
with pytest.raises(
|
||||
RuntimeError,
|
||||
match="Cannot register task without an active turn",
|
||||
):
|
||||
pipeline.register_turn_task(task)
|
||||
finally:
|
||||
await task
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_pipeline_cancels_running_turn_task():
|
||||
pipeline, _, _, _, _ = create_pipeline()
|
||||
pipeline.start_turn()
|
||||
|
||||
started = asyncio.Event()
|
||||
cancelled = asyncio.Event()
|
||||
|
||||
async def worker():
|
||||
started.set()
|
||||
|
||||
try:
|
||||
await asyncio.Event().wait()
|
||||
finally:
|
||||
cancelled.set()
|
||||
|
||||
task = asyncio.create_task(worker())
|
||||
|
||||
await started.wait()
|
||||
|
||||
pipeline.register_turn_task(task)
|
||||
|
||||
await pipeline.cancel_turn_task()
|
||||
|
||||
assert cancelled.is_set()
|
||||
assert task.done()
|
||||
assert task.cancelled()
|
||||
assert pipeline.turn_task is None
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_pipeline_cancel_without_registered_task():
|
||||
pipeline, _, _, _, _ = create_pipeline()
|
||||
|
||||
await pipeline.cancel_turn_task()
|
||||
|
||||
assert pipeline.turn_task is None
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_pipeline_cancel_completed_task():
|
||||
pipeline, _, _, _, _ = create_pipeline()
|
||||
pipeline.start_turn()
|
||||
|
||||
async def worker():
|
||||
return None
|
||||
|
||||
task = asyncio.create_task(worker())
|
||||
pipeline.register_turn_task(task)
|
||||
|
||||
await task
|
||||
await pipeline.cancel_turn_task()
|
||||
|
||||
assert task.done()
|
||||
assert pipeline.turn_task is None
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_pipeline_rejects_self_cancellation():
|
||||
"""
|
||||
Verify that a turn task cannot cancel itself.
|
||||
|
||||
The task reference must remain available so another
|
||||
task can subsequently perform cleanup.
|
||||
"""
|
||||
pipeline, _, _, _, _ = create_pipeline()
|
||||
pipeline.start_turn()
|
||||
|
||||
async def worker():
|
||||
await pipeline.cancel_turn_task()
|
||||
|
||||
task = asyncio.create_task(worker())
|
||||
pipeline.register_turn_task(task)
|
||||
|
||||
# Self-cancellation must be rejected.
|
||||
with pytest.raises(
|
||||
RuntimeError,
|
||||
match="A turn task cannot cancel itself",
|
||||
):
|
||||
await task
|
||||
|
||||
# The failed task remains registered until explicit cleanup.
|
||||
assert pipeline.turn_task is task
|
||||
|
||||
# Cleanup is performed from the test task, not the worker.
|
||||
with pytest.raises(
|
||||
RuntimeError,
|
||||
match="A turn task cannot cancel itself",
|
||||
):
|
||||
await pipeline.cancel_turn_task()
|
||||
|
||||
# The reference must be released after the completed task
|
||||
# has been awaited, even when its exception is propagated.
|
||||
assert pipeline.turn_task is None
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_pipeline_propagates_turn_task_failure():
|
||||
pipeline, _, _, _, _ = create_pipeline()
|
||||
pipeline.start_turn()
|
||||
|
||||
async def worker():
|
||||
raise ValueError(
|
||||
"Provider execution failed."
|
||||
)
|
||||
|
||||
task = asyncio.create_task(worker())
|
||||
pipeline.register_turn_task(task)
|
||||
|
||||
# Allow the worker to execute and finish with its error.
|
||||
await asyncio.sleep(0)
|
||||
|
||||
assert task.done()
|
||||
|
||||
with pytest.raises(
|
||||
ValueError,
|
||||
match="Provider execution failed",
|
||||
):
|
||||
await pipeline.cancel_turn_task()
|
||||
|
||||
assert task.done()
|
||||
assert pipeline.turn_task is None
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_pipeline_propagates_waiter_cancellation():
|
||||
pipeline, _, _, _, _ = create_pipeline()
|
||||
pipeline.start_turn()
|
||||
|
||||
worker_started = asyncio.Event()
|
||||
allow_cleanup = asyncio.Event()
|
||||
|
||||
async def worker():
|
||||
worker_started.set()
|
||||
|
||||
try:
|
||||
await asyncio.Event().wait()
|
||||
finally:
|
||||
await allow_cleanup.wait()
|
||||
|
||||
task = asyncio.create_task(worker())
|
||||
|
||||
await worker_started.wait()
|
||||
pipeline.register_turn_task(task)
|
||||
|
||||
waiter = asyncio.create_task(
|
||||
pipeline.cancel_turn_task()
|
||||
)
|
||||
|
||||
try:
|
||||
# Allow cancellation to reach the worker.
|
||||
await asyncio.sleep(0)
|
||||
|
||||
waiter.cancel()
|
||||
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await waiter
|
||||
|
||||
# The worker is still completing its cancellation cleanup.
|
||||
assert pipeline.turn_task is task
|
||||
|
||||
finally:
|
||||
allow_cleanup.set()
|
||||
|
||||
await pipeline.cancel_turn_task()
|
||||
|
||||
assert task.done()
|
||||
assert pipeline.turn_task is None
|
||||
|
||||
|
||||
def test_pipeline_identifies_active_turn():
|
||||
pipeline, _, _, _, _ = create_pipeline()
|
||||
|
||||
turn = pipeline.start_turn()
|
||||
|
||||
assert pipeline.is_active_turn(turn.turn_id) is True
|
||||
|
||||
|
||||
def test_pipeline_rejects_inactive_turn_id():
|
||||
pipeline, _, _, _, _ = create_pipeline()
|
||||
|
||||
turn = pipeline.start_turn()
|
||||
|
||||
assert pipeline.is_active_turn(turn.turn_id + 1) is False
|
||||
|
||||
pipeline.interrupt_turn(turn.turn_id)
|
||||
|
||||
assert pipeline.is_active_turn(turn.turn_id) is False
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_pipeline_waits_for_completed_turn_task():
|
||||
pipeline, _, _, _, _ = create_pipeline()
|
||||
pipeline.start_turn()
|
||||
|
||||
completed = asyncio.Event()
|
||||
|
||||
async def worker():
|
||||
completed.set()
|
||||
|
||||
task = asyncio.create_task(worker())
|
||||
pipeline.register_turn_task(task)
|
||||
|
||||
await pipeline.wait_turn_task()
|
||||
|
||||
assert completed.is_set()
|
||||
assert task.done()
|
||||
assert pipeline.turn_task is None
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_pipeline_wait_propagates_task_failure():
|
||||
pipeline, _, _, _, _ = create_pipeline()
|
||||
pipeline.start_turn()
|
||||
|
||||
async def worker():
|
||||
raise ValueError("Agent processing failed.")
|
||||
|
||||
task = asyncio.create_task(worker())
|
||||
pipeline.register_turn_task(task)
|
||||
|
||||
with pytest.raises(
|
||||
ValueError,
|
||||
match="Agent processing failed",
|
||||
):
|
||||
await pipeline.wait_turn_task()
|
||||
|
||||
assert pipeline.turn_task is None
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_pipeline_wait_without_registered_task():
|
||||
pipeline, _, _, _, _ = create_pipeline()
|
||||
|
||||
await pipeline.wait_turn_task()
|
||||
|
||||
assert pipeline.turn_task is None
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_pipeline_cancel_rejects_invalid_timeout():
|
||||
pipeline, _, _, _, _ = create_pipeline()
|
||||
|
||||
with pytest.raises(
|
||||
ValueError,
|
||||
match="Turn cancellation timeout must be positive",
|
||||
):
|
||||
await pipeline.cancel_turn_task(timeout=0)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_pipeline_cancel_completes_within_timeout():
|
||||
pipeline, _, _, _, _ = create_pipeline()
|
||||
pipeline.start_turn()
|
||||
|
||||
started = asyncio.Event()
|
||||
|
||||
async def worker():
|
||||
started.set()
|
||||
await asyncio.Event().wait()
|
||||
|
||||
task = asyncio.create_task(worker())
|
||||
|
||||
await started.wait()
|
||||
pipeline.register_turn_task(task)
|
||||
|
||||
await pipeline.cancel_turn_task(timeout=1.0)
|
||||
|
||||
assert task.done()
|
||||
assert task.cancelled()
|
||||
assert pipeline.turn_task is None
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_pipeline_cancel_timeout_preserves_task_ownership():
|
||||
pipeline, _, _, _, _ = create_pipeline()
|
||||
pipeline.start_turn()
|
||||
|
||||
started = asyncio.Event()
|
||||
allow_cleanup = asyncio.Event()
|
||||
cancellation_received = asyncio.Event()
|
||||
|
||||
async def worker():
|
||||
started.set()
|
||||
|
||||
try:
|
||||
await asyncio.Event().wait()
|
||||
|
||||
finally:
|
||||
cancellation_received.set()
|
||||
await allow_cleanup.wait()
|
||||
|
||||
task = asyncio.create_task(worker())
|
||||
|
||||
await started.wait()
|
||||
pipeline.register_turn_task(task)
|
||||
|
||||
try:
|
||||
with pytest.raises(
|
||||
TimeoutError,
|
||||
match="Turn task cancellation timed out",
|
||||
):
|
||||
await pipeline.cancel_turn_task(timeout=0.01)
|
||||
|
||||
assert cancellation_received.is_set()
|
||||
assert not task.done()
|
||||
assert pipeline.turn_task is task
|
||||
|
||||
finally:
|
||||
allow_cleanup.set()
|
||||
await pipeline.cancel_turn_task(timeout=1.0)
|
||||
|
||||
assert task.done()
|
||||
assert pipeline.turn_task is None
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_pipeline_rejects_new_turn_while_previous_task_runs():
|
||||
pipeline, session, _, _, _ = create_pipeline()
|
||||
|
||||
first_turn = pipeline.start_turn()
|
||||
|
||||
started = asyncio.Event()
|
||||
release = asyncio.Event()
|
||||
|
||||
async def worker():
|
||||
started.set()
|
||||
await release.wait()
|
||||
|
||||
task = asyncio.create_task(worker())
|
||||
|
||||
await started.wait()
|
||||
pipeline.register_turn_task(task)
|
||||
|
||||
# Simulate semantic interruption without task cancellation.
|
||||
pipeline.interrupt_turn(first_turn.turn_id)
|
||||
|
||||
try:
|
||||
with pytest.raises(
|
||||
RuntimeError,
|
||||
match="previous turn task is still running",
|
||||
):
|
||||
pipeline.start_turn()
|
||||
|
||||
assert session.active_turn_id is None
|
||||
|
||||
finally:
|
||||
release.set()
|
||||
await pipeline.wait_turn_task()
|
||||
|
||||
second_turn = pipeline.start_turn()
|
||||
|
||||
assert second_turn.turn_id == 2
|
||||
assert session.active_turn_id == 2
|
||||
|
||||
147
tests/test_voxcpm_tts.py
Normal file
147
tests/test_voxcpm_tts.py
Normal file
@@ -0,0 +1,147 @@
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from voice_assistant.tts.voxcpm import VoxCPMTTSAdapter
|
||||
|
||||
|
||||
class MockHTTPStream:
|
||||
def __init__(
|
||||
self,
|
||||
chunks: list[bytes],
|
||||
status_code: int = 200,
|
||||
) -> None:
|
||||
self.chunks = chunks
|
||||
self.status_code = status_code
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
def raise_for_status(self):
|
||||
if self.status_code >= 400:
|
||||
raise httpx.HTTPStatusError(
|
||||
"mock error",
|
||||
request=httpx.Request("POST", "http://test"),
|
||||
response=httpx.Response(
|
||||
status_code=self.status_code,
|
||||
),
|
||||
)
|
||||
|
||||
async def aiter_bytes(self):
|
||||
for chunk in self.chunks:
|
||||
yield chunk
|
||||
|
||||
|
||||
class MockHTTPClient:
|
||||
last_request = None
|
||||
|
||||
def __init__(self, *args, **kwargs) -> None:
|
||||
pass
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
def stream(self, method, url, json):
|
||||
self.last_request = {
|
||||
"method": method,
|
||||
"url": url,
|
||||
"json": json,
|
||||
}
|
||||
|
||||
MockHTTPClient.last_request = self.last_request
|
||||
|
||||
return MockHTTPStream(
|
||||
[
|
||||
b"\x01\x02\x03\x04",
|
||||
b"\x05\x06\x07\x08",
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_voxcpm_tts_streams_audio(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
httpx,
|
||||
"AsyncClient",
|
||||
MockHTTPClient,
|
||||
)
|
||||
|
||||
tts = VoxCPMTTSAdapter(
|
||||
server="http://127.0.0.1:8000",
|
||||
model="openbmb/VoxCPM2",
|
||||
voice="default",
|
||||
)
|
||||
|
||||
chunks = [
|
||||
chunk
|
||||
async for chunk in tts.synthesize(
|
||||
"Olá Alex, tudo bem?"
|
||||
)
|
||||
]
|
||||
|
||||
assert chunks == [
|
||||
b"\x01\x02\x03\x04",
|
||||
b"\x05\x06\x07\x08",
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_voxcpm_tts_sends_expected_payload(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
httpx,
|
||||
"AsyncClient",
|
||||
MockHTTPClient,
|
||||
)
|
||||
|
||||
tts = VoxCPMTTSAdapter(
|
||||
server="http://127.0.0.1:8000",
|
||||
model="openbmb/VoxCPM2",
|
||||
voice="default",
|
||||
)
|
||||
|
||||
chunks = [
|
||||
chunk
|
||||
async for chunk in tts.synthesize("Olá")
|
||||
]
|
||||
|
||||
assert chunks
|
||||
|
||||
request = MockHTTPClient.last_request
|
||||
|
||||
assert request is not None
|
||||
|
||||
assert request["method"] == "POST"
|
||||
assert request["url"] == (
|
||||
"http://127.0.0.1:8000/v1/audio/speech"
|
||||
)
|
||||
assert request["json"] == {
|
||||
"input": "Olá",
|
||||
"model": "openbmb/VoxCPM2",
|
||||
"voice": "default",
|
||||
"response_format": "pcm",
|
||||
"stream": True,
|
||||
"stream_format": "audio",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_voxcpm_tts_rejects_empty_text():
|
||||
tts = VoxCPMTTSAdapter(
|
||||
server="http://127.0.0.1:8000",
|
||||
model="openbmb/VoxCPM2",
|
||||
voice="default",
|
||||
)
|
||||
|
||||
with pytest.raises(
|
||||
ValueError,
|
||||
match="Text cannot be empty",
|
||||
):
|
||||
chunks = [
|
||||
chunk
|
||||
async for chunk in tts.synthesize("")
|
||||
]
|
||||
29
tests/test_voxcpm_tts_integration.py
Normal file
29
tests/test_voxcpm_tts_integration.py
Normal file
@@ -0,0 +1,29 @@
|
||||
import pytest
|
||||
|
||||
from voice_assistant.tts.voxcpm import VoxCPMTTSAdapter
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_voxcpm_tts_real():
|
||||
tts = VoxCPMTTSAdapter(
|
||||
server="http://127.0.0.1:8000",
|
||||
model="openbmb/VoxCPM2",
|
||||
voice="default",
|
||||
)
|
||||
|
||||
chunks = [
|
||||
chunk
|
||||
async for chunk in tts.synthesize(
|
||||
"Olá Alex, este é um teste do VoxCPM."
|
||||
)
|
||||
]
|
||||
|
||||
assert chunks
|
||||
assert all(isinstance(chunk, bytes) for chunk in chunks)
|
||||
|
||||
total_bytes = sum(len(chunk) for chunk in chunks)
|
||||
|
||||
assert total_bytes > 0
|
||||
|
||||
print(f"Chunks recebidos : {len(chunks)}")
|
||||
print(f"Bytes recebidos : {total_bytes}")
|
||||
Reference in New Issue
Block a user