"""Tests for Discord RPC module (discord_rpc.py).""" import time from unittest import mock import pytest # ── _safe_artists ───────────────────────────────────────────────────────────── class TestSafeArtists: """Tests for the _safe_artists helper.""" def _import(self): from discord_rpc import _safe_artists return _safe_artists def test_none_song(self): fn = self._import() assert fn(None) == "" def test_empty_dict(self): fn = self._import() assert fn({}) == "" def test_missing_artists_key(self): fn = self._import() assert fn({"title": "Test"}) == "" def test_artists_not_list(self): fn = self._import() assert fn({"artists": "not a list"}) == "" def test_empty_artists_list(self): fn = self._import() assert fn({"artists": []}) == "" def test_single_artist(self): fn = self._import() song = {"artists": [{"name": "Test Artist", "id": "x"}]} assert fn(song) == "Test Artist" def test_multiple_artists(self): fn = self._import() song = { "artists": [ {"name": "Artist One", "id": "1"}, {"name": "Artist Two", "id": "2"}, ] } assert fn(song) == "Artist One, Artist Two" def test_artist_without_name(self): fn = self._import() song = {"artists": [{"id": "x"}, {"name": "Real Artist", "id": "y"}]} assert fn(song) == "Real Artist" def test_artist_is_not_dict(self): fn = self._import() song = {"artists": ["just a string"]} assert fn(song) == "" def test_artist_with_empty_name(self): fn = self._import() song = {"artists": [{"name": "", "id": "x"}]} assert fn(song) == "" # ── DiscordRPC ──────────────────────────────────────────────────────────────── class TestDiscordRPC: """Tests for DiscordRPC class with mocked pypresence.""" @pytest.fixture def rpc(self): with mock.patch("discord_rpc.Presence") as mock_presence: # Make the mock Presence instance return a mock mock_instance = mock.MagicMock() mock_presence.return_value = mock_instance from discord_rpc import DiscordRPC rpc = DiscordRPC(client_id="test_client_123") yield rpc, mock_instance rpc.stop() def test_constructor(self, rpc): drpc, _ = rpc assert drpc._client_id == "test_client_123" assert drpc._connected is False assert drpc._song is None def test_start_and_connect(self, rpc): drpc, mock_presence = rpc drpc.start() # Give the thread a moment to connect import time time.sleep(0.1) assert drpc._connected is True mock_presence.connect.assert_called_once() def test_update_song_sets_song_and_resets_start(self, rpc): drpc, _ = rpc song = {"title": "Test", "artists": [{"name": "A"}], "duration": 200} drpc.update_song(song, reset_start=True) assert drpc._song == song assert drpc._start_ts is not None def test_update_song_none(self, rpc): drpc, _ = rpc drpc.update_song(None) assert drpc._song is None assert drpc._start_ts is None def test_update_song_no_reset(self, rpc): drpc, _ = rpc song = {"title": "Test", "artists": [{"name": "A"}], "duration": 200} drpc.update_song(song, reset_start=True) old_start = drpc._start_ts time.sleep(0.01) # Without reset, start_ts should stay the same drpc.update_song(song, reset_start=False) assert drpc._start_ts == old_start def test_clear_presence(self, rpc): drpc, _ = rpc drpc.update_song({"title": "Test"}) assert drpc._song is not None drpc.clear() assert drpc._song is None assert drpc._start_ts is None def test_seek_to_adjusts_start_ts(self, rpc): drpc, _ = rpc drpc.update_song({"title": "Test"}, reset_start=True) time.sleep(0.01) drpc.seek_to(position_ms=30_000) # seek to 30s # Now the diff between now and start_ts should be ~30s diff = int(time.time()) - drpc._start_ts assert 28 <= diff <= 32 # allow some fuzz def test_stop_disconnects_and_joins(self, rpc): drpc, _ = rpc drpc.start() time.sleep(0.1) drpc.stop() assert drpc._rpc is None assert drpc._connected is False def test_send_presence_clears_when_no_song(self, rpc): drpc, _mock = rpc drpc.start() time.sleep(0.1) drpc.clear() time.sleep(0.1) _mock.clear.assert_called() def test_double_start_is_idempotent(self, rpc): drpc, _ = rpc drpc.start() drpc.start() # should not crash # If we get here without exception the test passes @mock.patch("discord_rpc.Presence") def test_connect_discord_not_found(self, mock_presence_cls): """When Discord is not running, DiscordNotFound is caught (covers lines 116-118).""" from discord_rpc import DiscordRPC, DiscordNotFound mock_instance = mock.MagicMock() mock_instance.connect.side_effect = DiscordNotFound() mock_presence_cls.return_value = mock_instance drpc = DiscordRPC(client_id="test") drpc.start() time.sleep(0.1) # Should not crash, just log a warning assert drpc._connected is False drpc.stop() def test_disconnect_close_exception_handled(self): """_disconnect handles close() failure gracefully (covers lines 128-129).""" with mock.patch("discord_rpc.Presence") as mock_presence_cls: mock_instance = mock.MagicMock() mock_instance.close.side_effect = RuntimeError("close failed") mock_presence_cls.return_value = mock_instance from discord_rpc import DiscordRPC drpc = DiscordRPC(client_id="test") drpc._rpc = mock_instance drpc._connected = True drpc._disconnect() assert drpc._connected is False assert drpc._rpc is None def test_send_presence_clear_exception_handled(self): """_send_presence handles clear() failure gracefully (covers lines 142-143).""" with mock.patch("discord_rpc.Presence") as mock_presence_cls: mock_instance = mock.MagicMock() mock_instance.clear.side_effect = RuntimeError("clear failed") mock_presence_cls.return_value = mock_instance from discord_rpc import DiscordRPC drpc = DiscordRPC(client_id="test") drpc._rpc = mock_instance drpc._connected = True drpc._song = None # no song -> goes to clear branch drpc._send_presence() assert drpc._connected is False def test_send_presence_update_exception_handled(self): """_send_presence handles update() failure gracefully (covers lines 160-167).""" with mock.patch("discord_rpc.Presence") as mock_presence_cls: mock_instance = mock.MagicMock() mock_instance.update.side_effect = RuntimeError("update failed") mock_presence_cls.return_value = mock_instance from discord_rpc import DiscordRPC drpc = DiscordRPC(client_id="test") drpc._rpc = mock_instance drpc._connected = True drpc._song = {"title": "Test", "artists": [{"name": "A"}], "duration": 100} drpc._start_ts = 1000 drpc._send_presence() assert drpc._connected is False def test_send_presence_rpc_is_none(self): """_send_presence does nothing when _rpc is None (covers line ~155).""" from discord_rpc import DiscordRPC drpc = DiscordRPC(client_id="test") drpc._rpc = None drpc._song = {"title": "Test"} drpc._start_ts = 1000 # Should not crash drpc._send_presence() def test_connect_oserror_handled(self): """_connect handles OSError gracefully (covers lines 119-122).""" with mock.patch("discord_rpc.Presence") as mock_presence_cls: mock_instance = mock.MagicMock() mock_instance.connect.side_effect = OSError("Connection refused") mock_presence_cls.return_value = mock_instance from discord_rpc import DiscordRPC drpc = DiscordRPC(client_id="test") result = drpc._connect() assert result is False assert drpc._connected is False def test_connect_runtimeerror_handled(self): """_connect handles RuntimeError gracefully (covers lines 119-122).""" with mock.patch("discord_rpc.Presence") as mock_presence_cls: mock_instance = mock.MagicMock() mock_instance.connect.side_effect = RuntimeError("pipe error") mock_presence_cls.return_value = mock_instance from discord_rpc import DiscordRPC drpc = DiscordRPC(client_id="test") result = drpc._connect() assert result is False assert drpc._connected is False def test_run_reconnects_on_failure(self): """_run loop retries connection when _connect fails (covers lines 174-175).""" with mock.patch("discord_rpc.Presence") as mock_presence_cls: mock_instance = mock.MagicMock() mock_instance.connect.side_effect = OSError("no discord") mock_presence_cls.return_value = mock_instance from discord_rpc import DiscordRPC drpc = DiscordRPC(client_id="test") # Start _run in a background thread so we can signal stop # from the main thread while _run blocks on wait(). import threading t = threading.Thread(target=drpc._run, daemon=True) t.start() time.sleep(0.1) # let _connect fail and enter wait(10) drpc._stop_event.set() # unblock wait() so _run can exit t.join(timeout=2) assert drpc._connected is False assert drpc._rpc is None