diff --git a/emmental/tracklist/sorter.py b/emmental/tracklist/sorter.py index 3b0b282..d6a157a 100644 --- a/emmental/tracklist/sorter.py +++ b/emmental/tracklist/sorter.py @@ -3,6 +3,8 @@ from gi.repository import GObject from gi.repository import Gio +SORT_ORDER_FLAGS = GObject.ParamFlags.READWRITE | \ + GObject.ParamFlags.EXPLICIT_NOTIFY SORT_FIELDS = {"Album": ["album"], "Artist": ["artist"], "Album Artist": ["albumartist"], "Filepath": ["filepath"], "Length": ["length"], @@ -10,6 +12,7 @@ SORT_FIELDS = {"Album": ["album"], "Artist": ["artist"], "Last Started Date": ["laststarted"], "Play Count": ["playcount"], "Release Date": ["release"], "Title": ["title"], "Track Number": ["mediumno", "number"]} +REVERSE_LOOKUP = {v: k for k, l in SORT_FIELDS.items() for v in l} class SortField(GObject.GObject): @@ -53,3 +56,99 @@ class SortField(GObject.GObject): def reverse(self) -> None: """Reverse the direction of this Sort Field.""" self.model.reverse(self) + + +class SortOrderModel(Gio.ListStore): + """A ListModel for managing Sort Order.""" + + n_enabled = GObject.Property(type=int, default=0) + sort_order = GObject.Property(type=str) + + def __init__(self): + """Initialize the Sort Order Model.""" + super().__init__(item_type=SortField) + for name in SORT_FIELDS.keys(): + self.append(SortField(model=self, name=name)) + + def __move_field(self, field: SortField, position: int, + *, update: bool) -> None: + self.remove(self.index(field)) + self.insert(position, field) + if update: + self.__update_sort_order() + + def __update_sort_order(self) -> None: + enabled = list(filter(lambda f: f.enabled, self)) + self.n_enabled = len(enabled) + self.sort_order = ", ".join([str(f) for f in enabled]) + + def disable(self, field: SortField) -> bool: + """Disable a SortField.""" + if field.enabled: + field.enabled = False + field.reversed = False + self.__move_field(field, self.n_enabled - 1, update=True) + return True + return False + + def enable(self, field: SortField) -> bool: + """Enable a SortField.""" + if not field.enabled: + field.enabled = True + self.__move_field(field, self.n_enabled, update=True) + return True + return False + + def index(self, field: SortField) -> int | None: + """Find the index of a specific field.""" + (found, position) = self.find(field) + return position if found else None + + def lookup(self, name: str) -> SortField | None: + """Find a SortField by name.""" + for field in self: + if field.name == name: + return field + + def move_down(self, field: SortField) -> bool: + """Move a SortField one position closer to the end of the list.""" + if (index := self.index(field)) < self.n_enabled - 1: + self.__move_field(field, index + 1, update=True) + return True + return False + + def move_up(self, field: SortField) -> bool: + """Move a SortField one position closer to the start of the list.""" + if 0 < (index := self.index(field)) < self.n_enabled: + self.__move_field(field, index - 1, update=True) + return True + return False + + def reverse(self, field: SortField) -> None: + """Reverse the ordering of a Sort Field.""" + field.reversed = not field.reversed + self.__update_sort_order() + + def set_sort_order(self, new_order: str | None) -> None: + """Manually set the sort order.""" + if self.sort_order == new_order or new_order is None: + return + + for field in self: + field.enabled = False + field.reversed = False + + if new_order == "user" or new_order == "": + self.n_enabled = 0 + self.sort_order = new_order + else: + position = 0 + for desc in new_order.split(", "): + col, order = (desc.split(" ") + [None])[:2] + if field := self.lookup(REVERSE_LOOKUP[col]): + if not field.enabled: + field.enabled = True + field.reversed = order == "DESC" + self.__move_field(field, position, update=False) + position += 1 + self.__update_sort_order() diff --git a/tests/tracklist/test_sorter.py b/tests/tracklist/test_sorter.py index e75b546..41e74c3 100644 --- a/tests/tracklist/test_sorter.py +++ b/tests/tracklist/test_sorter.py @@ -9,6 +9,12 @@ from gi.repository import Gio class TestSortConstants(unittest.TestCase): """Test case for Sort Order constants.""" + def test_flags(self): + """Test flags passed to the sort_order property.""" + self.assertEqual(emmental.tracklist.sorter.SORT_ORDER_FLAGS, + GObject.ParamFlags.READWRITE | + GObject.ParamFlags.EXPLICIT_NOTIFY) + def test_sort_fields(self): """Test the sort fields definition dictionary.""" self.assertDictEqual(emmental.tracklist.sorter.SORT_FIELDS, @@ -22,6 +28,19 @@ class TestSortConstants(unittest.TestCase): "Title": ["title"], "Track Number": ["mediumno", "number"]}) + def test_reverse_lookup(self): + """Test the sort field reverse lookup dictionary.""" + self.assertDictEqual(emmental.tracklist.sorter.REVERSE_LOOKUP, + {"album": "Album", "artist": "Artist", + "albumartist": "Album Artist", + "filepath": "Filepath", "length": "Length", + "lastplayed": "Last Played Date", + "laststarted": "Last Started Date", + "playcount": "Play Count", + "release": "Release Date", "title": "Title", + "mediumno": "Track Number", + "number": "Track Number"}) + class TestSortField(unittest.TestCase): """Test case for our SortField object.""" @@ -84,3 +103,197 @@ class TestSortField(unittest.TestCase): self.model.reverse = unittest.mock.Mock() self.field.reverse() self.model.reverse.assert_called_with(self.field) + + +class TestSortOrderModel(unittest.TestCase): + """Test case for our Sort Order Model.""" + + def setUp(self): + """Set up common variables.""" + self.sort_fields = emmental.tracklist.sorter.SORT_FIELDS + self.model = emmental.tracklist.sorter.SortOrderModel() + + def test_init(self): + """Test that the model is initialized properly.""" + self.assertIsInstance(self.model, Gio.ListStore) + self.assertEqual(self.model.n_enabled, 0) + self.assertEqual(self.model.get_item_type(), + emmental.tracklist.sorter.SortField.__gtype__) + self.assertEqual(self.model.sort_order, "") + + for i, name in enumerate(self.sort_fields.keys()): + with self.subTest(i=i, name=name): + self.assertIsInstance(self.model[i], + emmental.tracklist.sorter.SortField) + self.assertEqual(self.model[i].name, name) + + def test_index(self): + """Test the Sort Order Model index() function.""" + for i, name in enumerate(emmental.tracklist.sorter.SORT_FIELDS.keys()): + with self.subTest(i=i, name=name): + self.assertEqual(self.model.index(self.model[i]), i) + + field2 = emmental.tracklist.sorter.SortField(self.model, "Album") + self.assertIsNone(self.model.index(field2)) + + def test_lookup(self): + """Test looping up sort fields by name.""" + for name in emmental.tracklist.sorter.SORT_FIELDS.keys(): + with self.subTest(name=name): + self.assertEqual(self.model.lookup(name).name, name) + self.assertIsNone(self.model.lookup("Invalid Name")) + + def test_disable(self): + """Test disabling a sort field.""" + (field_0 := self.model[0]).enable() + (field_1 := self.model[1]).enable() + (field_2 := self.model[2]).enable() + field_2.reversed = True + + self.assertTrue(self.model.disable(field_2)) + self.assertFalse(field_2.enabled) + self.assertFalse(field_2.reversed) + self.assertEqual(self.model.n_enabled, 2) + self.assertEqual(self.model.index(field_2), 2) + self.assertEqual(self.model.sort_order, + f"{str(field_0)}, {str(field_1)}") + + self.assertFalse(self.model.disable(field_2)) + self.assertEqual(self.model.n_enabled, 2) + + self.assertTrue(self.model.disable(field_0)) + self.assertFalse(field_0.enabled) + self.assertEqual(self.model.n_enabled, 1) + self.assertEqual(self.model.index(field_0), 1) + self.assertEqual(self.model.sort_order, str(field_1)) + + def test_enable(self): + """Test enabling a sort field.""" + field_0 = self.model[0] + self.assertTrue(self.model.enable(field_0)) + self.assertTrue(field_0.enabled) + self.assertEqual(self.model.n_enabled, 1) + self.assertEqual(self.model.index(field_0), 0) + self.assertEqual(self.model.sort_order, str(field_0)) + + self.assertFalse(self.model.enable(field_0)) + self.assertEqual(self.model.n_enabled, 1) + + field_3 = self.model[3] + self.assertTrue(self.model.enable(field_3)) + self.assertTrue(field_3.enabled) + self.assertEqual(self.model.n_enabled, 2) + self.assertEqual(self.model.index(field_3), 1) + self.assertEqual(self.model.sort_order, + f"{str(field_0)}, {str(field_3)}") + + def test_move_down(self): + """Test moving a sort field down in the list.""" + (field_0 := self.model[0]).enable() + (field_1 := self.model[1]).enable() + + for res in [True, False]: + with self.subTest(res=res): + self.assertEqual(self.model.move_down(field_0), res) + self.assertEqual(self.model[0], field_1) + self.assertEqual(self.model[1], field_0) + self.assertEqual(self.model.sort_order, + f"{str(field_1)}, {str(field_0)}") + + def test_move_up(self): + """Test moving a sort field down in the list.""" + (field_0 := self.model[0]).enable() + (field_1 := self.model[1]).enable() + + for res in [True, False]: + with self.subTest(res=res): + self.assertEqual(self.model.move_up(field_1), res) + self.assertEqual(self.model[0], field_1) + self.assertEqual(self.model[1], field_0) + self.assertEqual(self.model.sort_order, + f"{str(field_1)}, {str(field_0)}") + + def test_reverse(self): + """Test reversing a sort field.""" + (field_0 := self.model[0]).enable() + self.model.reverse(field_0) + self.assertTrue(field_0.reversed) + self.assertEqual(self.model.sort_order, str(field_0)) + + self.model.reverse(field_0) + self.assertFalse(field_0.reversed) + self.assertEqual(self.model.sort_order, str(field_0)) + + def test_set_sort_order_artist(self): + """Test setting the Sort Order property to "artist".""" + self.model.set_sort_order("artist") + self.assertEqual(self.model.n_enabled, 1) + self.assertEqual(self.model.sort_order, "artist") + + self.assertEqual(self.model[0].name, "Artist") + self.assertTrue(self.model[0].enabled) + self.assertFalse(self.model[0].reversed) + + self.assertFalse(self.model[1].enabled) + + def test_set_sort_order_album_title(self): + """Test setting the Sort Order property to "album DESC, title".""" + self.model[1].enable() + self.model[1].reverse() + + self.model.set_sort_order("album DESC, title") + self.assertEqual(self.model.n_enabled, 2) + self.assertEqual(self.model.sort_order, "album DESC, title") + + self.assertEqual(self.model[0].name, "Album") + self.assertTrue(self.model[0].enabled) + self.assertTrue(self.model[0].reversed) + + self.assertEqual(self.model[1].name, "Title") + self.assertTrue(self.model[1].enabled) + self.assertFalse(self.model[1].reversed) + + self.assertFalse(self.model[2].enabled) + self.assertFalse(self.model[2].reversed) + + def test_set_sort_order_trackno_playcount(self): + """Test setting the Sort Order property to "album DESC, title".""" + self.model.set_sort_order("mediumno, number, playcount") + self.assertEqual(self.model.n_enabled, 2) + self.assertEqual(self.model.sort_order, "mediumno, number, playcount") + + self.assertEqual(self.model[0].name, "Track Number") + self.assertTrue(self.model[0].enabled) + self.assertFalse(self.model[0].reversed) + + self.assertEqual(self.model[1].name, "Play Count") + self.assertTrue(self.model[1].enabled) + self.assertFalse(self.model[1].reversed) + + self.assertFalse(self.model[2].enabled) + self.assertFalse(self.model[2].reversed) + + def test_set_sort_order_user(self): + """Test setting the Sort Order property to "user".""" + self.model[0].enable() + + self.model.set_sort_order("user") + self.assertEqual(self.model.n_enabled, 0) + self.assertEqual(self.model.sort_order, "user") + self.assertFalse(self.model[0].enabled) + + def test_set_sort_order_same(self): + """Test setting the Sort Order property to the same value.""" + self.model.set_sort_order("user") + + notify = unittest.mock.Mock() + self.model.connect("notify::sort-order", notify) + self.model.set_sort_order("user") + notify.assert_not_called() + + def test_set_sort_order_none(self): + """Test setting the Sort Order property to 'None'.""" + notify = unittest.mock.Mock() + self.model.connect("notify::sort-order", notify) + self.model.set_sort_order(None) + notify.assert_not_called()