Co-authored-by: Quentin Torroba <quentin.torroba@mistral.ai> Co-authored-by: Clement Sirieix <clem.sirieix@gmail.com> Co-authored-by: Kim-Adeline Miguel <kimadeline.miguel@mistral.ai> Co-authored-by: Simon Van de Kerckhove <simon.vandekerckhove@mistral.ai> Co-authored-by: Vincent Guilloux <vincent.guilloux@mistral.ai> Co-authored-by: Michel Thomazo <michel.thomazo@mistral.ai> Co-authored-by: Mistral Vibe <vibe@mistral.ai>
394 lines
13 KiB
Python
394 lines
13 KiB
Python
from __future__ import annotations
|
|
|
|
from unittest.mock import patch
|
|
|
|
import pytest
|
|
|
|
from tests.conftest import build_test_vibe_config
|
|
from tests.stubs.fake_audio_recorder import FakeAudioRecorder
|
|
from tests.stubs.fake_transcribe_client import FakeTranscribeClient
|
|
from vibe.cli.voice_manager.voice_manager import VoiceManager
|
|
from vibe.cli.voice_manager.voice_manager_port import (
|
|
RecordingStartError,
|
|
TranscribeState,
|
|
VoiceManagerListener,
|
|
VoiceToggleResult,
|
|
)
|
|
from vibe.core.audio_recorder.audio_recorder_port import (
|
|
AudioBackendUnavailableError,
|
|
NoAudioInputDeviceError,
|
|
)
|
|
from vibe.core.config import VibeConfig
|
|
from vibe.core.transcribe.transcribe_client_port import (
|
|
TranscribeDone,
|
|
TranscribeError,
|
|
TranscribeSessionCreated,
|
|
TranscribeTextDelta,
|
|
)
|
|
|
|
|
|
class StateListener(VoiceManagerListener):
|
|
def __init__(self) -> None:
|
|
self.state_changes: list[TranscribeState] = []
|
|
self.voice_mode_changes: list[bool] = []
|
|
self.transcribed_texts: list[str] = []
|
|
|
|
def on_transcribe_state_change(self, state: TranscribeState) -> None:
|
|
self.state_changes.append(state)
|
|
|
|
def on_voice_mode_change(self, enabled: bool) -> None:
|
|
self.voice_mode_changes.append(enabled)
|
|
|
|
def on_transcribe_text(self, text: str) -> None:
|
|
self.transcribed_texts.append(text)
|
|
|
|
|
|
def _make_manager(
|
|
*,
|
|
voice_mode_enabled: bool = True,
|
|
transcribe_client: FakeTranscribeClient | None = None,
|
|
) -> tuple[VoiceManager, FakeAudioRecorder, FakeTranscribeClient]:
|
|
recorder = FakeAudioRecorder()
|
|
client = transcribe_client or FakeTranscribeClient()
|
|
config = build_test_vibe_config(voice_mode_enabled=voice_mode_enabled)
|
|
manager = VoiceManager(
|
|
config_getter=lambda: config, audio_recorder=recorder, transcribe_client=client
|
|
)
|
|
return manager, recorder, client
|
|
|
|
|
|
class TestStartRecording:
|
|
@pytest.mark.asyncio
|
|
async def test_start_sets_state_to_recording(self) -> None:
|
|
manager, _, _ = _make_manager()
|
|
manager.start_recording()
|
|
assert manager.transcribe_state == TranscribeState.RECORDING
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_start_starts_audio_recorder_in_stream_mode(self) -> None:
|
|
manager, recorder, _ = _make_manager()
|
|
manager.start_recording()
|
|
assert recorder.is_recording
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_start_noop_when_not_idle(self) -> None:
|
|
manager, recorder, _ = _make_manager()
|
|
manager.start_recording()
|
|
recorder.set_peak(0.5)
|
|
manager.start_recording()
|
|
assert recorder.peak == 0.5
|
|
assert manager.transcribe_state == TranscribeState.RECORDING
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_start_raises_when_no_audio_input(self) -> None:
|
|
def raise_no_input(*a, **kw):
|
|
raise NoAudioInputDeviceError("no device")
|
|
|
|
manager, recorder, _ = _make_manager()
|
|
recorder.start = raise_no_input
|
|
with pytest.raises(RecordingStartError, match="No audio input device found"):
|
|
manager.start_recording()
|
|
assert manager.transcribe_state == TranscribeState.IDLE
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_start_raises_when_no_backend(self) -> None:
|
|
def raise_no_backend(*a, **kw):
|
|
raise AudioBackendUnavailableError("no sd")
|
|
|
|
manager, recorder, _ = _make_manager()
|
|
recorder.start = raise_no_backend
|
|
with pytest.raises(RecordingStartError, match="Audio backend is unavailable"):
|
|
manager.start_recording()
|
|
assert manager.transcribe_state == TranscribeState.IDLE
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_start_raises_when_no_transcribe_client(self) -> None:
|
|
recorder = FakeAudioRecorder()
|
|
config = build_test_vibe_config(voice_mode_enabled=True)
|
|
manager = VoiceManager(
|
|
config_getter=lambda: config,
|
|
audio_recorder=recorder,
|
|
transcribe_client=None,
|
|
)
|
|
with pytest.raises(
|
|
RecordingStartError, match="Transcribe client is not available"
|
|
):
|
|
manager.start_recording()
|
|
assert manager.transcribe_state == TranscribeState.IDLE
|
|
assert not recorder.is_recording
|
|
|
|
|
|
class TestStopRecording:
|
|
@pytest.mark.asyncio
|
|
async def test_stop_transitions_through_flushing_to_idle(self) -> None:
|
|
manager, _, _ = _make_manager()
|
|
listener = StateListener()
|
|
manager.add_listener(listener)
|
|
|
|
manager.start_recording()
|
|
await manager.stop_recording()
|
|
|
|
assert listener.state_changes == [
|
|
TranscribeState.RECORDING,
|
|
TranscribeState.FLUSHING,
|
|
TranscribeState.IDLE,
|
|
]
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_stop_noop_when_not_recording(self) -> None:
|
|
manager, _, _ = _make_manager()
|
|
listener = StateListener()
|
|
manager.add_listener(listener)
|
|
await manager.stop_recording()
|
|
assert listener.state_changes == []
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_stop_recovers_after_transcription_timeout(self) -> None:
|
|
import asyncio
|
|
|
|
class HangingTranscribeClient:
|
|
def __init__(self, provider=None, model=None) -> None:
|
|
pass
|
|
|
|
async def transcribe(self, audio_stream):
|
|
await asyncio.Event().wait()
|
|
return
|
|
yield # makes this an async generator
|
|
|
|
recorder = FakeAudioRecorder()
|
|
config = build_test_vibe_config(voice_mode_enabled=True)
|
|
manager = VoiceManager(
|
|
config_getter=lambda: config,
|
|
audio_recorder=recorder,
|
|
transcribe_client=HangingTranscribeClient(),
|
|
)
|
|
manager.start_recording()
|
|
|
|
with patch(
|
|
"vibe.cli.voice_manager.voice_manager.TRANSCRIPTION_DRAIN_TIMEOUT", 0.01
|
|
):
|
|
await manager.stop_recording()
|
|
|
|
assert manager.transcribe_state == TranscribeState.IDLE
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_stop_recovers_when_no_audio_was_sent(self) -> None:
|
|
client = FakeTranscribeClient(
|
|
events=[
|
|
TranscribeSessionCreated(),
|
|
TranscribeError(
|
|
message="Cannot flush audio before sending any audio bytes"
|
|
),
|
|
]
|
|
)
|
|
manager, recorder, _ = _make_manager(transcribe_client=client)
|
|
listener = StateListener()
|
|
manager.add_listener(listener)
|
|
|
|
manager.start_recording()
|
|
await manager.stop_recording()
|
|
|
|
assert manager.transcribe_state == TranscribeState.IDLE
|
|
assert not recorder.is_recording
|
|
|
|
|
|
class TestCancelRecording:
|
|
@pytest.mark.asyncio
|
|
async def test_cancel_from_recording(self) -> None:
|
|
manager, recorder, _ = _make_manager()
|
|
manager.start_recording()
|
|
manager.cancel_recording()
|
|
assert manager.transcribe_state == TranscribeState.IDLE
|
|
assert not recorder.is_recording
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cancel_then_start_not_corrupted_by_stale_finally(self) -> None:
|
|
import asyncio
|
|
|
|
class HangingTranscribeClient:
|
|
def __init__(self, provider=None, model=None) -> None:
|
|
pass
|
|
|
|
async def transcribe(self, audio_stream):
|
|
await asyncio.Event().wait()
|
|
return
|
|
yield
|
|
|
|
recorder = FakeAudioRecorder()
|
|
config = build_test_vibe_config(voice_mode_enabled=True)
|
|
manager = VoiceManager(
|
|
config_getter=lambda: config,
|
|
audio_recorder=recorder,
|
|
transcribe_client=HangingTranscribeClient(),
|
|
)
|
|
|
|
manager.start_recording()
|
|
manager.cancel_recording()
|
|
manager.start_recording()
|
|
|
|
await asyncio.sleep(0)
|
|
|
|
assert manager.transcribe_state == TranscribeState.RECORDING
|
|
|
|
def test_cancel_noop_when_idle(self) -> None:
|
|
manager, _, _ = _make_manager()
|
|
listener = StateListener()
|
|
manager.add_listener(listener)
|
|
manager.cancel_recording()
|
|
assert listener.state_changes == []
|
|
|
|
|
|
class TestToggleVoiceMode:
|
|
@patch.object(VibeConfig, "save_updates")
|
|
def test_toggle_enables(self, _mock_save) -> None:
|
|
manager, _, _ = _make_manager(voice_mode_enabled=False)
|
|
result = manager.toggle_voice_mode()
|
|
assert result == VoiceToggleResult(enabled=True)
|
|
|
|
@patch.object(VibeConfig, "save_updates")
|
|
def test_toggle_disables(self, _mock_save) -> None:
|
|
manager, _, _ = _make_manager(voice_mode_enabled=True)
|
|
result = manager.toggle_voice_mode()
|
|
assert result == VoiceToggleResult(enabled=False)
|
|
|
|
@pytest.mark.asyncio
|
|
@patch.object(VibeConfig, "save_updates")
|
|
async def test_toggle_disable_cancels_active_recording(self, _mock_save) -> None:
|
|
manager, _, _ = _make_manager(voice_mode_enabled=True)
|
|
manager.start_recording()
|
|
manager.toggle_voice_mode()
|
|
assert manager.transcribe_state == TranscribeState.IDLE
|
|
|
|
|
|
class TestListeners:
|
|
@pytest.mark.asyncio
|
|
async def test_listener_notified_on_state_change(self) -> None:
|
|
manager, _, _ = _make_manager()
|
|
listener = StateListener()
|
|
manager.add_listener(listener)
|
|
manager.start_recording()
|
|
assert listener.state_changes == [TranscribeState.RECORDING]
|
|
|
|
@patch.object(VibeConfig, "save_updates")
|
|
def test_listener_notified_on_voice_mode_change(self, _mock_save) -> None:
|
|
manager, _, _ = _make_manager(voice_mode_enabled=False)
|
|
listener = StateListener()
|
|
manager.add_listener(listener)
|
|
manager.toggle_voice_mode()
|
|
assert listener.voice_mode_changes == [True]
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_remove_listener(self) -> None:
|
|
manager, _, _ = _make_manager()
|
|
listener = StateListener()
|
|
manager.add_listener(listener)
|
|
manager.remove_listener(listener)
|
|
manager.start_recording()
|
|
assert listener.state_changes == []
|
|
|
|
def test_remove_nonexistent_listener_no_error(self) -> None:
|
|
manager, _, _ = _make_manager()
|
|
listener = StateListener()
|
|
manager.remove_listener(listener)
|
|
|
|
|
|
class TestPeak:
|
|
def test_peak_delegates_to_audio_recorder(self) -> None:
|
|
manager, recorder, _ = _make_manager()
|
|
recorder.set_peak(0.42)
|
|
assert manager.peak == 0.42
|
|
|
|
|
|
class TestTranscription:
|
|
@pytest.mark.asyncio
|
|
async def test_text_deltas_notify_listeners(self) -> None:
|
|
client = FakeTranscribeClient(
|
|
events=[
|
|
TranscribeSessionCreated(),
|
|
TranscribeTextDelta(text="hello "),
|
|
TranscribeTextDelta(text="world"),
|
|
TranscribeDone(),
|
|
]
|
|
)
|
|
manager, _, _ = _make_manager(transcribe_client=client)
|
|
listener = StateListener()
|
|
manager.add_listener(listener)
|
|
|
|
manager.start_recording()
|
|
await manager.stop_recording()
|
|
|
|
assert listener.transcribed_texts == ["hello ", "world"]
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_transcription_error_does_not_crash(self) -> None:
|
|
client = FakeTranscribeClient(
|
|
events=[
|
|
TranscribeSessionCreated(),
|
|
TranscribeTextDelta(text="partial"),
|
|
TranscribeError(message="something broke"),
|
|
TranscribeDone(),
|
|
]
|
|
)
|
|
manager, _, _ = _make_manager(transcribe_client=client)
|
|
listener = StateListener()
|
|
manager.add_listener(listener)
|
|
|
|
manager.start_recording()
|
|
await manager.stop_recording()
|
|
|
|
assert "partial" in listener.transcribed_texts
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cancel_during_transcription(self) -> None:
|
|
client = FakeTranscribeClient(
|
|
events=[TranscribeSessionCreated(), TranscribeTextDelta(text="hello")]
|
|
)
|
|
manager, _, _ = _make_manager(transcribe_client=client)
|
|
listener = StateListener()
|
|
manager.add_listener(listener)
|
|
|
|
manager.start_recording()
|
|
manager.cancel_recording()
|
|
|
|
assert manager.transcribe_state == TranscribeState.IDLE
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_session_created_is_silent(self) -> None:
|
|
client = FakeTranscribeClient(
|
|
events=[TranscribeSessionCreated(), TranscribeDone()]
|
|
)
|
|
manager, _, _ = _make_manager(transcribe_client=client)
|
|
listener = StateListener()
|
|
manager.add_listener(listener)
|
|
|
|
manager.start_recording()
|
|
await manager.stop_recording()
|
|
|
|
assert listener.transcribed_texts == []
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_transcription_exception_stops_recorder(self) -> None:
|
|
import asyncio
|
|
|
|
class CrashingTranscribeClient:
|
|
def __init__(self, provider=None, model=None) -> None:
|
|
pass
|
|
|
|
async def transcribe(self, audio_stream):
|
|
raise RuntimeError("network error")
|
|
yield # makes this an async generator
|
|
|
|
recorder = FakeAudioRecorder()
|
|
config = build_test_vibe_config(voice_mode_enabled=True)
|
|
manager = VoiceManager(
|
|
config_getter=lambda: config,
|
|
audio_recorder=recorder,
|
|
transcribe_client=CrashingTranscribeClient(),
|
|
)
|
|
manager.start_recording()
|
|
assert recorder.is_recording
|
|
|
|
await asyncio.sleep(0)
|
|
|
|
assert manager.transcribe_state == TranscribeState.IDLE
|
|
assert not recorder.is_recording
|