"""Round 8: the four 'Open / future' features. 1. "Show in Playlist…" — LibraryManager.playlists_containing. 2. Multi-track Get Info — LibraryManager.edit_tracks_fields (one composite undo, only genuinely-changed fields per track). 3. Library search — the _matches_search predicate behind the filter box. 4. Custom start/stop times — Get Info time parsing/formatting, library-only persistence, and the player honoring them during playback. """ from unittest.mock import MagicMock, patch from lintunes.models import Library, Track, Playlist, PlaylistType from lintunes.library_manager import LibraryManager from lintunes.gui.library_view import _matches_search from lintunes.gui.info_dialog import _format_ms, _parse_time_to_ms from lintunes import player as player_module from lintunes.player import Player def _track(tid, **kw): return Track(track_id=tid, name=kw.pop("name", f"Track {tid}"), **kw) # -------------------------------------------------------------------------- # 1. Show in Playlist # -------------------------------------------------------------------------- class TestPlaylistsContaining: def _manager(self, tmp_path): tracks = {i: _track(i) for i in (1, 2, 3)} library = Library(tracks=tracks) # Two regular playlists, a folder, and a smart playlist. library.playlists = { "P1": Playlist(name="Beta", persistent_id="P1", playlist_type=PlaylistType.REGULAR, track_ids=[1, 2]), "P2": Playlist(name="alpha", persistent_id="P2", playlist_type=PlaylistType.REGULAR, track_ids=[2]), "F1": Playlist(name="Folder", persistent_id="F1", playlist_type=PlaylistType.FOLDER, track_ids=[1]), "S1": Playlist(name="Smart", persistent_id="S1", playlist_type=PlaylistType.SMART, track_ids=[1]), } return LibraryManager(library, tmp_path) def test_lists_only_regular_playlists_with_the_track(self, qapp, tmp_path): manager = self._manager(tmp_path) # Track 1 is in P1 (regular), F1 (folder), S1 (smart): only P1 qualifies. assert manager.playlists_containing(1) == [("P1", "Beta")] def test_sorted_by_name_casefolded(self, qapp, tmp_path): manager = self._manager(tmp_path) # Track 2 is in both regular playlists; "alpha" sorts before "Beta". assert manager.playlists_containing(2) == [("P2", "alpha"), ("P1", "Beta")] def test_track_in_no_playlist_is_empty(self, qapp, tmp_path): manager = self._manager(tmp_path) assert manager.playlists_containing(3) == [] # -------------------------------------------------------------------------- # 2. Multi-track Get Info # -------------------------------------------------------------------------- class TestEditTracksFields: def _manager(self, tmp_path): tracks = { 1: _track(1, artist="A", album="Old", genre="Rock"), 2: _track(2, artist="B", album="New", genre="Rock"), 3: _track(3, artist="C", album="Old", genre="Jazz"), } # No file locations, so _write_track_tags is a no-op success. return LibraryManager(Library(tracks=tracks), tmp_path), tracks def test_applies_to_all_selected(self, qapp, tmp_path): manager, tracks = self._manager(tmp_path) manager.edit_tracks_fields([1, 2, 3], {"genre": "Pop"}) assert all(t.genre == "Pop" for t in tracks.values()) def test_one_composite_undo_reverts_everything(self, qapp, tmp_path): manager, tracks = self._manager(tmp_path) manager.edit_tracks_fields([1, 2, 3], {"genre": "Pop"}) # A single undo entry, not one per track. assert manager.undo_stack.can_undo() manager.undo_stack.undo() assert [t.genre for t in (tracks[1], tracks[2], tracks[3])] == \ ["Rock", "Rock", "Jazz"] assert not manager.undo_stack.can_undo() def test_only_changed_fields_per_track(self, qapp, tmp_path): manager, tracks = self._manager(tmp_path) edited = [] manager.track_fields_edited.connect(lambda tid, f: edited.append(tid)) # album="Old" already matches tracks 1 and 3, so only track 2 changes. manager.edit_tracks_fields([1, 2, 3], {"album": "Old"}) assert edited == [2] assert tracks[2].album == "Old" def test_no_change_pushes_no_undo(self, qapp, tmp_path): manager, tracks = self._manager(tmp_path) manager.edit_tracks_fields([1, 2, 3], {"genre": "Rock", "album": "Old"}) # Tracks 1 & 3 already match on both touched fields; track 2 changes # genre? no — genre Rock matches track 2 too. album New != Old -> change. # So track 2 changes album only; an undo IS pushed. manager.undo_stack.undo() assert tracks[2].album == "New" def test_truly_noop_pushes_nothing(self, qapp, tmp_path): manager, tracks = self._manager(tmp_path) manager.edit_tracks_fields([1], {"genre": "Rock"}) # already Rock assert not manager.undo_stack.can_undo() # -------------------------------------------------------------------------- # 3. Library search predicate # -------------------------------------------------------------------------- class TestSearchMatch: def test_substring_case_insensitive_across_fields(self): track = _track(1, name="Yesterday", artist="The Beatles", album="Help!", year=1965) assert _matches_search(track, ["beat"]) # artist substring assert _matches_search(track, ["YESTER"]) # name, case-folded assert _matches_search(track, ["1965"]) # numeric field stringified def test_all_tokens_must_match_and(self): track = _track(1, name="Yesterday", artist="The Beatles", year=1965) assert _matches_search(track, ["beatles", "1965"]) assert not _matches_search(track, ["beatles", "1999"]) def test_empty_tokens_match_everything(self): assert _matches_search(_track(1), []) def test_no_match(self): track = _track(1, name="Yesterday", artist="The Beatles") assert not _matches_search(track, ["zzdoesnotexist"]) # -------------------------------------------------------------------------- # 4. Custom start/stop times # -------------------------------------------------------------------------- class TestTimeParsing: def test_format_round_seconds(self): assert _format_ms(30_000) == "0:30" assert _format_ms(90_000) == "1:30" assert _format_ms(0) == "" def test_format_with_millis(self): assert _format_ms(30_500) == "0:30.500" def test_parse_variants(self): assert _parse_time_to_ms("0:30") == 30_000 assert _parse_time_to_ms("1:30") == 90_000 assert _parse_time_to_ms("0:30.5") == 30_500 assert _parse_time_to_ms("") == 0 def test_parse_invalid_is_none(self): assert _parse_time_to_ms("abc") is None def test_round_trip(self): for ms in (15_000, 62_250, 125_000): assert _parse_time_to_ms(_format_ms(ms)) == ms class TestStartStopPersistence: def test_times_persist_to_library_json(self, qapp, tmp_path): track = _track(1, total_time=200_000) # no location -> no file write manager = LibraryManager(Library(tracks={1: track}), tmp_path) manager.edit_track_fields(1, {"start_time": 30_000, "stop_time": 60_000}) assert track.start_time == 30_000 and track.stop_time == 60_000 manager.flush() # Reload from disk and confirm they survived. from lintunes.storage import json_storage reloaded = json_storage.load_library(tmp_path) assert reloaded.tracks[1].start_time == 30_000 assert reloaded.tracks[1].stop_time == 60_000 class TestPlayerStartStop: def _player(self, qapp, tracks): library = Library() for t in tracks: library.tracks[t.track_id] = t manager = MagicMock() manager.library = library with patch.multiple( player_module, QMediaPlayer=MagicMock(), QAudioOutput=MagicMock(), QAudioBufferOutput=MagicMock(), QMediaDevices=MagicMock(), ): player = Player(manager) return player, manager def test_stop_time_arms_only_when_before_end(self, qapp, tmp_path): loc = str(tmp_path / "a.mp3") (tmp_path / "a.mp3").write_bytes(b"x") track = _track(1, location=loc, total_time=200_000, stop_time=60_000) player, _ = self._player(qapp, [track]) player.play_queue([1], 0) assert player._stop_at_ms == 60_000 def test_stop_time_at_or_past_end_is_disabled(self, qapp, tmp_path): loc = str(tmp_path / "a.mp3") (tmp_path / "a.mp3").write_bytes(b"x") track = _track(1, location=loc, total_time=60_000, stop_time=60_000) player, _ = self._player(qapp, [track]) player.play_queue([1], 0) assert player._stop_at_ms == 0 def test_position_past_stop_records_play_and_advances(self, qapp, tmp_path): for name in ("a.mp3", "b.mp3"): (tmp_path / name).write_bytes(b"x") t1 = _track(1, location=str(tmp_path / "a.mp3"), total_time=200_000, stop_time=60_000) t2 = _track(2, location=str(tmp_path / "b.mp3"), total_time=200_000) player, manager = self._player(qapp, [t1, t2]) finished = [] player.track_finished.connect(lambda t: finished.append(t.track_id)) player.play_queue([1, 2], 0) player._on_position(59_999) # before stop: nothing assert manager.record_play.call_count == 0 player._on_position(60_001) # crosses the stop time manager.record_play.assert_called_once_with(1) assert finished == [1] assert player.current_track.track_id == 2 # advanced def test_note_finished_guards_double_count(self, qapp, tmp_path): (tmp_path / "a.mp3").write_bytes(b"x") track = _track(1, location=str(tmp_path / "a.mp3"), total_time=200_000) player, manager = self._player(qapp, [track]) player._current_track = track player._counted_finish_id = None player._note_finished(track) player._note_finished(track) # same track again manager.record_play.assert_called_once_with(1) def test_loaded_media_seeks_to_pending_start(self, qapp, tmp_path): (tmp_path / "a.mp3").write_bytes(b"x") track = _track(1, location=str(tmp_path / "a.mp3"), total_time=200_000, start_time=30_000) player, _ = self._player(qapp, [track]) player.play_queue([1], 0) assert player._pending_start_ms == 30_000 player._on_media_status(player_module.QMediaPlayer.MediaStatus.LoadedMedia) player._media.setPosition.assert_called_with(30_000) assert player._pending_start_ms == 0 # consumed (one-shot)