Snapshot of the existing codebase before working through the TASKS.md backlog. Real library data (data/) and the iTunes import fixture (itunes-test-library/) are gitignored. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
255 lines
11 KiB
Python
255 lines
11 KiB
Python
"""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)
|