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>
524 lines
18 KiB
Python
524 lines
18 KiB
Python
"""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)
|