Co-authored-by: Bastien <bastien.baret@gmail.com>
Co-authored-by: Clément Sirieix <clement.sirieix@mistral.ai>
Co-authored-by: Julien Legrand <72564015+JulienLGRD@users.noreply.github.com>
Co-authored-by: Kim-Adeline Miguel <51720070+kimadeline@users.noreply.github.com>
Co-authored-by: Mathias Gesbert <mathias.gesbert@mistral.ai>
Co-authored-by: Pierre Rossinès <pierre.rossines@mistral.ai>
Co-authored-by: Quentin <quentin.torroba@mistral.ai>
Co-authored-by: Vincent G <10739306+VinceOPS@users.noreply.github.com>
Co-authored-by: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
Clément Drouin 2026-04-14 10:33:15 +02:00 committed by GitHub
parent e9a9217cc8
commit e1a25caa52
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
85 changed files with 2830 additions and 594 deletions

View file

View file

@ -0,0 +1,192 @@
from __future__ import annotations
import asyncio
from unittest.mock import MagicMock
import pytest
from tests.conftest import build_test_vibe_config
from tests.stubs.fake_audio_player import FakeAudioPlayer
from tests.stubs.fake_tts_client import FakeTTSClient
from vibe.cli.narrator_manager import NarratorManager, NarratorState
from vibe.cli.turn_summary import TurnSummaryResult
from vibe.core.tts.tts_client_port import TTSResult
def _make_manager(
*,
narrator_enabled: bool = True,
telemetry_client: MagicMock | None = None,
tts_client: FakeTTSClient | None = None,
) -> tuple[NarratorManager, FakeAudioPlayer]:
config = build_test_vibe_config(narrator_enabled=narrator_enabled)
audio_player = FakeAudioPlayer()
manager = NarratorManager(
config_getter=lambda: config,
audio_player=audio_player,
telemetry_client=telemetry_client,
)
manager._tts_client = tts_client or FakeTTSClient(
result=TTSResult(audio_data=b"fake-audio")
)
return manager, audio_player
def _find_telemetry_calls(mock: MagicMock, event_name: str) -> list[dict[str, object]]:
results: list[dict[str, object]] = []
for call in mock.send_telemetry_event.call_args_list:
if call[0][0] == event_name:
results.append(call[0][1])
return results
class TestTelemetryTracking:
@pytest.mark.asyncio
async def test_requested_event_on_turn_end(self) -> None:
mock_telemetry = MagicMock()
manager, _ = _make_manager(telemetry_client=mock_telemetry)
manager._turn_summary.start_turn("test")
manager.on_turn_end()
calls = _find_telemetry_calls(mock_telemetry, "vibe.read_aloud.requested")
assert len(calls) == 1
assert calls[0]["trigger"] == "autoplay_next_message"
assert isinstance(calls[0]["read_aloud_session_id"], str)
assert len(calls[0]["read_aloud_session_id"]) == 36
def test_no_requested_event_when_narrator_disabled(self) -> None:
mock_telemetry = MagicMock()
manager, _ = _make_manager(
narrator_enabled=False, telemetry_client=mock_telemetry
)
manager.on_turn_end()
calls = _find_telemetry_calls(mock_telemetry, "vibe.read_aloud.requested")
assert len(calls) == 0
@pytest.mark.asyncio
async def test_play_started_on_speak(self) -> None:
mock_telemetry = MagicMock()
manager, _ = _make_manager(telemetry_client=mock_telemetry)
manager._turn_summary.start_turn("test")
manager.on_turn_end()
manager._on_turn_summary(
TurnSummaryResult(
summary="Test summary", generation=manager._turn_summary.generation
)
)
await asyncio.sleep(0)
calls = _find_telemetry_calls(mock_telemetry, "vibe.read_aloud.play_started")
assert len(calls) == 1
assert calls[0]["speed_selection"] is None
assert isinstance(calls[0]["time_to_first_read_s"], float)
assert calls[0]["time_to_first_read_s"] >= 0.0
@pytest.mark.asyncio
async def test_ended_completed_on_playback_finished(self) -> None:
mock_telemetry = MagicMock()
manager, _ = _make_manager(telemetry_client=mock_telemetry)
manager._turn_summary.start_turn("test")
manager.on_turn_end()
manager._on_turn_summary(
TurnSummaryResult(
summary="Test summary", generation=manager._turn_summary.generation
)
)
await asyncio.sleep(0)
assert manager.state == NarratorState.SPEAKING
manager._on_playback_finished()
calls = _find_telemetry_calls(mock_telemetry, "vibe.read_aloud.ended")
assert len(calls) == 1
assert calls[0]["status"] == "completed"
assert calls[0]["error_type"] is None
assert calls[0]["speed_selection"] is None
assert isinstance(calls[0]["elapsed_seconds"], float)
assert calls[0]["elapsed_seconds"] >= 0.0
@pytest.mark.asyncio
async def test_ended_error_on_tts_failure(self) -> None:
mock_telemetry = MagicMock()
class FailingTTSClient:
def __init__(self, *_args: object, **_kwargs: object) -> None:
pass
async def speak(self, text: str) -> TTSResult:
raise RuntimeError("TTS failed")
async def close(self) -> None:
pass
manager, _ = _make_manager(telemetry_client=mock_telemetry)
manager._tts_client = FailingTTSClient()
manager._turn_summary.start_turn("test")
manager.on_turn_end()
manager._on_turn_summary(
TurnSummaryResult(
summary="Test summary", generation=manager._turn_summary.generation
)
)
await asyncio.sleep(0)
calls = _find_telemetry_calls(mock_telemetry, "vibe.read_aloud.ended")
assert len(calls) == 1
assert calls[0]["status"] == "error"
assert calls[0]["error_type"] == "RuntimeError"
@pytest.mark.asyncio
async def test_ended_canceled_on_cancel(self) -> None:
mock_telemetry = MagicMock()
manager, _ = _make_manager(telemetry_client=mock_telemetry)
manager._turn_summary.start_turn("test")
manager.on_turn_end()
assert manager.state == NarratorState.SUMMARIZING
manager.cancel()
calls = _find_telemetry_calls(mock_telemetry, "vibe.read_aloud.ended")
assert len(calls) == 1
assert calls[0]["status"] == "canceled"
@pytest.mark.asyncio
async def test_cancel_during_speaking_fires_single_ended_event(self) -> None:
mock_telemetry = MagicMock()
manager, _ = _make_manager(telemetry_client=mock_telemetry)
manager._turn_summary.start_turn("test")
manager.on_turn_end()
manager._on_turn_summary(
TurnSummaryResult(
summary="Test summary", generation=manager._turn_summary.generation
)
)
await asyncio.sleep(0)
assert manager.state == NarratorState.SPEAKING
manager.cancel()
# Simulate the delayed callback that would come from call_soon_threadsafe
manager._on_playback_finished()
calls = _find_telemetry_calls(mock_telemetry, "vibe.read_aloud.ended")
assert len(calls) == 1
assert calls[0]["status"] == "canceled"
@pytest.mark.asyncio
async def test_no_error_without_telemetry_client(self) -> None:
manager, _ = _make_manager()
manager._turn_summary.start_turn("test")
manager.on_turn_end()
manager._on_turn_summary(
TurnSummaryResult(
summary="Test summary", generation=manager._turn_summary.generation
)
)
await asyncio.sleep(0)
manager._on_playback_finished()

