vibe/tests/snapshots/test_ui_snapshot_narrator_flow.py
Mathias Gesbert 9c1c32e058
v2.7.2 (#556)
Co-authored-by: Clément Sirieix <clement.sirieix@mistral.ai>
Co-authored-by: Vincent G <10739306+VinceOPS@users.noreply.github.com>
Co-authored-by: Mistral Vibe <vibe@mistral.ai>
2026-04-01 18:38:22 +02:00

154 lines
5.4 KiB
Python

from __future__ import annotations
import asyncio
from typing import Any, cast
from textual.pilot import Pilot
from tests.conftest import build_test_vibe_config
from tests.mock.utils import mock_llm_chunk
from tests.snapshots.base_snapshot_test_app import BaseSnapshotTestApp
from tests.snapshots.snap_compare import SnapCompare
from tests.stubs.fake_audio_player import FakeAudioPlayer
from tests.stubs.fake_backend import FakeBackend
from tests.stubs.fake_tts_client import FakeTTSClient
from vibe.cli.narrator_manager import NarratorManager, NarratorState
import vibe.cli.textual_ui.widgets.narrator_status as narrator_status_mod
from vibe.cli.textual_ui.widgets.narrator_status import NarratorStatus
from vibe.cli.turn_summary import TurnSummaryTracker
narrator_status_mod.SHRINK_FRAMES = ""
narrator_status_mod.BAR_FRAMES = ["▂▅▇"]
from vibe.core.config import ModelConfig
from vibe.core.tts.tts_client_port import TTSResult
from vibe.core.types import LLMChunk
_TEST_MODEL = ModelConfig(name="test-model", provider="test", alias="test-model")
def _narrator_config():
return build_test_vibe_config(
narrator_enabled=True,
disable_welcome_banner_animation=True,
displayed_workdir="/test/workdir",
)
class GatedBackend(FakeBackend):
def __init__(self, chunks: LLMChunk) -> None:
super().__init__(chunks)
self._gate = asyncio.Event()
def release(self) -> None:
self._gate.set()
async def complete(self, **kwargs: Any) -> LLMChunk:
await self._gate.wait()
return await super().complete(**kwargs)
class GatedTTSClient(FakeTTSClient):
def __init__(self, *args: Any, **kwargs: Any) -> None:
super().__init__(*args, **kwargs)
self._gate = asyncio.Event()
def release(self) -> None:
self._gate.set()
async def speak(self, text: str) -> TTSResult:
await self._gate.wait()
return await super().speak(text)
class NarratorFlowApp(BaseSnapshotTestApp):
def __init__(self) -> None:
self.summary_gate = GatedBackend(
mock_llm_chunk(content="Summary of the conversation")
)
self.tts_gate = GatedTTSClient()
self.fake_audio_player = FakeAudioPlayer()
narrator_manager = NarratorManager(
config_getter=_narrator_config, audio_player=self.fake_audio_player
)
# Override turn_summary and tts_client with gated test doubles.
narrator_manager._turn_summary = TurnSummaryTracker(
backend=self.summary_gate, model=_TEST_MODEL
)
narrator_manager._turn_summary.on_summary = narrator_manager._on_turn_summary
narrator_manager._tts_client = self.tts_gate
super().__init__(
config=_narrator_config(),
backend=FakeBackend(
mock_llm_chunk(
content="Hello! I can help you.",
prompt_tokens=10_000,
completion_tokens=2_500,
)
),
narrator_manager=narrator_manager,
)
def test_snapshot_narrator_summarizing(snap_compare: SnapCompare) -> None:
async def run_before(pilot: Pilot) -> None:
app = cast(NarratorFlowApp, pilot.app)
# Send message and wait for agent response to complete
await pilot.press(*"Hello")
await pilot.press("enter")
await pilot.pause(0.5)
# on_turn_end has fired, SUMMARIZING is set, summary backend is gated
assert app.summary_gate._gate.is_set() is False
# Freeze animation at frame 0 for deterministic snapshot
app.query_one(NarratorStatus)._stop_timer()
assert snap_compare(
"test_ui_snapshot_narrator_flow.py:NarratorFlowApp",
terminal_size=(120, 36),
run_before=run_before,
)
def test_snapshot_narrator_speaking(snap_compare: SnapCompare) -> None:
async def run_before(pilot: Pilot) -> None:
app = cast(NarratorFlowApp, pilot.app)
await pilot.press(*"Hello")
await pilot.press("enter")
await pilot.pause(0.5)
# Release summary gate → summary resolves → speak task starts → blocks on TTS gate
app.summary_gate.release()
await pilot.pause(0.2)
# Release TTS gate → TTS resolves → SPEAKING set
app.tts_gate.release()
await pilot.pause(0.2)
# Freeze animation at frame 0 for deterministic snapshot
app.query_one(NarratorStatus)._stop_timer()
assert snap_compare(
"test_ui_snapshot_narrator_flow.py:NarratorFlowApp",
terminal_size=(120, 36),
run_before=run_before,
)
def test_snapshot_narrator_idle_after_speaking(snap_compare: SnapCompare) -> None:
async def run_before(pilot: Pilot) -> None:
app = cast(NarratorFlowApp, pilot.app)
await pilot.press(*"Hello")
await pilot.press("enter")
await pilot.pause(0.5)
# Release both gates to reach SPEAKING
app.summary_gate.release()
await pilot.pause(0.2)
app.tts_gate.release()
await pilot.pause(0.2)
# Simulate playback finishing (same thread, so call directly)
app.fake_audio_player.stop()
narrator = cast(NarratorManager, app._narrator_manager)
narrator._set_state(NarratorState.IDLE)
await pilot.pause(0.2)
assert snap_compare(
"test_ui_snapshot_narrator_flow.py:NarratorFlowApp",
terminal_size=(120, 36),
run_before=run_before,
)