311 lines
11 KiB
Python
311 lines
11 KiB
Python
"""Tests for the MusicBrainz API helper module."""
|
|
|
|
import importlib.util
|
|
import json
|
|
import sys
|
|
import time
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
# -- Load module directly ----------------------------------------------------
|
|
|
|
_spec = importlib.util.spec_from_file_location(
|
|
"_musicbrainz", "plugins/_musicbrainz.py",
|
|
)
|
|
_mod = importlib.util.module_from_spec(_spec)
|
|
sys.modules["_musicbrainz"] = _mod
|
|
_spec.loader.exec_module(_mod)
|
|
|
|
|
|
# -- Helpers -----------------------------------------------------------------
|
|
|
|
|
|
def _make_resp(data: dict) -> MagicMock:
|
|
"""Create a fake HTTP response with JSON body."""
|
|
resp = MagicMock()
|
|
resp.read.return_value = json.dumps(data).encode()
|
|
return resp
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# TestMbRequest
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestMbRequest:
|
|
def setup_method(self):
|
|
_mod._last_request = 0.0
|
|
|
|
def test_returns_parsed_json(self):
|
|
resp = _make_resp({"status": "ok"})
|
|
with patch("derp.http.urlopen", return_value=resp):
|
|
result = _mod._mb_request("artist", {"query": "Tool"})
|
|
assert result == {"status": "ok"}
|
|
|
|
def test_rate_delay_enforced(self):
|
|
"""Second call within rate interval triggers sleep."""
|
|
_mod._last_request = time.monotonic()
|
|
resp = _make_resp({})
|
|
slept = []
|
|
with patch("derp.http.urlopen", return_value=resp), \
|
|
patch.object(_mod.time, "sleep", side_effect=slept.append), \
|
|
patch.object(_mod.time, "monotonic", return_value=_mod._last_request + 0.2):
|
|
_mod._mb_request("artist", {"query": "X"})
|
|
assert len(slept) == 1
|
|
assert slept[0] > 0
|
|
|
|
def test_no_delay_when_interval_elapsed(self):
|
|
"""No sleep when enough time has passed since last request."""
|
|
_mod._last_request = time.monotonic() - 5.0
|
|
resp = _make_resp({})
|
|
with patch("derp.http.urlopen", return_value=resp), \
|
|
patch.object(_mod.time, "sleep") as mock_sleep:
|
|
_mod._mb_request("artist", {"query": "X"})
|
|
mock_sleep.assert_not_called()
|
|
|
|
def test_returns_empty_on_error(self):
|
|
with patch("derp.http.urlopen", side_effect=ConnectionError("fail")):
|
|
result = _mod._mb_request("artist", {"query": "X"})
|
|
assert result == {}
|
|
|
|
def test_updates_last_request_on_success(self):
|
|
_mod._last_request = 0.0
|
|
resp = _make_resp({})
|
|
with patch("derp.http.urlopen", return_value=resp):
|
|
_mod._mb_request("test")
|
|
assert _mod._last_request > 0
|
|
|
|
def test_updates_last_request_on_error(self):
|
|
_mod._last_request = 0.0
|
|
with patch("derp.http.urlopen", side_effect=Exception("boom")):
|
|
_mod._mb_request("test")
|
|
assert _mod._last_request > 0
|
|
|
|
def test_none_params(self):
|
|
"""Handles None params without error."""
|
|
resp = _make_resp({"ok": True})
|
|
with patch("derp.http.urlopen", return_value=resp):
|
|
result = _mod._mb_request("test", None)
|
|
assert result == {"ok": True}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# TestMbSearchArtist
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestMbSearchArtist:
|
|
def setup_method(self):
|
|
_mod._last_request = 0.0
|
|
|
|
def test_returns_mbid(self):
|
|
data = {"artists": [{"id": "abc-123", "name": "Tool", "score": 100}]}
|
|
with patch.object(_mod, "_mb_request", return_value=data):
|
|
result = _mod.mb_search_artist("Tool")
|
|
assert result == "abc-123"
|
|
|
|
def test_returns_none_no_results(self):
|
|
with patch.object(_mod, "_mb_request", return_value={"artists": []}):
|
|
assert _mod.mb_search_artist("Unknown") is None
|
|
|
|
def test_returns_none_on_empty_response(self):
|
|
with patch.object(_mod, "_mb_request", return_value={}):
|
|
assert _mod.mb_search_artist("X") is None
|
|
|
|
def test_returns_none_low_score(self):
|
|
"""Rejects matches with score below 50."""
|
|
data = {"artists": [{"id": "low", "name": "Mismatch", "score": 30}]}
|
|
with patch.object(_mod, "_mb_request", return_value=data):
|
|
assert _mod.mb_search_artist("Tool") is None
|
|
|
|
def test_returns_none_on_error(self):
|
|
with patch.object(_mod, "_mb_request", return_value={}):
|
|
assert _mod.mb_search_artist("Error") is None
|
|
|
|
def test_accepts_high_score(self):
|
|
data = {"artists": [{"id": "abc", "name": "Tool", "score": 85}]}
|
|
with patch.object(_mod, "_mb_request", return_value=data):
|
|
assert _mod.mb_search_artist("Tool") == "abc"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# TestMbArtistTags
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestMbArtistTags:
|
|
def setup_method(self):
|
|
_mod._last_request = 0.0
|
|
|
|
def test_returns_sorted_top_5(self):
|
|
data = {"tags": [
|
|
{"name": "rock", "count": 50},
|
|
{"name": "metal", "count": 100},
|
|
{"name": "prog", "count": 80},
|
|
{"name": "alternative", "count": 60},
|
|
{"name": "hard rock", "count": 40},
|
|
{"name": "grunge", "count": 30},
|
|
]}
|
|
with patch.object(_mod, "_mb_request", return_value=data):
|
|
result = _mod.mb_artist_tags("mbid-123")
|
|
assert len(result) == 5
|
|
assert result[0] == "metal"
|
|
assert result[1] == "prog"
|
|
assert result[2] == "alternative"
|
|
assert result[3] == "rock"
|
|
assert result[4] == "hard rock"
|
|
|
|
def test_empty_tags(self):
|
|
with patch.object(_mod, "_mb_request", return_value={"tags": []}):
|
|
assert _mod.mb_artist_tags("mbid") == []
|
|
|
|
def test_no_tags_key(self):
|
|
with patch.object(_mod, "_mb_request", return_value={}):
|
|
assert _mod.mb_artist_tags("mbid") == []
|
|
|
|
def test_skips_nameless_tags(self):
|
|
data = {"tags": [
|
|
{"name": "rock", "count": 50},
|
|
{"count": 100}, # no name
|
|
{"name": "", "count": 80}, # empty name
|
|
]}
|
|
with patch.object(_mod, "_mb_request", return_value=data):
|
|
result = _mod.mb_artist_tags("mbid")
|
|
assert result == ["rock"]
|
|
|
|
def test_fewer_than_5_tags(self):
|
|
data = {"tags": [{"name": "jazz", "count": 10}]}
|
|
with patch.object(_mod, "_mb_request", return_value=data):
|
|
result = _mod.mb_artist_tags("mbid")
|
|
assert result == ["jazz"]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# TestMbFindSimilarRecordings
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestMbFindSimilarRecordings:
|
|
def setup_method(self):
|
|
_mod._last_request = 0.0
|
|
|
|
def test_returns_dicts(self):
|
|
data = {"recordings": [
|
|
{
|
|
"title": "Song A",
|
|
"artist-credit": [{"name": "Other Artist"}],
|
|
},
|
|
{
|
|
"title": "Song B",
|
|
"artist-credit": [{"name": "Another Band"}],
|
|
},
|
|
]}
|
|
with patch.object(_mod, "_mb_request", return_value=data):
|
|
result = _mod.mb_find_similar_recordings(
|
|
"Tool", ["rock", "metal"],
|
|
)
|
|
assert len(result) == 2
|
|
assert result[0] == {"artist": "Other Artist", "title": "Song A"}
|
|
assert result[1] == {"artist": "Another Band", "title": "Song B"}
|
|
|
|
def test_excludes_original_artist(self):
|
|
data = {"recordings": [
|
|
{
|
|
"title": "Own Song",
|
|
"artist-credit": [{"name": "Tool"}],
|
|
},
|
|
{
|
|
"title": "Other Song",
|
|
"artist-credit": [{"name": "Deftones"}],
|
|
},
|
|
]}
|
|
with patch.object(_mod, "_mb_request", return_value=data):
|
|
result = _mod.mb_find_similar_recordings(
|
|
"Tool", ["rock"],
|
|
)
|
|
assert len(result) == 1
|
|
assert result[0]["artist"] == "Deftones"
|
|
|
|
def test_excludes_original_artist_case_insensitive(self):
|
|
data = {"recordings": [
|
|
{
|
|
"title": "Song",
|
|
"artist-credit": [{"name": "TOOL"}],
|
|
},
|
|
]}
|
|
with patch.object(_mod, "_mb_request", return_value=data):
|
|
result = _mod.mb_find_similar_recordings(
|
|
"Tool", ["rock"],
|
|
)
|
|
assert result == []
|
|
|
|
def test_deduplicates(self):
|
|
data = {"recordings": [
|
|
{
|
|
"title": "Song A",
|
|
"artist-credit": [{"name": "Band X"}],
|
|
},
|
|
{
|
|
"title": "Song A",
|
|
"artist-credit": [{"name": "Band X"}],
|
|
},
|
|
]}
|
|
with patch.object(_mod, "_mb_request", return_value=data):
|
|
result = _mod.mb_find_similar_recordings(
|
|
"Other", ["rock"],
|
|
)
|
|
assert len(result) == 1
|
|
|
|
def test_empty_tags(self):
|
|
result = _mod.mb_find_similar_recordings("Tool", [])
|
|
assert result == []
|
|
|
|
def test_no_recordings(self):
|
|
with patch.object(_mod, "_mb_request", return_value={"recordings": []}):
|
|
result = _mod.mb_find_similar_recordings(
|
|
"Tool", ["rock"],
|
|
)
|
|
assert result == []
|
|
|
|
def test_empty_response(self):
|
|
with patch.object(_mod, "_mb_request", return_value={}):
|
|
result = _mod.mb_find_similar_recordings(
|
|
"Tool", ["rock"],
|
|
)
|
|
assert result == []
|
|
|
|
def test_skips_missing_title(self):
|
|
data = {"recordings": [
|
|
{
|
|
"title": "",
|
|
"artist-credit": [{"name": "Band"}],
|
|
},
|
|
]}
|
|
with patch.object(_mod, "_mb_request", return_value=data):
|
|
result = _mod.mb_find_similar_recordings(
|
|
"Other", ["rock"],
|
|
)
|
|
assert result == []
|
|
|
|
def test_skips_missing_artist_credit(self):
|
|
data = {"recordings": [
|
|
{"title": "Song", "artist-credit": []},
|
|
{"title": "Song2"},
|
|
]}
|
|
with patch.object(_mod, "_mb_request", return_value=data):
|
|
result = _mod.mb_find_similar_recordings(
|
|
"Other", ["rock"],
|
|
)
|
|
assert result == []
|
|
|
|
def test_uses_top_two_tags(self):
|
|
"""Query should use at most 2 tags."""
|
|
with patch.object(_mod, "_mb_request", return_value={}) as mock_req:
|
|
_mod.mb_find_similar_recordings(
|
|
"Tool", ["rock", "metal", "prog"],
|
|
)
|
|
call_args = mock_req.call_args
|
|
args = call_args[1] or {}
|
|
query = args.get("query") or call_args[0][1].get("query", "")
|
|
# Verify the query contains both tag references
|
|
assert "rock" in query or "metal" in query
|