diff --git a/test/dbcore/test_sort.py b/test/dbcore/test_sort.py index d87784d199..d0a97497d1 100644 --- a/test/dbcore/test_sort.py +++ b/test/dbcore/test_sort.py @@ -15,550 +15,257 @@ """Various tests for querying the library database.""" import os -from unittest.mock import patch + +import pytest import beets.library -from beets import config, util +from beets import util from beets.dbcore import types from beets.dbcore.query import TrueQuery -from beets.dbcore.sort import FixedFieldSort, MultipleSort, SlowFieldSort -from beets.library import Album +from beets.dbcore.sort import FixedFieldSort, SlowFieldSort +from beets.library import Album, Item from beets.test import _common -from beets.test.helper import BeetsTestCase + +_p = pytest.param def abs_test_path(path: str) -> str: return os.fsdecode(util.normpath(path)) -# A test case class providing a library with some dummy data and some -# assertions involving that data. -class DummyDataTestCase(BeetsTestCase): - def setUp(self): - super().setUp() +@pytest.fixture(scope="class") +def helper(class_helper): + return class_helper - albums = [ - Album( - album="Album A", - genres=["Rock"], - year=2001, - flex1="Flex1-1", - flex2="Flex2-A", - albumartist="Foo", - ), - Album( - album="Album B", - genres=["Rock"], - year=2001, - flex1="Flex1-2", - flex2="Flex2-A", - albumartist="Bar", - ), + +@pytest.fixture(scope="class") +def setup_library(request: pytest.FixtureRequest, helper): + album_ids = [ + helper.lib.add( Album( - album="Album C", - genres=["Jazz"], - year=2005, - flex1="Flex1-1", - flex2="Flex2-B", - albumartist="Baz", - ), - ] - for album in albums: - self.lib.add(album) - - items = [_common.item() for _ in range(4)] - items[0].title = "Foo bar" - items[0].artist = "One" - items[0].album = "Baz" - items[0].year = 2001 - items[0].comp = True - items[0].flex1 = "Flex1-0" - items[0].flex2 = "Flex2-A" - items[0].album_id = albums[0].id - items[0].artist_sort = None - items[0].path = abs_test_path("/path0.mp3") - items[0].track = 1 - items[1].title = "Baz qux" - items[1].artist = "Two" - items[1].album = "Baz" - items[1].year = 2002 - items[1].comp = True - items[1].flex1 = "Flex1-1" - items[1].flex2 = "Flex2-A" - items[1].album_id = albums[0].id - items[1].artist_sort = None - items[1].path = abs_test_path("/patH1.mp3") - items[1].track = 2 - items[2].title = "Beets 4 eva" - items[2].artist = "Three" - items[2].album = "Foo" - items[2].year = 2003 - items[2].comp = False - items[2].flex1 = "Flex1-2" - items[2].flex2 = "Flex1-B" - items[2].album_id = albums[1].id - items[2].artist_sort = None - items[2].path = abs_test_path("/paTH2.mp3") - items[2].track = 3 - items[3].title = "Beets 4 eva" - items[3].artist = "Three" - items[3].album = "Foo2" - items[3].year = 2004 - items[3].comp = False - items[3].flex1 = "Flex1-2" - items[3].flex2 = "Flex1-C" - items[3].album_id = albums[2].id - items[3].artist_sort = None - items[3].path = abs_test_path("/PATH3.mp3") - items[3].track = 4 - for item in items: - self.lib.add(item) - - -class SortFixedFieldTest(DummyDataTestCase): - def test_sort_asc(self): - q = "" - sort = FixedFieldSort("year", True) - results = self.lib.items(q, sort) - assert results[0]["year"] <= results[1]["year"] - assert results[0]["year"] == 2001 - # same thing with query string - q = "year+" - results2 = self.lib.items(q) - for r1, r2 in zip(results, results2): - assert r1.id == r2.id - - def test_sort_desc(self): - q = "" - sort = FixedFieldSort("year", False) - results = self.lib.items(q, sort) - assert results[0]["year"] >= results[1]["year"] - assert results[0]["year"] == 2004 - # same thing with query string - q = "year-" - results2 = self.lib.items(q) - for r1, r2 in zip(results, results2): - assert r1.id == r2.id - - def test_sort_two_field_asc(self): - q = "" - s1 = FixedFieldSort("album", True) - s2 = FixedFieldSort("year", True) - sort = MultipleSort() - sort.add_sort(s1) - sort.add_sort(s2) - results = self.lib.items(q, sort) - assert results[0]["album"] <= results[1]["album"] - assert results[1]["album"] <= results[2]["album"] - assert results[0]["album"] == "Baz" - assert results[1]["album"] == "Baz" - assert results[0]["year"] <= results[1]["year"] - # same thing with query string - q = "album+ year+" - results2 = self.lib.items(q) - for r1, r2 in zip(results, results2): - assert r1.id == r2.id + id=id_, + album=album, + genres=genres, + year=year, + flex1=flex1, + flex2=flex2, + albumartist=albumartist, + ) + ) + for id_, album, genres, year, flex1, flex2, albumartist in ( + [1, "Album A", ["Rock"], 2001, "Flex1-1", "Flex2-A", "Foo"], + [2, "Album B", ["Rock"], 2002, "Flex1-2", "Flex2-A", "Bar"], + [3, "Album C", ["Jazz"], 2005, "Flex1-1", "Flex2-B", "Baz"], + ) + ] + + for item in [ + _common.item( + id=1, + title="first", + artist="One", + album="Album A", + year=2001, + flex1="Flex1-0", + flex2="Flex2-A", + album_id=album_ids[0], + path=abs_test_path("/path0.mp3"), + track=1, + ), + _common.item( + id=2, + title="second", + artist="Two", + album="Album A", + year=2002, + flex1="Flex1-1", + flex2="Flex2-A", + album_id=album_ids[0], + path=abs_test_path("/patH1.mp3"), + track=2, + ), + _common.item( + id=3, + title="third", + artist="Three", + album="Album B", + year=2003, + flex1="Flex1-2", + flex2="Flex1-B", + album_id=album_ids[1], + path=abs_test_path("/paTH2.mp3"), + track=3, + ), + _common.item( + id=4, + title="fourth", + artist="Three", + album="Album C", + year=2004, + flex1="Flex1-2", + flex2="Flex1-C", + album_id=album_ids[2], + path=abs_test_path("/PATH3.mp3"), + track=4, + ), + ]: + helper.lib.add(item) + + request.cls.lib = helper.lib + + +@pytest.mark.usefixtures("setup_library") +class TestSort: + @pytest.mark.parametrize( + "model,query,expected_ids", + [ + _p(Album, "year+", [1, 2, 3], id="fixed"), + _p(Album, "flex1-", [2, 1, 3], id="flex"), + _p(Album, "path+", [1, 2, 3], id="calculated"), + _p(Album, "year-", [3, 2, 1], id="fixed-desc"), + _p(Album, "genres+ album+", [3, 1, 2], id="multi-fixed-field"), + _p(Album, "flex2+ flex1+", [1, 2, 3], id="multi-flex-field"), + _p(Album, "path+ year+", [1, 2, 3], id="computed"), + _p(Album, "year+ path+", [1, 2, 3], id="computed-reverse"), + _p(Item, "flex2- flex1+", [1, 2, 4, 3], id="item-multi-flex-field"), + ], + ) + def test_sort(self, model, query, expected_ids): + results = self.lib._fetch(model, query, None) + assert [r.id for r in results] == expected_ids def test_sort_path_field(self): - q = "" - sort = FixedFieldSort("path", True) - results = self.lib.items(q, sort) - assert results[0]["path"] == util.normpath("/path0.mp3") - assert results[1]["path"] == util.normpath("/patH1.mp3") - assert results[2]["path"] == util.normpath("/paTH2.mp3") - assert results[3]["path"] == util.normpath("/PATH3.mp3") - - -class SortFlexFieldTest(DummyDataTestCase): - def test_sort_asc(self): - q = "" - sort = SlowFieldSort("flex1", True) - results = self.lib.items(q, sort) - assert results[0]["flex1"] <= results[1]["flex1"] - assert results[0]["flex1"] == "Flex1-0" - # same thing with query string - q = "flex1+" - results2 = self.lib.items(q) - for r1, r2 in zip(results, results2): - assert r1.id == r2.id - - def test_sort_desc(self): - q = "" - sort = SlowFieldSort("flex1", False) - results = self.lib.items(q, sort) - assert results[0]["flex1"] >= results[1]["flex1"] - assert results[1]["flex1"] >= results[2]["flex1"] - assert results[2]["flex1"] >= results[3]["flex1"] - assert results[0]["flex1"] == "Flex1-2" - # same thing with query string - q = "flex1-" - results2 = self.lib.items(q) - for r1, r2 in zip(results, results2): - assert r1.id == r2.id - - def test_sort_two_field(self): - q = "" - s1 = SlowFieldSort("flex2", False) - s2 = SlowFieldSort("flex1", True) - sort = MultipleSort() - sort.add_sort(s1) - sort.add_sort(s2) - results = self.lib.items(q, sort) - assert results[0]["flex2"] >= results[1]["flex2"] - assert results[1]["flex2"] >= results[2]["flex2"] - assert results[0]["flex2"] == "Flex2-A" - assert results[1]["flex2"] == "Flex2-A" - assert results[0]["flex1"] <= results[1]["flex1"] - # same thing with query string - q = "flex2- flex1+" - results2 = self.lib.items(q) - for r1, r2 in zip(results, results2): - assert r1.id == r2.id - - -class SortAlbumFixedFieldTest(DummyDataTestCase): - def test_sort_asc(self): - q = "" - sort = FixedFieldSort("year", True) - results = self.lib.albums(q, sort) - assert results[0]["year"] <= results[1]["year"] - assert results[0]["year"] == 2001 - # same thing with query string - q = "year+" - results2 = self.lib.albums(q) - for r1, r2 in zip(results, results2): - assert r1.id == r2.id - - def test_sort_desc(self): - q = "" - sort = FixedFieldSort("year", False) - results = self.lib.albums(q, sort) - assert results[0]["year"] >= results[1]["year"] - assert results[0]["year"] == 2005 - # same thing with query string - q = "year-" - results2 = self.lib.albums(q) - for r1, r2 in zip(results, results2): - assert r1.id == r2.id - - def test_sort_two_field_asc(self): - q = "" - s1 = FixedFieldSort("genres", True) - s2 = FixedFieldSort("album", True) - sort = MultipleSort() - sort.add_sort(s1) - sort.add_sort(s2) - results = self.lib.albums(q, sort) - assert results[0]["genres"] <= results[1]["genres"] - assert results[1]["genres"] <= results[2]["genres"] - assert results[1]["genres"] == ["Rock"] - assert results[2]["genres"] == ["Rock"] - assert results[1]["album"] <= results[2]["album"] - # same thing with query string - q = "genres+ album+" - results2 = self.lib.albums(q) - for r1, r2 in zip(results, results2): - assert r1.id == r2.id - - -class SortAlbumFlexFieldTest(DummyDataTestCase): - def test_sort_asc(self): - q = "" - sort = SlowFieldSort("flex1", True) - results = self.lib.albums(q, sort) - assert results[0]["flex1"] <= results[1]["flex1"] - assert results[1]["flex1"] <= results[2]["flex1"] - # same thing with query string - q = "flex1+" - results2 = self.lib.albums(q) - for r1, r2 in zip(results, results2): - assert r1.id == r2.id - - def test_sort_desc(self): - q = "" - sort = SlowFieldSort("flex1", False) - results = self.lib.albums(q, sort) - assert results[0]["flex1"] >= results[1]["flex1"] - assert results[1]["flex1"] >= results[2]["flex1"] - # same thing with query string - q = "flex1-" - results2 = self.lib.albums(q) - for r1, r2 in zip(results, results2): - assert r1.id == r2.id - - def test_sort_two_field_asc(self): - q = "" - s1 = SlowFieldSort("flex2", True) - s2 = SlowFieldSort("flex1", True) - sort = MultipleSort() - sort.add_sort(s1) - sort.add_sort(s2) - results = self.lib.albums(q, sort) - assert results[0]["flex2"] <= results[1]["flex2"] - assert results[1]["flex2"] <= results[2]["flex2"] - assert results[0]["flex2"] == "Flex2-A" - assert results[1]["flex2"] == "Flex2-A" - assert results[0]["flex1"] <= results[1]["flex1"] - # same thing with query string - q = "flex2+ flex1+" - results2 = self.lib.albums(q) - for r1, r2 in zip(results, results2): - assert r1.id == r2.id - - -class SortAlbumComputedFieldTest(DummyDataTestCase): - def test_sort_asc(self): - q = "" - sort = SlowFieldSort("path", True) - results = self.lib.albums(q, sort) - assert results[0]["path"] <= results[1]["path"] - assert results[1]["path"] <= results[2]["path"] - # same thing with query string - q = "path+" - results2 = self.lib.albums(q) - for r1, r2 in zip(results, results2): - assert r1.id == r2.id - - def test_sort_desc(self): - q = "" - sort = SlowFieldSort("path", False) - results = self.lib.albums(q, sort) - assert results[0]["path"] >= results[1]["path"] - assert results[1]["path"] >= results[2]["path"] - # same thing with query string - q = "path-" - results2 = self.lib.albums(q) - for r1, r2 in zip(results, results2): - assert r1.id == r2.id - - -class SortCombinedFieldTest(DummyDataTestCase): - def test_computed_first(self): - q = "" - s1 = SlowFieldSort("path", True) - s2 = FixedFieldSort("year", True) - sort = MultipleSort() - sort.add_sort(s1) - sort.add_sort(s2) - results = self.lib.albums(q, sort) - assert results[0]["path"] <= results[1]["path"] - assert results[1]["path"] <= results[2]["path"] - q = "path+ year+" - results2 = self.lib.albums(q) - for r1, r2 in zip(results, results2): - assert r1.id == r2.id - - def test_computed_second(self): - q = "" - s1 = FixedFieldSort("year", True) - s2 = SlowFieldSort("path", True) - sort = MultipleSort() - sort.add_sort(s1) - sort.add_sort(s2) - results = self.lib.albums(q, sort) - assert results[0]["year"] <= results[1]["year"] - assert results[1]["year"] <= results[2]["year"] - assert results[0]["path"] <= results[1]["path"] - q = "year+ path+" - results2 = self.lib.albums(q) - for r1, r2 in zip(results, results2): - assert r1.id == r2.id - - -class ConfigSortTest(DummyDataTestCase): - def test_default_sort_item(self): - results = list(self.lib.items()) - assert results[0].artist < results[1].artist - - def test_config_opposite_sort_item(self): - config["sort_item"] = "artist-" - results = list(self.lib.items()) - assert results[0].artist > results[1].artist - - def test_default_sort_album(self): - results = list(self.lib.albums()) - assert results[0].albumartist < results[1].albumartist - - def test_config_opposite_sort_album(self): - config["sort_album"] = "albumartist-" - results = list(self.lib.albums()) - assert results[0].albumartist > results[1].albumartist - - -class CaseSensitivityTest(DummyDataTestCase): - """If case_insensitive is false, lower-case values should be placed - after all upper-case values. E.g., `Foo Qux bar` - """ + results = self.lib.items("", FixedFieldSort("path", True)) + expected_paths = [ + b"/path0.mp3", + b"/patH1.mp3", + b"/paTH2.mp3", + b"/PATH3.mp3", + ] + expected_paths_with_prefix = list(map(util.normpath, expected_paths)) + assert [i.path for i in results] == expected_paths_with_prefix - def setUp(self): - super().setUp() + def test_config_defaults(self): + artists = [r.artist for r in self.lib.items()] + albumartists = [r.albumartist for r in self.lib.albums()] - album = Album( - album="album", - genres=["alternative"], - year="2001", - flex1="flex1", - flex2="flex2-A", - albumartist="bar", - ) - self.lib.add(album) - - item = _common.item() - item.title = "another" - item.artist = "lowercase" - item.album = "album" - item.year = 2001 - item.comp = True - item.flex1 = "flex1" - item.flex2 = "flex2-A" - item.album_id = album.id - item.artist_sort = None - item.track = 10 - self.lib.add(item) - - self.new_album = album - self.new_item = item - - def tearDown(self): - self.new_item.remove(delete=True) - self.new_album.remove(delete=True) - super().tearDown() - - def test_smart_artist_case_insensitive(self): - config["sort_case_insensitive"] = True - q = "artist+" - results = list(self.lib.items(q)) - assert results[0].artist == "lowercase" - assert results[1].artist == "One" + assert artists == ["One", "Three", "Three", "Two"] + assert albumartists == ["Bar", "Baz", "Foo"] - def test_smart_artist_case_sensitive(self): - config["sort_case_insensitive"] = False - q = "artist+" - results = list(self.lib.items(q)) - assert results[0].artist == "One" - assert results[-1].artist == "lowercase" + def test_config_overrides(self, config): + config.set({"sort_item": "artist-", "sort_album": "albumartist-"}) - def test_fixed_field_case_insensitive(self): - config["sort_case_insensitive"] = True - q = "album+" - results = list(self.lib.albums(q)) - assert results[0].album == "album" - assert results[1].album == "Album A" + artists = [r.artist for r in self.lib.items()] + albumartists = [r.albumartist for r in self.lib.albums()] + + assert artists == ["Two", "Three", "Three", "One"] + assert albumartists == ["Foo", "Baz", "Bar"] - def test_fixed_field_case_sensitive(self): - config["sort_case_insensitive"] = False - q = "album+" - results = list(self.lib.albums(q)) - assert results[0].album == "Album A" - assert results[-1].album == "album" - def test_flex_field_case_insensitive(self): +class TestCaseSensitivity: + """If case_insensitive is false, lower-case values should be placed + after all upper-case values. E.g., `Foo Qux bar` + """ + + @pytest.fixture(autouse=True, scope="class") + def setup(self, helper): + helper.lib.add(Album(album="album", albumartist="bar")) + helper.lib.add(Album(album="Album", albumartist="Bar")) + helper.add_item(artist="artist", flex1="flex1", track=10) + helper.add_item(artist="Artist", flex1="Flex1", track=2) + + @pytest.mark.parametrize( + "getter,query,attr,expected_insensitive,expected_sensitive", + [ + _p( + "items", + "artist+", + "artist", + ["artist", "Artist"], + ["Artist", "artist"], + id="smart-artist-case", + ), + _p( + "albums", + "album+", + "album", + ["album", "Album"], + ["Album", "album"], + id="fixed-field-case", + ), + _p( + "items", + "flex1+", + "flex1", + ["flex1", "Flex1"], + ["Flex1", "flex1"], + id="flex-field-case", + ), + ], + ) + def test_text_field_case_sorting( + self, + config, + getter, + query, + attr, + expected_insensitive, + expected_sensitive, + helper, + ): config["sort_case_insensitive"] = True - q = "flex1+" - results = list(self.lib.items(q)) - assert results[0].flex1 == "flex1" - assert results[1].flex1 == "Flex1-0" + results = getattr(helper.lib, getter)(query) + assert [r[attr] for r in results] == expected_insensitive - def test_flex_field_case_sensitive(self): config["sort_case_insensitive"] = False - q = "flex1+" - results = list(self.lib.items(q)) - assert results[0].flex1 == "Flex1-0" - assert results[-1].flex1 == "flex1" + results = getattr(helper.lib, getter)(query) + assert [r[attr] for r in results] == expected_sensitive - def test_case_sensitive_only_affects_text(self): + def test_case_sensitive_only_affects_text(self, config, helper): config["sort_case_insensitive"] = True - q = "track+" - results = list(self.lib.items(q)) + results = helper.lib.items("track+") # If the numerical values were sorted as strings, - # then ['1', '10', '2'] would be valid. - # print([r.track for r in results]) - assert results[0].track == 1 - assert results[1].track == 2 - assert results[-1].track == 10 + # then ['10', '2'] would be valid. + assert [r.track for r in results] == [2, 10] -class NonExistingFieldTest(DummyDataTestCase): +@pytest.mark.usefixtures("setup_library") +class TestNonExistingField: """Test sorting by non-existing fields""" - def test_non_existing_fields_not_fail(self): - qs = ["foo+", "foo-", "--", "-+", "+-", "++", "-foo-", "-foo+", "---"] - - q0 = "foo+" - results0 = list(self.lib.items(q0)) - for q1 in qs: - results1 = list(self.lib.items(q1)) - for r1, r2 in zip(results0, results1): - assert r1.id == r2.id - - def test_combined_non_existing_field_asc(self): - all_results = list(self.lib.items("id+")) - q = "foo+ id+" - results = list(self.lib.items(q)) - assert len(all_results) == len(results) - for r1, r2 in zip(all_results, results): - assert r1.id == r2.id - - def test_combined_non_existing_field_desc(self): - all_results = list(self.lib.items("id+")) - q = "foo- id+" - results = list(self.lib.items(q)) - assert len(all_results) == len(results) - for r1, r2 in zip(all_results, results): - assert r1.id == r2.id - - def test_field_present_in_some_items(self): - """Test ordering by a (string) field not present on all items.""" - # append 'foo' to two items (1,2) - lower_foo_item, higher_foo_item, *items_without_foo = self.lib.items( - "id+" - ) - lower_foo_item.foo, higher_foo_item.foo = "bar1", "bar2" - lower_foo_item.store() - higher_foo_item.store() - - results_asc = list(self.lib.items("foo+ id+")) - assert [i.id for i in results_asc] == [ - # items without field first - *[i.id for i in items_without_foo], - lower_foo_item.id, - higher_foo_item.id, - ] + @pytest.mark.parametrize( + "q", ["foo+", "foo-", "--", "-+", "+-", "++", "-foo-", "-foo+", "---"] + ) + def test_non_existing_fields_not_fail(self, q): + expected_ids = [i.id for i in self.lib.items("foo+")] - results_desc = list(self.lib.items("foo- id+")) - assert [i.id for i in results_desc] == [ - higher_foo_item.id, - lower_foo_item.id, - # items without field last - *[i.id for i in items_without_foo], - ] + actual_ids = [i.id for i in self.lib.items(q)] - @patch("beets.library.Item._types", {"myint": types.Integer()}) - def test_int_field_present_in_some_items(self): + assert actual_ids == expected_ids + + def test_combined_non_existing_field(self): + expected_ids = [i.id for i in self.lib.items("id+")] + + actual_ids = [i.id for i in self.lib.items("foo+ id+")] + + assert actual_ids == expected_ids + + def test_field_present_in_some_items(self, monkeypatch): """Test ordering by an int-type field not present on all items.""" - # append int-valued 'myint' to two items (1,2) - lower_myint_item, higher_myint_item, *items_without_myint = ( - self.lib.items("id+") - ) - lower_myint_item.myint, higher_myint_item.myint = 1, 2 - lower_myint_item.store() - higher_myint_item.store() - - results_asc = list(self.lib.items("myint+ id+")) - assert [i.id for i in results_asc] == [ - # items without field first - *[i.id for i in items_without_myint], - lower_myint_item.id, - higher_myint_item.id, - ] + monkeypatch.setitem(Item._types, "myint", types.Integer()) - results_desc = list(self.lib.items("myint- id+")) - assert [i.id for i in results_desc] == [ - higher_myint_item.id, - lower_myint_item.id, - # items without field last - *[i.id for i in items_without_myint], - ] + lower_item, higher_item, *items_without_val = self.lib.items("id+") + for item, value in zip((lower_item, higher_item), (2, 10)): + item.myint = value + item.store() + + null_values_ids = [i.id for i in items_without_val] + + ids_asc = [i.id for i in self.lib.items("myint+ id+")] + ids_desc = [i.id for i in self.lib.items("myint- id+")] + + assert ids_asc == [*null_values_ids, lower_item.id, higher_item.id] + assert ids_desc == [higher_item.id, lower_item.id, *null_values_ids] def test_negation_interaction(self): """Test the handling of negation and sorting together.