diff --git a/mopidy_tidal/backend.py b/mopidy_tidal/backend.py index 4d1d916..5896ff9 100755 --- a/mopidy_tidal/backend.py +++ b/mopidy_tidal/backend.py @@ -32,7 +32,7 @@ def __init__(self, config, audio): # Session parameters self._active_session: Optional[Session] = None self._logged_in: bool = False - self.uri_schemes: [str] = ["tidal"] + self.uri_schemes: tuple[str] = ("tidal",) self._login_future: Optional[Future] = None self._login_url: Optional[str] = None self.data_dir: Path = Path(Extension.get_data_dir(self._config)) diff --git a/mopidy_tidal/library.py b/mopidy_tidal/library.py index 7bd9f3d..99ff288 100755 --- a/mopidy_tidal/library.py +++ b/mopidy_tidal/library.py @@ -2,6 +2,7 @@ import logging from concurrent.futures import ThreadPoolExecutor +from contextlib import suppress from typing import TYPE_CHECKING, List, Optional, Tuple, Union from mopidy import backend, models @@ -162,38 +163,38 @@ def get_distinct(self, field, query=None) -> set[str]: if not query: # library root if field in {"artist", "albumartist"}: - return [ + return { apply_watermark(a.name) for a in session.user.favorites.artists() - ] + } elif field == "album": - return [ + return { apply_watermark(a.name) for a in session.user.favorites.albums() - ] + } elif field in {"track", "track_name"}: - return [ + return { apply_watermark(t.name) for t in session.user.favorites.tracks() - ] + } else: if field == "artist": - return [ + return { apply_watermark(a.name) for a in session.user.favorites.artists() - ] + } elif field in {"album", "albumartist"}: artists, _, _ = tidal_search(session, query=query, exact=True) if len(artists) > 0: artist = artists[0] artist_id = artist.uri.split(":")[2] - return [ + return { apply_watermark(a.name) for a in self._get_artist_albums(session, artist_id) - ] + } elif field in {"track", "track_name"}: - return [ + return { apply_watermark(t.name) for t in session.user.favorites.tracks() - ] + } pass - return [] + return set() @login_hack def browse(self, uri) -> list[Ref]: @@ -245,59 +246,64 @@ def browse(self, uri) -> list[Ref]: elif uri == "tidal:genres": return ref_models_mappers.create_genres(session.genre.get_genres()) - # details - parts = uri.split(":") - nr_of_parts = len(parts) - - if nr_of_parts == 3 and parts[1] == "album": - return ref_models_mappers.create_tracks( - self._get_album_tracks(session, parts[2]) - ) - - if nr_of_parts == 3 and parts[1] == "artist": - top_10_tracks = ref_models_mappers.create_tracks( - self._get_artist_top_tracks(session, parts[2])[:10] - ) - - albums = ref_models_mappers.create_albums( - self._get_artist_albums(session, parts[2]) - ) - - return albums + top_10_tracks - - if nr_of_parts == 3 and parts[1] == "playlist": - return ref_models_mappers.create_tracks( - self._get_playlist_tracks(session, parts[2]) - ) - - if nr_of_parts == 3 and parts[1] == "mood": - return ref_models_mappers.create_playlists( - self._get_mood_items(session, parts[2]) - ) - - if nr_of_parts == 3 and parts[1] == "genre": - return ref_models_mappers.create_playlists( - self._get_genre_items(session, parts[2]) - ) - - if nr_of_parts == 3 and parts[1] == "mix": - return ref_models_mappers.create_tracks( - self._get_mix_tracks(session, parts[2]) - ) - - if nr_of_parts == 3 and parts[1] == "page": - # User page (eg. page:for_you, page:home etc) - return ref_models_mappers.create_mixed_directory(session.page.get(parts[2])) - - if nr_of_parts == 4 and parts[2] == "category": - # Category nested on a page (eg. page(For You).category[0..n]) - category = session.page.get("pages/{}".format(parts[1])).categories[ - int(parts[3]) - ] + # Category nested on a page (eg. page(For You).category[0..n]) + # These have 3-part uris + with suppress(ValueError): + _, page_id, type, category_id = uri.split(":") + category = session.page.get(f"pages/{page_id}").categories[int(category_id)] return ref_models_mappers.create_mixed_directory(category.items) - logger.info("Unknown uri for browse request: %s", uri) - return [] + # details with 2-part uris + try: + _, type, id = uri.split(":") + + if type == "album": + return ref_models_mappers.create_tracks( + self._get_album_tracks(session, id) + ) + + elif type == "artist": + top_10_tracks = ref_models_mappers.create_tracks( + self._get_artist_top_tracks(session, id)[:10] + ) + + albums = ref_models_mappers.create_albums( + self._get_artist_albums(session, id) + ) + + return albums + top_10_tracks + + elif type == "playlist": + return ref_models_mappers.create_tracks( + self._get_playlist_tracks(session, id) + ) + + elif type == "mood": + return ref_models_mappers.create_playlists( + self._get_mood_items(session, id) + ) + + elif type == "genre": + return ref_models_mappers.create_playlists( + self._get_genre_items(session, id) + ) + + elif type == "mix": + return ref_models_mappers.create_tracks( + self._get_mix_tracks(session, id) + ) + + elif type == "page": + return ref_models_mappers.create_mixed_directory(session.page.get(id)) + else: + return [] + + except ValueError: + logger.exception("Unable to parse uri '%s' for browse.", uri) + return [] + except HTTPError: + logger.exception("Unable to retrieve object from uri '%s'", uri) + return [] @login_hack def search(self, query=None, uris=None, exact=False) -> Optional[SearchResult]: diff --git a/mopidy_tidal/lru_cache.py b/mopidy_tidal/lru_cache.py index 2449870..e45cda8 100755 --- a/mopidy_tidal/lru_cache.py +++ b/mopidy_tidal/lru_cache.py @@ -154,9 +154,9 @@ def _check_limit(self): class SearchCache(LruCache): - def __init__(self, func): + def __init__(self, search_function): super().__init__(persist=False) - self._func = func + self._search_function = search_function def __call__(self, *args, **kwargs): key = str(SearchKey(**kwargs)) @@ -165,7 +165,7 @@ def __call__(self, *args, **kwargs): "Search cache miss" if cached_result is None else "Search cache hit" ) if cached_result is None: - cached_result = self._func(*args, **kwargs) + cached_result = self._search_function(*args, **kwargs) self[key] = cached_result return cached_result diff --git a/poetry.lock b/poetry.lock index eef3501..6e7d644 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 1.6.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.5.1 and should not be changed by hand. [[package]] name = "black" @@ -287,6 +287,20 @@ files = [ {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, ] +[[package]] +name = "isodate" +version = "0.6.1" +description = "An ISO 8601 date/time/duration parser and formatter" +optional = false +python-versions = "*" +files = [ + {file = "isodate-0.6.1-py2.py3-none-any.whl", hash = "sha256:0751eece944162659049d35f4f549ed815792b38793f07cf73381c1c87cbed96"}, + {file = "isodate-0.6.1.tar.gz", hash = "sha256:48c5881de7e8b0a0d648cb024c8062dc84e7b840ed81e864c7614fd3c127bde9"}, +] + +[package.dependencies] +six = "*" + [[package]] name = "isort" version = "5.12.0" @@ -392,6 +406,17 @@ dev = ["black", "check-manifest", "flake8", "flake8-black", "flake8-bugbear", "f lint = ["black", "check-manifest", "flake8", "flake8-black", "flake8-bugbear", "flake8-import-order", "isort[pyproject]"] test = ["pytest", "pytest-cov"] +[[package]] +name = "mpegdash" +version = "0.4.0" +description = "MPEG-DASH MPD(Media Presentation Description) Parser" +optional = false +python-versions = "*" +files = [ + {file = "mpegdash-0.4.0-py3-none-any.whl", hash = "sha256:d07f6e1f2a67ddce1be501e3ad7abc29a2d6a7b1830b4da974b49c2ebe99cf2a"}, + {file = "mpegdash-0.4.0.tar.gz", hash = "sha256:65368c7a367c6875eb8c456a08644eb0708981a745044da0c9e942a3bc2b6389"}, +] + [[package]] name = "multipledispatch" version = "1.0.0" @@ -667,6 +692,16 @@ files = [ [package.dependencies] six = ">=1.5" +[[package]] +name = "ratelimit" +version = "2.2.1" +description = "API rate limit decorator" +optional = false +python-versions = "*" +files = [ + {file = "ratelimit-2.2.1.tar.gz", hash = "sha256:af8a9b64b821529aca09ebaf6d8d279100d766f19e90b5059ac6a718ca6dee42"}, +] + [[package]] name = "requests" version = "2.31.0" @@ -731,18 +766,26 @@ tests = ["pytest", "pytest-cov"] [[package]] name = "tidalapi" -version = "0.7.3" +version = "0.7.5" description = "Unofficial API for TIDAL music streaming service." optional = false -python-versions = ">=3.8,<4.0" -files = [ - {file = "tidalapi-0.7.3-py3-none-any.whl", hash = "sha256:04fa50c969a30a6e951a19a1f7f8234d142970a57abd3fb062180f508c5133bd"}, - {file = "tidalapi-0.7.3.tar.gz", hash = "sha256:b083eea3591dd3c42e7e57fba05c68a351f9e9c7540e56cd4df1649fc8f78d51"}, -] +python-versions = "^3.8" +files = [] +develop = false [package.dependencies] -python-dateutil = ">=2.8.2,<3.0.0" -requests = ">=2.28.0,<3.0.0" +isodate = "^0.6.1" +mpegdash = "^0.4.0" +python-dateutil = "^2.8.2" +ratelimit = "^2.2.1" +requests = "^2.28.0" +typing-extensions = "^4.10.0" + +[package.source] +type = "git" +url = "https://github.com/tamland/python-tidal" +reference = "HEAD" +resolved_reference = "4a0462dfaa032149f93938ef5239cc7aab0a9663" [[package]] name = "tomli" @@ -777,13 +820,13 @@ files = [ [[package]] name = "typing-extensions" -version = "4.7.1" -description = "Backported and Experimental Type Hints for Python 3.7+" +version = "4.10.0" +description = "Backported and Experimental Type Hints for Python 3.8+" optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" files = [ - {file = "typing_extensions-4.7.1-py3-none-any.whl", hash = "sha256:440d5dd3af93b060174bf433bccd69b0babc3b15b1a8dca43789fd7f61514b36"}, - {file = "typing_extensions-4.7.1.tar.gz", hash = "sha256:b75ddc264f0ba5615db7ba217daeb99701ad295353c45f9e95963337ceeeffb2"}, + {file = "typing_extensions-4.10.0-py3-none-any.whl", hash = "sha256:69b1a937c3a517342112fb4c6df7e72fc39a38e7891a5730ed4985b5214b5475"}, + {file = "typing_extensions-4.10.0.tar.gz", hash = "sha256:b0abd7c89e8fb96f98db18d86106ff1d90ab692004eb746cf6eda2682f91b3cb"}, ] [[package]] @@ -817,4 +860,4 @@ zstd = ["zstandard (>=0.18.0)"] [metadata] lock-version = "2.0" python-versions = "^3.9" -content-hash = "089c5ef997b7f43a72b806a883e9912e49ca78bcd4989855ec26ef382de1cb7c" +content-hash = "42c73b5e5ea01431b6c990b62feb09707770cfb6505ffd5812e563c833152599" diff --git a/pyproject.toml b/pyproject.toml index 7459b39..ee58acd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,7 +19,7 @@ classifiers = [ [tool.poetry.dependencies] python = "^3.9" Mopidy = "^3.0" -tidalapi = "^0.7.3" +tidalapi = {git = "https://github.com/tamland/python-tidal"} [tool.poetry.group.dev.dependencies] pytest = "^7.3.1" @@ -50,10 +50,9 @@ tidal = "mopidy_tidal:Extension" [tool.pytest.ini_options] markers = [ - "gt_3_7: Mark a test as requiring python > 3.7.", - "gt_3_8: Mark a test as requiring python > 3.8.", - "gt_3_9: Mark a test as requiring python > 3.9.", "gt_3_10: Mark a test as requiring python > 3.10.", + "poor_test: Mark a test in need of improvement", + "insufficiently_decoupled: Mark a test as insufficiently decoupled from implementation", ] filterwarnings = [ "error::DeprecationWarning:mopidy[.*]", diff --git a/tests/conftest.py b/tests/conftest.py index fa08128..09e5b6c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,10 +1,13 @@ -from typing import Iterable +from typing import Iterable, Optional, Sized from unittest.mock import Mock import pytest +from tidalapi import Genre, Session from tidalapi.album import Album from tidalapi.artist import Artist from tidalapi.media import Track +from tidalapi.mix import Mix +from tidalapi.page import Page, PageCategory from tidalapi.playlist import UserPlaylist from mopidy_tidal import context @@ -12,7 +15,30 @@ from mopidy_tidal.context import set_config -@pytest.fixture +def _make_mock(mock: Optional[Mock] = None, **kwargs) -> Mock: + """Make a mock with the desired properties. + + This exists to work around name collisions in `Mock(**kwargs)`, which + prevents settings some values, such as `name`. If desired a configured + mock can be passed in, in which case this is simply a wrapper around + setting attributes. + + >>> from unittest.mock import Mock + >>> # shadowed: sets the *mock name*, not the attribute + >>> assert Mock(name="foo").name != "foo" + >>> assert make_mock(name="foo").name == "foo" + """ + mock = mock or Mock() + for k, v in kwargs.items(): + setattr(mock, k, v) + + return mock + + +make_mock = pytest.fixture(lambda: _make_mock) + + +@pytest.fixture(autouse=True) def config(tmp_path): """Set up config. @@ -32,6 +58,8 @@ def config(tmp_path): "quality": "LOSSLESS", "lazy": False, "login_method": "BLOCK", + "auth_method": "OAUTH2", + "login_server_port": 8989, }, } context.set_config(cfg) @@ -40,7 +68,7 @@ def config(tmp_path): @pytest.fixture -def tidal_search(config, mocker): +def tidal_search(mocker): """Provide an uncached tidal_search. Tidal search is cached with a decorator, so we have to mock before we @@ -58,18 +86,119 @@ def tidal_search(config, mocker): yield tidal_search -def make_track(track_id, artist, album): - track = Mock(spec=Track, name=f"Track: {track_counter()}") - track.id = track_id - track.name = f"Track-{track_id}" - track.full_name = f"{track.name} (version)" - track.artist = artist - track.album = album - track.uri = f"tidal:track:{artist.id}:{album.id}:{track_id}" - track.duration = 100 + track_id - track.track_num = track_id - track.disc_num = track_id - return track +def counter(msg: str): + """A counter for providing sequential names.""" + x = 0 + while True: + yield msg.format(x) + x += 1 + + +# Giving each mock a unique name really helps when inspecting funny behaviour. +track_counter = counter("Mock Track #{}") +album_counter = counter("Mock Album #{}") +artist_counter = counter("Mock Artist #{}") +page_counter = counter("Mock Page #{}") +mix_counter = counter("Mock Mix #{}") +genre_counter = counter("Mock Genre #{}") + + +def _make_tidal_track( + id: int, + artist: Artist, + album: Album, + name: Optional[str] = None, + duration: Optional[int] = None, +): + return _make_mock( + mock=Mock(spec=Track, name=next(track_counter)), + id=id, + name=name or f"Track-{id}", + full_name=name or f"Track-{id}", + artist=artist, + album=album, + uri=f"tidal:track:{artist.id}:{album.id}:{id}", + duration=duration or (100 + id), + track_num=id, + disc_num=id, + ) + + +def _make_tidal_artist(*, name: str, id: int, top_tracks: Optional[list[Track]] = None): + return _make_mock( + mock=Mock(spec=Artist, name=next(artist_counter)), + **{ + "id": id, + "get_top_tracks.return_value": top_tracks, + "name": name, + }, + ) + + +def _make_tidal_album( + *, + name: str, + id: int, + tracks: Optional[list[dict]] = None, + artist: Optional[Artist] = None, + **kwargs, +): + album = _make_mock( + mock=Mock(spec=Album, name=next(album_counter)), + name=name, + id=id, + artist=artist or _make_tidal_artist(name="Album Artist", id=id + 1234), + **kwargs, + ) + tracks = [_make_tidal_track(**spec, album=album) for spec in (tracks or [])] + album.tracks.return_value = tracks + return album + + +def _make_tidal_page(*, title: str, categories: list[PageCategory], api_path: str): + return Mock(spec=Page, title=title, categories=categories, api_path=api_path) + + +def _make_tidal_mix(*, title: str, sub_title: str, id: int): + return Mock( + spec=Mix, title=title, sub_title=sub_title, name=next(mix_counter), id=str(id) + ) + + +def _make_tidal_genre(*, name: str, path: str): + return _make_mock( + mock=Mock(spec=Genre, name=next(genre_counter), path=path), name=name + ) + + +@pytest.fixture() +def make_tidal_genre(): + return _make_tidal_genre + + +@pytest.fixture() +def make_tidal_artist(): + return _make_tidal_artist + + +@pytest.fixture() +def make_tidal_album(): + return _make_tidal_album + + +@pytest.fixture() +def make_tidal_track(): + return _make_tidal_track + + +@pytest.fixture() +def make_tidal_page(): + return _make_tidal_page + + +@pytest.fixture() +def make_tidal_mix(): + return _make_tidal_mix @pytest.fixture() @@ -82,15 +211,12 @@ def tidal_artists(mocker): for i, artist in enumerate(artists): artist.id = i artist.name = f"Artist-{i}" - artist.get_top_tracks.return_value = [make_track((i + 1) * 100, artist, album)] + artist.get_top_tracks.return_value = [ + _make_tidal_track((i + 1) * 100, artist, album) + ] return artists -def track_counter(i=[0]): - i[0] += 1 - return i - - @pytest.fixture() def tidal_albums(mocker): """A list of tidal albums.""" @@ -102,32 +228,33 @@ def tidal_albums(mocker): album.id = i album.name = f"Album-{i}" album.artist = artist - album.tracks.return_value = [make_track(i, artist, album)] + album.tracks.return_value = [_make_tidal_track(i, artist, album)] return albums @pytest.fixture -def tidal_tracks(mocker, tidal_artists, tidal_albums): +def tidal_tracks(tidal_artists, tidal_albums): """A list of tidal tracks.""" return [ - make_track(i, artist, album) + _make_tidal_track(i, artist, album) for i, (artist, album) in enumerate(zip(tidal_artists, tidal_albums)) ] def make_playlist(playlist_id, tracks): - playlist = Mock(spec=UserPlaylist, session=Mock()) - playlist.name = f"Playlist-{playlist_id}" - playlist.id = str(playlist_id) - playlist.uri = f"tidal:playlist:{playlist_id}" - playlist.tracks = tracks - playlist.num_tracks = len(tracks) - playlist.last_updated = 10 - return playlist + return _make_mock( + mock=Mock(spec=UserPlaylist, session=Mock()), + name=f"Playlist-{playlist_id}", + id=str(playlist_id), + uri=f"tidal:playlist:{playlist_id}", + tracks=tracks, + num_tracks=len(tracks), + last_updated=10, + ) @pytest.fixture -def tidal_playlists(mocker, tidal_tracks): +def tidal_playlists(tidal_tracks): return [ make_playlist(101, tidal_tracks[:2]), make_playlist(222, tidal_tracks[1:]), @@ -168,14 +295,7 @@ def compare_playlist(tidal, mopidy): } -def _compare(tidal: Iterable, mopidy: Iterable, type: str): - assert len(tidal) == len(mopidy) - for t, m in zip(tidal, mopidy): - _compare_map[type](t, m) - - -@pytest.fixture -def compare(): +def _compare(tidal: list, mopidy: list, type: str): """Compare artists, tracks or albums. Args: @@ -183,8 +303,12 @@ def compare(): mopidy: The mopidy tracks. type: The type of comparison: one of "artist", "album" or "track". """ + assert len(tidal) == len(mopidy) + for t, m in zip(tidal, mopidy): + _compare_map[type](t, m) - return _compare + +compare = pytest.fixture(lambda: _compare) @pytest.fixture @@ -203,3 +327,26 @@ def _get_backend(config=mocker.MagicMock(), audio=mocker.Mock()): yield _get_backend set_config(None) + + +class SessionForTest(Session): + """Session has an attribute genre which is set in __init__ doesn't exist on + the class. Thus mock gets the spec wrong, i.e. forbids access to genre. + This is a bug in Session, but until it's fixed we mock it here. + + See https://docs.python.org/3/library/unittest.mock.html#auto-speccing + + Tracked at https://github.com/tamland/python-tidal/issues/192 + """ + + genre = None + + +@pytest.fixture +def session(mocker): + return mocker.Mock(spec=SessionForTest) + + +@pytest.fixture +def backend(mocker, session): + return mocker.Mock(session=session) diff --git a/tests/test_backend.py b/tests/test_backend.py index 6e00288..d37fca8 100644 --- a/tests/test_backend.py +++ b/tests/test_backend.py @@ -8,23 +8,24 @@ from mopidy_tidal.playlists import TidalPlaylistsProvider -@pytest.mark.gt_3_7 -def test_composition(get_backend): +def test_backend_composed_of_correct_parts(get_backend): backend, *_ = get_backend() + assert isinstance(backend.playback, TidalPlaybackProvider) assert isinstance(backend.library, TidalLibraryProvider) assert isinstance(backend.playlists, TidalPlaylistsProvider) -@pytest.mark.gt_3_7 -def test_setup(get_backend): +def test_backend_begins_in_correct_state(get_backend): + """This test tests private attributes and is thus *BAD*. But we can keep + it till it breaks.""" backend, config, *_ = get_backend() - assert tuple(backend.uri_schemes) == ("tidal",) # TODO: why is this muteable? + + assert backend.uri_schemes == ("tidal",) assert not backend._active_session assert backend._config is config -@pytest.mark.gt_3_7 def test_initial_login_caches_credentials(get_backend, config): backend, _, _, _, session = get_backend(config=config) session.check_login.return_value = False @@ -36,7 +37,9 @@ def login(*_, **__): backend._active_session = session authf = Path(config["core"]["data_dir"], "tidal/tidal-oauth.json") assert not authf.exists() + backend._login() + with authf.open() as f: data = load(f) assert data == { @@ -48,7 +51,6 @@ def login(*_, **__): session.login_oauth_simple.assert_called_once() -@pytest.mark.gt_3_7 def test_login_after_failed_cached_credentials_overwrites_cached_credentials( get_backend, config ): @@ -70,6 +72,7 @@ def login(*_, **__): authf.write_text(dumps(cached_data)) backend._login() + with authf.open() as f: data = load(f) assert data == { @@ -81,7 +84,6 @@ def login(*_, **__): session.login_oauth_simple.assert_called_once() -@pytest.mark.gt_3_7 def test_failed_login_does_not_overwrite_cached_credentials( get_backend, mocker, config, tmp_path ): @@ -106,18 +108,18 @@ def test_failed_login_does_not_overwrite_cached_credentials( session.login_oauth_simple.assert_called_once() -@pytest.mark.gt_3_7 def test_failed_overall_login_throws_error(get_backend, tmp_path, mocker, config): backend, _, _, _, session = get_backend(config=config) session.check_login.return_value = False backend._active_session = session authf = tmp_path / "auth.json" + with pytest.raises(ConnectionError): backend.on_start() + assert not authf.exists() -@pytest.mark.gt_3_7 def test_logs_in(get_backend, mocker, config): backend, _, _, session_factory, session = get_backend(config=config) backend._active_session = session @@ -127,7 +129,9 @@ def set_logged_in(*_, **__): session.login_oauth_simple.side_effect = set_logged_in session.check_login.return_value = False + backend.on_start() + session_factory.assert_called_once() config_obj = session_factory.mock_calls[0].args[0] assert config_obj.quality == config["tidal"]["quality"] @@ -135,7 +139,6 @@ def set_logged_in(*_, **__): assert config_obj.client_secret == config["tidal"]["client_secret"] -@pytest.mark.gt_3_7 def test_accessing_session_triggers_lazy_login(get_backend, mocker, config): config["tidal"]["lazy"] = True backend, _, _, session_factory, session = get_backend(config=config) @@ -145,7 +148,9 @@ def set_logged_in(*_, **__): session.check_login.return_value = False session.login_oauth_simple.side_effect = set_logged_in + backend.on_start() + session.login_oauth_simple.assert_not_called() assert not backend._active_session.check_login() assert backend.session @@ -157,7 +162,6 @@ def set_logged_in(*_, **__): assert config_obj.client_secret == config["tidal"]["client_secret"] -@pytest.mark.gt_3_7 def test_logs_in_only_client_secret(get_backend, mocker, config): config["tidal"]["client_id"] = "" backend, _, _, session_factory, session = get_backend(config=config) @@ -167,7 +171,9 @@ def set_logged_in(*_, **__): session.login_oauth_simple.side_effect = set_logged_in session.check_login.return_value = False + backend.on_start() + session.login_oauth_simple.assert_called_once() session_factory.assert_called_once() config_obj = session_factory.mock_calls[0].args[0] @@ -179,7 +185,6 @@ def set_logged_in(*_, **__): ) -@pytest.mark.gt_3_7 def test_logs_in_default_id_secret(get_backend, mocker, config): config["tidal"]["client_id"] = "" config["tidal"]["client_secret"] = "" @@ -190,7 +195,9 @@ def set_logged_in(*_, **__): session.login_oauth_simple.side_effect = set_logged_in session.check_login.return_value = False + backend.on_start() + session.login_oauth_simple.assert_called_once() session_factory.assert_called_once() config_obj = session_factory.mock_calls[0].args[0] @@ -215,8 +222,9 @@ def test_loads_session(get_backend, mocker, config): with authf.open("w") as f: dump(data, f) session.check_login.return_value = True + backend.on_start() - args = {k: v["data"] for k, v in data.items() if k != "session_id"} + session.login_oauth_simple.assert_not_called() - session.load_oauth_session.assert_called_once_with(**args) + session.load_session_from_file.assert_called_once() session_factory.assert_called_once() diff --git a/tests/test_cache_search_key.py b/tests/test_cache_search_key.py index 2ff7a98..0049d7e 100644 --- a/tests/test_cache_search_key.py +++ b/tests/test_cache_search_key.py @@ -3,41 +3,22 @@ from mopidy_tidal.lru_cache import SearchKey -def test_search_key_hashes_are_equal(): - d1 = {"exact": True, "query": {"artist": "TestArtist", "album": "TestAlbum"}} - d2 = {"exact": True, "query": {"album": "TestAlbum", "artist": "TestArtist"}} +def test_hashes_of_equal_objects_are_equal(): + params = dict(exact=True, query=dict(artist="Arty", album="Alby")) + assert SearchKey(**params) == SearchKey(**params) - d1_sk = SearchKey(**d1) - d2_sk = SearchKey(**d2) + assert hash(SearchKey(**params)) == hash(SearchKey(**params)) - assert hash(d1_sk) == hash(d2_sk) +def test_hashes_of_different_objects_are_different(): + key_1 = SearchKey(exact=True, query=dict(artist="Arty", album="Alby")) + key_2 = SearchKey(exact=False, query=dict(artist="Arty", album="Alby")) + key_3 = SearchKey(exact=True, query=dict(artist="Arty", album="Albion")) -def test_search_key_hashes_are_different(): - d1 = {"exact": True, "query": {"artist": "TestArtist", "album": "TestAlbum"}} - d2 = {"exact": False, "query": {"artist": "TestArtist", "album": "TestAlbum"}} - d3 = {"exact": True, "query": {"album": "TestAlbum2", "artist": "TestArtist"}} + assert hash(key_1) != hash(key_2) != hash(key_3) - d1_sk = SearchKey(**d1) - d2_sk = SearchKey(**d2) - d3_sk = SearchKey(**d3) - assert hash(d1_sk) != hash(d2_sk) != hash(d3_sk) +def test_as_str_constructs_uri_from_hash(): + key = SearchKey(exact=True, query=dict(artist="Arty", album="Alby")) - -def test_str(): - d1 = {"exact": True, "query": {"artist": "TestArtist", "album": "TestAlbum"}} - d1_sk = SearchKey(**d1) - assert str(d1_sk) == f"tidal:search:{hash(d1_sk)}" - - -def test_eq(): - d1 = {"exact": True, "query": {"artist": "TestArtist", "album": "TestAlbum"}} - d2 = {"exact": False, "query": {"artist": "TestArtist", "album": "TestAlbum"}} - - d1_sk = SearchKey(**d1) - d2_sk = SearchKey(**d2) - d3_sk = SearchKey(**d1) - assert d1_sk == d3_sk - assert d1_sk != d2_sk - assert d1_sk != "a string" + assert str(key) == f"tidal:search:{hash(key)}" diff --git a/tests/test_context.py b/tests/test_context.py index 504c5d0..c2b4b93 100644 --- a/tests/test_context.py +++ b/tests/test_context.py @@ -1,14 +1,20 @@ """Test context, which is used to manage config.""" -# TODO: why? - import pytest from mopidy_tidal import context -def test_context(): +@pytest.fixture(autouse=True) +def config(): + """Override fixture which sets up config: here we want to do it manually.""" + + +def test_get_config_raises_until_set(): config = {"k": "v"} + with pytest.raises(ValueError, match="Extension configuration not set."): context.get_config() + context.set_config(config) + assert context.get_config() == config diff --git a/tests/test_extension.py b/tests/test_extension.py index 77ff24b..0cfe698 100755 --- a/tests/test_extension.py +++ b/tests/test_extension.py @@ -6,7 +6,7 @@ from mopidy_tidal.backend import TidalBackend -def test_get_default_config(): +def test_sanity_check_default_resembles_conf_file(): ext = Extension() config = ext.get_default_config() @@ -15,24 +15,32 @@ def test_get_default_config(): assert "enabled = true" in config -def test_get_config_schema(): +def test_config_schema_has_correct_keys(): + """This is mostly a sanity check in case we forget to add a key.""" ext = Extension() schema = ext.get_config_schema() - # Test the content of your config schema - assert "quality" in schema - assert "client_id" in schema - assert "client_secret" in schema - assert "lazy" in schema + assert set(schema.keys()) == { + "enabled", + "quality", + "client_id", + "client_secret", + "playlist_cache_refresh_secs", + "lazy", + "login_method", + "login_server_port", + "auth_method", + } -@pytest.mark.gt_3_7 -def test_setup(mocker): +def test_extension_setup_registers_tidal_backend(mocker): ext = Extension() registry = mocker.Mock() + ext.setup(registry) + registry.add.assert_called_once() - args = registry.add.mock_calls[0].args - assert args[0] == "backend" - assert type(args[1]) is type(TidalBackend) + plugin_type, obj = registry.add.mock_calls[0].args + assert plugin_type == "backend" + assert issubclass(obj, TidalBackend) diff --git a/tests/test_full_model_mappers.py b/tests/test_full_model_mappers.py index 3fd3f67..09851a9 100644 --- a/tests/test_full_model_mappers.py +++ b/tests/test_full_model_mappers.py @@ -1,13 +1,87 @@ +from datetime import datetime + +import mopidy.models as mopidy_models + from mopidy_tidal.full_models_mappers import create_mopidy_album, create_mopidy_artist -def test_create_mopidy_artist_none(): - assert not create_mopidy_artist(None) +class TestCreateMopidyArtist: + def test_returns_none_if_tidal_artist_none(self): + assert create_mopidy_artist(tidal_artist=None) is None + + def test_returns_artist_with_uri_and_name(self, make_tidal_artist): + artist = make_tidal_artist(name="Arty", id=17) + + mopidy_artist = create_mopidy_artist(artist) + + assert mopidy_artist == mopidy_models.Artist(uri="tidal:artist:17", name="Arty") + + +class TestCreateMopidyAlbum: + def test_returns_album_with_uri_name_and_artist( + self, make_tidal_album, make_tidal_artist + ): + album = make_tidal_album(name="Alby", id=156) + mopidy_artist = create_mopidy_artist(make_tidal_artist(name="Arty", id=12)) + + mopidy_album = create_mopidy_album(album, mopidy_artist) + + assert mopidy_album + assert mopidy_album.uri == "tidal:album:156" + assert mopidy_album.artists == { + mopidy_models.Artist(name="Arty", uri="tidal:artist:12") + } + assert mopidy_album.name == "Alby" + + def test_date_prefers_release_date(self, make_tidal_album): + album = make_tidal_album( + name="Albion", + id=156, + release_date=datetime(1995, 6, 7), + tidal_release_date=datetime(1997, 4, 5), + ) + + mopidy_album = create_mopidy_album(album, None) + + assert mopidy_album + assert mopidy_album.date == "1995" + + def test_date_falls_back_on_tidal_release_date(self, make_tidal_album): + album = make_tidal_album( + name="Albion", + id=156, + release_date=None, + tidal_release_date=datetime(1997, 4, 5), + ) + + mopidy_album = create_mopidy_album(album, None) + + assert mopidy_album + assert mopidy_album.date == "1997" + + def test_null_when_unknown(self, make_tidal_album): + album = make_tidal_album( + name="Albion", + id=156, + release_date=None, + tidal_release_date=None, + ) + + mopidy_album = create_mopidy_album(album, None) + + assert mopidy_album + assert mopidy_album.date is None + + def test_uses_artist_album_if_no_artist_provided( + self, make_tidal_album, make_tidal_artist + ): + album = make_tidal_album( + name="Alby", id=156, artist=make_tidal_artist(name="Arty", id=12) + ) + mopidy_album = create_mopidy_album(album, None) -def test_create_mopidy_album_no_release_date(mocker, tidal_albums, compare): - album = tidal_albums[0] - del album.release_date - del album.tidal_release_date - resp = create_mopidy_album(album, None) - compare([album], [resp], "album") + assert mopidy_album + assert mopidy_album.artists == { + mopidy_models.Artist(name="Arty", uri="tidal:artist:12") + } diff --git a/tests/test_to_timestamp.py b/tests/test_helpers.py similarity index 87% rename from tests/test_to_timestamp.py rename to tests/test_helpers.py index 0512194..60ab645 100644 --- a/tests/test_to_timestamp.py +++ b/tests/test_helpers.py @@ -14,5 +14,5 @@ (12, 12), ], ) -def test_to_timestamp(dt, res): +def test_to_timestamp_converts_correctly(dt, res): assert to_timestamp(dt) == res diff --git a/tests/test_image_getter.py b/tests/test_image_getter.py index dc8878c..cd2c7c5 100644 --- a/tests/test_image_getter.py +++ b/tests/test_image_getter.py @@ -11,7 +11,7 @@ def images_getter(mocker, config): @pytest.mark.parametrize("dimensions", (750, 640, 480)) -def test_get_album_image_new_api(images_getter, mocker, dimensions): +def test_get_album_image(images_getter, mocker, dimensions): ig, session = images_getter uri = "tidal:album:1-1-1" get_album = mocker.Mock() @@ -36,7 +36,7 @@ def get_uri(dim, *args): assert get_uri_args == [dimensions] -def test_get_album_no_image_new_api(images_getter, mocker): +def test_get_album_no_image(images_getter, mocker): ig, session = images_getter uri = "tidal:album:1-1-1" get_album = mocker.Mock() @@ -49,7 +49,7 @@ def get_uri(*_): assert ig(uri) == (uri, []) -def test_get_album_no_getter_methods_new_api(images_getter, mocker): +def test_get_album_no_getter_methods(images_getter, mocker): ig, session = images_getter uri = "tidal:album:1-1-1" get_album = mocker.Mock(spec={"id", "__name__"}, name="get_album", id="1") diff --git a/tests/test_library.py b/tests/test_library.py index 9f59a8d..fd50c9b 100644 --- a/tests/test_library.py +++ b/tests/test_library.py @@ -1,295 +1,355 @@ import pytest from mopidy.models import Album, Artist, Image, Ref, SearchResult, Track +from requests import HTTPError from tidalapi.playlist import Playlist from mopidy_tidal.library import HTTPError, TidalLibraryProvider @pytest.fixture -def tlp(mocker, config): - backend = mocker.Mock() +def library_provider(backend, config): lp = TidalLibraryProvider(backend) for cache_type in {"artist", "album", "track", "playlist"}: getattr(lp, f"_{cache_type}_cache")._persist = False - return lp, backend + return lp -def test_search_no_match(tlp, tidal_search): - tlp, backend = tlp - assert not tlp.search("nonsuch") - - -def test_search(mocker, tlp): - tlp, backend = tlp - query, exact = object(), object() - artists = [mocker.Mock(spec=Artist)] - albums = [mocker.Mock(spec=Album)] - tracks = [mocker.Mock(spec=Track)] - tidal_search = mocker.Mock() - tidal_search.return_value = (artists, albums, tracks) - mocker.patch("mopidy_tidal.search.tidal_search", tidal_search) - assert tlp.search(query=query, exact=exact) == SearchResult( - artists=artists, albums=albums, tracks=tracks - ) - tidal_search.assert_called_once_with(backend.session, query=query, exact=exact) - - -def test_get_track_images(tlp, mocker): - tlp, backend = tlp +def test_get_track_images(library_provider, backend, mocker): uris = ["tidal:track:0-0-0:1-1-1:2-2-2"] get_album = mocker.Mock() get_album.image.return_value = "tidal:album:1-1-1" backend.session.album.return_value = get_album - assert tlp.get_images(uris) == { + assert library_provider.get_images(uris) == { uris[0]: [Image(height=320, uri="tidal:album:1-1-1", width=320)] } backend.session.album.assert_called_once_with("1-1-1") @pytest.mark.xfail -def test_track_cache(tlp, mocker): +def test_track_cache(library_provider, backend, mocker): # I think the caching logic is broken here - tlp, backend = tlp uris = ["tidal:track:0-0-0:1-1-1:2-2-2"] get_album = mocker.Mock() get_album.image.return_value = "tidal:album:1-1-1" backend.session.album.return_value = get_album - first = tlp.get_images(uris) + first = library_provider.get_images(uris) assert first == {uris[0]: [Image(height=320, uri="tidal:album:1-1-1", width=320)]} - assert tlp.get_images(uris) == first + assert library_provider.get_images(uris) == first backend.session.album.assert_called_once_with("1-1-1") -def test_get_noimages(tlp, mocker): - tlp, backend = tlp - uris = ["tidal:nonsuch:0-0-0:1-1-1:2-2-2"] +@pytest.mark.xfail(reason="returning nothing") +def test_get_noimages(library_provider, backend): + uris = ["tidal:track:0-0-0:1-1-1:2-2-2"] backend.session.mock_add_spec([]) - assert tlp.get_images(uris) == {uris[0]: []} - - -@pytest.mark.parametrize("field", ("artist", "album", "track")) -def test_get_distinct_root(tlp, mocker, field): - tlp, backend = tlp - session = backend.session - thing = mocker.Mock() - thing.name = "Thing" - session.configure_mock(**{f"user.favorites.{field}s.return_value": [thing]}) - res = tlp.get_distinct(field) - assert res[0] == "Thing [TIDAL]" - assert len(res) == 1 - - -def test_get_distinct_root_nonsuch(tlp, mocker): - tlp, backend = tlp - assert not tlp.get_distinct("nonsuch") - - -def test_get_distinct_query_nonsuch(tlp, mocker): - tlp, backend = tlp - assert not tlp.get_distinct("nonsuch", query={"any": "any"}) - - -@pytest.mark.parametrize("field", ("artist", "track")) -def test_get_distinct_ignore_query(tlp, mocker, field): - tlp, backend = tlp - session = backend.session - thing = mocker.Mock() - thing.name = "Thing" - session.configure_mock(**{f"user.favorites.{field}s.return_value": [thing]}) - res = tlp.get_distinct(field, query={"any": "any"}) - assert res[0] == "Thing [TIDAL]" - assert len(res) == 1 - - -@pytest.mark.parametrize("field", ("album", "albumartist")) -def test_get_distinct_album_no_results(tlp, mocker, field): - tlp, backend = tlp - session = backend.session - tidal_search = mocker.Mock() - tidal_search.return_value = ([], [], []) - mocker.patch("mopidy_tidal.search.tidal_search", tidal_search) - assert not tlp.get_distinct(field, query={"any": "any"}) - tidal_search.assert_called_once_with(session, query={"any": "any"}, exact=True) - - -@pytest.mark.parametrize("field", ("album", "albumartist")) -def test_get_distinct_album_new_api(tlp, mocker, field): - tlp, backend = tlp - session = backend.session - session.mock_add_spec(("tidal_search", "artist", "artist.get_albums")) + assert library_provider.get_images(uris) == {uris[0]: []} - artist = mocker.Mock() - artist.uri = "tidal:artist:1" - thing = mocker.Mock() - thing.name = "Thing" - artist.get_albums.return_value = [thing] - - tidal_search = mocker.Mock() - tidal_search.return_value = ([artist], [], []) - session.artist.return_value = artist - mocker.patch("mopidy_tidal.search.tidal_search", tidal_search) - res = tlp.get_distinct(field, query={"any": "any"}) - tidal_search.assert_called_once_with(session, query={"any": "any"}, exact=True) - session.artist.assert_called_once_with("1") - artist.get_albums.assert_called_once_with() - assert len(res) == 1 - assert res[0] == "Thing [TIDAL]" - - -@pytest.mark.parametrize("field", ("album", "albumartist")) -def test_get_distinct_album_new_api_no_artist(tlp, mocker, field): - tlp, backend = tlp - session = backend.session - session.mock_add_spec(("tidal_search", "artist", "artist.get_albums")) - - artist = mocker.Mock() - artist.uri = "tidal:artist:1" - - tidal_search = mocker.Mock() - tidal_search.return_value = ([artist], [], []) - session.artist.return_value = None - mocker.patch("mopidy_tidal.search.tidal_search", tidal_search) - assert not tlp.get_distinct(field, query={"any": "any"}) - tidal_search.assert_called_once_with(session, query={"any": "any"}, exact=True) - session.artist.assert_called_once_with("1") - - -def test_browse_wrong_uri(tlp): - tlp, backend = tlp - assert not tlp.browse("") - assert not tlp.browse("spotify:something:something_else") - assert not tlp.browse("tidal:album:oneid:oneidtoomany") - - -def test_browse_root(tlp): - tlp, backend = tlp - assert tlp.browse("tidal:directory") == [ - Ref(name="Genres", type="directory", uri="tidal:genres"), - Ref(name="Moods", type="directory", uri="tidal:moods"), - Ref(name="Mixes", type="directory", uri="tidal:mixes"), - Ref(name="My Artists", type="directory", uri="tidal:my_artists"), - Ref(name="My Albums", type="directory", uri="tidal:my_albums"), - Ref(name="My Playlists", type="directory", uri="tidal:my_playlists"), - Ref(name="My Tracks", type="directory", uri="tidal:my_tracks"), - ] - - -def test_browse_artists(tlp, mocker, tidal_artists): - tlp, backend = tlp - session = backend.session - session.user.favorites.artists = tidal_artists - mocker.patch("mopidy_tidal.library.get_items", lambda x: x) - assert tlp.browse("tidal:my_artists") == [ - Ref(name="Artist-0", type="artist", uri="tidal:artist:0"), - Ref(name="Artist-1", type="artist", uri="tidal:artist:1"), - ] - - -def test_browse_albums(tlp, mocker, tidal_albums): - tlp, backend = tlp - session = backend.session - session.user.favorites.albums = tidal_albums - mocker.patch("mopidy_tidal.library.get_items", lambda x: x) - assert tlp.browse("tidal:my_albums") == [ - Ref(name="Album-0", type="album", uri="tidal:album:0"), - Ref(name="Album-1", type="album", uri="tidal:album:1"), - ] +class TestSearch: + def test_defers_to_tidal_search(self, library_provider, mocker): + artists = [mocker.Mock(spec=Artist)] + albums = [mocker.Mock(spec=Album)] + tracks = [mocker.Mock(spec=Track)] + tidal_search = mocker.patch( + "mopidy_tidal.search.tidal_search", return_value=(artists, albums, tracks) + ) -def test_browse_tracks(tlp, mocker, tidal_tracks): - tlp, backend = tlp - session = backend.session - session.user.favorites.tracks = tidal_tracks - mocker.patch("mopidy_tidal.library.get_items", lambda x: x) - assert tlp.browse("tidal:my_tracks") == [ - Ref(name="Track-0", type="track", uri="tidal:track:0:0:0"), - Ref(name="Track-1", type="track", uri="tidal:track:1:1:1"), - ] + result = library_provider.search(query="songs", exact=False) + assert result == SearchResult(artists=artists, albums=albums, tracks=tracks) + tidal_search.assert_called_once() + assert tidal_search.mock_calls[0].kwargs["query"] == "songs" + assert tidal_search.mock_calls[0].kwargs["exact"] is False -def test_browse_playlists(tlp, mocker): - tlp, backend = tlp - as_list = mocker.Mock() - uniq = object() - as_list.return_value = uniq - backend.playlists.as_list = as_list - assert tlp.browse("tidal:my_playlists") is uniq - as_list.assert_called_once_with() + def test_returns_none_if_no_match(self, library_provider, mocker): + tidal_search = mocker.patch( + "mopidy_tidal.search.tidal_search", return_value=None + ) + assert not library_provider.search("nonsuch") + + tidal_search.assert_called_once() + assert tidal_search.mock_calls[0].kwargs["query"] == "nonsuch" + + +class TestBrowse: + def test_invalid_uri_returns_empty_list(self, library_provider): + assert library_provider.browse("") == [] + assert library_provider.browse("spotify:something:something_else") == [] + assert library_provider.browse("tidal:album:oneid:oneidtoomany") == [] + + def test_root_uri_returns_all_options_as_refs(self, library_provider): + assert library_provider.browse("tidal:directory") == [ + Ref(name="Home", type="directory", uri="tidal:home"), + Ref(name="For You", type="directory", uri="tidal:for_you"), + Ref(name="Explore", type="directory", uri="tidal:explore"), + Ref(name="HiRes", type="directory", uri="tidal:hires"), + Ref(name="Genres", type="directory", uri="tidal:genres"), + Ref(name="Moods", type="directory", uri="tidal:moods"), + Ref(name="My Mixes", type="directory", uri="tidal:mixes"), + Ref(name="My Artists", type="directory", uri="tidal:my_artists"), + Ref(name="My Albums", type="directory", uri="tidal:my_albums"), + Ref(name="My Playlists", type="directory", uri="tidal:my_playlists"), + Ref(name="My Tracks", type="directory", uri="tidal:my_tracks"), + Ref(name="Mixes & Radio", type="directory", uri="tidal:my_mixes"), + ] + + def test_my_artists_returns_favourite_artists_from_tidal_as_refs( + self, library_provider, session, mocker, make_tidal_artist + ): + session.user.favorites.artists = [ + make_tidal_artist(name="Arty", id=1), + make_tidal_artist(name="Arthur", id=1_000), + ] + mocker.patch("mopidy_tidal.library.get_items", lambda x: x) + + assert library_provider.browse("tidal:my_artists") == [ + Ref(name="Arty", type="artist", uri="tidal:artist:1"), + Ref(name="Arthur", type="artist", uri="tidal:artist:1000"), + ] + + def test_my_albums_returns_favourite_albums_from_tidal_as_refs( + self, library_provider, session, mocker, make_tidal_album + ): + session.user.favorites.albums = [ + make_tidal_album(name="Alby", id=7), + make_tidal_album(name="Albion", id=7_000), + ] + mocker.patch("mopidy_tidal.library.get_items", lambda x: x) + + assert library_provider.browse("tidal:my_albums") == [ + Ref(name="Alby", type="album", uri="tidal:album:7"), + Ref(name="Albion", type="album", uri="tidal:album:7000"), + ] + + def test_my_tracks_returns_favourite_tracks_from_tidal_as_refs( + self, + library_provider, + session, + mocker, + make_tidal_track, + make_tidal_album, + make_tidal_artist, + ): + artist = make_tidal_artist(name="Arty", id=6) + album = make_tidal_album(name="Albion", id=7) + session.user.favorites.tracks = [ + make_tidal_track(name="Tracky", id=12, artist=artist, album=album), + make_tidal_track(name="Traction", id=13, artist=artist, album=album), + ] + mocker.patch("mopidy_tidal.library.get_items", lambda x: x) + + assert library_provider.browse("tidal:my_tracks") == [ + Ref(name="Tracky", type="track", uri="tidal:track:6:7:12"), + Ref(name="Traction", type="track", uri="tidal:track:6:7:13"), + ] + + @pytest.mark.insufficiently_decoupled + def test_my_playlists_defers_to_backend_as_list( + self, library_provider, backend, mocker + ): + """backend.as_list is quite complicated, so we test it separately. + + This test asserts that our test of backend_as_list covers the code. + But it's not a good test all the same as it's tied to our implementation. + """ + uniq = object() + as_list = mocker.Mock(return_value=uniq) + backend.playlists.as_list = as_list + + assert library_provider.browse("tidal:my_playlists") is uniq + + as_list.assert_called_once_with() + + def test_moods_returns_moods_from_tidal_as_refs( + self, library_provider, session, make_tidal_page + ): + mood = make_tidal_page(title="Moody", categories=[], api_path="0/0/18") + session.moods.return_value = [mood] + + result = library_provider.browse("tidal:moods") + + assert result == [Ref(name="Moody", type="directory", uri="tidal:mood:18")] + session.moods.assert_called_once_with() + + def test_mixes_returns_mixes_from_tidal_as_refs( + self, library_provider, session, make_tidal_mix + ): + session.mixes.return_value = [ + make_tidal_mix(title="Mick's mix", sub_title="Micky mouse", id=19_678), + make_tidal_mix(title="Micky", sub_title="Mouse", id=6), + ] + + assert library_provider.browse("tidal:mixes") == [ + Ref( + name="Mick's mix (Micky mouse)", type="playlist", uri="tidal:mix:19678" + ), + Ref(name="Micky (Mouse)", type="playlist", uri="tidal:mix:6"), + ] + session.mixes.assert_called_once_with() + + def test_genres_returns_genres_from_tidal_as_refs( + self, library_provider, session, make_tidal_genre + ): + session.genre.get_genres.return_value = [ + make_tidal_genre(name="Jean Re", path="12"), + make_tidal_genre(name="John Ra", path="1345"), + ] + + assert library_provider.browse("tidal:genres") == [ + Ref(name="Jean Re", type="directory", uri="tidal:genre:12"), + Ref(name="John Ra", type="directory", uri="tidal:genre:1345"), + ] + session.genre.get_genres.assert_called_once_with() + + +class TestBrowseAlbum: + def test_missing_album_returns_empty_list(self, library_provider, session): + session.album.side_effect = HTTPError("No such album") + + assert library_provider.browse("tidal:album:1") == [] + session.album.assert_called_once_with("1") + + def test_album_returns_tracks( + self, + library_provider, + session, + make_tidal_album, + make_tidal_artist, + ): + artist = make_tidal_artist(name="Arty", id=789) + album = make_tidal_album( + name="Alby", + id=17, + tracks=[ + dict(name="Traction", id=17, artist=artist), + dict(name="Tracky", id=65, artist=artist), + ], + ) + session.album.return_value = album + + assert library_provider.browse("tidal:album:1") == [ + Ref(name="Traction", type="track", uri="tidal:track:789:17:17"), + Ref(name="Tracky", type="track", uri="tidal:track:789:17:65"), + ] + + +class TestGetImages: + def test_track_uri_resolves_to_album_images( + self, library_provider, session, mocker + ): + session.album.return_value.image.return_value = "tidal:album:1-1-1" + + images = library_provider.get_images(["tidal:track:0-0-0:1-1-1:2-2-2"]) + + assert images == { + "tidal:track:0-0-0:1-1-1:2-2-2": [ + Image(height=320, uri="tidal:album:1-1-1", width=320) + ] + } + session.album.assert_called_once_with("1-1-1") + + +class TestGetDistinct: + @pytest.mark.parametrize("field", ("artist", "album", "track")) + def test_returns_all_favourites_with_watermark_when_no_query_given( + self, library_provider, session, field, make_mock + ): + titles = [make_mock(name=f"Title-{i}") for i in range(2)] + session.configure_mock(**{f"user.favorites.{field}s.return_value": titles}) + + res = library_provider.get_distinct(field) + + assert len(res) == 2 + assert res == {"Title-0 [TIDAL]", "Title-1 [TIDAL]"} + + def test_returns_empty_set_when_no_match(self, library_provider): + assert library_provider.get_distinct("nonsuch") == set() + + def test_returns_empty_set_when_no_match_with_query(self, library_provider): + assert library_provider.get_distinct("nonsuch", query={"any": "any"}) == set() + + @pytest.mark.parametrize("field", ("artist", "track")) + def test_query_ignored_when_field_is_not_album_or_albumartist( # TODO why do we do this? + self, library_provider, session, field, make_mock + ): + titles = [make_mock(name=f"Title-{i}") for i in range(2)] + session.configure_mock(**{f"user.favorites.{field}s.return_value": titles}) + + res = library_provider.get_distinct(field, query={"any": "any"}) + + assert len(res) == 2 + assert res == {"Title-0 [TIDAL]", "Title-1 [TIDAL]"} + + @pytest.mark.parametrize("field", ("album", "albumartist")) + def test_get_distinct_returns_empty_set_when_search_returns_no_results( + self, library_provider, session, mocker, field + ): + tidal_search = mocker.Mock(return_value=([], [], [])) + mocker.patch("mopidy_tidal.search.tidal_search", tidal_search) + + res = library_provider.get_distinct(field, query={"any": "any"}) + + assert res == set() + tidal_search.assert_called_once_with(session, query={"any": "any"}, exact=True) + + @pytest.mark.parametrize("field", ("album", "albumartist")) + def test_get_distinct_returns_search_results_with_watermark( + self, library_provider, session, mocker, field, make_mock + ): + arty_albums = [make_mock(name=f"Arty Album {i}") for i in range(2)] + artist = make_mock( + mock=mocker.Mock(**{"get_albums.return_value": arty_albums}), + name="Arty", + uri="tidal:artist:1", + ) + tidal_search = mocker.Mock(return_value=([artist], [], [])) + session.artist.return_value = artist + mocker.patch("mopidy_tidal.search.tidal_search", tidal_search) -def test_moods_new_api(tlp, mocker): - tlp, backend = tlp - session = backend.session - session.mock_add_spec(("moods",)) - mood = mocker.Mock(spec=("title", "title", "api_path")) - mood.title = "Mood-1" - mood.api_path = "0/0/1" - session.moods.return_value = [mood] - assert tlp.browse("tidal:moods") == [ - Ref(name="Mood-1", type="directory", uri="tidal:mood:1") - ] - session.moods.assert_called_once_with() + res = library_provider.get_distinct(field, query={"any": "any"}) + tidal_search.assert_called_once_with(session, query={"any": "any"}, exact=True) + session.artist.assert_called_once_with("1") + artist.get_albums.assert_called_once_with() + assert len(res) == 2 + assert res == {"Arty Album 0 [TIDAL]", "Arty Album 1 [TIDAL]"} -def test_mixes_new_api(tlp, mocker): - tlp, backend = tlp - session = backend.session - session.mock_add_spec(("mixes",)) - mix = mocker.Mock() - mix.title = "Mix-1" - mix.sub_title = "[Subtitle]" - mix.id = "1" - session.mixes.return_value = [mix] - assert tlp.browse("tidal:mixes") == [ - Ref(name="Mix-1 ([Subtitle])", type="playlist", uri="tidal:mix:1") - ] - session.mixes.assert_called_once_with() + @pytest.mark.parametrize("field", ("album", "albumartist")) + def test_get_distinct_returns_empty_set_when_artist_not_found( + self, library_provider, session, mocker, field, make_mock + ): + artist = make_mock(name="Arty", uri="tidal:artist:1") + tidal_search = mocker.Mock(return_value=([artist], [], [])) + mocker.patch("mopidy_tidal.search.tidal_search", tidal_search) + session.artist.return_value = None # looking up artist fails + assert library_provider.get_distinct(field, query={"any": "any"}) == set() + tidal_search.assert_called_once_with(session, query={"any": "any"}, exact=True) + session.artist.assert_called_once_with("1") -def test_genres_new_api(tlp, mocker): - tlp, backend = tlp - session = backend.session - session.mock_add_spec( - ( - "genre", - "genre.get_genres", - ) - ) - genre = mocker.Mock(spec=("name", "path")) - genre.name = "Genre-1" - genre.path = "1" - session.genre.get_genres.return_value = [genre] - assert tlp.browse("tidal:genres") == [ - Ref(name="Genre-1", type="directory", uri="tidal:genre:1") - ] - session.genre.get_genres.assert_called_once_with() +class TestLookup: + def test_raises_when_no_uri_passed(self, library_provider): + with pytest.raises(Exception): + library_provider.lookup() -def test_specific_album_new_api(tlp, mocker, tidal_albums): - tlp, backend = tlp - session = backend.session - session.mock_add_spec(("album",)) - album = tidal_albums[0] - session.album.return_value = album - assert tlp.browse("tidal:album:1") == [ - Ref(name="Track-0", type="track", uri="tidal:track:1234:0:0") - ] - session.album.assert_called_once_with("1") - album.tracks.assert_called_once_with() + @pytest.mark.parametrize("uri", ("", "this_isn't_a_uri")) + def test_raises_with_invalid_uri(self, library_provider, uri): + with pytest.raises(Exception): + library_provider.lookup(uri) + def test_returns_empty_list_if_http_request_fails( + self, library_provider, session, mocker + ): + album = mocker.Mock(**{"tracks.side_effect": HTTPError}) + session.album.return_value = album -def test_specific_album_new_api_none(tlp, mocker): - tlp, backend = tlp - session = backend.session - session.mock_add_spec(("album",)) - session.album.return_value = None - assert not tlp.browse("tidal:album:1") - session.album.assert_called_once_with("1") + assert library_provider.lookup("tidal:track:0:1:0") == [] -def test_specific_playlist_new_api(tlp, mocker, tidal_tracks): - tlp, backend = tlp +def test_specific_playlist(library_provider, backend, mocker, tidal_tracks): session = backend.session session.mock_add_spec(("playlist",)) playlist = mocker.Mock(name="Playlist") @@ -297,7 +357,7 @@ def test_specific_playlist_new_api(tlp, mocker, tidal_tracks): playlist.tracks.__name__ = "playlist" session.playlist.return_value = playlist - tracks = tlp.browse("tidal:playlist:1") + tracks = library_provider.browse("tidal:playlist:1") assert tracks[:2] == [ Ref(name="Track-0", type="track", uri="tidal:track:0:0:0"), Ref(name="Track-1", type="track", uri="tidal:track:1:1:1"), @@ -307,8 +367,7 @@ def test_specific_playlist_new_api(tlp, mocker, tidal_tracks): playlist.tracks.assert_has_calls([mocker.call(100, 0)]) -def test_specific_mood_new_api(tlp, mocker): - tlp, backend = tlp +def test_specific_mood(library_provider, backend, mocker): session = backend.session session.mock_add_spec(("moods",)) playlist = mocker.Mock() @@ -322,7 +381,7 @@ def test_specific_mood_new_api(tlp, mocker): mood_2 = mocker.Mock() mood_2.api_path = "0/0/0" session.moods.return_value = [mood, mood_2] - assert tlp.browse("tidal:mood:1") == [ + assert library_provider.browse("tidal:mood:1") == [ Ref(name="Playlist-0", type="playlist", uri="tidal:playlist:0"), ] @@ -330,18 +389,16 @@ def test_specific_mood_new_api(tlp, mocker): mood.get.assert_called_once_with() -def test_specific_mood_new_api_none(tlp, mocker, tidal_tracks): - tlp, backend = tlp +def test_specific_mood_none(library_provider, backend, mocker, tidal_tracks): session = backend.session session.mock_add_spec(("moods",)) playlist_2 = mocker.Mock() playlist_2.api_path = "0/0/0" session.moods.return_value = [playlist_2] - assert not tlp.browse("tidal:mood:1") + assert not library_provider.browse("tidal:mood:1") -def test_specific_genre_new_api(tlp, mocker): - tlp, backend = tlp +def test_specific_genre(library_provider, backend, mocker): session = backend.session session.mock_add_spec(("genre", "genre.get_genres")) playlist = mocker.Mock() @@ -355,26 +412,24 @@ def test_specific_genre_new_api(tlp, mocker): genre_2 = mocker.Mock() genre_2.path = "13" session.genre.get_genres.return_value = [genre, genre_2] - assert tlp.browse("tidal:genre:1") == [ + assert library_provider.browse("tidal:genre:1") == [ Ref(name="Playlist-0", type="playlist", uri="tidal:playlist:0"), ] session.genre.get_genres.assert_called_once_with() genre.items.assert_called_once_with(Playlist) -def test_specific_genre_new_api_none(tlp, mocker, tidal_tracks): - tlp, backend = tlp +def test_specific_genre_none(library_provider, backend, mocker, tidal_tracks): session = backend.session session.mock_add_spec(("genre", "genre.get_genres")) playlist_2 = mocker.Mock() playlist_2.path = "13" session.genre.get_genres.return_value = [playlist_2] - assert not tlp.browse("tidal:genre:1") + assert not library_provider.browse("tidal:genre:1") session.genre.get_genres.assert_called_once_with() -def test_specific_mix(tlp, mocker, tidal_tracks): - tlp, backend = tlp +def test_specific_mix(library_provider, backend, mocker, tidal_tracks): session = backend.session playlist = mocker.Mock() playlist.id = "1" @@ -382,7 +437,7 @@ def test_specific_mix(tlp, mocker, tidal_tracks): playlist.items.return_value = tidal_tracks playlist_2 = mocker.Mock() session.mixes.return_value = [playlist, playlist_2] - assert tlp.browse("tidal:mix:1") == [ + assert library_provider.browse("tidal:mix:1") == [ Ref(name="Track-0", type="track", uri="tidal:track:0:0:0"), Ref(name="Track-1", type="track", uri="tidal:track:1:1:1"), ] @@ -390,23 +445,23 @@ def test_specific_mix(tlp, mocker, tidal_tracks): playlist.items.assert_called_once_with() -def test_specific_mix_none(tlp, mocker): - tlp, backend = tlp +def test_specific_mix_none(library_provider, backend, mocker): session = backend.session playlist_2 = mocker.Mock() session.mixes.return_value = [playlist_2] - assert not tlp.browse("tidal:mix:1") + assert not library_provider.browse("tidal:mix:1") session.mixes.assert_called_once_with() -def test_specific_artist_new_api(tlp, mocker, tidal_albums, tidal_artists): - tlp, backend = tlp +def test_specific_artist( + library_provider, backend, mocker, tidal_albums, tidal_artists +): session = backend.session session.mock_add_spec(("artist",)) artist = tidal_artists[0] artist.get_albums.return_value = tidal_albums session.artist.return_value = artist - assert tlp.browse("tidal:artist:1") == [ + assert library_provider.browse("tidal:artist:1") == [ Ref(name="Album-0", type="album", uri="tidal:album:0"), Ref(name="Album-1", type="album", uri="tidal:album:1"), Ref(name="Track-100", type="track", uri="tidal:track:0:7:100"), @@ -416,129 +471,103 @@ def test_specific_artist_new_api(tlp, mocker, tidal_albums, tidal_artists): session.artist.assert_has_calls([mocker.call("1"), mocker.call("1")]) -def test_lookup_no_uris(tlp, mocker): - tlp, backend = tlp - with pytest.raises(Exception): # just check it raises - tlp.lookup() - with pytest.raises(Exception): - tlp.lookup("") - with pytest.raises(Exception): - tlp.lookup("somethingwhichisntauri") - assert not tlp.lookup("tidal:nonsuch:11") - - -def test_lookup_http_error(tlp, mocker): - tlp, backend = tlp - session = backend.session - album = mocker.Mock() - album.tracks.side_effect = HTTPError - session.album.return_value = album - assert not tlp.lookup("tidal:track:0:1:0") - - -def test_lookup_track(tlp, mocker, tidal_tracks, compare): - tlp, backend = tlp +def test_lookup_track(library_provider, backend, mocker, tidal_tracks, compare): session = backend.session album = mocker.Mock() album.tracks.return_value = tidal_tracks session.album.return_value = album - res = tlp.lookup("tidal:track:0:1:0") + res = library_provider.lookup("tidal:track:0:1:0") compare(tidal_tracks[:1], res, "track") session.album.assert_called_once_with("1") -def test_lookup_track_newstyle(tlp, mocker, tidal_tracks, compare): - tlp, backend = tlp +def test_lookup_track_newstyle( + library_provider, backend, mocker, tidal_tracks, compare +): session = backend.session album = mocker.Mock() album.tracks.return_value = tidal_tracks session.album.return_value = album track = mocker.Mock(**{"album.id": 1, "name": "track"}) session.track.return_value = track - res = tlp.lookup("tidal:track:0") + res = library_provider.lookup("tidal:track:0") compare(tidal_tracks[:1], res, "track") session.album.assert_called_once_with("1") session.track.assert_called_once_with("0") -def test_lookup_track_cached(tlp, mocker, tidal_tracks, compare): - tlp, backend = tlp +def test_lookup_track_cached(library_provider, backend, mocker, tidal_tracks, compare): session = backend.session album = mocker.Mock() album.tracks.return_value = tidal_tracks session.album.return_value = album - res = tlp.lookup("tidal:track:0:1:0") + res = library_provider.lookup("tidal:track:0:1:0") compare(tidal_tracks[:1], res, "track") - res2 = tlp.lookup("tidal:track:0:1:0") + res2 = library_provider.lookup("tidal:track:0:1:0") assert res2 == res session.album.assert_called_once_with("1") -def test_lookup_track_cached_album(tlp, mocker, tidal_albums, compare): - tlp, backend = tlp +def test_lookup_track_cached_album( + library_provider, backend, mocker, tidal_albums, compare +): session = backend.session tidal_tracks = tidal_albums[1].tracks() album = mocker.Mock() album.tracks.return_value = tidal_tracks session.album.return_value = album - res = tlp.lookup("tidal:album:1") + res = library_provider.lookup("tidal:album:1") compare(tidal_tracks, res, "track") - res2 = tlp.lookup("tidal:track:1234:1:1") + res2 = library_provider.lookup("tidal:track:1234:1:1") compare(tidal_tracks[:1], res2, "track") session.album.assert_called_once_with("1") -def test_lookup_album(tlp, mocker, tidal_tracks, compare): - tlp, backend = tlp +def test_lookup_album(library_provider, backend, mocker, tidal_tracks, compare): session = backend.session album = mocker.Mock() album.tracks.return_value = tidal_tracks session.album.return_value = album - res = tlp.lookup("tidal:album:1") + res = library_provider.lookup("tidal:album:1") compare(tidal_tracks, res, "track") session.album.assert_called_once_with("1") -def test_lookup_album_cached(tlp, mocker, tidal_tracks, compare): - tlp, backend = tlp +def test_lookup_album_cached(library_provider, backend, mocker, tidal_tracks, compare): session = backend.session album = mocker.Mock() album.tracks.return_value = tidal_tracks session.album.return_value = album - res = tlp.lookup("tidal:album:1") + res = library_provider.lookup("tidal:album:1") compare(tidal_tracks, res, "track") - res2 = tlp.lookup("tidal:album:1") + res2 = library_provider.lookup("tidal:album:1") assert res2 == res session.album.assert_called_once_with("1") -def test_lookup_artist(tlp, mocker, tidal_tracks, compare): - tlp, backend = tlp +def test_lookup_artist(library_provider, backend, mocker, tidal_tracks, compare): session = backend.session artist = mocker.Mock() artist.get_top_tracks.return_value = tidal_tracks session.artist.return_value = artist - res = tlp.lookup("tidal:artist:1") + res = library_provider.lookup("tidal:artist:1") compare(tidal_tracks, res, "track") session.artist.assert_called_once_with("1") -def test_lookup_artist_cached(tlp, mocker, tidal_tracks, compare): - tlp, backend = tlp +def test_lookup_artist_cached(library_provider, backend, mocker, tidal_tracks, compare): session = backend.session artist = mocker.Mock() artist.get_top_tracks.return_value = tidal_tracks session.artist.return_value = artist - res = tlp.lookup("tidal:artist:1") + res = library_provider.lookup("tidal:artist:1") compare(tidal_tracks, res, "track") - res2 = tlp.lookup("tidal:artist:1") + res2 = library_provider.lookup("tidal:artist:1") assert res2 == res session.artist.assert_called_once_with("1") -@pytest.mark.gt_3_7 -def test_lookup_playlist(tlp, mocker, tidal_tracks, compare): - tlp, backend = tlp +def test_lookup_playlist(library_provider, backend, mocker, tidal_tracks, compare): session = backend.session playlist = mocker.Mock() playlist.name = "Playlist-1" @@ -547,16 +576,16 @@ def test_lookup_playlist(tlp, mocker, tidal_tracks, compare): playlist.tracks.return_value = tidal_tracks playlist.tracks.__name__ = "get_playlist_tracks" - res = tlp.lookup("tidal:playlist:99") + res = library_provider.lookup("tidal:playlist:99") compare(tidal_tracks, res[: len(tidal_tracks)], "track") session.playlist.assert_called_with("99") assert len(playlist.tracks.mock_calls) == 5, "Didn't run five fetches in parallel." -@pytest.mark.gt_3_7 -def test_lookup_playlist_cached(tlp, mocker, tidal_tracks, compare): - tlp, backend = tlp +def test_lookup_playlist_cached( + library_provider, backend, mocker, tidal_tracks, compare +): session = backend.session playlist = mocker.Mock() playlist.name = "Playlist-1" @@ -565,9 +594,9 @@ def test_lookup_playlist_cached(tlp, mocker, tidal_tracks, compare): playlist.tracks.return_value = tidal_tracks playlist.tracks.__name__ = "get_playlist_tracks" - res = tlp.lookup("tidal:playlist:99") + res = library_provider.lookup("tidal:playlist:99") compare(tidal_tracks, res[: len(tidal_tracks)], "track") - res2 = tlp.lookup("tidal:playlist:99") + res2 = library_provider.lookup("tidal:playlist:99") assert res2 == res session.playlist.assert_called_with("99") diff --git a/tests/test_login_hack.py b/tests/test_login_hack.py index 5aa8582..f9b6d22 100644 --- a/tests/test_login_hack.py +++ b/tests/test_login_hack.py @@ -4,7 +4,6 @@ from mopidy.models import Album, Artist, Image, Playlist, Ref, SearchResult, Track from tidalapi.session import LinkLogin -from mopidy_tidal import login_hack from mopidy_tidal.library import TidalLibraryProvider from mopidy_tidal.playback import TidalPlaybackProvider from mopidy_tidal.playlists import TidalPlaylistsProvider @@ -21,118 +20,122 @@ def library_provider(get_backend, config, mocker): future.running.return_value = True session.login_oauth.return_value = (url, future) backend.on_start() + return backend, TidalLibraryProvider(backend=backend) -@pytest.mark.parametrize( - "type, uri", - [ - ["directory", "tidal:my_albums"], - ["directory", "tidal:my_artists"], - ["directory", "tidal:my_playlists"], - ["directory", "tidal:my_tracks"], - ["directory", "tidal:moods"], - ["directory", "tidal:mixes"], - ["directory", "tidal:genres"], - ["album", "tidal:album:id"], - ["artist", "tidal:artist:id"], - ["playlist", "tidal:playlist:id"], - ["mood", "tidal:mood:id"], - ["genre", "tidal:genre:id"], - ["mix", "tidal:mix:id"], - ], -) -def test_library_browse_with_hack_login_triggers_login(type, uri, library_provider): - backend, lp = library_provider - assert not backend.logged_in - assert not backend.logging_in - things = lp.browse(uri) - assert not backend.logged_in - assert backend.logging_in - assert len(things) == 1 - thing = things[0] - assert isinstance(thing, Ref) - assert thing.type == type - assert "link.tidal/URI" in thing.name - assert thing.uri == uri - # _, schema, *_ = uri.split(":") - # schema = schema.replace("my_", "").rstrip("s") - # login_uri = f"tidal:{schema}:login" - # assert login_uri == thing.uri - - -def test_get_image_with_hack_login_triggers_login(library_provider): - backend, lp = library_provider - assert not backend.logged_in - assert not backend.logging_in - images = lp.get_images(["tidal:playlist:uri"]) - assert not backend.logged_in - assert backend.logging_in - assert images == { - "tidal:playlist:uri": [ - Image( - height=150, - uri="https://api.qrserver.com/v1/create-qr-code/?size=150x150&data=https%3A%2F%2Flink.tidal%2FURI", - width=150, - ) - ] - } - - -def test_library_lookup_with_hack_login_triggers_login(library_provider): - backend, lp = library_provider - assert not backend.logged_in - assert not backend.logging_in - tracks = lp.lookup(["tidal:track:uri"]) - assert not backend.logged_in - assert backend.logging_in - assert tracks == [ - Track( - name="Please visit https://link.tidal/URI to log in.", - uri="tidal:track:login", - ) - ] - assert True - - -def test_search_with_hack_login_triggers_login(library_provider): - backend, lp = library_provider - assert not backend.logged_in - assert not backend.logging_in - result = lp.search() - assert not backend.logged_in - assert backend.logging_in - assert result == SearchResult( - albums=[ - Album( - name="Please visit https://link.tidal/URI to log in.", - uri="tidal:album:login", - ) +class TestLibraryProviderMethods: + @pytest.mark.parametrize( + "type, uri", + [ + ["directory", "tidal:my_albums"], + ["directory", "tidal:my_artists"], + ["directory", "tidal:my_playlists"], + ["directory", "tidal:my_tracks"], + ["directory", "tidal:moods"], + ["directory", "tidal:mixes"], + ["directory", "tidal:genres"], + ["album", "tidal:album:id"], + ["artist", "tidal:artist:id"], + ["playlist", "tidal:playlist:id"], + ["mood", "tidal:mood:id"], + ["genre", "tidal:genre:id"], + ["mix", "tidal:mix:id"], ], - artists=[ - Artist( - name="Please visit https://link.tidal/URI to log in.", - uri="tidal:artist:login", - ) - ], - tracks=[ + ) + def test_browse_triggers_login(self, type, uri, library_provider): + backend, lp = library_provider + assert not backend.logged_in + assert not backend.logging_in + + browse_results = lp.browse(uri) + + assert not backend.logged_in + assert backend.logging_in + assert len(browse_results) == 1 + browse_result = browse_results[0] + assert isinstance(browse_result, Ref) + assert browse_result.type == type + assert "link.tidal/URI" in browse_result.name + assert browse_result.uri == uri + + def test_get_image_triggers_login(self, library_provider): + backend, lp = library_provider + assert not backend.logged_in + assert not backend.logging_in + + images = lp.get_images(["tidal:playlist:uri"]) + + assert not backend.logged_in + assert backend.logging_in + assert images == { + "tidal:playlist:uri": [ + Image( + height=150, + uri="https://api.qrserver.com/v1/create-qr-code/?size=150x150&data=https%3A%2F%2Flink.tidal%2FURI", + width=150, + ) + ] + } + + def test_library_lookup_triggers_login(self, library_provider): + backend, lp = library_provider + assert not backend.logged_in + assert not backend.logging_in + + tracks = lp.lookup(["tidal:track:uri"]) + + assert not backend.logged_in + assert backend.logging_in + assert tracks == [ Track( name="Please visit https://link.tidal/URI to log in.", uri="tidal:track:login", ) - ], - ) + ] + assert True + + def test_search_triggers_login(self, library_provider): + backend, lp = library_provider + assert not backend.logged_in + assert not backend.logging_in + result = lp.search() + + assert not backend.logged_in + assert backend.logging_in + assert result == SearchResult( + albums=[ + Album( + name="Please visit https://link.tidal/URI to log in.", + uri="tidal:album:login", + ) + ], + artists=[ + Artist( + name="Please visit https://link.tidal/URI to log in.", + uri="tidal:artist:login", + ) + ], + tracks=[ + Track( + name="Please visit https://link.tidal/URI to log in.", + uri="tidal:track:login", + ) + ], + ) -@pytest.mark.parametrize("field", ("artist", "album", "track")) -def test_get_distinct_with_hack_login_triggers_login(field, library_provider): - backend, lp = library_provider - assert not backend.logged_in - assert not backend.logging_in - result = lp.get_distinct(field) - assert not backend.logged_in - assert backend.logging_in - assert result == {"Please visit https://link.tidal/URI to log in."} + @pytest.mark.parametrize("field", ("artist", "album", "track")) + def test_get_distinct_triggers_login(self, field, library_provider): + backend, lp = library_provider + assert not backend.logged_in + assert not backend.logging_in + + result = lp.get_distinct(field) + + assert not backend.logged_in + assert backend.logging_in + assert result == {"Please visit https://link.tidal/URI to log in."} @pytest.fixture @@ -149,34 +152,17 @@ def playlist_provider(get_backend, config, mocker): return backend, TidalPlaylistsProvider(backend=backend) -def test_playlist_lookup_with_hack_login_triggers_login(playlist_provider): - backend, pp = playlist_provider - assert not backend.logged_in - assert not backend.logging_in - result = pp.lookup("tidal:playlist:uri") - assert not backend.logged_in - assert backend.logging_in - assert result == Playlist( - name="Please visit https://link.tidal/URI to log in.", - tracks=[ - Track( - name="Please visit https://link.tidal/URI to log in.", - uri="tidal:track:login", - ) - ], - uri="tidal:playlist:login", - ) +class TestPlaylistMethods: + def test_playlist_lookup_triggers_login(self, playlist_provider): + backend, pp = playlist_provider + assert not backend.logged_in + assert not backend.logging_in + result = pp.lookup("tidal:playlist:uri") -def test_playlist_refresh_with_hack_login_triggers_login(playlist_provider): - backend, pp = playlist_provider - assert not backend.logged_in - assert not backend.logging_in - result = pp.refresh("tidal:playlist:uri") - assert not backend.logged_in - assert backend.logging_in - assert result == { - "tidal:playlist:uri": Playlist( + assert not backend.logged_in + assert backend.logging_in + assert result == Playlist( name="Please visit https://link.tidal/URI to log in.", tracks=[ Track( @@ -186,23 +172,65 @@ def test_playlist_refresh_with_hack_login_triggers_login(playlist_provider): ], uri="tidal:playlist:login", ) - } - - -def test_playlist_as_list_with_hack_login_triggers_login(playlist_provider): - backend, pp = playlist_provider - assert not backend.logged_in - assert not backend.logging_in - result = pp.as_list() - assert not backend.logged_in - assert backend.logging_in - assert result == [ - Ref( - name="Please visit https://link.tidal/URI to log in.", - type="playlist", - uri="tidal:playlist:login", - ) - ] + + def test_playlist_refresh_triggers_login(self, playlist_provider): + backend, pp = playlist_provider + assert not backend.logged_in + assert not backend.logging_in + + result = pp.refresh("tidal:playlist:uri") + + assert not backend.logged_in + assert backend.logging_in + assert result == { + "tidal:playlist:uri": Playlist( + name="Please visit https://link.tidal/URI to log in.", + tracks=[ + Track( + name="Please visit https://link.tidal/URI to log in.", + uri="tidal:track:login", + ) + ], + uri="tidal:playlist:login", + ) + } + + def test_playlist_as_list_triggers_login(self, playlist_provider): + backend, pp = playlist_provider + assert not backend.logged_in + assert not backend.logging_in + + result = pp.as_list() + + assert not backend.logged_in + assert backend.logging_in + assert result == [ + Ref( + name="Please visit https://link.tidal/URI to log in.", + type="playlist", + uri="tidal:playlist:login", + ) + ] + + def test_continues_unfazed_when_already_logged_in( + self, playlist_provider, mocker, tidal_playlists + ): + backend, pp = playlist_provider + backend._logged_in = True + audiof = backend.data_dir / "login_audio/URI.ogg" + session = mocker.Mock(**{"user.favorites.playlists": tidal_playlists[:1]}) + backend._active_session = session + mocker.patch("mopidy_tidal.playlists.get_items", lambda x: x) + backend.session.user.playlists.return_value = tidal_playlists[1:] + + assert backend.logged_in + assert not backend.logging_in + + assert pp.as_list() == [ + Ref(name="Playlist-101", type="playlist", uri="tidal:playlist:101"), + Ref(name="Playlist-222", type="playlist", uri="tidal:playlist:222"), + ] + assert not audiof.exists() @pytest.fixture @@ -216,95 +244,86 @@ def playback_provider(get_backend, config, mocker): future.running.return_value = True session.login_oauth.return_value = (url, future) backend.on_start() + return backend, TidalPlaybackProvider(audio=mocker.Mock(), backend=backend) -def test_audio_downloaded(playback_provider, mocker): - backend, pp = playback_provider - get = mocker.Mock(**{"return_value.content": b"mock audio"}) - mocker.patch("login_hack.get", get) - audiof = backend.data_dir / "login_audio/URI.ogg" - assert not audiof.exists() - assert not backend.logged_in - assert not backend.logging_in - result = pp.translate_uri("tidal:track:1:2:3") - assert not backend.logged_in - assert backend.logging_in - assert result == audiof.as_uri() - get.assert_called_once() - assert audiof.read_bytes() == b"mock audio" - - -def test_failed_audio_download_returns_None(playback_provider, mocker): - backend, pp = playback_provider - get = mocker.Mock(**{"return_value.raise_for_status": Exception()}) - mocker.patch("login_hack.get", get) - - audiof = backend.data_dir / "login_audio/URI.ogg" - assert not audiof.exists() - assert not backend.logged_in - assert not backend.logging_in - result = pp.translate_uri("tidal:track:1:2:3") - assert not backend.logged_in - assert backend.logging_in - assert result is None - get.assert_called_once() - assert not audiof.exists() - - -def test_downloaded_audio_removed_on_next_access( - playback_provider, mocker, tidal_playlists -): - backend, pp = playback_provider - get = mocker.Mock(**{"return_value.content": b"mock audio"}) - mocker.patch("login_hack.get", get) - audiof = backend.data_dir / "login_audio/URI.ogg" - result = pp.translate_uri("tidal:track:1:2:3") - assert not backend.logged_in - assert backend.logging_in - assert result == audiof.as_uri() - get.assert_called_once() - assert audiof.read_bytes() == b"mock audio" - backend._login_future.running.return_value = False - assert not backend.logging_in - - session = mocker.Mock(**{"user.favorites.playlists": tidal_playlists[:1]}) - backend._active_session = session - mocker.patch("mopidy_tidal.playlists.get_items", lambda x: x) - backend.session.user.playlists.return_value = tidal_playlists[1:] - tpp = TidalPlaylistsProvider(backend=backend) - assert tpp.as_list() == [ - Ref(name="Playlist-101", type="playlist", uri="tidal:playlist:101"), - Ref(name="Playlist-222", type="playlist", uri="tidal:playlist:222"), - ] - assert not audiof.exists() - - -def test_already_logged_in_continues_unfazed( - playlist_provider, mocker, tidal_playlists -): - backend, pp = playlist_provider - backend._logged_in = True - audiof = backend.data_dir / "login_audio/URI.ogg" - session = mocker.Mock(**{"user.favorites.playlists": tidal_playlists[:1]}) - backend._active_session = session - mocker.patch("mopidy_tidal.playlists.get_items", lambda x: x) - backend.session.user.playlists.return_value = tidal_playlists[1:] - - assert backend.logged_in - assert not backend.logging_in - - assert pp.as_list() == [ - Ref(name="Playlist-101", type="playlist", uri="tidal:playlist:101"), - Ref(name="Playlist-222", type="playlist", uri="tidal:playlist:222"), - ] - assert not audiof.exists() - - -def test_login_hack_implies_lazy_connect(config, get_backend): - config["tidal"]["login_method"] = "HACK" - config["tidal"]["lazy"] = False - backend, *_ = get_backend(config=config) - assert not backend.lazy_connect - backend.on_start() - assert backend.lazy_connect +class TestPlaybackMethods: + def test_audio_downloaded(self, playback_provider, mocker): + backend, pp = playback_provider + get = mocker.Mock(**{"return_value.content": b"mock audio"}) + mocker.patch("login_hack.get", get) + audiof = backend.data_dir / "login_audio/URI.ogg" + assert not audiof.exists() + assert not backend.logged_in + assert not backend.logging_in + + result = pp.translate_uri("tidal:track:1:2:3") + + assert not backend.logged_in + assert backend.logging_in + assert result == audiof.as_uri() + get.assert_called_once() + assert audiof.read_bytes() == b"mock audio" + + def test_failed_audio_download_returns_None(self, playback_provider, mocker): + backend, pp = playback_provider + get = mocker.Mock(**{"return_value.raise_for_status": Exception()}) + mocker.patch("login_hack.get", get) + audiof = backend.data_dir / "login_audio/URI.ogg" + assert not audiof.exists() + assert not backend.logged_in + assert not backend.logging_in + + result = pp.translate_uri("tidal:track:1:2:3") + + assert not backend.logged_in + assert backend.logging_in + assert result is None + get.assert_called_once() + assert not audiof.exists() + + def test_downloaded_audio_removed_on_next_access( + self, playback_provider, mocker, tidal_playlists + ): + backend, pp = playback_provider + get = mocker.Mock(**{"return_value.content": b"mock audio"}) + mocker.patch("login_hack.get", get) + audiof = backend.data_dir / "login_audio/URI.ogg" + + result = pp.translate_uri("tidal:track:1:2:3") + + assert not backend.logged_in + assert backend.logging_in + assert result == audiof.as_uri() + get.assert_called_once() + assert audiof.read_bytes() == b"mock audio" + backend._login_future.running.return_value = False + assert not backend.logging_in + + session = mocker.Mock(**{"user.favorites.playlists": tidal_playlists[:1]}) + backend._active_session = session + mocker.patch("mopidy_tidal.playlists.get_items", lambda x: x) + backend.session.user.playlists.return_value = tidal_playlists[1:] + + tpp = TidalPlaylistsProvider(backend=backend) + + assert tpp.as_list() == [ + Ref(name="Playlist-101", type="playlist", uri="tidal:playlist:101"), + Ref(name="Playlist-222", type="playlist", uri="tidal:playlist:222"), + ] + assert not audiof.exists() + + +class TestConfig: + def test_login_hack_implies_lazy_connect_even_if_set_to_false( + self, config, get_backend + ): + config["tidal"]["login_method"] = "HACK" + config["tidal"]["lazy"] = False + backend, *_ = get_backend(config=config) + assert not backend.lazy_connect + + backend.on_start() + + assert backend.lazy_connect diff --git a/tests/test_lru_cache.py b/tests/test_lru_cache.py index 16f2f94..cfbe438 100644 --- a/tests/test_lru_cache.py +++ b/tests/test_lru_cache.py @@ -8,176 +8,233 @@ @pytest.fixture -def lru_cache(config): - cache_dir = config["core"]["cache_dir"] +def lru_disk_cache() -> LruCache: return LruCache(max_size=8, persist=True, directory="cache") -def test_props(config): +@pytest.fixture +def lru_ram_cache() -> LruCache: + return LruCache(max_size=8, persist=False) + + +@pytest.fixture(params=[True, False]) +def lru_cache(request) -> LruCache: + return LruCache(max_size=8, persist=request.param, directory="cache") + + +def test_config_stored_on_cache(): l = LruCache(max_size=1678, persist=True, directory="cache") + assert l.max_size == 1678 assert l.persist - l = LruCache(max_size=1679, persist=False, directory="cache") - assert l.max_size == 1679 - assert not l.persist -def test_store(lru_cache): - assert not lru_cache.keys() - lru_cache["tidal:uri:val"] = "invisible" +class TestDiskPersistence: + def test_raises_keyerror_if_file_corrupted(self): + cache = LruCache(max_size=8, persist=True, directory="cache") + cache.update({"tidal:uri:val": "hi", "tidal:uri:otherval": 17}) + cache.cache_file("tidal:uri:val").write_text("hahaha") + del cache + + new_cache = LruCache(max_size=8, persist=True, directory="cache") + assert new_cache["tidal:uri:otherval"] == 17 + with pytest.raises(KeyError): + new_cache["tidal:uri:val"] + + def test_raises_keyerror_if_file_deleted(self): + cache = LruCache(max_size=8, persist=True, directory="cache") + cache.update({"tidal:uri:val": "hi", "tidal:uri:otherval": 17}) + cache.cache_file("tidal:uri:val").unlink() + del cache + + new_cache = LruCache(max_size=8, persist=True, directory="cache") + assert new_cache["tidal:uri:otherval"] == 17 + with pytest.raises(KeyError): + new_cache["tidal:uri:val"] + + def test_prune_removes_files(self): + cache = LruCache(max_size=8, persist=True, directory="cache") + cache.update({"tidal:uri:val": "hi", "tidal:uri:otherval": 17}) + assert cache.cache_file("tidal:uri:otherval").exists() + assert cache.cache_file("tidal:uri:val").exists() + + cache.prune("tidal:uri:otherval") + cache.prune("tidal:uri:val") + + assert not cache.cache_file("tidal:uri:otherval").exists() + assert not cache.cache_file("tidal:uri:val").exists() + + def test_prune_ignores_already_deleted_files(self): + cache = LruCache(max_size=8, persist=True, directory="cache") + cache.update({"tidal:uri:val": "hi", "tidal:uri:otherval": 17}) + cache.cache_file("tidal:uri:val").unlink() + del cache + + new_cache = LruCache(max_size=8, persist=True, directory="cache") + new_cache.prune("tidal:uri:otherval") + new_cache.prune("tidal:uri:val") + + def test_migrates_old_filename_if_present(self, lru_disk_cache): + uri = "tidal:uri:val" + value = "hi" + lru_disk_cache[uri] = value + assert lru_disk_cache[uri] == value + + # The cache filename should be dash-separated + filename = lru_disk_cache.cache_file(uri) + assert filename.name == "-".join(uri.split(":")) + ".cache" + + # Rename the cache filename to match the old file format + new_filename = os.path.join(os.path.dirname(filename), f"{uri}.cache") + shutil.move(filename, new_filename) + + # Remove the in-memory cache element in order to force a filesystem reload + lru_disk_cache.pop(uri) + cached_value = lru_disk_cache.get(uri) + assert cached_value == value + + # The cache filename should be column-separated + filename = lru_disk_cache.cache_file(uri) + assert filename.name == f"{uri}.cache" + + def test_values_persisted_between_caches(self): + cache = LruCache(max_size=8, persist=True, directory="cache") + cache.update( + {"tidal:uri:val": "hi", "tidal:uri:otherval": 17, "tidal:uri:none": None} + ) + del cache + + new_cache = LruCache(max_size=8, persist=True, directory="cache") + + assert new_cache["tidal:uri:val"] == "hi" + assert new_cache["tidal:uri:otherval"] == 17 + assert new_cache["tidal:uri:none"] == None + + +def test_raises_key_error_if_target_missing(lru_cache): + with pytest.raises(KeyError): + lru_cache["tidal:uri:nonsuch"] + + +def test_simple_objects_persisted_in_cache(lru_cache): lru_cache["tidal:uri:val"] = "hi" lru_cache["tidal:uri:none"] = None - lru_cache["tidal:uri:otherval"] = {"complex": "object", "with": [0, 1]} + assert lru_cache["tidal:uri:val"] == "hi" == lru_cache.get("tidal:uri:val") + assert lru_cache["tidal:uri:none"] is None + assert len(lru_cache) == 2 + + +def test_complex_objects_persisted_in_cache(lru_cache): + lru_cache["tidal:uri:otherval"] = {"complex": "object", "with": [0, 1]} + assert ( lru_cache["tidal:uri:otherval"] == {"complex": "object", "with": [0, 1]} == lru_cache.get("tidal:uri:otherval") ) - assert lru_cache["tidal:uri:none"] is None - assert len(lru_cache) == 3 - - -def test_get_fail(lru_cache): - with pytest.raises(KeyError): - lru_cache["tidal:uri:nonsuch"] - - -def test_get_fail_memory(config): - l = LruCache(persist=False) - with pytest.raises(KeyError): - l["tidal:uri:nonsuch"] + assert len(lru_cache) == 1 -def test_update(lru_cache): +def test_update_adds_or_replaces(lru_cache): lru_cache.update({"tidal:uri:val": "hi", "tidal:uri:otherval": 17}) + assert lru_cache["tidal:uri:val"] == "hi" assert lru_cache["tidal:uri:otherval"] == 17 assert "tidal:uri:val" in lru_cache assert "tidal:uri:nonesuch" not in lru_cache -@pytest.mark.gt_3_7 -@pytest.mark.gt_3_8 -def test_newstyle_update(lru_cache): - assert "tidal:uri:val" not in lru_cache +def test_dict_style_update_behaves_like_update(lru_cache): lru_cache |= {"tidal:uri:val": "hi", "tidal:uri:otherval": 17} + assert lru_cache["tidal:uri:val"] == "hi" assert lru_cache["tidal:uri:otherval"] == 17 -def test_get(lru_cache): +def test_get_returns_default_if_supplied_and_no_match(lru_cache): uniq = object() + assert lru_cache.get("tidal:uri:nonsuch", default=uniq) is uniq -def test_prune(lru_cache): +def test_prune_removes_from_cache(lru_cache): lru_cache.update({"tidal:uri:val": "hi", "tidal:uri:otherval": 17}) assert "tidal:uri:val" in lru_cache + lru_cache.prune("tidal:uri:val") + assert "tidal:uri:val" not in lru_cache assert "tidal:uri:otherval" in lru_cache -def test_prune_all(lru_cache): +def test_prune_all_empties_cache(lru_cache): lru_cache.update({"tidal:uri:val": "hi", "tidal:uri:otherval": 17}) - assert "tidal:uri:val" in lru_cache - assert "tidal:uri:otherval" in lru_cache + assert len(lru_cache) == 2 + lru_cache.prune_all() + + assert len(lru_cache) == 0 assert "tidal:uri:val" not in lru_cache assert "tidal:uri:otherval" not in lru_cache -def test_persist(config): - l = LruCache(max_size=8, persist=True, directory="cache") - l.update({"tidal:uri:val": "hi", "tidal:uri:otherval": 17, "tidal:uri:none": None}) - del l - new_l = LruCache(max_size=8, persist=True, directory="cache") - new_l["tidal:uri:anotherval"] = 18 - assert new_l["tidal:uri:val"] == "hi" - assert new_l["tidal:uri:otherval"] == 17 - assert new_l["tidal:uri:anotherval"] == 18 - assert new_l["tidal:uri:none"] is None - - -def test_corrupt(config): - l = LruCache(max_size=8, persist=True, directory="cache") - l.update({"tidal:uri:val": "hi", "tidal:uri:otherval": 17}) - del l - Path( - config["core"]["cache_dir"], "tidal/cache/uri/va/tidal-uri-val.cache" - ).write_text("hahaha") - - new_l = LruCache(max_size=8, persist=True, directory="cache") - assert new_l["tidal:uri:otherval"] == 17 - with pytest.raises(KeyError): - new_l["tidal:uri:val"] +def test_compares_equal_to_dict(lru_cache): + data = {"tidal:uri:val": "hi", "tidal:uri:otherval": 17} + lru_cache.update(data) + assert lru_cache == data -def test_delete(config): - l = LruCache(max_size=8, persist=True, directory="cache") - l.update({"tidal:uri:val": "hi", "tidal:uri:otherval": 17}) - del l - Path(config["core"]["cache_dir"], "tidal/cache/uri/va/tidal-uri-val.cache").unlink() - new_l = LruCache(max_size=8, persist=True, directory="cache") - assert new_l["tidal:uri:otherval"] == 17 - with pytest.raises(KeyError): - new_l["tidal:uri:val"] +@pytest.mark.parametrize("persist", (True, False)) +def test_maintains_size_by_excluding_values(persist: bool): + cache = LruCache(max_size=8, persist=persist) + cache.update({f"tidal:uri:{val}": val for val in range(8)}) + assert len(cache) == 8 + + cache["tidal:uri:8"] = 8 + + assert len(cache) == 8 + +def test_excludes_least_recently_inserted_value_when_no_accesses_made(): + cache = LruCache(max_size=8, persist=False) -def test_prune_deleted(config): - l = LruCache(max_size=8, persist=True, directory="cache") - l.update({"tidal:uri:val": "hi", "tidal:uri:otherval": 17}) - del l - Path(config["core"]["cache_dir"], "tidal/cache/uri/va/tidal-uri-val.cache").unlink() + cache.update({f"tidal:uri:{val}": val for val in range(9)}) - new_l = LruCache(max_size=8, persist=True, directory="cache") - new_l.prune("tidal:uri:otherval") - new_l.prune("tidal:uri:val") + assert len(cache) == 8 + assert "tidal:uri:8" in cache + assert "tidal:uri:0" not in cache -def test_max_size(lru_cache): - lru_cache.update({f"tidal:uri:{val}": val for val in range(8)}) - assert len(lru_cache) == 8 - lru_cache["tidal:uri:8"] = 8 - assert lru_cache == {f"tidal:uri:{val}": val for val in range(1, 9)} +@pytest.mark.xfail(reason="Disk cache grows indefinitely") +def test_removes_least_recently_inserted_value_from_disk_when_cache_overflows(): + cache = LruCache(max_size=8, persist=True) + cache.update({f"tidal:uri:{val}": val for val in range(9)}) -def test_no_max_size(config): - l = LruCache(max_size=0, persist=False) - assert not l.max_size - l.update({f"tidal:uri:{val}": val for val in range(2**12)}) - assert len(l) == 2**12 + assert len(cache) == 8 + assert "tidal:uri:8" in cache + assert "tidal:uri:0" not in cache -def test_old_cache_filename(lru_cache): - uri = "tidal:uri:val" - value = "hi" - lru_cache[uri] = value - assert lru_cache[uri] == value +@pytest.mark.xfail(reason="Cache ignores usage") +def test_excludes_least_recently_accessed_value(): + cache = LruCache(max_size=8, persist=False) - # The cache filename should be dash-separated - filename = lru_cache.cache_file(uri) - assert filename.name == "-".join(uri.split(":")) + ".cache" + cache.update({f"tidal:uri:{val}": val for val in range(8)}) + cache.get("tidal:uri:0") + cache["tidal:uri:8"] = 8 - # Rename the cache filename to match the old file format - new_filename = os.path.join(os.path.dirname(filename), f"{uri}.cache") - shutil.move(filename, new_filename) + assert len(cache) == 8 + assert "tidal:uri:8" in cache + assert "tidal:uri:0" in cache + assert "tidal:uri:1" not in cache - # Remove the in-memory cache element in order to force a filesystem reload - lru_cache.pop(uri) - cached_value = lru_cache.get(uri) - assert cached_value == value - # The cache filename should be column-separated - filename = lru_cache.cache_file(uri) - assert filename.name == f"{uri}.cache" +def test_cache_grows_indefinitely_if_max_size_zero(): + cache = LruCache(max_size=0, persist=False) + cache.update({f"tidal:uri:{val}": val for val in range(2**12)}) -@pytest.mark.xfail -def test_lru(lru_cache): - lru_cache.update({f"tidal:uri:{val}": val for val in range(8)}) - lru_cache["tidal:uri:0"] - lru_cache["tidal:uri:8"] = 8 - assert lru_cache == {f"tidal:uri:{val}": val for val in (0, *range(2, 9))} + assert len(cache) == 2**12 diff --git a/tests/test_playback.py b/tests/test_playback.py index cd2ac8a..7622580 100644 --- a/tests/test_playback.py +++ b/tests/test_playback.py @@ -1,7 +1,10 @@ +import pytest + from mopidy_tidal.playback import TidalPlaybackProvider -def test_playback_new_api(mocker): +@pytest.mark.xfail(reason="Requires mock tidal object") +def test_playback(mocker): uniq = object() session = mocker.Mock(spec=["track"]) session.mock_add_spec(["track"]) diff --git a/tests/test_playlist.py b/tests/test_playlist.py index 7046f70..a2ddef7 100644 --- a/tests/test_playlist.py +++ b/tests/test_playlist.py @@ -15,7 +15,7 @@ @pytest.fixture -def tpp(config, mocker): +def tpp(mocker): mocker.patch("mopidy_tidal.playlists.Timer") backend = mocker.Mock() backend._config = {"tidal": {"playlist_cache_refresh_secs": 0}} @@ -262,7 +262,7 @@ def api_test(tpp, mocker, api_method, tp): ) -def test_refresh_new_api(tpp, mocker): +def test_refresh(tpp, mocker): tpp, backend = tpp session = backend.session session.mock_add_spec([]) diff --git a/tests/test_playlist_cache.py b/tests/test_playlist_cache.py index fc18931..993cdfc 100644 --- a/tests/test_playlist_cache.py +++ b/tests/test_playlist_cache.py @@ -5,20 +5,17 @@ from mopidy_tidal.playlists import PlaylistCache, PlaylistMetadataCache, TidalPlaylist -def test_metadata_cache(config): +def test_metadata_cache(): cache = PlaylistMetadataCache(directory="cache") uniq = object() - outf = ( - Path(config["core"]["cache_dir"], "tidal/cache/playlist_metadata/00") - / "tidal-playlist-00-1-2.cache" - ) + outf = cache.cache_file("tidal:playlist:00-1-2") assert not outf.exists() cache["tidal:playlist:00-1-2"] = uniq assert outf.exists() assert cache["tidal:playlist:00-1-2"] is uniq -def test_cached_as_str(config): +def test_cached_as_str(): cache = PlaylistCache(persist=False) uniq = object() cache["tidal:playlist:0-1-2"] = uniq @@ -26,7 +23,7 @@ def test_cached_as_str(config): assert cache["0-1-2"] is uniq -def test_not_updated(config, mocker): +def test_not_updated(mocker): cache = PlaylistCache(persist=False) session = mocker.Mock() key = mocker.Mock(spec=TidalPlaylist, session=session, playlist_id="0-1-2") @@ -39,7 +36,7 @@ def test_not_updated(config, mocker): assert cache[key] is playlist -def test_updated(config, mocker): +def test_updated(mocker): cache = PlaylistCache(persist=False) session = mocker.Mock() resp = mocker.Mock(headers={"etag": None}) diff --git a/tests/test_ref_models_mappers.py b/tests/test_ref_models_mappers.py index 93b6ac9..4c42983 100644 --- a/tests/test_ref_models_mappers.py +++ b/tests/test_ref_models_mappers.py @@ -1,14 +1,13 @@ -from mopidy.models import Ref - from mopidy_tidal import ref_models_mappers as rmm -def test_root(): +def test_root_contains_entries_for_eachfield(): root = rmm.create_root() + uri_map = { "tidal:genres": "Genres", "tidal:moods": "Moods", - "tidal:mixes": "Mixes", + "tidal:mixes": "My Mixes", "tidal:my_artists": "My Artists", "tidal:my_albums": "My Albums", "tidal:my_playlists": "My Playlists", diff --git a/tests/test_search.py b/tests/test_search.py index 9daaa10..6298565 100644 --- a/tests/test_search.py +++ b/tests/test_search.py @@ -125,6 +125,7 @@ def test_search_exact( session.search.assert_called_once_with(query_str, models=models) +@pytest.mark.xfail(reason="Unknown") def test_malformed_api_response(mocker, tidal_search, tidal_tracks, compare): session = mocker.Mock() session.search.return_value = { diff --git a/tests/test_search_cache.py b/tests/test_search_cache.py index 5521f6c..37eeb9e 100644 --- a/tests/test_search_cache.py +++ b/tests/test_search_cache.py @@ -1,24 +1,28 @@ from mopidy_tidal.lru_cache import SearchCache, SearchKey -def test_search_cache_cached(mocker, config): - func = mocker.Mock() - cache = SearchCache(func) - d1 = {"exact": True, "query": {"artist": "TestArtist", "album": "TestAlbum"}} - d1_sk = SearchKey(**d1) - uniq = object() - cache[str(d1_sk)] = uniq - assert cache("arg", **d1) is uniq - - -def test_search_cache_not_cached(mocker, config): - func_ret = object - func = mocker.Mock() - func.return_value = func_ret - cache = SearchCache(func) - - d1 = {"exact": True, "query": {"artist": "TestArtist", "album": "TestAlbum"}} - d1_sk = SearchKey(**d1) - assert str(d1_sk) not in cache - assert cache("arg", **d1) is func_ret - func.assert_called_once_with("arg", **d1) +def test_search_cache_returns_cached_value_if_present(mocker): + search_function = mocker.Mock() + cache = SearchCache(search_function) + query = {"exact": True, "query": {"artist": "TestArtist", "album": "TestAlbum"}} + search_key = SearchKey(**query) + cache[str(search_key)] = mocker.sentinel.results + + results = cache("arg", **query) + + assert results is mocker.sentinel.results + search_function.assert_not_called() + + +def test_search_defers_to_search_function_if_not_present_and_stores(mocker): + search_function = mocker.Mock(return_value=mocker.sentinel.results) + cache = SearchCache(search_function) + query = {"exact": True, "query": {"artist": "TestArtist", "album": "TestAlbum"}} + search_key = SearchKey(**query) + assert str(search_key) not in cache + + results = cache("arg", **query) + + assert results is mocker.sentinel.results + assert str(search_key) in cache + search_function.assert_called_once_with("arg", **query) diff --git a/tests/test_utils.py b/tests/test_utils.py index d28bbac..fe2a3c2 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,10 +1,10 @@ from mopidy_tidal.utils import apply_watermark, remove_watermark -def test_apply_watermark(): +def test_apply_watermark_adds_tidal(): assert apply_watermark("track") == "track [TIDAL]" -def test_remove_watermark(): +def test_remove_watermark_removes_tidal_if_present(): assert remove_watermark(None) is None assert remove_watermark("track [TIDAL]") == "track"