"""Tests for the Last.fm music discovery plugin.""" import asyncio import importlib.util import json 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 = {} class _FakeBot: def __init__(self, *, api_key: str = "test-key"): self.replied: list[str] = [] self.config: dict = {"lastfm": {"api_key": api_key}} if api_key else {} self._pstate: dict = {} self.registry = _FakeRegistry() 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") # --------------------------------------------------------------------------- # 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 TestCmdSimilar: def test_no_api_key_mb_fallback(self): """No API key falls back to MusicBrainz for similar results.""" bot = _FakeBot(api_key="") msg = _Msg(text="!similar 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_api_key_play_mode(self): """No API key + play mode delegates to cmd_play via MB results.""" bot = _FakeBot(api_key="") msg = _Msg(text="!similar play Tool") mb_picks = [{"artist": "MB Band", "title": "MB Track"}] play_called = [] async def fake_play(b, m): play_called.append(m.text) music_mod = MagicMock() music_mod.cmd_play = fake_play 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): asyncio.run(_mod.cmd_similar(bot, msg)) assert len(play_called) == 1 assert "MB Band" in play_called[0] 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_artist_query_shows_similar(self): bot = _FakeBot() msg = _Msg(text="!similar 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_track_level_similarity(self): """When current track has artist + title, tries track similarity first.""" bot = _FakeBot() bot._pstate["music"] = { "current": _FakeTrack(title="Tool - Lateralus"), } msg = _Msg(text="!similar") 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_falls_back_to_artist(self): """Falls back to artist similarity when no track results.""" bot = _FakeBot() bot._pstate["music"] = { "current": _FakeTrack(title="Tool - Lateralus"), } msg = _Msg(text="!similar") 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_play_mode_artist(self): """!similar play delegates to music cmd_play.""" bot = _FakeBot() msg = _Msg(text="!similar play Tool") artists = [{"name": "Deftones", "match": "0.8"}] play_called = [] async def fake_play(b, m): play_called.append(m.text) music_mod = MagicMock() music_mod.cmd_play = fake_play bot.registry._modules["music"] = music_mod 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 len(play_called) == 1 assert "Deftones" in play_called[0] def test_play_mode_track(self): """!similar play with track-level results delegates to cmd_play.""" bot = _FakeBot() bot._pstate["music"] = { "current": _FakeTrack(title="Tool - Lateralus"), } msg = _Msg(text="!similar play") tracks = [{"name": "Schism", "artist": {"name": "Tool"}, "match": "0.9"}] play_called = [] async def fake_play(b, m): play_called.append(m.text) music_mod = MagicMock() music_mod.cmd_play = fake_play bot.registry._modules["music"] = music_mod with patch.object(_mod, "_get_similar_tracks", return_value=tracks): asyncio.run(_mod.cmd_similar(bot, msg)) assert len(play_called) == 1 assert "Tool" in play_called[0] assert "Schism" in play_called[0] def test_match_score_displayed(self): bot = _FakeBot() msg = _Msg(text="!similar 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_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") 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) # --------------------------------------------------------------------------- # 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) # --------------------------------------------------------------------------- # 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")