Import existing LinTunes project
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>
This commit is contained in:
254
tests/test_round8.py
Normal file
254
tests/test_round8.py
Normal file
@ -0,0 +1,254 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user