"""Round 35: startup speed. The track table used to sort through a QSortFilterProxyModel, which called data() once per comparison — 8.5 s to show a 21k-track library. It now sorts itself over a precomputed key list. Smart-playlist rules are compiled to closures once per evaluate() instead of being re-dispatched per track, and .resolved snapshots are pruned instead of growing without bound. """ from datetime import datetime from pathlib import Path import pytest from PyQt6.QtCore import Qt from lintunes.models import Track from lintunes.gui.track_table import TrackTableView, INDEX_FIELD def _tracks(rows): return [Track(track_id=tid, name=name, artist=artist, play_count=plays) for tid, name, artist, plays in rows] @pytest.fixture def view(qapp): table = TrackTableView() table.model_.set_visible_columns(["name", "artist", "play_count"]) return table # ---- in-model sorting ------------------------------------------------------- class TestSorting: ROWS = [(1, "Ship", "Zoe", 3), (2, "Black Blood", "adele", 10), (3, "Thunder Peel", "Bob", 10), (4, "Sev Beni", None, 1)] def test_sorts_case_insensitively(self, view): view.set_tracks(_tracks(self.ROWS)) view.sortByColumn(view.model_.fields.index("artist"), Qt.SortOrder.AscendingOrder) # None sorts with the empty string, then adele < Bob < Zoe (casefolded: # a plain str sort would have put "Bob" and "Zoe" before "adele"). assert view.view_order_track_ids() == [4, 2, 3, 1] def test_descending_reverses(self, view): view.set_tracks(_tracks(self.ROWS)) view.sortByColumn(view.model_.fields.index("artist"), Qt.SortOrder.DescendingOrder) assert view.view_order_track_ids() == [1, 3, 2, 4] def test_sort_is_stable_within_ties(self, view): view.set_tracks(_tracks(self.ROWS)) view.sortByColumn(view.model_.fields.index("play_count"), Qt.SortOrder.AscendingOrder) # 2 and 3 both have 10 plays and keep their given order. assert view.view_order_track_ids() == [4, 1, 2, 3] def test_mixed_none_and_numbers_do_not_raise(self, view): view.set_tracks([Track(track_id=1, name="A", year=None), Track(track_id=2, name="B", year=1999)]) view.model_.set_visible_columns(["name", "year"]) view.sortByColumn(view.model_.fields.index("year"), Qt.SortOrder.AscendingOrder) assert view.view_order_track_ids() == [1, 2] def test_set_tracks_keeps_the_current_sort(self, view): view.set_tracks(_tracks(self.ROWS)) view.sortByColumn(view.model_.fields.index("artist"), Qt.SortOrder.AscendingOrder) view.set_tracks(_tracks(self.ROWS[:2])) assert view.view_order_track_ids() == [2, 1] def test_selection_survives_a_re_sort(self, view): view.set_tracks(_tracks(self.ROWS)) view.sortByColumn(view.model_.fields.index("artist"), Qt.SortOrder.AscendingOrder) view.selectRow(0) # track 4 assert view.selected_track_ids() == [4] view.sortByColumn(view.model_.fields.index("artist"), Qt.SortOrder.DescendingOrder) assert view.selected_track_ids() == [4] class TestManualOrder: """A playlist's canonical order must survive sorting: '#' restores it, and 'source rows' stay playlist positions so drag-reorder targets the right tracks.""" ROWS = [(1, "C", "c", 0), (2, "A", "a", 0), (3, "B", "b", 0)] @pytest.fixture def playlist_view(self, qapp): table = TrackTableView(playlist_mode=True) table.model_.set_visible_columns(["name"]) table.set_tracks(_tracks(self.ROWS)) return table def test_index_column_restores_manual_order(self, playlist_view): playlist_view.sortByColumn(playlist_view.model_.fields.index("name"), Qt.SortOrder.AscendingOrder) assert playlist_view.view_order_track_ids() == [2, 3, 1] playlist_view.sortByColumn( playlist_view.model_.fields.index(INDEX_FIELD), Qt.SortOrder.AscendingOrder) assert playlist_view.view_order_track_ids() == [1, 2, 3] assert playlist_view.is_manual_sort() def test_selection_maps_to_canonical_rows_while_sorted(self, playlist_view): playlist_view.sortByColumn(playlist_view.model_.fields.index("name"), Qt.SortOrder.AscendingOrder) playlist_view.selectRow(0) # displays "A" = track 2, playlist row 1 assert playlist_view.selected_source_rows() == [1] assert playlist_view.selected_track_ids() == [2] def test_index_column_numbers_the_displayed_rows(self, playlist_view): playlist_view.sortByColumn(playlist_view.model_.fields.index("name"), Qt.SortOrder.AscendingOrder) model = playlist_view.model_ col = model.fields.index(INDEX_FIELD) assert [model.data(model.index(row, col)) for row in range(3)] == [1, 2, 3] def test_reveal_track_finds_a_sorted_row(self, playlist_view): playlist_view.sortByColumn(playlist_view.model_.fields.index("name"), Qt.SortOrder.AscendingOrder) assert playlist_view.reveal_track(1) # "C", last when sorted assert playlist_view.selected_track_ids() == [1] # ---- compiled smart rules --------------------------------------------------- class TestSmartCompilation: """The rewrite is only safe if it matches the old evaluator exactly.""" def _criteria(self, rules, conjunction="all"): from lintunes.smart import SmartCriteria, SmartGroup, SmartRule return SmartCriteria(root=SmartGroup( conjunction=conjunction, children=[SmartRule(**r) for r in rules])) def test_string_match_is_case_insensitive(self): from lintunes import smart tracks = [Track(track_id=1, name="A", artist="Björk"), Track(track_id=2, name="B", artist="bjork")] criteria = self._criteria( [dict(field="artist", operator="contains", value="BJÖ")]) assert smart.evaluate(criteria, tracks) == [1] def test_date_rule_constant_is_parsed_once_not_per_track(self): from lintunes import smart tracks = [Track(track_id=1, name="A", play_date_utc="2026-01-01T00:00:00"), Track(track_id=2, name="B", play_date_utc="2020-01-01T00:00:00"), Track(track_id=3, name="C", play_date_utc=None)] criteria = self._criteria([dict(field="last_played", operator="after", value="2025-01-01T00:00:00")]) assert smart.evaluate(criteria, tracks) == [1] # A null date is the distant past: "before" matches it, "after" doesn't. criteria = self._criteria([dict(field="last_played", operator="before", value="2025-01-01T00:00:00")]) assert smart.evaluate(criteria, tracks) == [2, 3] def test_unparseable_date_constant_matches_nothing(self): from lintunes import smart tracks = [Track(track_id=1, name="A", play_date_utc="2026-01-01T00:00:00")] criteria = self._criteria([dict(field="last_played", operator="after", value="not a date")]) assert smart.evaluate(criteria, tracks) == [] def test_in_last_window(self): from lintunes import smart now = datetime(2026, 8, 19, 12, 0, 0) tracks = [Track(track_id=1, name="A", play_date_utc="2026-08-18T12:00:00"), Track(track_id=2, name="B", play_date_utc="2026-01-01T00:00:00"), Track(track_id=3, name="C", play_date_utc=None)] criteria = self._criteria([dict(field="last_played", operator="in_last", value="7", unit="days")]) assert smart.evaluate(criteria, tracks, now=now) == [1] criteria = self._criteria([dict(field="last_played", operator="not_in_last", value="7", unit="days")]) assert smart.evaluate(criteria, tracks, now=now) == [2, 3] def test_any_conjunction(self): from lintunes import smart tracks = [Track(track_id=1, name="A", play_count=50), Track(track_id=2, name="B", artist="Zoe"), Track(track_id=3, name="C")] criteria = self._criteria( [dict(field="play_count", operator="greater", value="10"), dict(field="artist", operator="is", value="Zoe")], conjunction="any") assert smart.evaluate(criteria, tracks) == [1, 2] def test_numeric_range_and_bad_values(self): from lintunes import smart tracks = [Track(track_id=1, name="A", play_count=5), Track(track_id=2, name="B", play_count=50)] criteria = self._criteria([dict(field="play_count", operator="in_range", value="40", value2="60")]) assert smart.evaluate(criteria, tracks) == [2] criteria = self._criteria([dict(field="play_count", operator="greater", value="not a number")]) assert smart.evaluate(criteria, tracks) == [] # ---- .resolved pruning ------------------------------------------------------ class TestBackupPruning: def test_keeps_only_the_newest_snapshots(self, tmp_path): from lintunes.storage import conflict_resolver root = tmp_path / ".resolved" for stamp in ("20260101-000000", "20260102-000000", "20260103-000000"): (root / stamp / "original").mkdir(parents=True) (root / stamp / "original" / "library.json").write_text("{}") conflict_resolver._prune_backups(tmp_path, keep=2) assert sorted(d.name for d in root.iterdir()) == ["20260102-000000", "20260103-000000"] def test_prune_runs_even_with_no_conflicts(self, tmp_path): from lintunes.storage import conflict_resolver root = tmp_path / ".resolved" for i in range(conflict_resolver.BACKUPS_TO_KEEP + 3): (root / f"2026010{i // 10}-00000{i % 10}").mkdir(parents=True) assert conflict_resolver.resolve_conflicts(tmp_path) == [] assert len(list(root.iterdir())) == conflict_resolver.BACKUPS_TO_KEEP def test_missing_backup_dir_is_fine(self, tmp_path): from lintunes.storage import conflict_resolver conflict_resolver._prune_backups(tmp_path) # must not raise