"""Round 67: friend mode — browsing a friend's library and cassetting songs. * Matching is best effort: exact = artist + album + title + length within 2 s; fuzzy = artist + title. Case, accents, punctuation, "feat." and a leading "The" don't count. * The friend's library is read-only here: no playing, no editing, no dragging their ids into my playlists. * Nothing is saved as you click. Save and Close writes requests.json; Cancel drops everything; leaving any other way with changes asks. * The size readout goes orange when Syncthing would stall (its free-space floor) and red when the songs won't fit. """ import json import pytest from PyQt6.QtCore import Qt from lintunes.cassette import matching, publish, requests as req, share, space from lintunes.cassette.friend_library import load_friend_library from lintunes.cassette.service import CassetteService from lintunes.cassette.state import Friend, Selection, in_dir, out_dir from lintunes.models import Playlist, Track from lintunes.models.library import Library def _t(tid, name, artist, album, ms=200_000, **kw): return Track(track_id=tid, name=name, artist=artist, album=album, total_time=ms, size=5_000_000, **kw) # ---- matching ---- class TestMatching: def test_normalize(self): assert matching.normalize("The Beatles") == "beatles" assert matching.normalize("Beyoncé") == "beyonce" assert matching.normalize("Crazy In Love (feat. Jay-Z)") == "crazy in love" assert matching.normalize("Hey Jude - Remastered 2015") == "hey jude remastered 2015" assert matching.normalize("Hey Jude (Remastered 2015)") == "hey jude" assert matching.normalize("Simon & Garfunkel") == "simon and garfunkel" assert matching.normalize("Song ft. Someone") == "song" def test_exact_needs_album_and_length(self): index = matching.LibraryIndex([_t(1, "Hey Jude", "The Beatles", "1")]) assert index.classify(_t(9, "hey jude", "Beatles", "1", ms=201_500)) == matching.EXACT assert index.classify(_t(9, "Hey Jude", "The Beatles", "1", ms=210_000)) == matching.FUZZY assert index.classify(_t(9, "Hey Jude", "The Beatles", "Past Masters")) == matching.FUZZY assert index.classify(_t(9, "Let It Be", "The Beatles", "1")) is None def test_album_artist_or_artist(self): mine = _t(1, "Track", "Guest", "Comp", album_artist="Various Artists") index = matching.LibraryIndex([mine]) assert index.classify(_t(9, "Track", "Guest", "Comp")) == matching.EXACT def test_unknown_length_still_exact(self): index = matching.LibraryIndex([_t(1, "A", "B", "C", ms=0)]) assert index.classify(_t(9, "A", "B", "C")) == matching.EXACT # ---- space ---- class TestSpace: def test_verdicts(self): gb = 10**9 assert space.verdict(1 * gb, 50 * gb, 50 * gb, 1 * gb) == space.OK assert space.verdict(49 * gb, 50 * gb, 50 * gb, 2 * gb) == space.TIGHT assert space.verdict(60 * gb, 50 * gb, 500 * gb, 1 * gb) == space.FULL def test_human(self): assert space.human(740_000) == "740 KB" assert space.human(1_200_000_000) == "1.2 GB" def test_disk_usage_of_missing_path_uses_its_parent(self, tmp_path): assert space.disk_usage(tmp_path / "not" / "yet") is not None # ---- requests & the friend's copy ---- class TestFiles: def test_requests_round_trip(self, tmp_path): assert req.write_requests(tmp_path, [3, 1, 3]) assert json.loads((tmp_path / req.REQUESTS_FILE).read_text())["tracks"] == [1, 3] assert not req.write_requests(tmp_path, {1, 3}) assert req.read_requests(tmp_path) == {1, 3} def test_garbled_requests_is_empty(self, tmp_path): (tmp_path / req.REQUESTS_FILE).write_text("{half a fi") assert req.read_requests(tmp_path) == set() def test_load_friend_library(self, tmp_path): library = Library() library.tracks[5] = _t(5, "Song", "Artist", "Album", rating=100) library.playlists["P"] = Playlist(name="Mix", persistent_id="P", track_ids=[5]) share.publish_share(tmp_path, library, Selection(all_library=True, all_playlists=True)) publish.publish_intro(tmp_path, "Sam", None) friend = load_friend_library(tmp_path) assert friend.name == "Sam" assert friend.tracks[5].name == "Song" assert friend.tracks[5].rating == 0 # never crossed over assert friend.playlists[0].name == "Mix" assert friend.playlists[0].track_ids == [5] def test_missing_or_garbled_is_empty(self, tmp_path): assert load_friend_library(tmp_path, "Sam").tracks == {} (tmp_path / share.LIBRARY_FILE).write_text("[1, 2") (tmp_path / share.PLAYLISTS_FILE).write_text(json.dumps( {"playlists": [{"id": "P", "track_ids": ["x", 99]}]})) friend = load_friend_library(tmp_path, "Sam") assert friend.name == "Sam" and friend.tracks == {} assert friend.playlists[0].track_ids == [] # ---- read-only table and the cassette column ---- class TestTable: def test_read_only_never_plays_or_copies(self, qapp): from lintunes.gui.track_table import TrackTableView table = TrackTableView() table.set_read_only(True) table.set_tracks([_t(1, "A", "B", "C")]) played = [] table.play_requested.connect(lambda *a: played.append(a)) table._on_double_click(table.model_.index(0, 0)) assert played == [] assert not table.dragEnabled() and not table.acceptDrops() def test_cassette_column_click_toggles(self, qapp): from PyQt6.QtCore import QPointF from PyQt6.QtGui import QMouseEvent from lintunes.gui.track_table import CASSETTE_FIELD, TrackTableView table = TrackTableView() on = set() table.cassette_hooks = (lambda tid: "on" if tid in on else "off", lambda tid: on.symmetric_difference_update({tid})) table.model_.set_visible_columns([CASSETTE_FIELD, "name"]) table.set_tracks([_t(7, "A", "B", "C")]) table.resize(400, 200) table.show() rect = table.visualRect(table.model_.index(0, 0)) event = QMouseEvent(QMouseEvent.Type.MouseButtonPress, QPointF(rect.center()), QPointF(rect.center()), Qt.MouseButton.LeftButton, Qt.MouseButton.LeftButton, Qt.KeyboardModifier.NoModifier) table.mousePressEvent(event) assert on == {7} assert table.cassette_state_at(table.model_.index(0, 0)) == "on" # ---- friend mode itself ---- class _Window: """What FriendMode touches on MainWindow, and nothing more.""" def __init__(self): from PyQt6.QtWidgets import QMainWindow, QStackedWidget from lintunes.gui.sidebar import SidebarPanel from unittest.mock import MagicMock self.main = QMainWindow() self.content_stack = QStackedWidget() self.main.setCentralWidget(self.content_stack) manager = MagicMock() manager.library.playlists = {} player = MagicMock() self.sidebar = SidebarPanel(manager, player) self.shown_mine = 0 def statusBar(self): return self.main.statusBar() def show_my_library(self): self.shown_mine += 1 @pytest.fixture def visit(qapp, tmp_path): from lintunes.gui.friend_mode import FriendMode friend_lib = Library() for tid, (name, artist, album) in enumerate( [("Hey Jude", "The Beatles", "1"), ("Let It Be", "The Beatles", "1"), ("Blackbird", "The Beatles", "White"), ("Doo Wop", "Lauryn Hill", "Mis")], start=10): friend_lib.tracks[tid] = _t(tid, name, artist, album) friend_lib.playlists["P"] = Playlist(name="Road Trip", persistent_id="P", track_ids=[13, 10]) service = CassetteService(root=tmp_path, config_loader=lambda: {}) service._state.friends["tok"] = Friend(token="tok", device_id="X", name="Sam", in_folder="f-in") share.publish_share(in_dir(tmp_path, "tok"), friend_lib, Selection(all_library=True, all_playlists=True)) mine = Library() mine.tracks[1] = _t(1, "Hey Jude", "Beatles", "1") # exact mine.tracks[2] = _t(2, "Blackbird", "The Beatles", "Other") # fuzzy window = _Window() mode = FriendMode(window.main, service, mine, tmp_path) mode._window = window mode.enter("tok") return mode, window, service class TestFriendMode: def test_enter_shows_their_library_purple(self, visit): mode, window, _ = visit assert window.sidebar.friend_mode assert window.sidebar.library_button.text() == "Sam's Library" assert window.content_stack.currentWidget() is mode._view assert mode._view.table.read_only assert mode.matches[10] == matching.EXACT assert mode.matches[12] == matching.FUZZY assert mode.matches[13] is None def test_artist_cassettes_down_the_tree(self, visit): mode, _, _ = visit ids = mode._view.group_track_ids("artist", "The Beatles") mode.toggle(ids) assert mode.cassetted == {10, 11, 12} assert mode.state_of([10, 13]) == "partial" mode.toggle(ids) assert mode.cassetted == set() def test_nothing_saved_until_save(self, visit, tmp_path): mode, window, service = visit mode.toggle([13]) mode._toggle_follow("P") assert not (out_dir(tmp_path, "tok") / req.REQUESTS_FILE).exists() assert service.state().friends["tok"].cassetted == [] assert mode.is_dirty() mode.save_and_close() friend = service.state().friends["tok"] assert friend.cassetted == [13] and friend.followed == ["P"] # the followed playlist's songs are asked for too assert req.read_requests(out_dir(tmp_path, "tok")) == {10, 13} assert not mode.active and not window.sidebar.friend_mode assert window.shown_mine == 1 def test_cancel_discards(self, visit, tmp_path): mode, _, service = visit mode.toggle([13]) mode.cancel() assert service.state().friends["tok"].cassetted == [] assert not (out_dir(tmp_path, "tok") / req.REQUESTS_FILE).exists() def test_leaving_with_changes_asks(self, visit, monkeypatch): from lintunes.gui import friend_mode mode, _, service = visit mode.toggle([11]) monkeypatch.setattr(friend_mode, "ask_save_changes", lambda parent: friend_mode.STAY) assert not mode.confirm_leave() and mode.active monkeypatch.setattr(friend_mode, "ask_save_changes", lambda parent: friend_mode.DISCARD) assert mode.confirm_leave() and not mode.active assert service.state().friends["tok"].cassetted == [] def test_leaving_clean_never_asks(self, visit, monkeypatch): from lintunes.gui import friend_mode mode, _, _ = visit monkeypatch.setattr(friend_mode, "ask_save_changes", lambda parent: pytest.fail("asked")) assert mode.confirm_leave() def test_hide_tracks_i_have(self, visit): mode, _, _ = visit mode.hide_have.setChecked(True) shown = {t.track_id for t in mode._view.table.model_.tracks} assert 10 not in shown # exact: hidden assert {11, 12, 13} <= shown # fuzzy stays (grey) grey = mode._foreground(mode.library.tracks[12]) assert grey is not None def test_their_playlist_in_order(self, visit): mode, window, _ = visit mode._show_playlist("P") assert window.content_stack.currentWidget() is mode._pane assert [t.track_id for t in mode._pane.table.model_.tracks] == [13, 10] assert mode._pane.table.read_only def test_space_turns_red(self, visit, monkeypatch): mode, _, _ = visit from collections import namedtuple usage = namedtuple("usage", "total used free") monkeypatch.setattr(space, "disk_usage", lambda path: usage(100_000_000, 99_000_000, 1_000_000)) mode.toggle([10, 11]) assert mode.space_state()[0] == space.FULL assert "#d0342c" in mode.size_label.styleSheet() class TestSelector: def test_hidden_until_there_is_a_friend(self, qapp): from unittest.mock import MagicMock from lintunes.gui.sidebar import SidebarPanel manager = MagicMock() manager.library.playlists = {} sidebar = SidebarPanel(manager, MagicMock()) assert sidebar.friend_button.isHidden() sidebar.set_friend_selector_visible(True) assert not sidebar.friend_button.isHidden() assert "border-top-right-radius: 0" in sidebar.library_button.styleSheet()