"""Tests for the Last.fm music discovery plugin.""" import asyncio import importlib.util import sys from dataclasses import dataclass from unittest.mock import AsyncMock, MagicMock, patch # -- Load plugin module directly --------------------------------------------- _spec = importlib.util.spec_from_file_location("lastfm", "plugins/lastfm.py") _mod = importlib.util.module_from_spec(_spec) sys.modules["lastfm"] = _mod _spec.loader.exec_module(_mod) # -- Fakes ------------------------------------------------------------------- class _FakeRegistry: def __init__(self): self._modules: dict = {} self._bots: dict = {} class _FakeBot: def __init__(self, *, api_key: str = "test-key", name: str = "derp"): self.replied: list[str] = [] self.config: dict = {"lastfm": {"api_key": api_key}} if api_key else {} self._pstate: dict = {} self._only_plugins: set[str] | None = None self.registry = _FakeRegistry() self._username = name async def reply(self, message, text: str) -> None: self.replied.append(text) async def long_reply(self, message, lines: list[str], *, label: str = "") -> None: for line in lines: self.replied.append(line) class _Msg: def __init__(self, text="!similar", nick="Alice", target="0", is_channel=True): self.text = text self.nick = nick self.target = target self.is_channel = is_channel self.prefix = nick self.command = "PRIVMSG" self.params = [target, text] self.tags = {} self.raw = {} @dataclass(slots=True) class _FakeTrack: url: str = "" title: str = "" requester: str = "" # -- API response fixtures --------------------------------------------------- SIMILAR_ARTISTS_RESP = { "similarartists": { "artist": [ {"name": "Artist B", "match": "0.85"}, {"name": "Artist C", "match": "0.72"}, {"name": "Artist D", "match": "0.60"}, ], }, } SIMILAR_TRACKS_RESP = { "similartracks": { "track": [ {"name": "Track X", "artist": {"name": "Artist B"}, "match": "0.9"}, {"name": "Track Y", "artist": {"name": "Artist C"}, "match": "0.7"}, ], }, } TOP_TAGS_RESP = { "toptags": { "tag": [ {"name": "rock", "count": 100}, {"name": "alternative", "count": 80}, {"name": "indie", "count": 60}, ], }, } TRACK_SEARCH_RESP = { "results": { "trackmatches": { "track": [ {"name": "Found Track", "artist": "Found Artist"}, {"name": "Another", "artist": "Someone"}, ], }, }, } # --------------------------------------------------------------------------- # TestGetApiKey # --------------------------------------------------------------------------- class TestGetApiKey: def test_from_config(self): bot = _FakeBot(api_key="cfg-key") assert _mod._get_api_key(bot) == "cfg-key" def test_from_env(self): bot = _FakeBot(api_key="") with patch.dict("os.environ", {"LASTFM_API_KEY": "env-key"}): assert _mod._get_api_key(bot) == "env-key" def test_env_takes_priority(self): bot = _FakeBot(api_key="cfg-key") with patch.dict("os.environ", {"LASTFM_API_KEY": "env-key"}): assert _mod._get_api_key(bot) == "env-key" def test_empty_when_unset(self): bot = _FakeBot(api_key="") with patch.dict("os.environ", {}, clear=True): assert _mod._get_api_key(bot) == "" # --------------------------------------------------------------------------- # TestApiCall # --------------------------------------------------------------------------- class TestApiCall: def test_parses_json(self): resp = MagicMock() resp.read.return_value = b'{"result": "ok"}' with patch.object(_mod, "urlopen", create=True, return_value=resp): # _api_call imports urlopen from derp.http at call time with patch("derp.http.urlopen", return_value=resp): data = _mod._api_call("key", "artist.getSimilar", artist="X") assert data == {"result": "ok"} def test_returns_empty_on_error(self): with patch("derp.http.urlopen", side_effect=ConnectionError("fail")): data = _mod._api_call("key", "artist.getSimilar", artist="X") assert data == {} # --------------------------------------------------------------------------- # TestGetSimilarArtists # --------------------------------------------------------------------------- class TestGetSimilarArtists: def test_returns_list(self): with patch.object(_mod, "_api_call", return_value=SIMILAR_ARTISTS_RESP): result = _mod._get_similar_artists("key", "Artist A") assert len(result) == 3 assert result[0]["name"] == "Artist B" def test_single_dict_wrapped(self): """Single artist result (dict instead of list) gets wrapped.""" data = {"similarartists": {"artist": {"name": "Solo", "match": "1.0"}}} with patch.object(_mod, "_api_call", return_value=data): result = _mod._get_similar_artists("key", "X") assert len(result) == 1 assert result[0]["name"] == "Solo" def test_empty_response(self): with patch.object(_mod, "_api_call", return_value={}): result = _mod._get_similar_artists("key", "X") assert result == [] def test_missing_key(self): with patch.object(_mod, "_api_call", return_value={"error": 6}): result = _mod._get_similar_artists("key", "X") assert result == [] # --------------------------------------------------------------------------- # TestGetTopTags # --------------------------------------------------------------------------- class TestGetTopTags: def test_returns_list(self): with patch.object(_mod, "_api_call", return_value=TOP_TAGS_RESP): result = _mod._get_top_tags("key", "Artist A") assert len(result) == 3 assert result[0]["name"] == "rock" def test_single_dict_wrapped(self): data = {"toptags": {"tag": {"name": "electronic", "count": 50}}} with patch.object(_mod, "_api_call", return_value=data): result = _mod._get_top_tags("key", "X") assert len(result) == 1 def test_empty_response(self): with patch.object(_mod, "_api_call", return_value={}): result = _mod._get_top_tags("key", "X") assert result == [] # --------------------------------------------------------------------------- # TestGetSimilarTracks # --------------------------------------------------------------------------- class TestGetSimilarTracks: def test_returns_list(self): with patch.object(_mod, "_api_call", return_value=SIMILAR_TRACKS_RESP): result = _mod._get_similar_tracks("key", "A", "T") assert len(result) == 2 assert result[0]["name"] == "Track X" def test_single_dict_wrapped(self): data = {"similartracks": {"track": {"name": "Solo", "artist": {"name": "X"}}}} with patch.object(_mod, "_api_call", return_value=data): result = _mod._get_similar_tracks("key", "X", "T") assert len(result) == 1 def test_empty_response(self): with patch.object(_mod, "_api_call", return_value={}): result = _mod._get_similar_tracks("key", "X", "T") assert result == [] # --------------------------------------------------------------------------- # TestSearchTrack # --------------------------------------------------------------------------- class TestSearchTrack: def test_returns_results(self): with patch.object(_mod, "_api_call", return_value=TRACK_SEARCH_RESP): result = _mod._search_track("key", "test") assert len(result) == 2 assert result[0]["name"] == "Found Track" def test_single_dict_wrapped(self): data = {"results": {"trackmatches": { "track": {"name": "One", "artist": "X"}, }}} with patch.object(_mod, "_api_call", return_value=data): result = _mod._search_track("key", "test") assert len(result) == 1 def test_empty_response(self): with patch.object(_mod, "_api_call", return_value={}): result = _mod._search_track("key", "test") assert result == [] # --------------------------------------------------------------------------- # TestCurrentMeta # --------------------------------------------------------------------------- class TestCurrentMeta: def test_no_music_state(self): bot = _FakeBot() assert _mod._current_meta(bot) == ("", "") def test_no_current_track(self): bot = _FakeBot() bot._pstate["music"] = {"current": None} assert _mod._current_meta(bot) == ("", "") def test_dash_separator(self): bot = _FakeBot() bot._pstate["music"] = {"current": _FakeTrack(title="Tool - Lateralus")} assert _mod._current_meta(bot) == ("Tool", "Lateralus") def test_double_dash_separator(self): bot = _FakeBot() bot._pstate["music"] = {"current": _FakeTrack(title="Tool -- Lateralus")} assert _mod._current_meta(bot) == ("Tool", "Lateralus") def test_pipe_separator(self): bot = _FakeBot() bot._pstate["music"] = {"current": _FakeTrack(title="Tool | Lateralus")} assert _mod._current_meta(bot) == ("Tool", "Lateralus") def test_tilde_separator(self): bot = _FakeBot() bot._pstate["music"] = {"current": _FakeTrack(title="Tool ~ Lateralus")} assert _mod._current_meta(bot) == ("Tool", "Lateralus") def test_no_separator(self): bot = _FakeBot() bot._pstate["music"] = {"current": _FakeTrack(title="Lateralus")} assert _mod._current_meta(bot) == ("", "Lateralus") def test_empty_title(self): bot = _FakeBot() bot._pstate["music"] = {"current": _FakeTrack(title="")} assert _mod._current_meta(bot) == ("", "") def test_strips_whitespace(self): bot = _FakeBot() bot._pstate["music"] = { "current": _FakeTrack(title=" Tool - Lateralus "), } assert _mod._current_meta(bot) == ("Tool", "Lateralus") def test_peer_bot_music_state(self): """Extra bot sees music state from peer bot via shared registry.""" music_bot = _FakeBot() music_bot._pstate["music"] = { "current": _FakeTrack(title="Tool - Lateralus"), } extra_bot = _FakeBot() # No music state on extra_bot # Share registry with _bots index shared_reg = _FakeRegistry() shared_reg._bots = {"derp": music_bot, "merlin": extra_bot} music_bot.registry = shared_reg extra_bot.registry = shared_reg assert _mod._current_meta(extra_bot) == ("Tool", "Lateralus") def test_peer_bot_no_music(self): """Returns empty when no bot has music state.""" bot_a = _FakeBot() bot_b = _FakeBot() shared_reg = _FakeRegistry() shared_reg._bots = {"a": bot_a, "b": bot_b} bot_a.registry = shared_reg bot_b.registry = shared_reg assert _mod._current_meta(bot_a) == ("", "") # --------------------------------------------------------------------------- # TestFmtMatch # --------------------------------------------------------------------------- class TestFmtMatch: def test_float_score(self): assert _mod._fmt_match(0.85) == "85%" def test_string_score(self): assert _mod._fmt_match("0.72") == "72%" def test_one(self): assert _mod._fmt_match(1.0) == "100%" def test_zero(self): assert _mod._fmt_match(0.0) == "0%" def test_invalid(self): assert _mod._fmt_match("bad") == "" def test_none(self): assert _mod._fmt_match(None) == "" # --------------------------------------------------------------------------- # TestCmdSimilar # --------------------------------------------------------------------------- class TestSearchQueries: def test_track_results(self): similar = [ {"name": "Track X", "artist": {"name": "Band A"}, "match": "0.9"}, {"name": "Track Y", "artist": {"name": "Band B"}, "match": "0.7"}, ] result = _mod._search_queries(similar, [], []) assert result == ["Band A Track X", "Band B Track Y"] def test_artist_results(self): artists = [{"name": "Deftones"}, {"name": "APC"}] result = _mod._search_queries([], artists, []) assert result == ["Deftones", "APC"] def test_mb_results(self): mb = [{"artist": "MB Band", "title": "MB Song"}] result = _mod._search_queries([], [], mb) assert result == ["MB Band MB Song"] def test_mixed_sources(self): """Track results come first, then artist, then MB.""" similar = [{"name": "T1", "artist": {"name": "A1"}}] artists = [{"name": "A2"}] mb = [{"artist": "MB", "title": "S1"}] result = _mod._search_queries(similar, artists, mb) assert result == ["A1 T1", "A2", "MB S1"] def test_limit(self): artists = [{"name": f"Band {i}"} for i in range(20)] result = _mod._search_queries([], artists, [], limit=5) assert len(result) == 5 def test_skips_empty(self): similar = [{"name": "", "artist": {"name": ""}}] artists = [{"name": ""}] mb = [{"artist": "", "title": ""}] result = _mod._search_queries(similar, artists, mb) assert result == [] def test_empty_inputs(self): assert _mod._search_queries([], [], []) == [] class TestCmdSimilar: def test_no_api_key_mb_list_fallback(self): """No API key + list mode falls back to MusicBrainz for results.""" bot = _FakeBot(api_key="") msg = _Msg(text="!similar list Tool") mb_picks = [{"artist": "MB Artist", "title": "MB Song"}] with patch.dict("os.environ", {}, clear=True), \ patch("plugins._musicbrainz.mb_search_artist", return_value="mbid-123"), \ patch("plugins._musicbrainz.mb_artist_tags", return_value=["rock", "metal"]), \ patch("plugins._musicbrainz.mb_find_similar_recordings", return_value=mb_picks): asyncio.run(_mod.cmd_similar(bot, msg)) assert any("Similar to Tool" in r for r in bot.replied) assert any("MB Artist" in r for r in bot.replied) def test_no_api_key_mb_no_results(self): """No API key + MusicBrainz returns nothing shows 'no similar'.""" bot = _FakeBot(api_key="") msg = _Msg(text="!similar Tool") with patch.dict("os.environ", {}, clear=True), \ patch("plugins._musicbrainz.mb_search_artist", return_value=None): asyncio.run(_mod.cmd_similar(bot, msg)) assert any("No similar artists" in r for r in bot.replied) def test_no_artist_nothing_playing(self): bot = _FakeBot() msg = _Msg(text="!similar") asyncio.run(_mod.cmd_similar(bot, msg)) assert any("Nothing playing" in r for r in bot.replied) def test_list_artist_shows_similar(self): """!similar list shows similar artists (display only).""" bot = _FakeBot() msg = _Msg(text="!similar list Tool") with patch.object(_mod, "_get_similar_tracks", return_value=[]): with patch.object(_mod, "_get_similar_artists", return_value=SIMILAR_ARTISTS_RESP["similarartists"]["artist"]): asyncio.run(_mod.cmd_similar(bot, msg)) assert any("Similar to Tool" in r for r in bot.replied) assert any("Artist B" in r for r in bot.replied) def test_list_track_level(self): """!similar list with track results shows track similarity.""" bot = _FakeBot() bot._pstate["music"] = { "current": _FakeTrack(title="Tool - Lateralus"), } msg = _Msg(text="!similar list") tracks = SIMILAR_TRACKS_RESP["similartracks"]["track"] with patch.object(_mod, "_get_similar_tracks", return_value=tracks): asyncio.run(_mod.cmd_similar(bot, msg)) assert any("Similar to Tool - Lateralus" in r for r in bot.replied) assert any("Track X" in r for r in bot.replied) def test_list_falls_back_to_artist(self): """!similar list falls back to artist similarity when no track results.""" bot = _FakeBot() bot._pstate["music"] = { "current": _FakeTrack(title="Tool - Lateralus"), } msg = _Msg(text="!similar list") artists = SIMILAR_ARTISTS_RESP["similarartists"]["artist"] with patch.object(_mod, "_get_similar_tracks", return_value=[]): with patch.object(_mod, "_get_similar_artists", return_value=artists): asyncio.run(_mod.cmd_similar(bot, msg)) assert any("Similar to Tool" in r for r in bot.replied) def test_no_similar_found(self): bot = _FakeBot() msg = _Msg(text="!similar Obscure Band") with patch.object(_mod, "_get_similar_tracks", return_value=[]): with patch.object(_mod, "_get_similar_artists", return_value=[]): asyncio.run(_mod.cmd_similar(bot, msg)) assert any("No similar artists" in r for r in bot.replied) def test_list_match_score_displayed(self): bot = _FakeBot() msg = _Msg(text="!similar list Tool") artists = [{"name": "Deftones", "match": "0.85"}] with patch.object(_mod, "_get_similar_tracks", return_value=[]): with patch.object(_mod, "_get_similar_artists", return_value=artists): asyncio.run(_mod.cmd_similar(bot, msg)) assert any("85%" in r for r in bot.replied) def test_list_current_track_no_separator(self): """Title without separator uses whole title as search artist.""" bot = _FakeBot() bot._pstate["music"] = { "current": _FakeTrack(title="Lateralus"), } msg = _Msg(text="!similar list") artists = [{"name": "APC", "match": "0.7"}] with patch.object(_mod, "_get_similar_tracks", return_value=[]): with patch.object(_mod, "_get_similar_artists", return_value=artists): asyncio.run(_mod.cmd_similar(bot, msg)) assert any("Similar to Lateralus" in r for r in bot.replied) def test_builds_playlist(self): """Default !similar builds playlist and starts playback.""" bot = _FakeBot() msg = _Msg(text="!similar Tool") artists = [{"name": "Deftones", "match": "0.8"}] fake_tracks = [_FakeTrack(url="http://yt/1", title="Song 1")] music_mod = MagicMock() music_mod._ps.return_value = { "queue": [], "current": None, "task": None, } music_mod._fade_and_cancel = AsyncMock() music_mod._ensure_loop = MagicMock() bot.registry._modules["music"] = music_mod with patch.object(_mod, "_get_similar_tracks", return_value=[]), \ patch.object(_mod, "_get_similar_artists", return_value=artists), \ patch.object(_mod, "_resolve_playlist", return_value=fake_tracks): asyncio.run(_mod.cmd_similar(bot, msg)) music_mod._fade_and_cancel.assert_called_once() music_mod._ensure_loop.assert_called_once() ps = music_mod._ps(bot) assert ps["queue"] == fake_tracks assert any("Playing 1 similar" in r for r in bot.replied) def test_builds_playlist_from_current_track(self): """!similar with no args discovers from currently playing track.""" bot = _FakeBot() bot._pstate["music"] = { "current": _FakeTrack(title="Tool - Lateralus"), } msg = _Msg(text="!similar") tracks = SIMILAR_TRACKS_RESP["similartracks"]["track"] fake_tracks = [_FakeTrack(url="http://yt/1", title="Song 1")] music_mod = MagicMock() music_mod._ps.return_value = { "queue": [], "current": None, "task": None, } music_mod._fade_and_cancel = AsyncMock() music_mod._ensure_loop = MagicMock() bot.registry._modules["music"] = music_mod with patch.object(_mod, "_get_similar_tracks", return_value=tracks), \ patch.object(_mod, "_resolve_playlist", return_value=fake_tracks): asyncio.run(_mod.cmd_similar(bot, msg)) assert any("Playing 1 similar" in r for r in bot.replied) assert any("Tool" in r for r in bot.replied) def test_no_music_mod_falls_back_to_display(self): """Without music plugin, !similar falls back to display mode.""" bot = _FakeBot() msg = _Msg(text="!similar Tool") artists = [{"name": "Deftones", "match": "0.8"}] with patch.object(_mod, "_get_similar_tracks", return_value=[]): with patch.object(_mod, "_get_similar_artists", return_value=artists): asyncio.run(_mod.cmd_similar(bot, msg)) # Falls back to display since no music module registered assert any("Similar to Tool" in r for r in bot.replied) assert any("Deftones" in r for r in bot.replied) def test_no_playable_tracks_resolved(self): """Shows error when resolution returns empty.""" bot = _FakeBot() msg = _Msg(text="!similar Tool") artists = [{"name": "Deftones", "match": "0.8"}] music_mod = MagicMock() music_mod._ps.return_value = {"queue": [], "current": None} bot.registry._modules["music"] = music_mod with patch.object(_mod, "_get_similar_tracks", return_value=[]), \ patch.object(_mod, "_get_similar_artists", return_value=artists), \ patch.object(_mod, "_resolve_playlist", return_value=[]): asyncio.run(_mod.cmd_similar(bot, msg)) assert any("No playable tracks" in r for r in bot.replied) def test_mb_builds_playlist(self): """MB fallback results build playlist in play mode.""" bot = _FakeBot(api_key="") msg = _Msg(text="!similar Tool") mb_picks = [{"artist": "MB Band", "title": "MB Track"}] fake_tracks = [_FakeTrack(url="http://yt/1", title="MB Track")] music_mod = MagicMock() music_mod._ps.return_value = { "queue": [], "current": None, "task": None, } music_mod._fade_and_cancel = AsyncMock() music_mod._ensure_loop = MagicMock() bot.registry._modules["music"] = music_mod with patch.dict("os.environ", {}, clear=True), \ patch("plugins._musicbrainz.mb_search_artist", return_value="mbid-123"), \ patch("plugins._musicbrainz.mb_artist_tags", return_value=["rock"]), \ patch("plugins._musicbrainz.mb_find_similar_recordings", return_value=mb_picks), \ patch.object(_mod, "_resolve_playlist", return_value=fake_tracks): asyncio.run(_mod.cmd_similar(bot, msg)) assert any("Playing 1 similar" in r for r in bot.replied) def test_cross_bot_delegates_to_music_bot(self): """When merlin runs !similar, playback starts on derp (music bot).""" # derp = music bot (has active music state) derp = _FakeBot(api_key="test-key", name="derp") derp._only_plugins = {"music", "voice"} derp._pstate["music"] = { "current": _FakeTrack(title="Tool - Lateralus"), "queue": [], } # merlin = calling bot (no music plugin) merlin = _FakeBot(api_key="test-key", name="merlin") shared_reg = _FakeRegistry() shared_reg._bots = {"derp": derp, "merlin": merlin} derp.registry = shared_reg merlin.registry = shared_reg artists = [{"name": "Deftones", "match": "0.8"}] fake_tracks = [_FakeTrack(url="http://yt/1", title="Song 1")] music_mod = MagicMock() music_mod._ps.return_value = { "queue": [], "current": None, "task": None, } music_mod._fade_and_cancel = AsyncMock() music_mod._ensure_loop = MagicMock() shared_reg._modules["music"] = music_mod msg = _Msg(text="!similar Tool") with patch.object(_mod, "_get_similar_tracks", return_value=[]), \ patch.object(_mod, "_get_similar_artists", return_value=artists), \ patch.object(_mod, "_resolve_playlist", return_value=fake_tracks): asyncio.run(_mod.cmd_similar(merlin, msg)) # Music ops must target derp, not merlin music_mod._fade_and_cancel.assert_called_once_with(derp, duration=3.0) music_mod._ensure_loop.assert_called_once_with(derp, fade_in=True) music_mod._ps.assert_called_with(derp) # Reply still goes through merlin (the bot the user talked to) assert any("Playing 1 similar" in r for r in merlin.replied) # --------------------------------------------------------------------------- # TestCmdTags # --------------------------------------------------------------------------- class TestCmdTags: def test_no_api_key_mb_fallback(self): """No API key falls back to MusicBrainz for tags.""" bot = _FakeBot(api_key="") msg = _Msg(text="!tags Tool") with patch.dict("os.environ", {}, clear=True), \ patch("plugins._musicbrainz.mb_search_artist", return_value="mbid-123"), \ patch("plugins._musicbrainz.mb_artist_tags", return_value=["rock", "progressive metal", "art rock"]): asyncio.run(_mod.cmd_tags(bot, msg)) assert any("Tool:" in r for r in bot.replied) assert any("rock" in r for r in bot.replied) assert any("progressive metal" in r for r in bot.replied) def test_no_api_key_mb_no_results(self): """No API key + MusicBrainz returns nothing shows 'no tags'.""" bot = _FakeBot(api_key="") msg = _Msg(text="!tags Obscure") with patch.dict("os.environ", {}, clear=True), \ patch("plugins._musicbrainz.mb_search_artist", return_value=None): asyncio.run(_mod.cmd_tags(bot, msg)) assert any("No tags found" in r for r in bot.replied) def test_no_artist_nothing_playing(self): bot = _FakeBot() msg = _Msg(text="!tags") asyncio.run(_mod.cmd_tags(bot, msg)) assert any("Nothing playing" in r for r in bot.replied) def test_shows_tags(self): bot = _FakeBot() msg = _Msg(text="!tags Tool") tags = TOP_TAGS_RESP["toptags"]["tag"] with patch.object(_mod, "_get_top_tags", return_value=tags): asyncio.run(_mod.cmd_tags(bot, msg)) assert any("rock" in r for r in bot.replied) assert any("alternative" in r for r in bot.replied) assert any("Tool:" in r for r in bot.replied) def test_no_tags_found(self): bot = _FakeBot() msg = _Msg(text="!tags Obscure") with patch.object(_mod, "_get_top_tags", return_value=[]): asyncio.run(_mod.cmd_tags(bot, msg)) assert any("No tags found" in r for r in bot.replied) def test_from_current_track(self): bot = _FakeBot() bot._pstate["music"] = { "current": _FakeTrack(title="Tool - Lateralus"), } msg = _Msg(text="!tags") tags = [{"name": "prog metal", "count": 100}] with patch.object(_mod, "_get_top_tags", return_value=tags): asyncio.run(_mod.cmd_tags(bot, msg)) assert any("Tool:" in r for r in bot.replied) assert any("prog metal" in r for r in bot.replied) def test_from_current_no_separator(self): """Uses full title as artist when no separator.""" bot = _FakeBot() bot._pstate["music"] = { "current": _FakeTrack(title="Lateralus"), } msg = _Msg(text="!tags") tags = [{"name": "rock", "count": 50}] with patch.object(_mod, "_get_top_tags", return_value=tags): asyncio.run(_mod.cmd_tags(bot, msg)) assert any("Lateralus:" in r for r in bot.replied) # --------------------------------------------------------------------------- # TestMusicBot # --------------------------------------------------------------------------- class TestMusicBot: def test_returns_self_when_active(self): """Returns calling bot when it has active music state.""" bot = _FakeBot() bot._pstate["music"] = {"current": _FakeTrack(title="X"), "queue": []} assert _mod._music_bot(bot) is bot def test_returns_peer_with_active_state(self): """Returns peer bot that has music playing.""" derp = _FakeBot(name="derp") derp._pstate["music"] = {"current": _FakeTrack(title="X"), "queue": []} merlin = _FakeBot(name="merlin") reg = _FakeRegistry() reg._bots = {"derp": derp, "merlin": merlin} derp.registry = reg merlin.registry = reg assert _mod._music_bot(merlin) is derp def test_falls_back_to_only_plugins(self): """Returns bot with only_plugins containing 'music' when no active state.""" derp = _FakeBot(name="derp") derp._only_plugins = {"music", "voice"} merlin = _FakeBot(name="merlin") reg = _FakeRegistry() reg._bots = {"derp": derp, "merlin": merlin} derp.registry = reg merlin.registry = reg assert _mod._music_bot(merlin) is derp def test_returns_self_as_last_resort(self): """Returns calling bot when no peer has music state or filters.""" bot = _FakeBot() assert _mod._music_bot(bot) is bot # --------------------------------------------------------------------------- # TestParseTitle # --------------------------------------------------------------------------- class TestParseTitle: def test_dash_separator(self): assert _mod._parse_title("Tool - Lateralus") == ("Tool", "Lateralus") def test_double_dash(self): assert _mod._parse_title("Tool -- Lateralus") == ("Tool", "Lateralus") def test_pipe_separator(self): assert _mod._parse_title("Tool | Lateralus") == ("Tool", "Lateralus") def test_tilde_separator(self): assert _mod._parse_title("Tool ~ Lateralus") == ("Tool", "Lateralus") def test_no_separator(self): assert _mod._parse_title("Lateralus") == ("", "Lateralus") def test_empty_string(self): assert _mod._parse_title("") == ("", "") def test_strips_whitespace(self): assert _mod._parse_title(" Tool - Lateralus ") == ("Tool", "Lateralus") def test_first_separator_wins(self): """Only the first matching separator is used.""" assert _mod._parse_title("A - B - C") == ("A", "B - C") def test_dash_priority_over_pipe(self): """Dash separator is tried before pipe.""" assert _mod._parse_title("A - B | C") == ("A", "B | C") # --------------------------------------------------------------------------- # TestDiscoverSimilar # --------------------------------------------------------------------------- class TestDiscoverSimilar: def test_lastfm_path(self): """Returns Last.fm result when API key + results available.""" bot = _FakeBot(api_key="test-key") tracks = [{"name": "Found", "artist": {"name": "Band"}, "match": "0.9"}] with patch.object(_mod, "_get_similar_tracks", return_value=tracks): result = asyncio.run( _mod.discover_similar(bot, "Tool - Lateralus"), ) assert result == ("Band", "Found") def test_lastfm_empty_falls_to_musicbrainz(self): """Falls back to MusicBrainz when Last.fm returns nothing.""" bot = _FakeBot(api_key="test-key") mb_picks = [{"artist": "MB Artist", "title": "MB Song"}] with patch.object(_mod, "_get_similar_tracks", return_value=[]), \ patch("plugins._musicbrainz.mb_search_artist", return_value="mbid"), \ patch("plugins._musicbrainz.mb_artist_tags", return_value=["rock"]), \ patch("plugins._musicbrainz.mb_find_similar_recordings", return_value=mb_picks): result = asyncio.run( _mod.discover_similar(bot, "Tool - Lateralus"), ) assert result == ("MB Artist", "MB Song") def test_no_api_key_uses_musicbrainz(self): """Skips Last.fm when no API key, goes straight to MusicBrainz.""" bot = _FakeBot(api_key="") mb_picks = [{"artist": "MB Band", "title": "MB Track"}] with patch.dict("os.environ", {}, clear=True), \ patch("plugins._musicbrainz.mb_search_artist", return_value="mbid"), \ patch("plugins._musicbrainz.mb_artist_tags", return_value=["rock"]), \ patch("plugins._musicbrainz.mb_find_similar_recordings", return_value=mb_picks): result = asyncio.run( _mod.discover_similar(bot, "Tool - Lateralus"), ) assert result == ("MB Band", "MB Track") def test_both_fail_returns_none(self): """Returns None when both Last.fm and MusicBrainz fail.""" bot = _FakeBot(api_key="test-key") with patch.object(_mod, "_get_similar_tracks", return_value=[]), \ patch("plugins._musicbrainz.mb_search_artist", return_value=None): result = asyncio.run( _mod.discover_similar(bot, "Tool - Lateralus"), ) assert result is None def test_no_artist_returns_none(self): """Returns None when title has no artist component.""" bot = _FakeBot(api_key="test-key") result = asyncio.run( _mod.discover_similar(bot, "Lateralus"), ) assert result is None def test_musicbrainz_import_error_handled(self): """Gracefully handles import error for _musicbrainz module.""" bot = _FakeBot(api_key="test-key") with patch.object(_mod, "_get_similar_tracks", return_value=[]), \ patch.dict("sys.modules", {"plugins._musicbrainz": None}): result = asyncio.run( _mod.discover_similar(bot, "Tool - Lateralus"), ) assert result is None def test_lastfm_exception_falls_to_musicbrainz(self): """Last.fm exception triggers MusicBrainz fallback.""" bot = _FakeBot(api_key="test-key") mb_picks = [{"artist": "Fallback", "title": "Song"}] with patch.object(_mod, "_get_similar_tracks", side_effect=Exception("API down")), \ patch("plugins._musicbrainz.mb_search_artist", return_value="mbid"), \ patch("plugins._musicbrainz.mb_artist_tags", return_value=["rock"]), \ patch("plugins._musicbrainz.mb_find_similar_recordings", return_value=mb_picks): result = asyncio.run( _mod.discover_similar(bot, "Tool - Lateralus"), ) assert result == ("Fallback", "Song") def test_lastfm_pick_missing_name_falls_to_musicbrainz(self): """Falls to MB when Last.fm result has empty artist/title.""" bot = _FakeBot(api_key="test-key") tracks = [{"name": "", "artist": {"name": ""}, "match": "0.9"}] mb_picks = [{"artist": "MB", "title": "Track"}] with patch.object(_mod, "_get_similar_tracks", return_value=tracks), \ patch("plugins._musicbrainz.mb_search_artist", return_value="mbid"), \ patch("plugins._musicbrainz.mb_artist_tags", return_value=["rock"]), \ patch("plugins._musicbrainz.mb_find_similar_recordings", return_value=mb_picks): result = asyncio.run( _mod.discover_similar(bot, "Tool - Lateralus"), ) assert result == ("MB", "Track")