# -*- coding: utf-8 -*- """Contract test for `src/voice/_discord_voice_adapter.py`. Purpose: catch drift when the vendored `discord-ext-voice-recv` is upgraded. If upstream renames/removes a method we depend on, this test fails LOUDLY before any downstream code breaks at runtime in a Discord voice call. Per VENDOR_INFO.md: this test MUST PASS after every vendor upgrade. Plain `import` + `hasattr` / `callable` checks — no mocks. We're verifying the SHAPE of the API surface, not behavior. """ from __future__ import annotations import inspect import pytest # --- Adapter re-exports import cleanly -------------------------------------- def test_adapter_exports_voice_receive_client(): from src.voice._discord_voice_adapter import VoiceReceiveClient assert VoiceReceiveClient is not None assert inspect.isclass(VoiceReceiveClient) def test_adapter_exports_audio_sink(): from src.voice._discord_voice_adapter import AudioSink assert AudioSink is not None assert inspect.isclass(AudioSink) def test_adapter_exports_voice_data(): from src.voice._discord_voice_adapter import VoiceData assert VoiceData is not None assert inspect.isclass(VoiceData) def test_adapter_exports_connect_helper(): from src.voice._discord_voice_adapter import connect_voice assert callable(connect_voice) assert inspect.iscoroutinefunction(connect_voice) # --- Re-exports point at the real vendored classes (no accidental shadowing) - def test_voice_receive_client_is_voice_recv_client(): from discord.ext import voice_recv from src.voice._discord_voice_adapter import VoiceReceiveClient assert VoiceReceiveClient is voice_recv.VoiceRecvClient def test_audio_sink_is_voice_recv_audio_sink(): from discord.ext import voice_recv from src.voice._discord_voice_adapter import AudioSink assert AudioSink is voice_recv.AudioSink def test_voice_data_is_voice_recv_voice_data(): from discord.ext import voice_recv from src.voice._discord_voice_adapter import VoiceData assert VoiceData is voice_recv.VoiceData # --- VoiceReceiveClient API surface used by the pipeline -------------------- @pytest.mark.parametrize( "method_name", [ "connect", # inherited from discord.VoiceClient "disconnect", # inherited from discord.VoiceClient "listen", # voice_recv extension "stop_listening", # voice_recv extension "is_listening", # voice_recv extension "stop", # voice_recv extension (stops play+listen) "cleanup", # voice_recv extension ], ) def test_voice_receive_client_has_method(method_name): from src.voice._discord_voice_adapter import VoiceReceiveClient attr = getattr(VoiceReceiveClient, method_name, None) assert attr is not None, f"VoiceReceiveClient is missing `.{method_name}()`" assert callable(attr), f"VoiceReceiveClient.{method_name} is not callable" def test_voice_receive_client_listen_accepts_sink_and_after(): """`.listen(sink, *, after=None)` is the canonical call shape.""" from src.voice._discord_voice_adapter import VoiceReceiveClient sig = inspect.signature(VoiceReceiveClient.listen) params = sig.parameters assert "sink" in params, f"VoiceReceiveClient.listen missing `sink` param; got {list(params)}" assert "after" in params, f"VoiceReceiveClient.listen missing `after` kwarg; got {list(params)}" def test_voice_receive_client_has_sink_property(): """`.sink` is read/write so we can swap sinks in place.""" from src.voice._discord_voice_adapter import VoiceReceiveClient sink_attr = inspect.getattr_static(VoiceReceiveClient, "sink", None) assert isinstance(sink_attr, property), "VoiceReceiveClient.sink must be a property" assert sink_attr.fget is not None, "VoiceReceiveClient.sink property missing getter" assert sink_attr.fset is not None, "VoiceReceiveClient.sink property missing setter" # --- AudioSink API surface -------------------------------------------------- @pytest.mark.parametrize( "method_name", [ "write", # write(user, voice_data) — the hot path "cleanup", "wants_opus", # bool: opus bytes vs decoded PCM ], ) def test_audio_sink_has_method(method_name): from src.voice._discord_voice_adapter import AudioSink attr = getattr(AudioSink, method_name, None) assert attr is not None, f"AudioSink is missing `.{method_name}()`" assert callable(attr), f"AudioSink.{method_name} is not callable" def test_audio_sink_write_signature(): """`.write(self, user, data)` — user is the speaker (Optional), data is VoiceData.""" from src.voice._discord_voice_adapter import AudioSink sig = inspect.signature(AudioSink.write) params = list(sig.parameters) # self, user, data assert len(params) >= 3, f"AudioSink.write expected (self, user, data), got {params}" # --- VoiceData attributes --------------------------------------------------- def test_voice_data_slots(): """VoiceData uses __slots__ for per-packet allocation. Pipeline reads these.""" from src.voice._discord_voice_adapter import VoiceData assert hasattr(VoiceData, "__slots__"), "VoiceData lost __slots__ — perf regression risk" slots = set(VoiceData.__slots__) # Documented attributes the pipeline depends on. assert "packet" in slots, f"VoiceData missing `packet` slot; got {slots}" assert "source" in slots, f"VoiceData missing `source` slot (speaker user); got {slots}" assert "pcm" in slots, f"VoiceData missing `pcm` slot (decoded audio); got {slots}" def test_voice_data_has_opus_property(): """`.opus` exposes the raw opus bytes from the underlying RTP packet.""" from src.voice._discord_voice_adapter import VoiceData opus_attr = inspect.getattr_static(VoiceData, "opus", None) assert isinstance(opus_attr, property), "VoiceData.opus must be a property" # --- Echo-core DAVE-decrypt fork guards ------------------------------------- # # Two contract tests pinned by the DAVE receive-side decrypt patch. # See plan: /home/moltbot/.claude/plans/wiggly-exploring-glade.md # # These fail fast on either: # 1. An upstream voice-recv re-install wiping the fork's version marker # (i.e. our patch is gone), OR # 2. A discord.py upgrade renaming the connection-level DAVE attrs the # patch reads (`dave_session`, `dave_protocol_version`). def test_voice_recv_fork_version(): """Echo-core fork tag for the DAVE-decrypt patch. Lane A bumps `voice_recv.__version__` to `'0.5.3a+echo.dave1'` (PEP 440 local segment). If this assertion fails after a vendor reinstall, the fork patch has been lost — re-apply `_maybe_dave_decrypt` + the `callback()` hook before deploying, or live voice will regress to the `opus_decode: corrupted stream` error chain. """ from discord.ext import voice_recv assert voice_recv.__version__ == "0.5.3a+echo.dave1", ( f"voice_recv.__version__ is {voice_recv.__version__!r}; expected " "'0.5.3a+echo.dave1'. The DAVE-decrypt fork patch has been " "overwritten — re-apply before reinstalling the vendored package." ) def test_voice_connection_state_has_dave_attrs(): """`_maybe_dave_decrypt` reads `dave_session` and `dave_protocol_version` off the discord.py `VoiceConnectionState`. If a future discord.py upgrade renames either attr, fail loudly here rather than in a live voice call (where the symptom is silent packet drops). """ from discord import voice_state src = inspect.getsource(voice_state.VoiceConnectionState) assert "dave_session" in src, ( "discord.voice_state.VoiceConnectionState source no longer mentions " "'dave_session' — discord.py may have renamed the attr. Update " "vendor/discord-ext-voice-recv/.../reader.py::_maybe_dave_decrypt." ) assert "dave_protocol_version" in src, ( "discord.voice_state.VoiceConnectionState source no longer mentions " "'dave_protocol_version' — discord.py may have renamed the attr. " "Update _maybe_dave_decrypt accordingly." )