Files
echo-core/tests/test_voice_session_cleanup.py
Marius Mutu e79bed7afe feat(voice): unify Discord voice↔text session (squash of voice/text-unify)
Voice utterances and text messages on the same Discord channel now share
one Claude session, and Echo's voice replies are mirrored back into the
text channel. Replaces the old voice:<id> session-key split.

Changes:
- src/adapters/_text_chunks.py: new leaf module for split_message
  (used by both discord_bot and voice pipeline)
- src/router.py: drop voice: prefix from session_key; add [voice] marker;
  strip leading [speaker:/[voice] tokens from user input (anti-jailbreak);
  remove dead double-clear of voice: key
- src/claude_session.py: include personality/VOICE_MODE.md unconditionally
  (rules become per-turn-aware via [speaker:] prefix instead of session flag)
- src/voice/pipeline.py: VoiceSession splits text_channel_id +
  voice_channel_id; resolve text channel per-send (no stale refs); mirror
  Echo's reply text into the text channel after route_message returns
- src/adapters/discord_voice.py: /voice join passes both channel ids
- src/adapters/discord_bot.py: import split_message from leaf module
- personality/VOICE_MODE.md: rewrite as per-turn dynamic rules;
  add synthesis instructions for text turns after voice turns

Tests:
- tests/test_router.py: 4 new cases (plain channel_id, anti-jailbreak,
  text-adapter regression, no-double-clear)
- tests/test_pipeline_mirror.py: new — Echo reply mirror chunking,
  empty guard, mirror_enabled=False, send-raises resilience
- tests/test_voice_session_channel_ids.py: new — split-attr contract
  + metrics payload schema
- tests/test_voice_session_cleanup.py: update for new kwargs

Plan: /home/moltbot/.claude/plans/vreau-ca-tot-textul-greedy-rivest.md

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 14:24:15 +00:00

323 lines
11 KiB
Python

"""Cleanup-path tests for ``src/voice/pipeline.py::VoiceSession``.
Pins the centralized ``cleanup()`` contract from the voice plan
(Engineering decision #5): every one of the FIVE exit paths must drain
state cleanly and idempotently — lock released, JSONL flushed or
discarded, presence cleared, ``voice_client.cleanup()`` invoked,
``ttsq.stop()`` invoked, and a second call to ``cleanup()`` MUST be a
no-op (side effects happen exactly once).
The 5 paths under test:
1. ``test_cleanup_on_voice_leave`` — explicit ``/voice leave``
2. ``test_cleanup_on_disconnect`` — Discord-level disconnect
3. ``test_cleanup_on_crash`` — exception via ``__exit__``
4. ``test_cleanup_on_auto_leave`` — 5-min inactivity timer
5. ``test_cleanup_on_user_leaves_channel`` — user leaves voice channel
"""
from __future__ import annotations
import json
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock
import pytest
from src.voice.pipeline import VoiceSession
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def mock_bot():
bot = MagicMock(name="bot")
bot.user = MagicMock()
bot.user.id = 999_999
bot.change_presence = AsyncMock(name="change_presence")
bot.get_user = MagicMock(return_value=None)
return bot
@pytest.fixture
def mock_voice_client():
vc = MagicMock(name="voice_client")
vc.cleanup = MagicMock(name="vc_cleanup")
return vc
@pytest.fixture
def mock_ttsq():
ttsq = MagicMock(name="ttsq")
ttsq.stop = MagicMock(name="ttsq_stop")
return ttsq
@pytest.fixture
def mock_text_channel():
tc = MagicMock(name="text_channel")
tc.send = AsyncMock(name="text_send")
return tc
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_session(
tmp_path: Path,
mock_bot,
mock_voice_client,
mock_ttsq,
mock_text_channel,
*,
record_enabled: bool = True,
) -> VoiceSession:
jsonl = tmp_path / ("transcripts.jsonl" if record_enabled else "noop.jsonl")
# mock_text_channel kept resolvable via bot.get_channel for any future
# send invocations; cleanup tests don't exercise mirror, just attribute.
mock_bot.get_channel = MagicMock(return_value=mock_text_channel)
return VoiceSession(
text_channel_id=1001,
voice_channel_id=2002,
guild_id=42,
voice_client=mock_voice_client,
bot=mock_bot,
ttsq=mock_ttsq,
whitelist={1234},
record_enabled=record_enabled,
mirror_enabled=True,
transcripts_jsonl_path=jsonl,
loop=None,
router_route_message=MagicMock(name="route_message"),
)
def _assert_clean_post_cleanup(
session: VoiceSession,
voice_client,
ttsq,
bot,
jsonl_path: Path,
record_enabled: bool,
) -> None:
"""Assertions shared across all five cleanup-path tests."""
# 1. Lock released — non-blocking acquire from this thread returns True.
acquired = session._lock.acquire(blocking=False)
assert acquired, "session._lock must be released after cleanup()"
session._lock.release()
# 2. voice_client.cleanup() called exactly once.
assert voice_client.cleanup.call_count == 1, (
f"voice_client.cleanup() called {voice_client.cleanup.call_count}x, "
f"expected 1"
)
# 3. ttsq.stop() called exactly once.
assert ttsq.stop.call_count == 1, (
f"ttsq.stop() called {ttsq.stop.call_count}x, expected 1"
)
# 4. bot.change_presence(activity=None) called at least once with that kwarg.
assert bot.change_presence.call_count >= 1, (
"bot.change_presence was never called — presence not restored"
)
bot.change_presence.assert_called_with(activity=None)
# 5. JSONL flushed (record=on) OR absent (record=off).
if record_enabled:
assert jsonl_path.exists(), (
"record=on: JSONL file must exist (was created by __enter__ and "
"left in place by cleanup so transcript can be persisted)"
)
else:
# record=off: cleanup unlinks the file if it ever existed.
assert not jsonl_path.exists() or jsonl_path.stat().st_size == 0
# ---------------------------------------------------------------------------
# Scenario 1 — explicit /voice leave
# ---------------------------------------------------------------------------
class TestCleanupOnVoiceLeave:
def test_cleanup_on_voice_leave(
self, tmp_path, mock_bot, mock_voice_client, mock_ttsq, mock_text_channel,
):
session = _make_session(
tmp_path, mock_bot, mock_voice_client, mock_ttsq, mock_text_channel,
record_enabled=True,
)
jsonl_path = session.transcripts_jsonl_path
with session:
# Simulate one transcript line.
session._jsonl_fh.write(json.dumps({"text": "salut"}) + "\n")
session.cleanup("voice_leave")
assert session._cleaned_up is True
# __exit__ called cleanup("exit") — must be a no-op the second time.
_assert_clean_post_cleanup(
session, mock_voice_client, mock_ttsq, mock_bot,
jsonl_path, record_enabled=True,
)
# Idempotency: a third explicit call still doesn't bump counts.
session.cleanup("redundant")
assert mock_voice_client.cleanup.call_count == 1
assert mock_ttsq.stop.call_count == 1
# ---------------------------------------------------------------------------
# Scenario 2 — Discord-level voice disconnect
# ---------------------------------------------------------------------------
class TestCleanupOnDisconnect:
def test_cleanup_on_disconnect(
self, tmp_path, mock_bot, mock_voice_client, mock_ttsq, mock_text_channel,
):
session = _make_session(
tmp_path, mock_bot, mock_voice_client, mock_ttsq, mock_text_channel,
record_enabled=False,
)
jsonl_path = session.transcripts_jsonl_path
session.__enter__()
# Network drop arrives outside the with-block.
session.cleanup("disconnect")
_assert_clean_post_cleanup(
session, mock_voice_client, mock_ttsq, mock_bot,
jsonl_path, record_enabled=False,
)
# Idempotency.
session.cleanup("disconnect-again")
assert mock_voice_client.cleanup.call_count == 1
assert mock_ttsq.stop.call_count == 1
# ---------------------------------------------------------------------------
# Scenario 3 — crash / exception via __exit__
# ---------------------------------------------------------------------------
class TestCleanupOnCrash:
def test_cleanup_on_crash(
self, tmp_path, mock_bot, mock_voice_client, mock_ttsq, mock_text_channel,
):
session = _make_session(
tmp_path, mock_bot, mock_voice_client, mock_ttsq, mock_text_channel,
record_enabled=True,
)
jsonl_path = session.transcripts_jsonl_path
with pytest.raises(RuntimeError, match="simulated crash"):
with session:
# Pipeline raises mid-call.
raise RuntimeError("simulated crash")
# __exit__ must have driven cleanup — every side effect happened once.
_assert_clean_post_cleanup(
session, mock_voice_client, mock_ttsq, mock_bot,
jsonl_path, record_enabled=True,
)
# Idempotency: explicit follow-up call (e.g. an outer error handler
# also tries to cleanup) MUST be a no-op.
session.cleanup("post-crash")
assert mock_voice_client.cleanup.call_count == 1
assert mock_ttsq.stop.call_count == 1
# ---------------------------------------------------------------------------
# Scenario 4 — auto-leave timer fires after 5 min inactivity
# ---------------------------------------------------------------------------
class TestCleanupOnAutoLeave:
def test_cleanup_on_auto_leave(
self, tmp_path, mock_bot, mock_voice_client, mock_ttsq, mock_text_channel,
):
session = _make_session(
tmp_path, mock_bot, mock_voice_client, mock_ttsq, mock_text_channel,
record_enabled=True,
)
jsonl_path = session.transcripts_jsonl_path
session.__enter__()
# The auto-leave timer trips outside the with-block.
session.cleanup("auto_leave")
_assert_clean_post_cleanup(
session, mock_voice_client, mock_ttsq, mock_bot,
jsonl_path, record_enabled=True,
)
# Idempotency.
session.cleanup("auto_leave_redundant")
assert mock_voice_client.cleanup.call_count == 1
assert mock_ttsq.stop.call_count == 1
# ---------------------------------------------------------------------------
# Scenario 5 — user leaves voice channel themselves
# ---------------------------------------------------------------------------
class TestCleanupOnUserLeaves:
def test_cleanup_on_user_leaves_channel(
self, tmp_path, mock_bot, mock_voice_client, mock_ttsq, mock_text_channel,
):
session = _make_session(
tmp_path, mock_bot, mock_voice_client, mock_ttsq, mock_text_channel,
record_enabled=False,
)
jsonl_path = session.transcripts_jsonl_path
session.__enter__()
# voice_state_update event handler invokes cleanup directly.
session.cleanup("user_left_channel")
_assert_clean_post_cleanup(
session, mock_voice_client, mock_ttsq, mock_bot,
jsonl_path, record_enabled=False,
)
# Idempotency.
session.cleanup("user_left_again")
assert mock_voice_client.cleanup.call_count == 1
assert mock_ttsq.stop.call_count == 1
# ---------------------------------------------------------------------------
# Cross-cutting: failures inside cleanup don't propagate
# ---------------------------------------------------------------------------
class TestCleanupRobustness:
def test_cleanup_swallows_voice_client_errors(
self, tmp_path, mock_bot, mock_voice_client, mock_ttsq, mock_text_channel,
):
"""If voice_client.cleanup() raises, ttsq.stop() must still run and
the lock must still release — otherwise a broken Discord state would
deadlock the channel forever."""
mock_voice_client.cleanup.side_effect = RuntimeError("vc died")
session = _make_session(
tmp_path, mock_bot, mock_voice_client, mock_ttsq, mock_text_channel,
record_enabled=False,
)
with session:
session.cleanup("voice_leave")
# ttsq.stop still ran exactly once.
assert mock_ttsq.stop.call_count == 1
# Lock released.
acquired = session._lock.acquire(blocking=False)
assert acquired, "lock must release even when voice_client.cleanup raises"
session._lock.release()