test: add test_lastfm.py (50 cases)
Covers API helpers (_get_similar_artists, _get_top_tags, _get_similar_tracks, _search_track), config resolution, metadata extraction from track titles, match score formatting, and both commands (!similar, !tags) including play mode delegation, fallback from track to artist similarity, and current-track integration. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2
TASKS.md
2
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)
|
||||
|
||||
523
tests/test_lastfm.py
Normal file
523
tests/test_lastfm.py
Normal file
@@ -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)
|
||||
Reference in New Issue
Block a user