import asyncio import os import sys import wave from pathlib import Path from types import SimpleNamespace sys.path.insert(0, os.path.dirname(os.path.dirname(__file__))) import discord import bot import voice class FakeChannel: def __init__(self, failures_before_success=0, channel_id=999): self.failures_before_success = failures_before_success self.messages = [] self.attempts = 0 self.id = channel_id async def send(self, text): self.attempts += 1 if self.attempts <= self.failures_before_success: response = SimpleNamespace(status=403, reason="Forbidden") raise discord.Forbidden(response, {"message": "Missing Permissions", "code": 50013}) self.messages.append(text) def test_safe_send_chunked_falls_back_to_secondary_channel(): async def run(): primary = FakeChannel(failures_before_success=1, channel_id=100) fallback = FakeChannel(channel_id=200) delivered = await bot.safe_send_chunked( primary, "hello world", fallback_channels=[fallback], purpose="test notification", ) return delivered, fallback.messages delivered, messages = asyncio.run(run()) assert messages == ["hello world"] assert delivered is not None def test_safe_send_chunked_returns_none_when_all_channels_fail(): async def run(): primary = FakeChannel(failures_before_success=10, channel_id=100) fallback = FakeChannel(failures_before_success=10, channel_id=200) return await bot.safe_send_chunked( primary, "hello world", fallback_channels=[fallback], purpose="test notification", ) assert asyncio.run(run()) is None def test_build_retry_notice_mentions_reason_and_path(): message = bot.build_retry_notice( file_path=str(Path("recordings") / "1" / "session" / "meeting.wav"), reason="OpenRouter 502", ) assert "OpenRouter 502" in message assert "meeting.wav" in message assert "`/retry`" in message def test_meeting_recorder_wraps_meeting_sink_with_silence_generator(tmp_path, monkeypatch): created = {} class FakeMeetingSink: def __init__(self, output_path, tracks_dir): self.output_path = output_path self.tracks_dir = tracks_dir created["meeting"] = self class FakeSilenceGeneratorSink: def __init__(self, destination): self.destination = destination created["silence"] = self class FakeVoiceClient: def __init__(self): self.listened_sink = None def is_listening(self): return False def listen(self, sink, after): self.listened_sink = sink created["after"] = after monkeypatch.setattr(voice, "MeetingSink", FakeMeetingSink) monkeypatch.setattr(voice.voice_recv, "SilenceGeneratorSink", FakeSilenceGeneratorSink) vc = FakeVoiceClient() output_path = tmp_path / "meeting.wav" recorder = voice.MeetingRecorder(vc, str(output_path)) asyncio.run(recorder.start(lambda error: None)) assert created["meeting"].output_path == str(output_path) assert created["meeting"].tracks_dir == str(tmp_path / "tracks") assert created["silence"].destination is created["meeting"] assert vc.listened_sink is created["silence"] assert recorder.recording is True def test_wait_for_file_ready_rejects_stable_but_invalid_wav(tmp_path): broken = tmp_path / "meeting.wav" broken.write_bytes(b"not a real wav file") ready = asyncio.run(bot.wait_for_file_ready(str(broken), attempts=3, delay=0.001)) assert ready is False def test_wait_for_file_ready_accepts_valid_wav(tmp_path): valid = tmp_path / "meeting.wav" with wave.open(str(valid), "wb") as wav_file: wav_file.setnchannels(1) wav_file.setsampwidth(2) wav_file.setframerate(16000) wav_file.writeframes(b"\x00\x00" * 160) ready = asyncio.run(bot.wait_for_file_ready(str(valid), attempts=3, delay=0.001)) assert ready is True def test_load_speaker_tracks_resolves_manifest_tracks_next_to_recording(tmp_path): meeting = tmp_path / "meeting.wav" meeting.write_bytes(b"RIFFdemo") tracks_dir = tmp_path / "tracks" tracks_dir.mkdir() alice = tracks_dir / "track-111.wav" alice.write_bytes(b"alice") missing = tracks_dir / "track-222.wav" (tracks_dir / "tracks.json").write_text( '{"tracks":[{"display_name":"Alice","path":"missing-prefix/track-111.wav"},' '{"display_name":"Bob","path":"' + str(missing) + '"}]}' ) tracks = bot._load_speaker_tracks(str(meeting)) assert tracks == [{"display_name": "Alice", "path": str(alice)}] def test_transcribe_recording_uses_speaker_tracks_when_manifest_exists(tmp_path, monkeypatch): meeting = tmp_path / "meeting.wav" meeting.write_bytes(b"RIFFdemo") tracks_dir = tmp_path / "tracks" tracks_dir.mkdir() alice = tracks_dir / "track-111.wav" alice.write_bytes(b"alice") (tracks_dir / "tracks.json").write_text( '{"tracks":[{"display_name":"Alice","path":"' + str(alice) + '"}]}' ) calls = [] async def fake_transcribe_tracks(tracks): calls.append(("tracks", tracks)) return "[Alice]\nhello" async def fake_transcribe(path): calls.append(("single", path)) return "single transcript" monkeypatch.setattr(bot, "transcribe_tracks", fake_transcribe_tracks) monkeypatch.setattr(bot, "transcribe", fake_transcribe) result = asyncio.run(bot._transcribe_recording(str(meeting), guild_id=123)) assert result == "[Alice]\nhello" assert calls == [("tracks", [{"display_name": "Alice", "path": str(alice)}])] def test_transcribe_recording_falls_back_to_single_file_for_old_recordings(tmp_path, monkeypatch): meeting = tmp_path / "meeting.wav" meeting.write_bytes(b"RIFFdemo") calls = [] async def fake_transcribe_tracks(tracks): calls.append(("tracks", tracks)) return "should not be used" async def fake_transcribe(path): calls.append(("single", path)) return "single transcript" monkeypatch.setattr(bot, "transcribe_tracks", fake_transcribe_tracks) monkeypatch.setattr(bot, "transcribe", fake_transcribe) result = asyncio.run(bot._transcribe_recording(str(meeting), guild_id=123)) assert result == "single transcript" assert calls == [("single", str(meeting))] def test_process_recording_skips_ready_wait_when_tracks_exist(tmp_path, monkeypatch): """When speaker tracks exist, process_recording should not call wait_for_file_ready.""" meeting = tmp_path / "meeting.wav" meeting.write_bytes(b"RIFFdemo") tracks_dir = tmp_path / "tracks" tracks_dir.mkdir() alice = tracks_dir / "track-111.wav" alice.write_bytes(b"alice audio") (tracks_dir / "tracks.json").write_text( '{"tracks":[{"display_name":"Alice","path":"' + str(alice) + '"}]}' ) calls = [] async def fake_transcribe_recording(path, guild_id): calls.append(("transcribe", path)) return "[Alice]\nhello" async def fake_summarize(transcript): calls.append(("summarize", transcript)) return "Summary text." async def fake_get_output_channel(guild_id): return 999 async def fake_clear_retry_state(_guild_id): pass async def fake_resolve_text_channel(_channel_id): return SimpleNamespace(id=999, send=lambda t: None) async def fake_safe_send_chunked(_channel, text, **kw): calls.append(("delivered", text[:30])) return True monkeypatch.setattr(bot, "_transcribe_recording", fake_transcribe_recording) monkeypatch.setattr(bot, "summarize", fake_summarize) monkeypatch.setattr(bot, "wait_for_file_ready", lambda *a, **kw: calls.append(("ready",)) or True) monkeypatch.setattr(bot, "safe_send_chunked", fake_safe_send_chunked) monkeypatch.setattr(bot, "config", SimpleNamespace( get_output_channel=fake_get_output_channel, clear_retry_state=fake_clear_retry_state, set_retry_state=lambda g, p, c, r: None, )) monkeypatch.setattr(bot, "resolve_text_channel", fake_resolve_text_channel) asyncio.run(bot.process_recording(str(meeting), 123, 456, None)) # wait_for_file_ready should NOT have been called assert ("ready",) not in calls, "wait_for_file_ready was called despite tracks existing" assert calls[:2] == [("transcribe", str(meeting)), ("summarize", "[Alice]\nhello")]