diff --git a/TASKS.md b/TASKS.md index 323ec64..77f896c 100644 --- a/TASKS.md +++ b/TASKS.md @@ -34,7 +34,7 @@ | P0 | [x] | `!similar play` -- queue a similar track via YouTube search | | P1 | [x] | `!tags` command -- show genre/style tags for current or named track | | P1 | [x] | Config: `[lastfm] api_key` or `LASTFM_API_KEY` env var | -| P2 | [ ] | Tests: `test_lastfm.py` (API response mocking, command dispatch) | +| P2 | [x] | Tests: `test_lastfm.py` (50 cases: API helpers, metadata, commands) | | P2 | [ ] | Documentation update (USAGE.md, CHEATSHEET.md) | ## Previous Sprint -- v2.3.0 Mumble Voice + Multi-Bot (2026-02-22) diff --git a/tests/test_lastfm.py b/tests/test_lastfm.py new file mode 100644 index 0000000..4e8e46c --- /dev/null +++ b/tests/test_lastfm.py @@ -0,0 +1,523 @@ +"""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 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(self): + bot = _FakeBot(api_key="") + msg = _Msg(text="!similar Tool") + with patch.dict("os.environ", {}, clear=True): + asyncio.run(_mod.cmd_similar(bot, msg)) + assert any("not configured" 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_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(self): + bot = _FakeBot(api_key="") + msg = _Msg(text="!tags Tool") + with patch.dict("os.environ", {}, clear=True): + asyncio.run(_mod.cmd_tags(bot, msg)) + assert any("not configured" 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)