Remove early return on missing Last.fm API key. Both commands now fall back to MusicBrainz (mb_search_artist -> mb_artist_tags -> mb_find_similar_recordings) when no API key is configured or when Last.fm returns empty results. Same pattern used by discover_similar. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
724 lines
27 KiB
Python
724 lines
27 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 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")
|