mirror of
https://github.com/deunix-educ/voice-assistant.git
synced 2026-10-13 08:21:58 +02:00
d20976fea2
État du projet au premier commit : - firmware ESP32 : micro I2S, lecture anti-gigue, MQTT (état retenu, testament), mains libres, domotique de démonstration, chien de garde, présence du serveur ; - serveur Python : sessions, VAD, faster-whisper, pyannote, identification, droits par profil, domotique, mot de réveil, Piper ; - broker Mosquitto : configuration, comptes et ACL (non activés en développement) ; - étape 15b écrite, en attente d'essai : agent MQTT pour machines Linux. Étapes 1 à 15 validées. 203 tests, pyright sans erreur. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
191 lines
7.1 KiB
Python
191 lines
7.1 KiB
Python
"""Tests du serveur, étape 4 : reconstitution des sessions (sans broker)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import wave
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from voice_server.mqtt_link import split_topic
|
|
from voice_server.session_manager import Outcome, SessionManager
|
|
from voice_server.settings import AudioSettings, SessionSettings, load_settings
|
|
|
|
AUDIO = AudioSettings(sample_rate=16000, bits=16, channels=1, codec="pcm_s16le", chunk_ms=50)
|
|
SESSIONS = SessionSettings(max_seconds=30.0, idle_timeout_s=5.0, end_grace_s=0.5)
|
|
CHUNK = bytes(1600)
|
|
|
|
|
|
class FakeClock:
|
|
"""Horloge pilotée par le test : on avance le temps sans attendre."""
|
|
|
|
def __init__(self) -> None:
|
|
self.now = 1000.0
|
|
|
|
def __call__(self) -> float:
|
|
return self.now
|
|
|
|
|
|
def start_event(session: str = "a1b2c3", rate: int = 16000) -> bytes:
|
|
return json.dumps({"event": "start", "session": session, "rate": rate, "bits": 16,
|
|
"channels": 1, "codec": "pcm_s16le", "chunk_ms": 50}).encode()
|
|
|
|
|
|
def end_event(chunks: int, session: str = "a1b2c3") -> bytes:
|
|
return json.dumps({"event": "end", "session": session, "chunks": chunks,
|
|
"duration_ms": chunks * 50, "send_failures": 0,
|
|
"capture_overruns": 0}).encode()
|
|
|
|
|
|
@pytest.fixture
|
|
def clock() -> FakeClock:
|
|
return FakeClock()
|
|
|
|
|
|
@pytest.fixture
|
|
def manager(tmp_path: Path, clock: FakeClock) -> SessionManager:
|
|
return SessionManager(AUDIO, SESSIONS, tmp_path, clock=clock)
|
|
|
|
|
|
def test_complete_session_is_written_immediately(manager: SessionManager) -> None:
|
|
"""START + 20 chunks + END : WAV d'une seconde, écrit dès le END."""
|
|
manager.on_event("esp32-01", start_event())
|
|
for _ in range(20):
|
|
manager.on_audio("esp32-01", CHUNK)
|
|
results = manager.on_event("esp32-01", end_event(20))
|
|
|
|
assert len(results) == 1
|
|
result = results[0]
|
|
assert result.outcome is Outcome.COMPLETE
|
|
assert result.chunks_received == 20
|
|
with wave.open(str(result.path)) as wav:
|
|
assert (wav.getframerate(), wav.getnchannels(), wav.getsampwidth()) == (16000, 1, 2)
|
|
assert wav.getnframes() == 16000
|
|
|
|
|
|
def test_lost_chunks_are_reported_after_grace(manager: SessionManager, clock: FakeClock) -> None:
|
|
"""END annonce 20 chunks, 18 arrivent : bilan « lost_chunks » après le délai de grâce."""
|
|
manager.on_event("esp32-01", start_event())
|
|
for _ in range(18):
|
|
manager.on_audio("esp32-01", CHUNK)
|
|
assert manager.on_event("esp32-01", end_event(20)) == []
|
|
|
|
clock.now += 0.2
|
|
assert manager.sweep() == [] # on attend encore les chunks en vol
|
|
clock.now += 0.4
|
|
results = manager.sweep()
|
|
assert [r.outcome for r in results] == [Outcome.LOST_CHUNKS]
|
|
assert results[0].chunks_announced == 20
|
|
|
|
|
|
def test_late_chunk_within_grace_completes(manager: SessionManager, clock: FakeClock) -> None:
|
|
"""Le END arrive avant le dernier chunk (topics différents) : la session reste complète."""
|
|
manager.on_event("esp32-01", start_event())
|
|
for _ in range(19):
|
|
manager.on_audio("esp32-01", CHUNK)
|
|
manager.on_event("esp32-01", end_event(20))
|
|
manager.on_audio("esp32-01", CHUNK) # retardataire
|
|
clock.now += 0.6
|
|
|
|
assert [r.outcome for r in manager.sweep()] == [Outcome.COMPLETE]
|
|
|
|
|
|
def test_session_without_end_times_out(manager: SessionManager, clock: FakeClock) -> None:
|
|
"""L'ESP32 disparaît en pleine session : clôture après 5 s sans rien recevoir."""
|
|
manager.on_event("esp32-01", start_event())
|
|
manager.on_audio("esp32-01", CHUNK)
|
|
clock.now += 4.9
|
|
assert manager.sweep() == []
|
|
clock.now += 0.2
|
|
|
|
results = manager.sweep()
|
|
assert [r.outcome for r in results] == [Outcome.TIMEOUT]
|
|
assert results[0].chunks_announced is None
|
|
|
|
|
|
def test_new_start_interrupts_previous(manager: SessionManager) -> None:
|
|
"""Un START sans END préalable clôt la session précédente."""
|
|
manager.on_event("esp32-01", start_event("aaaaaa"))
|
|
manager.on_audio("esp32-01", CHUNK)
|
|
results = manager.on_event("esp32-01", start_event("bbbbbb"))
|
|
|
|
assert [(r.session_id, r.outcome) for r in results] == [("aaaaaa", Outcome.INTERRUPTED)]
|
|
|
|
|
|
def test_start_during_grace_keeps_end_verdict(manager: SessionManager) -> None:
|
|
"""Nouvel appui pendant le délai de grâce : la session précédente garde son vrai bilan.
|
|
|
|
Cas trouvé par l'essai de bout en bout : elle était classée « interrompue ».
|
|
"""
|
|
manager.on_event("esp32-01", start_event("aaaaaa"))
|
|
for _ in range(18):
|
|
manager.on_audio("esp32-01", CHUNK)
|
|
manager.on_event("esp32-01", end_event(20, "aaaaaa")) # 2 chunks manquent
|
|
results = manager.on_event("esp32-01", start_event("bbbbbb"))
|
|
|
|
assert [(r.session_id, r.outcome) for r in results] == [("aaaaaa", Outcome.LOST_CHUNKS)]
|
|
|
|
|
|
def test_wrong_format_is_refused(manager: SessionManager) -> None:
|
|
"""Une session annoncée à 48 kHz n'est pas ouverte : ses chunks sont orphelins."""
|
|
manager.on_event("esp32-01", start_event(rate=48000))
|
|
manager.on_audio("esp32-01", CHUNK)
|
|
|
|
assert manager.orphan_chunks == 1
|
|
|
|
|
|
def test_devices_are_independent(manager: SessionManager) -> None:
|
|
"""Deux cartes parlent en même temps : deux fichiers distincts."""
|
|
manager.on_event("esp32-01", start_event("111111"))
|
|
manager.on_event("esp32-02", start_event("222222"))
|
|
manager.on_audio("esp32-01", CHUNK)
|
|
manager.on_audio("esp32-02", CHUNK)
|
|
manager.on_audio("esp32-02", CHUNK)
|
|
|
|
first = manager.on_event("esp32-01", end_event(1, "111111"))[0]
|
|
second = manager.on_event("esp32-02", end_event(2, "222222"))[0]
|
|
assert (first.chunks_received, second.chunks_received) == (1, 2)
|
|
assert first.path != second.path
|
|
|
|
|
|
def test_garbage_event_is_ignored(manager: SessionManager) -> None:
|
|
"""Un événement illisible ne fait rien planter."""
|
|
assert manager.on_event("esp32-01", b"\xff\x00pas du json") == []
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("topic", "expected"),
|
|
[
|
|
("voice/esp32-01/audio/in", ("esp32-01", "audio/in")),
|
|
("voice/esp32-01/event", ("esp32-01", "event")),
|
|
("other/esp32-01/event", None),
|
|
("voice/esp32-01", None),
|
|
("voice//event", None),
|
|
],
|
|
)
|
|
def test_split_topic(topic: str, expected: tuple[str, str] | None) -> None:
|
|
"""Découpage des topics reçus."""
|
|
assert split_topic(topic, "voice") == expected
|
|
|
|
|
|
def test_project_config_loads() -> None:
|
|
"""Le config.yaml du dépôt se charge et reste cohérent avec le firmware."""
|
|
settings = load_settings(Path(__file__).resolve().parents[1] / "config.yaml")
|
|
|
|
assert settings.audio.bytes_per_second == 32000
|
|
assert settings.mqtt.topic_prefix == "voice"
|
|
assert settings.recordings_dir.is_absolute()
|
|
|
|
|
|
def test_finished_sessions_are_notified(tmp_path: Path, clock: FakeClock) -> None:
|
|
"""Le rappel on_finished reçoit chaque session terminée (utilisé par le mode écho)."""
|
|
finished: list[str] = []
|
|
manager = SessionManager(AUDIO, SESSIONS, tmp_path, clock=clock,
|
|
on_finished=lambda result: finished.append(result.session_id))
|
|
manager.on_event("esp32-01", start_event())
|
|
manager.on_audio("esp32-01", CHUNK)
|
|
manager.on_event("esp32-01", end_event(1))
|
|
|
|
assert finished == ["a1b2c3"]
|