When merlin ran !similar, music operations (fade, queue, play loop) targeted merlin instead of derp, causing audio to play over derp's stream. New _music_bot() helper resolves the DJ bot via active state or plugin filter config, so playback always routes to derp. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
917 lines
36 KiB
Python
917 lines
36 KiB
Python
"""Tests for the Last.fm music discovery plugin."""
|
|
|
|
import asyncio
|
|
import importlib.util
|
|
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 = {}
|
|
self._bots: dict = {}
|
|
|
|
|
|
class _FakeBot:
|
|
def __init__(self, *, api_key: str = "test-key", name: str = "derp"):
|
|
self.replied: list[str] = []
|
|
self.config: dict = {"lastfm": {"api_key": api_key}} if api_key else {}
|
|
self._pstate: dict = {}
|
|
self._only_plugins: set[str] | None = None
|
|
self.registry = _FakeRegistry()
|
|
self._username = name
|
|
|
|
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")
|
|
|
|
def test_peer_bot_music_state(self):
|
|
"""Extra bot sees music state from peer bot via shared registry."""
|
|
music_bot = _FakeBot()
|
|
music_bot._pstate["music"] = {
|
|
"current": _FakeTrack(title="Tool - Lateralus"),
|
|
}
|
|
extra_bot = _FakeBot()
|
|
# No music state on extra_bot
|
|
# Share registry with _bots index
|
|
shared_reg = _FakeRegistry()
|
|
shared_reg._bots = {"derp": music_bot, "merlin": extra_bot}
|
|
music_bot.registry = shared_reg
|
|
extra_bot.registry = shared_reg
|
|
assert _mod._current_meta(extra_bot) == ("Tool", "Lateralus")
|
|
|
|
def test_peer_bot_no_music(self):
|
|
"""Returns empty when no bot has music state."""
|
|
bot_a = _FakeBot()
|
|
bot_b = _FakeBot()
|
|
shared_reg = _FakeRegistry()
|
|
shared_reg._bots = {"a": bot_a, "b": bot_b}
|
|
bot_a.registry = shared_reg
|
|
bot_b.registry = shared_reg
|
|
assert _mod._current_meta(bot_a) == ("", "")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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 TestSearchQueries:
|
|
def test_track_results(self):
|
|
similar = [
|
|
{"name": "Track X", "artist": {"name": "Band A"}, "match": "0.9"},
|
|
{"name": "Track Y", "artist": {"name": "Band B"}, "match": "0.7"},
|
|
]
|
|
result = _mod._search_queries(similar, [], [])
|
|
assert result == ["Band A Track X", "Band B Track Y"]
|
|
|
|
def test_artist_results(self):
|
|
artists = [{"name": "Deftones"}, {"name": "APC"}]
|
|
result = _mod._search_queries([], artists, [])
|
|
assert result == ["Deftones", "APC"]
|
|
|
|
def test_mb_results(self):
|
|
mb = [{"artist": "MB Band", "title": "MB Song"}]
|
|
result = _mod._search_queries([], [], mb)
|
|
assert result == ["MB Band MB Song"]
|
|
|
|
def test_mixed_sources(self):
|
|
"""Track results come first, then artist, then MB."""
|
|
similar = [{"name": "T1", "artist": {"name": "A1"}}]
|
|
artists = [{"name": "A2"}]
|
|
mb = [{"artist": "MB", "title": "S1"}]
|
|
result = _mod._search_queries(similar, artists, mb)
|
|
assert result == ["A1 T1", "A2", "MB S1"]
|
|
|
|
def test_limit(self):
|
|
artists = [{"name": f"Band {i}"} for i in range(20)]
|
|
result = _mod._search_queries([], artists, [], limit=5)
|
|
assert len(result) == 5
|
|
|
|
def test_skips_empty(self):
|
|
similar = [{"name": "", "artist": {"name": ""}}]
|
|
artists = [{"name": ""}]
|
|
mb = [{"artist": "", "title": ""}]
|
|
result = _mod._search_queries(similar, artists, mb)
|
|
assert result == []
|
|
|
|
def test_empty_inputs(self):
|
|
assert _mod._search_queries([], [], []) == []
|
|
|
|
|
|
class TestCmdSimilar:
|
|
def test_no_api_key_mb_list_fallback(self):
|
|
"""No API key + list mode falls back to MusicBrainz for results."""
|
|
bot = _FakeBot(api_key="")
|
|
msg = _Msg(text="!similar list 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_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_list_artist_shows_similar(self):
|
|
"""!similar list <artist> shows similar artists (display only)."""
|
|
bot = _FakeBot()
|
|
msg = _Msg(text="!similar list 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_list_track_level(self):
|
|
"""!similar list with track results shows track similarity."""
|
|
bot = _FakeBot()
|
|
bot._pstate["music"] = {
|
|
"current": _FakeTrack(title="Tool - Lateralus"),
|
|
}
|
|
msg = _Msg(text="!similar list")
|
|
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_list_falls_back_to_artist(self):
|
|
"""!similar list falls back to artist similarity when no track results."""
|
|
bot = _FakeBot()
|
|
bot._pstate["music"] = {
|
|
"current": _FakeTrack(title="Tool - Lateralus"),
|
|
}
|
|
msg = _Msg(text="!similar list")
|
|
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_list_match_score_displayed(self):
|
|
bot = _FakeBot()
|
|
msg = _Msg(text="!similar list 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_list_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 list")
|
|
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)
|
|
|
|
def test_builds_playlist(self):
|
|
"""Default !similar builds playlist and starts playback."""
|
|
bot = _FakeBot()
|
|
msg = _Msg(text="!similar Tool")
|
|
artists = [{"name": "Deftones", "match": "0.8"}]
|
|
fake_tracks = [_FakeTrack(url="http://yt/1", title="Song 1")]
|
|
|
|
music_mod = MagicMock()
|
|
music_mod._ps.return_value = {
|
|
"queue": [], "current": None, "task": None,
|
|
}
|
|
music_mod._fade_and_cancel = AsyncMock()
|
|
music_mod._ensure_loop = MagicMock()
|
|
bot.registry._modules["music"] = music_mod
|
|
|
|
with patch.object(_mod, "_get_similar_tracks", return_value=[]), \
|
|
patch.object(_mod, "_get_similar_artists", return_value=artists), \
|
|
patch.object(_mod, "_resolve_playlist",
|
|
return_value=fake_tracks):
|
|
asyncio.run(_mod.cmd_similar(bot, msg))
|
|
|
|
music_mod._fade_and_cancel.assert_called_once()
|
|
music_mod._ensure_loop.assert_called_once()
|
|
ps = music_mod._ps(bot)
|
|
assert ps["queue"] == fake_tracks
|
|
assert any("Playing 1 similar" in r for r in bot.replied)
|
|
|
|
def test_builds_playlist_from_current_track(self):
|
|
"""!similar with no args discovers from currently playing track."""
|
|
bot = _FakeBot()
|
|
bot._pstate["music"] = {
|
|
"current": _FakeTrack(title="Tool - Lateralus"),
|
|
}
|
|
msg = _Msg(text="!similar")
|
|
tracks = SIMILAR_TRACKS_RESP["similartracks"]["track"]
|
|
fake_tracks = [_FakeTrack(url="http://yt/1", title="Song 1")]
|
|
|
|
music_mod = MagicMock()
|
|
music_mod._ps.return_value = {
|
|
"queue": [], "current": None, "task": None,
|
|
}
|
|
music_mod._fade_and_cancel = AsyncMock()
|
|
music_mod._ensure_loop = MagicMock()
|
|
bot.registry._modules["music"] = music_mod
|
|
|
|
with patch.object(_mod, "_get_similar_tracks", return_value=tracks), \
|
|
patch.object(_mod, "_resolve_playlist",
|
|
return_value=fake_tracks):
|
|
asyncio.run(_mod.cmd_similar(bot, msg))
|
|
|
|
assert any("Playing 1 similar" in r for r in bot.replied)
|
|
assert any("Tool" in r for r in bot.replied)
|
|
|
|
def test_no_music_mod_falls_back_to_display(self):
|
|
"""Without music plugin, !similar falls back to display mode."""
|
|
bot = _FakeBot()
|
|
msg = _Msg(text="!similar Tool")
|
|
artists = [{"name": "Deftones", "match": "0.8"}]
|
|
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))
|
|
# Falls back to display since no music module registered
|
|
assert any("Similar to Tool" in r for r in bot.replied)
|
|
assert any("Deftones" in r for r in bot.replied)
|
|
|
|
def test_no_playable_tracks_resolved(self):
|
|
"""Shows error when resolution returns empty."""
|
|
bot = _FakeBot()
|
|
msg = _Msg(text="!similar Tool")
|
|
artists = [{"name": "Deftones", "match": "0.8"}]
|
|
|
|
music_mod = MagicMock()
|
|
music_mod._ps.return_value = {"queue": [], "current": None}
|
|
bot.registry._modules["music"] = music_mod
|
|
|
|
with patch.object(_mod, "_get_similar_tracks", return_value=[]), \
|
|
patch.object(_mod, "_get_similar_artists", return_value=artists), \
|
|
patch.object(_mod, "_resolve_playlist",
|
|
return_value=[]):
|
|
asyncio.run(_mod.cmd_similar(bot, msg))
|
|
assert any("No playable tracks" in r for r in bot.replied)
|
|
|
|
def test_mb_builds_playlist(self):
|
|
"""MB fallback results build playlist in play mode."""
|
|
bot = _FakeBot(api_key="")
|
|
msg = _Msg(text="!similar Tool")
|
|
mb_picks = [{"artist": "MB Band", "title": "MB Track"}]
|
|
fake_tracks = [_FakeTrack(url="http://yt/1", title="MB Track")]
|
|
|
|
music_mod = MagicMock()
|
|
music_mod._ps.return_value = {
|
|
"queue": [], "current": None, "task": None,
|
|
}
|
|
music_mod._fade_and_cancel = AsyncMock()
|
|
music_mod._ensure_loop = MagicMock()
|
|
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), \
|
|
patch.object(_mod, "_resolve_playlist",
|
|
return_value=fake_tracks):
|
|
asyncio.run(_mod.cmd_similar(bot, msg))
|
|
assert any("Playing 1 similar" in r for r in bot.replied)
|
|
|
|
def test_cross_bot_delegates_to_music_bot(self):
|
|
"""When merlin runs !similar, playback starts on derp (music bot)."""
|
|
# derp = music bot (has active music state)
|
|
derp = _FakeBot(api_key="test-key", name="derp")
|
|
derp._only_plugins = {"music", "voice"}
|
|
derp._pstate["music"] = {
|
|
"current": _FakeTrack(title="Tool - Lateralus"),
|
|
"queue": [],
|
|
}
|
|
# merlin = calling bot (no music plugin)
|
|
merlin = _FakeBot(api_key="test-key", name="merlin")
|
|
shared_reg = _FakeRegistry()
|
|
shared_reg._bots = {"derp": derp, "merlin": merlin}
|
|
derp.registry = shared_reg
|
|
merlin.registry = shared_reg
|
|
|
|
artists = [{"name": "Deftones", "match": "0.8"}]
|
|
fake_tracks = [_FakeTrack(url="http://yt/1", title="Song 1")]
|
|
|
|
music_mod = MagicMock()
|
|
music_mod._ps.return_value = {
|
|
"queue": [], "current": None, "task": None,
|
|
}
|
|
music_mod._fade_and_cancel = AsyncMock()
|
|
music_mod._ensure_loop = MagicMock()
|
|
shared_reg._modules["music"] = music_mod
|
|
|
|
msg = _Msg(text="!similar Tool")
|
|
with patch.object(_mod, "_get_similar_tracks", return_value=[]), \
|
|
patch.object(_mod, "_get_similar_artists", return_value=artists), \
|
|
patch.object(_mod, "_resolve_playlist",
|
|
return_value=fake_tracks):
|
|
asyncio.run(_mod.cmd_similar(merlin, msg))
|
|
|
|
# Music ops must target derp, not merlin
|
|
music_mod._fade_and_cancel.assert_called_once_with(derp, duration=3.0)
|
|
music_mod._ensure_loop.assert_called_once_with(derp, fade_in=True)
|
|
music_mod._ps.assert_called_with(derp)
|
|
# Reply still goes through merlin (the bot the user talked to)
|
|
assert any("Playing 1 similar" in r for r in merlin.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)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# TestMusicBot
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestMusicBot:
|
|
def test_returns_self_when_active(self):
|
|
"""Returns calling bot when it has active music state."""
|
|
bot = _FakeBot()
|
|
bot._pstate["music"] = {"current": _FakeTrack(title="X"), "queue": []}
|
|
assert _mod._music_bot(bot) is bot
|
|
|
|
def test_returns_peer_with_active_state(self):
|
|
"""Returns peer bot that has music playing."""
|
|
derp = _FakeBot(name="derp")
|
|
derp._pstate["music"] = {"current": _FakeTrack(title="X"), "queue": []}
|
|
merlin = _FakeBot(name="merlin")
|
|
reg = _FakeRegistry()
|
|
reg._bots = {"derp": derp, "merlin": merlin}
|
|
derp.registry = reg
|
|
merlin.registry = reg
|
|
assert _mod._music_bot(merlin) is derp
|
|
|
|
def test_falls_back_to_only_plugins(self):
|
|
"""Returns bot with only_plugins containing 'music' when no active state."""
|
|
derp = _FakeBot(name="derp")
|
|
derp._only_plugins = {"music", "voice"}
|
|
merlin = _FakeBot(name="merlin")
|
|
reg = _FakeRegistry()
|
|
reg._bots = {"derp": derp, "merlin": merlin}
|
|
derp.registry = reg
|
|
merlin.registry = reg
|
|
assert _mod._music_bot(merlin) is derp
|
|
|
|
def test_returns_self_as_last_resort(self):
|
|
"""Returns calling bot when no peer has music state or filters."""
|
|
bot = _FakeBot()
|
|
assert _mod._music_bot(bot) is bot
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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")
|