"""Tests for the wishlist-cycle album grouping helper that drives the per-album bundle dispatch. Pins the bucketing contract so future changes to the dispatch flow don't silently regress the user-visible behavior: wishlist 'albums' cycle should emit one album-bundle search per missing album, not one per missing track. """ from __future__ import annotations from core.wishlist.album_grouping import ( WishlistAlbumGroup, WishlistGroupingResult, group_wishlist_tracks_by_album, ) def _wt(track_name, artist, album_id, album_name, **extra): """Build a wishlist row in the shape the wishlist service returns.""" return { 'track_name': track_name, 'artist_name': artist, 'spotify_data': { 'name': track_name, 'artists': [{'name': artist}], 'album': { 'id': album_id, 'name': album_name, **extra, }, }, } def test_empty_input_returns_empty_result(): res = group_wishlist_tracks_by_album([]) assert res.album_groups == [] assert res.residual_tracks == [] def test_single_album_groups_all_tracks_together(): tracks = [ _wt('Dragon Soul', 'Ryoto', 'alb1', 'Cha-La Head-Cha-La'), _wt('Cha-La Head-Cha-La', 'Ryoto', 'alb1', 'Cha-La Head-Cha-La'), _wt('Zenkai Power', 'Ryoto', 'alb1', 'Cha-La Head-Cha-La'), ] res = group_wishlist_tracks_by_album(tracks) assert len(res.album_groups) == 1 g = res.album_groups[0] assert g.album_key == 'alb1' assert g.album_context['name'] == 'Cha-La Head-Cha-La' assert g.artist_context['name'] == 'Ryoto' assert len(g.tracks) == 3 def test_multiple_albums_emit_separate_groups(): """Two tracks in alb1 promotes that album to a group at the default threshold of 2; alb2's one solo track falls to residual.""" tracks = [ _wt('Song A', 'Artist 1', 'alb1', 'Album 1'), _wt('Song B', 'Artist 1', 'alb1', 'Album 1'), _wt('Song C', 'Artist 2', 'alb2', 'Album 2'), ] res = group_wishlist_tracks_by_album(tracks) assert len(res.album_groups) == 1 assert res.album_groups[0].album_key == 'alb1' assert len(res.album_groups[0].tracks) == 2 assert len(res.residual_tracks) == 1 assert res.residual_tracks[0]['track_name'] == 'Song C' def test_missing_album_metadata_falls_through_to_residual(): tracks = [ # No spotify_data.album at all {'track_name': 'Orphan', 'artist_name': 'X', 'spotify_data': {'artists': [{'name': 'X'}]}}, # Empty album dict {'track_name': 'Empty Album', 'artist_name': 'X', 'spotify_data': {'album': {}, 'artists': [{'name': 'X'}]}}, ] res = group_wishlist_tracks_by_album(tracks) assert res.album_groups == [] assert len(res.residual_tracks) == 2 def test_missing_artist_demotes_to_residual(): """Album-bundle search needs an artist; if we can't recover one, skip the bundle path and let the track go through per-track.""" tracks = [{ 'track_name': 'Song', 'spotify_data': { 'artists': [], 'album': {'id': 'a', 'name': 'Album'}, }, }] res = group_wishlist_tracks_by_album(tracks) assert res.album_groups == [] assert res.residual_tracks == tracks def test_min_tracks_threshold_demotes_solos(): """When ``min_tracks_per_album=2`` (the default), single-track albums fall to residual so the user doesn't fire a bundle search for a 1-track rip when per-track would do.""" tracks = [ _wt('Solo Track', 'Artist 1', 'alb1', 'Album 1'), _wt('Song A', 'Artist 2', 'alb2', 'Album 2'), _wt('Song B', 'Artist 2', 'alb2', 'Album 2'), ] res = group_wishlist_tracks_by_album(tracks, min_tracks_per_album=2) assert len(res.album_groups) == 1 assert res.album_groups[0].album_key == 'alb2' assert len(res.residual_tracks) == 1 assert res.residual_tracks[0]['track_name'] == 'Solo Track' def test_default_threshold_demotes_solo_albums(): """Default ``min_tracks_per_album=2`` — a wishlist with one missing track from one album falls to residual so the per-track flow handles it instead of engaging album-bundle for a single file. Real-world reason: typical wishlist looks like "26 single tracks from 26 different albums" and bundling each one downloads ~85% bandwidth as unwanted files, hammers slskd with concurrent searches, and re-downloads the same album every cycle when the staging-match step doesn't claim the requested track.""" tracks = [_wt('Solo', 'Artist 1', 'alb1', 'Album 1')] res = group_wishlist_tracks_by_album(tracks) assert res.album_groups == [] assert len(res.residual_tracks) == 1 assert res.residual_tracks[0]['track_name'] == 'Solo' def test_default_threshold_promotes_multi_track_albums(): """Default still promotes albums with 2+ missing tracks — the album-bundle path is the right tool when several files from the same release are missing (downloading 5 tracks to claim 2 still beats five separate per-track searches against the same album).""" tracks = [ _wt('Song A', 'Artist', 'alb1', 'Album'), _wt('Song B', 'Artist', 'alb1', 'Album'), ] res = group_wishlist_tracks_by_album(tracks) assert len(res.album_groups) == 1 assert len(res.album_groups[0].tracks) == 2 assert res.residual_tracks == [] def test_explicit_threshold_one_restores_solo_promotion(): """Power users / tests can opt back into the old "bundle even one track" behaviour by passing ``min_tracks_per_album=1`` explicitly. Default change is opt-out via the public kwarg, not a removed capability.""" tracks = [_wt('Solo', 'Artist 1', 'alb1', 'Album 1')] res = group_wishlist_tracks_by_album(tracks, min_tracks_per_album=1) assert len(res.album_groups) == 1 assert res.residual_tracks == [] def test_album_without_id_uses_name_normalized_key(): """Some older wishlist rows are missing the album id. Group by a name-normalized key so they still bucket together.""" tracks = [ _wt('S1', 'Artist', None, 'Same Album'), _wt('S2', 'Artist', None, 'Same Album'), ] # First track has explicit id=None which is filtered; the fallback # is ``_name_``. Build manually so the # helper sees no id at all. for t in tracks: del t['spotify_data']['album']['id'] res = group_wishlist_tracks_by_album(tracks) assert len(res.album_groups) == 1 assert res.album_groups[0].album_key == '_name_same album' assert len(res.album_groups[0].tracks) == 2 def test_nested_track_data_payloads_normalized(): """The wishlist service sometimes nests spotify_data under track_data (JSON-string in DB → re-parsed). Ensure the grouper digs through the same shapes ``classify_wishlist_track`` does.""" tracks = [{ 'track_data': { 'spotify_data': { 'artists': [{'name': 'Artist'}], 'album': {'id': 'a', 'name': 'Album'}, }, }, }] # Use threshold=1 here — the test is about payload-shape parsing, # not the threshold rule, so don't conflate the two. res = group_wishlist_tracks_by_album(tracks, min_tracks_per_album=1) assert len(res.album_groups) == 1 assert res.album_groups[0].album_key == 'a'