Files
discord-meeting-bot/tests/test_bot.py
T

203 lines
6.4 KiB
Python

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))]