View file

@ -0,0 +1,61 @@
from __future__ import annotations
from vibe.cli.narrator_manager.telemetry import ReadAloudTrackingState
class TestReadAloudTrackingState:
def test_default_state(self) -> None:
state = ReadAloudTrackingState()
assert state.session_id == ""
assert state.request_time == 0.0
assert state.play_start_time == 0.0
def test_reset_generates_session_id(self) -> None:
state = ReadAloudTrackingState()
state.reset()
assert state.session_id != ""
assert len(state.session_id) == 36 # UUID format
def test_reset_generates_unique_session_ids(self) -> None:
state = ReadAloudTrackingState()
state.reset()
first_id = state.session_id
state.reset()
assert state.session_id != first_id
def test_reset_clears_play_start_time(self) -> None:
state = ReadAloudTrackingState()
state.reset()
state.mark_play_started()
assert state.play_start_time > 0.0
state.reset()
assert state.play_start_time == 0.0
def test_mark_play_started(self) -> None:
state = ReadAloudTrackingState()
state.reset()
state.mark_play_started()
assert state.play_start_time > 0.0
def test_time_to_first_read_s(self) -> None:
state = ReadAloudTrackingState()
state.reset()
state.mark_play_started()
ttfr = state.time_to_first_read_s()
assert ttfr >= 0.0
def test_time_to_first_read_s_before_play(self) -> None:
state = ReadAloudTrackingState()
state.reset()
assert state.time_to_first_read_s() == 0.0
def test_elapsed_since_play_s(self) -> None:
state = ReadAloudTrackingState()
state.reset()
state.mark_play_started()
elapsed = state.elapsed_since_play_s()
assert elapsed >= 0.0
def test_elapsed_since_play_s_before_play(self) -> None:
state = ReadAloudTrackingState()
assert state.elapsed_since_play_s() == 0.0