Files
derp/tests/test_lastfm.py
user 135a3791e2
Some checks failed
CI / gitleaks (push) Failing after 2s
CI / lint (push) Failing after 23s
CI / test (3.11) (push) Has been skipped
CI / test (3.12) (push) Has been skipped
CI / test (3.13) (push) Has been skipped
CI / build (push) Has been skipped
feat: add MusicBrainz fallback to !similar and !tags commands
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>
2026-02-23 21:56:39 +01:00

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")