From 09b22949c0c9c6d41b5869fd0b5bf58ee0a763b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Sun, 1 Jun 2025 13:53:34 +0100 Subject: [PATCH] Refactor test_query And rewrite test_query.py --- beets/test/_common.py | 5 +- test/conftest.py | 20 + test/test_query.py | 1206 +++++++++++++---------------------------- 3 files changed, 413 insertions(+), 818 deletions(-) diff --git a/beets/test/_common.py b/beets/test/_common.py index 86319c011..da81a587c 100644 --- a/beets/test/_common.py +++ b/beets/test/_common.py @@ -63,8 +63,8 @@ HAVE_SYMLINK = sys.platform != "win32" HAVE_HARDLINK = sys.platform != "win32" -def item(lib=None): - i = beets.library.Item( +def item(lib=None, **kwargs): + defaults = dict( title="the title", artist="the artist", albumartist="the album artist", @@ -99,6 +99,7 @@ def item(lib=None): album_id=None, mtime=12345, ) + i = beets.library.Item(**{**defaults, **kwargs}) if lib: lib.add(i) return i diff --git a/test/conftest.py b/test/conftest.py index 95509bdb6..3107ad690 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -1,7 +1,10 @@ +import inspect import os import pytest +from beets.dbcore.query import Query + def skip_marked_items(items: list[pytest.Item], marker_name: str, reason: str): for item in (i for i in items if i.get_closest_marker(marker_name)): @@ -21,3 +24,20 @@ def pytest_collection_modifyitems( skip_marked_items( items, "on_lyrics_update", "No change in lyrics source code" ) + + +def pytest_make_parametrize_id(config, val, argname): + """Generate readable test identifiers for pytest parametrized tests. + + Provides custom string representations for: + - Query classes/instances: use class name + - Lambda functions: show abbreviated source + - Other values: use standard repr() + """ + if inspect.isclass(val) and issubclass(val, Query): + return val.__name__ + + if inspect.isfunction(val) and val.__name__ == "": + return inspect.getsource(val).split("lambda")[-1][:30] + + return repr(val) diff --git a/test/test_query.py b/test/test_query.py index 252367c14..0ddf83e3a 100644 --- a/test/test_query.py +++ b/test/test_query.py @@ -15,837 +15,39 @@ """Various tests for querying the library database.""" import sys -import unittest +from functools import partial from pathlib import Path import pytest -from mock import patch -from beets import dbcore from beets.dbcore import types from beets.dbcore.query import ( - InvalidQueryArgumentValueError, + AndQuery, + BooleanQuery, + DateQuery, + FalseQuery, + MatchQuery, NoneQuery, + NotQuery, + NumericQuery, + OrQuery, ParsingError, PathQuery, + RegexpQuery, + StringFieldQuery, + StringQuery, + SubstringQuery, + TrueQuery, ) +from beets.library import Item from beets.test import _common -from beets.test.helper import BeetsTestCase, TestHelper +from beets.test.helper import TestHelper # Because the absolute path begins with something like C:, we # can't disambiguate it from an ordinary query. WIN32_NO_IMPLICIT_PATHS = "Implicit paths are not supported on Windows" - -class AssertsMixin: - def assert_items_matched(self, results, titles): - assert {i.title for i in results} == set(titles) - - def assert_albums_matched(self, results, albums): - assert {a.album for a in results} == set(albums) - - -# A test case class providing a library with some dummy data and some -# assertions involving that data. -class DummyDataTestCase(BeetsTestCase, AssertsMixin): - def setUp(self): - super().setUp() - items = [_common.item() for _ in range(3)] - items[0].title = "foo bar" - items[0].artist = "one" - items[0].artists = ["one", "eleven"] - items[0].album = "baz" - items[0].year = 2001 - items[0].comp = True - items[0].genre = "rock" - items[1].title = "baz qux" - items[1].artist = "two" - items[1].artists = ["two", "twelve"] - items[1].album = "baz" - items[1].year = 2002 - items[1].comp = True - items[1].genre = "Rock" - items[2].title = "beets 4 eva" - items[2].artist = "three" - items[2].artists = ["three", "one"] - items[2].album = "foo" - items[2].year = 2003 - items[2].comp = False - items[2].genre = "Hard Rock" - for item in items: - self.lib.add(item) - self.album = self.lib.add_album(items[:2]) - - def assert_items_matched_all(self, results): - self.assert_items_matched( - results, - [ - "foo bar", - "baz qux", - "beets 4 eva", - ], - ) - - -class GetTest(DummyDataTestCase): - def test_get_empty(self): - q = "" - results = self.lib.items(q) - self.assert_items_matched_all(results) - - def test_get_none(self): - q = None - results = self.lib.items(q) - self.assert_items_matched_all(results) - - def test_get_one_keyed_term(self): - q = "title:qux" - results = self.lib.items(q) - self.assert_items_matched(results, ["baz qux"]) - - def test_get_one_keyed_exact(self): - q = "genre:=rock" - results = self.lib.items(q) - self.assert_items_matched(results, ["foo bar"]) - q = "genre:=Rock" - results = self.lib.items(q) - self.assert_items_matched(results, ["baz qux"]) - q = 'genre:="Hard Rock"' - results = self.lib.items(q) - self.assert_items_matched(results, ["beets 4 eva"]) - - def test_get_one_keyed_exact_nocase(self): - q = 'genre:=~"hard rock"' - results = self.lib.items(q) - self.assert_items_matched(results, ["beets 4 eva"]) - - def test_get_one_keyed_regexp(self): - q = "artist::t.+r" - results = self.lib.items(q) - self.assert_items_matched(results, ["beets 4 eva"]) - - def test_get_one_unkeyed_term(self): - q = "three" - results = self.lib.items(q) - self.assert_items_matched(results, ["beets 4 eva"]) - - def test_get_one_unkeyed_exact(self): - q = "=rock" - results = self.lib.items(q) - self.assert_items_matched(results, ["foo bar"]) - - def test_get_one_unkeyed_exact_nocase(self): - q = '=~"hard rock"' - results = self.lib.items(q) - self.assert_items_matched(results, ["beets 4 eva"]) - - def test_get_one_unkeyed_regexp(self): - q = ":x$" - results = self.lib.items(q) - self.assert_items_matched(results, ["baz qux"]) - - def test_get_no_matches(self): - q = "popebear" - results = self.lib.items(q) - self.assert_items_matched(results, []) - - def test_invalid_key(self): - q = "pope:bear" - results = self.lib.items(q) - # Matches nothing since the flexattr is not present on the - # objects. - self.assert_items_matched(results, []) - - def test_get_no_matches_exact(self): - q = 'genre:="hard rock"' - results = self.lib.items(q) - self.assert_items_matched(results, []) - - def test_term_case_insensitive(self): - q = "oNE" - results = self.lib.items(q) - self.assert_items_matched(results, ["foo bar"]) - - def test_regexp_case_sensitive(self): - q = ":oNE" - results = self.lib.items(q) - self.assert_items_matched(results, []) - q = ":one" - results = self.lib.items(q) - self.assert_items_matched(results, ["foo bar"]) - - def test_term_case_insensitive_with_key(self): - q = "artist:thrEE" - results = self.lib.items(q) - self.assert_items_matched(results, ["beets 4 eva"]) - - def test_term_case_regex_with_multi_key_matches(self): - q = "artists::eleven" - results = self.lib.items(q) - self.assert_items_matched(results, ["foo bar"]) - - def test_term_case_regex_with_multi_key_matches_multiple_columns(self): - q = "artists::one" - results = self.lib.items(q) - self.assert_items_matched(results, ["foo bar", "beets 4 eva"]) - - def test_key_case_insensitive(self): - q = "ArTiST:three" - results = self.lib.items(q) - self.assert_items_matched(results, ["beets 4 eva"]) - - def test_keyed_matches_exact_nocase(self): - q = "genre:=~rock" - results = self.lib.items(q) - self.assert_items_matched( - results, - [ - "foo bar", - "baz qux", - ], - ) - - def test_unkeyed_term_matches_multiple_columns(self): - q = "baz" - results = self.lib.items(q) - self.assert_items_matched( - results, - [ - "foo bar", - "baz qux", - ], - ) - - def test_unkeyed_regexp_matches_multiple_columns(self): - q = ":z$" - results = self.lib.items(q) - self.assert_items_matched( - results, - [ - "foo bar", - "baz qux", - ], - ) - - def test_keyed_term_matches_only_one_column(self): - q = "title:baz" - results = self.lib.items(q) - self.assert_items_matched(results, ["baz qux"]) - - def test_keyed_regexp_matches_only_one_column(self): - q = "title::baz" - results = self.lib.items(q) - self.assert_items_matched( - results, - [ - "baz qux", - ], - ) - - def test_multiple_terms_narrow_search(self): - q = "qux baz" - results = self.lib.items(q) - self.assert_items_matched( - results, - [ - "baz qux", - ], - ) - - def test_multiple_regexps_narrow_search(self): - q = ":baz :qux" - results = self.lib.items(q) - self.assert_items_matched(results, ["baz qux"]) - - def test_mixed_terms_regexps_narrow_search(self): - q = ":baz qux" - results = self.lib.items(q) - self.assert_items_matched(results, ["baz qux"]) - - def test_single_year(self): - q = "year:2001" - results = self.lib.items(q) - self.assert_items_matched(results, ["foo bar"]) - - def test_year_range(self): - q = "year:2000..2002" - results = self.lib.items(q) - self.assert_items_matched( - results, - [ - "foo bar", - "baz qux", - ], - ) - - def test_singleton_true(self): - q = "singleton:true" - results = self.lib.items(q) - self.assert_items_matched(results, ["beets 4 eva"]) - - def test_singleton_1(self): - q = "singleton:1" - results = self.lib.items(q) - self.assert_items_matched(results, ["beets 4 eva"]) - - def test_singleton_false(self): - q = "singleton:false" - results = self.lib.items(q) - self.assert_items_matched(results, ["foo bar", "baz qux"]) - - def test_singleton_0(self): - q = "singleton:0" - results = self.lib.items(q) - self.assert_items_matched(results, ["foo bar", "baz qux"]) - - def test_compilation_true(self): - q = "comp:true" - results = self.lib.items(q) - self.assert_items_matched(results, ["foo bar", "baz qux"]) - - def test_compilation_false(self): - q = "comp:false" - results = self.lib.items(q) - self.assert_items_matched(results, ["beets 4 eva"]) - - def test_unknown_field_name_no_results(self): - q = "xyzzy:nonsense" - results = self.lib.items(q) - titles = [i.title for i in results] - assert titles == [] - - def test_unknown_field_name_no_results_in_album_query(self): - q = "xyzzy:nonsense" - results = self.lib.albums(q) - names = [a.album for a in results] - assert names == [] - - def test_item_field_name_matches_nothing_in_album_query(self): - q = "format:nonsense" - results = self.lib.albums(q) - names = [a.album for a in results] - assert names == [] - - def test_unicode_query(self): - item = self.lib.items().get() - item.title = "caf\xe9" - item.store() - - q = "title:caf\xe9" - results = self.lib.items(q) - self.assert_items_matched(results, ["caf\xe9"]) - - def test_numeric_search_positive(self): - q = dbcore.query.NumericQuery("year", "2001") - results = self.lib.items(q) - assert results - - def test_numeric_search_negative(self): - q = dbcore.query.NumericQuery("year", "1999") - results = self.lib.items(q) - assert not results - - def test_album_field_fallback(self): - self.album["albumflex"] = "foo" - self.album.store() - - q = "albumflex:foo" - results = self.lib.items(q) - self.assert_items_matched(results, ["foo bar", "baz qux"]) - - def test_invalid_query(self): - with pytest.raises(InvalidQueryArgumentValueError, match="not an int"): - dbcore.query.NumericQuery("year", "199a") - - msg_match = r"not a regular expression.*unterminated subpattern" - with pytest.raises(ParsingError, match=msg_match): - dbcore.query.RegexpQuery("year", "199(") - - -class MatchTest(unittest.TestCase): - def setUp(self): - super().setUp() - self.item = _common.item() - - def test_regex_match_positive(self): - q = dbcore.query.RegexpQuery("album", "^the album$") - assert q.match(self.item) - - def test_regex_match_negative(self): - q = dbcore.query.RegexpQuery("album", "^album$") - assert not q.match(self.item) - - def test_regex_match_non_string_value(self): - q = dbcore.query.RegexpQuery("disc", "^6$") - assert q.match(self.item) - - def test_substring_match_positive(self): - q = dbcore.query.SubstringQuery("album", "album") - assert q.match(self.item) - - def test_substring_match_negative(self): - q = dbcore.query.SubstringQuery("album", "ablum") - assert not q.match(self.item) - - def test_substring_match_non_string_value(self): - q = dbcore.query.SubstringQuery("disc", "6") - assert q.match(self.item) - - def test_exact_match_nocase_positive(self): - q = dbcore.query.StringQuery("genre", "the genre") - assert q.match(self.item) - q = dbcore.query.StringQuery("genre", "THE GENRE") - assert q.match(self.item) - - def test_exact_match_nocase_negative(self): - q = dbcore.query.StringQuery("genre", "genre") - assert not q.match(self.item) - - def test_year_match_positive(self): - q = dbcore.query.NumericQuery("year", "1") - assert q.match(self.item) - - def test_year_match_negative(self): - q = dbcore.query.NumericQuery("year", "10") - assert not q.match(self.item) - - def test_bitrate_range_positive(self): - q = dbcore.query.NumericQuery("bitrate", "100000..200000") - assert q.match(self.item) - - def test_bitrate_range_negative(self): - q = dbcore.query.NumericQuery("bitrate", "200000..300000") - assert not q.match(self.item) - - def test_open_range(self): - dbcore.query.NumericQuery("bitrate", "100000..") - - def test_eq(self): - q1 = dbcore.query.MatchQuery("foo", "bar") - q2 = dbcore.query.MatchQuery("foo", "bar") - q3 = dbcore.query.MatchQuery("foo", "baz") - q4 = dbcore.query.StringFieldQuery("foo", "bar") - assert q1 == q2 - assert q1 != q3 - assert q1 != q4 - assert q3 != q4 - - -class IntQueryTest(BeetsTestCase): - def test_exact_value_match(self): - item = self.add_item(bpm=120) - matched = self.lib.items("bpm:120").get() - assert item.id == matched.id - - def test_range_match(self): - item = self.add_item(bpm=120) - self.add_item(bpm=130) - - matched = self.lib.items("bpm:110..125") - assert 1 == len(matched) - assert item.id == matched.get().id - - @patch("beets.library.Item._types", {"myint": types.Integer()}) - def test_flex_range_match(self): - item = self.add_item(myint=2) - matched = self.lib.items("myint:2").get() - assert item.id == matched.id - - @patch("beets.library.Item._types", {"myint": types.Integer()}) - def test_flex_dont_match_missing(self): - self.add_item() - matched = self.lib.items("myint:2").get() - assert matched is None - - def test_no_substring_match(self): - self.add_item(bpm=120) - matched = self.lib.items("bpm:12").get() - assert matched is None - - -@patch("beets.library.Item._types", {"flexbool": types.Boolean()}) -class BoolQueryTest(BeetsTestCase, AssertsMixin): - def test_parse_true(self): - item_true = self.add_item(comp=True) - item_false = self.add_item(comp=False) - matched = self.lib.items("comp:true") - assert item_true.id in {i.id for i in matched} - assert item_false.id not in {i.id for i in matched} - - def test_flex_parse_true(self): - item_true = self.add_item(flexbool=True) - item_false = self.add_item(flexbool=False) - matched = self.lib.items("flexbool:true") - assert item_true.id in {i.id for i in matched} - assert item_false.id not in {i.id for i in matched} - - def test_flex_parse_false(self): - item_true = self.add_item(flexbool=True) - item_false = self.add_item(flexbool=False) - matched = self.lib.items("flexbool:false") - assert item_false.id in {i.id for i in matched} - assert item_true.id not in {i.id for i in matched} - - def test_flex_parse_1(self): - item_true = self.add_item(flexbool=True) - item_false = self.add_item(flexbool=False) - matched = self.lib.items("flexbool:1") - assert item_true.id in {i.id for i in matched} - assert item_false.id not in {i.id for i in matched} - - def test_flex_parse_0(self): - item_true = self.add_item(flexbool=True) - item_false = self.add_item(flexbool=False) - matched = self.lib.items("flexbool:0") - assert item_false.id in {i.id for i in matched} - assert item_true.id not in {i.id for i in matched} - - def test_flex_parse_any_string(self): - # TODO this should be the other way around - item_true = self.add_item(flexbool=True) - item_false = self.add_item(flexbool=False) - matched = self.lib.items("flexbool:something") - assert item_false.id in {i.id for i in matched} - assert item_true.id not in {i.id for i in matched} - - -class DefaultSearchFieldsTest(DummyDataTestCase): - def test_albums_matches_album(self): - albums = list(self.lib.albums("baz")) - assert len(albums) == 1 - - def test_albums_matches_albumartist(self): - albums = list(self.lib.albums(["album artist"])) - assert len(albums) == 1 - - def test_items_matches_title(self): - items = self.lib.items("beets") - self.assert_items_matched(items, ["beets 4 eva"]) - - def test_items_does_not_match_year(self): - items = self.lib.items("2001") - self.assert_items_matched(items, []) - - -class NoneQueryTest(BeetsTestCase, AssertsMixin): - def test_match_singletons(self): - singleton = self.add_item() - album_item = self.add_album().items().get() - - matched = self.lib.items(NoneQuery("album_id")) - assert singleton.id in {i.id for i in matched} - assert album_item.id not in {i.id for i in matched} - - def test_match_after_set_none(self): - item = self.add_item(rg_track_gain=0) - matched = self.lib.items(NoneQuery("rg_track_gain")) - assert item.id not in {i.id for i in matched} - - item["rg_track_gain"] = None - item.store() - matched = self.lib.items(NoneQuery("rg_track_gain")) - assert item.id in {i.id for i in matched} - - def test_match_slow(self): - item = self.add_item() - matched = self.lib.items(NoneQuery("rg_track_peak", fast=False)) - assert item.id in {i.id for i in matched} - - def test_match_slow_after_set_none(self): - item = self.add_item(rg_track_gain=0) - matched = self.lib.items(NoneQuery("rg_track_gain", fast=False)) - assert item.id not in {i.id for i in matched} - - item["rg_track_gain"] = None - item.store() - matched = self.lib.items(NoneQuery("rg_track_gain", fast=False)) - assert item.id in {i.id for i in matched} - - -class NotQueryMatchTest(unittest.TestCase): - """Test `query.NotQuery` matching against a single item, using the same - cases and assertions as on `MatchTest`, plus assertion on the negated - queries (ie. assert q -> assert not NotQuery(q)). - """ - - def setUp(self): - super().setUp() - self.item = _common.item() - - def test_regex_match_positive(self): - q = dbcore.query.RegexpQuery("album", "^the album$") - assert q.match(self.item) - assert not dbcore.query.NotQuery(q).match(self.item) - - def test_regex_match_negative(self): - q = dbcore.query.RegexpQuery("album", "^album$") - assert not q.match(self.item) - assert dbcore.query.NotQuery(q).match(self.item) - - def test_regex_match_non_string_value(self): - q = dbcore.query.RegexpQuery("disc", "^6$") - assert q.match(self.item) - assert not dbcore.query.NotQuery(q).match(self.item) - - def test_substring_match_positive(self): - q = dbcore.query.SubstringQuery("album", "album") - assert q.match(self.item) - assert not dbcore.query.NotQuery(q).match(self.item) - - def test_substring_match_negative(self): - q = dbcore.query.SubstringQuery("album", "ablum") - assert not q.match(self.item) - assert dbcore.query.NotQuery(q).match(self.item) - - def test_substring_match_non_string_value(self): - q = dbcore.query.SubstringQuery("disc", "6") - assert q.match(self.item) - assert not dbcore.query.NotQuery(q).match(self.item) - - def test_year_match_positive(self): - q = dbcore.query.NumericQuery("year", "1") - assert q.match(self.item) - assert not dbcore.query.NotQuery(q).match(self.item) - - def test_year_match_negative(self): - q = dbcore.query.NumericQuery("year", "10") - assert not q.match(self.item) - assert dbcore.query.NotQuery(q).match(self.item) - - def test_bitrate_range_positive(self): - q = dbcore.query.NumericQuery("bitrate", "100000..200000") - assert q.match(self.item) - assert not dbcore.query.NotQuery(q).match(self.item) - - def test_bitrate_range_negative(self): - q = dbcore.query.NumericQuery("bitrate", "200000..300000") - assert not q.match(self.item) - assert dbcore.query.NotQuery(q).match(self.item) - - def test_open_range(self): - q = dbcore.query.NumericQuery("bitrate", "100000..") - dbcore.query.NotQuery(q) - - -class TestNotQuery: - """Test `query.NotQuery` against the dummy data.""" - - @pytest.fixture(autouse=True, scope="class") - def lib(self): - test_case = DummyDataTestCase() - test_case.setUp() - return test_case.lib - - @pytest.mark.parametrize( - "q, expected_results", - [ - ( - dbcore.query.BooleanQuery("comp", True), - {"beets 4 eva"}, - ), - ( - dbcore.query.DateQuery("added", "2000-01-01"), - {"foo bar", "baz qux", "beets 4 eva"}, - ), - ( - dbcore.query.FalseQuery(), - {"foo bar", "baz qux", "beets 4 eva"}, - ), - ( - dbcore.query.MatchQuery("year", "2003"), - {"foo bar", "baz qux"}, - ), - ( - dbcore.query.NoneQuery("rg_track_gain"), - set(), - ), - ( - dbcore.query.NumericQuery("year", "2001..2002"), - {"beets 4 eva"}, - ), - ( - dbcore.query.AnyFieldQuery( - "baz", ["album"], dbcore.query.MatchQuery - ), - {"beets 4 eva"}, - ), - ( - dbcore.query.AndQuery( - [ - dbcore.query.BooleanQuery("comp", True), - dbcore.query.NumericQuery("year", "2002"), - ] - ), - {"foo bar", "beets 4 eva"}, - ), - ( - dbcore.query.OrQuery( - [ - dbcore.query.BooleanQuery("comp", True), - dbcore.query.NumericQuery("year", "2002"), - ] - ), - {"beets 4 eva"}, - ), - ( - dbcore.query.RegexpQuery("artist", "^t"), - {"foo bar"}, - ), - ( - dbcore.query.SubstringQuery("album", "ba"), - {"beets 4 eva"}, - ), - ( - dbcore.query.TrueQuery(), - set(), - ), - ], - ids=lambda x: x.__class__ if isinstance(x, dbcore.query.Query) else "", - ) - def test_query_type(self, lib, q, expected_results): - def get_results(*args): - return {i.title for i in lib.items(*args)} - - # not(a and b) <-> not(a) or not(b) - not_q = dbcore.query.NotQuery(q) - not_q_results = get_results(not_q) - assert not_q_results == expected_results - - # assert using OrQuery, AndQuery - q_or = dbcore.query.OrQuery([q, not_q]) - - q_and = dbcore.query.AndQuery([q, not_q]) - assert get_results(q_or) == {"foo bar", "baz qux", "beets 4 eva"} - assert get_results(q_and) == set() - - # assert manually checking the item titles - all_titles = get_results() - q_results = get_results(q) - assert q_results.union(not_q_results) == all_titles - assert q_results.intersection(not_q_results) == set() - - # round trip - not_not_q = dbcore.query.NotQuery(not_q) - assert get_results(q) == get_results(not_not_q) - - -class NegationPrefixTest(DummyDataTestCase): - """Tests negation prefixes.""" - - def test_get_prefixes_keyed(self): - """Test both negation prefixes on a keyed query.""" - q0 = "-title:qux" - q1 = "^title:qux" - results0 = self.lib.items(q0) - results1 = self.lib.items(q1) - self.assert_items_matched(results0, ["foo bar", "beets 4 eva"]) - self.assert_items_matched(results1, ["foo bar", "beets 4 eva"]) - - def test_get_prefixes_unkeyed(self): - """Test both negation prefixes on an unkeyed query.""" - q0 = "-qux" - q1 = "^qux" - results0 = self.lib.items(q0) - results1 = self.lib.items(q1) - self.assert_items_matched(results0, ["foo bar", "beets 4 eva"]) - self.assert_items_matched(results1, ["foo bar", "beets 4 eva"]) - - def test_get_one_keyed_regexp(self): - q = "-artist::t.+r" - results = self.lib.items(q) - self.assert_items_matched(results, ["foo bar", "baz qux"]) - - def test_get_one_unkeyed_regexp(self): - q = "-:x$" - results = self.lib.items(q) - self.assert_items_matched(results, ["foo bar", "beets 4 eva"]) - - def test_get_multiple_terms(self): - q = "baz -bar" - results = self.lib.items(q) - self.assert_items_matched(results, ["baz qux"]) - - def test_get_mixed_terms(self): - q = "baz -title:bar" - results = self.lib.items(q) - self.assert_items_matched(results, ["baz qux"]) - - def test_fast_vs_slow(self): - """Test that the results are the same regardless of the `fast` flag - for negated `FieldQuery`s. - - TODO: investigate NoneQuery(fast=False), as it is raising - AttributeError: type object 'NoneQuery' has no attribute 'field' - at NoneQuery.match() (due to being @classmethod, and no self?) - """ - classes = [ - (dbcore.query.DateQuery, ["added", "2001-01-01"]), - (dbcore.query.MatchQuery, ["artist", "one"]), - # (dbcore.query.NoneQuery, ['rg_track_gain']), - (dbcore.query.NumericQuery, ["year", "2002"]), - (dbcore.query.StringFieldQuery, ["year", "2001"]), - (dbcore.query.RegexpQuery, ["album", "^.a"]), - (dbcore.query.SubstringQuery, ["title", "x"]), - ] - - for klass, args in classes: - q_fast = dbcore.query.NotQuery(klass(*(args + [True]))) - q_slow = dbcore.query.NotQuery(klass(*(args + [False]))) - - try: - assert [i.title for i in self.lib.items(q_fast)] == [ - i.title for i in self.lib.items(q_slow) - ] - except NotImplementedError: - # ignore classes that do not provide `fast` implementation - pass - - -class RelatedQueriesTest(BeetsTestCase, AssertsMixin): - """Test album-level queries with track-level filters and vice-versa.""" - - def setUp(self): - super().setUp() - - albums = [] - for album_idx in range(1, 3): - album_name = f"Album{album_idx}" - album_items = [] - for item_idx in range(1, 3): - item = _common.item() - item.album = album_name - item.title = f"{album_name} Item{item_idx}" - self.lib.add(item) - album_items.append(item) - album = self.lib.add_album(album_items) - album.artpath = f"{album_name} Artpath" - album.catalognum = "ABC" - album.store() - albums.append(album) - - self.album, self.another_album = albums - - def test_get_albums_filter_by_track_field(self): - q = "title:Album1" - results = self.lib.albums(q) - self.assert_albums_matched(results, ["Album1"]) - - def test_get_items_filter_by_album_field(self): - q = "artpath::Album1" - results = self.lib.items(q) - self.assert_items_matched(results, ["Album1 Item1", "Album1 Item2"]) - - def test_filter_albums_by_common_field(self): - # title:Album1 ensures that the items table is joined for the query - q = "title:Album1 Album1" - results = self.lib.albums(q) - self.assert_albums_matched(results, ["Album1"]) - - def test_filter_items_by_common_field(self): - # artpath::A ensures that the albums table is joined for the query - q = "artpath::A Album1" - results = self.lib.items(q) - self.assert_items_matched(results, ["Album1 Item1", "Album1 Item2"]) +_p = pytest.param @pytest.fixture(scope="class") @@ -858,8 +60,228 @@ def helper(): helper.teardown_beets() +class TestGet: + @pytest.fixture(scope="class") + def lib(self, helper): + album_items = [ + helper.create_item( + title="first", + artist="one", + artists=["one", "eleven"], + album="baz", + year=2001, + comp=True, + genre="rock", + ), + helper.create_item( + title="second", + artist="two", + artists=["two", "twelve"], + album="baz", + year=2002, + comp=True, + genre="Rock", + ), + ] + album = helper.lib.add_album(album_items) + album.albumflex = "foo" + album.store() + + helper.add_item( + title="third", + artist="three", + artists=["three", "one"], + album="foo", + year=2003, + comp=False, + genre="Hard Rock", + comments="caf\xe9", + ) + + return helper.lib + + @pytest.mark.parametrize( + "q, expected_titles", + [ + ("", ["first", "second", "third"]), + (None, ["first", "second", "third"]), + (":oNE", []), + (":one", ["first"]), + (":sec :ond", ["second"]), + (":second", ["second"]), + ("=rock", ["first"]), + ('=~"hard rock"', ["third"]), + (":t$", ["first"]), + ("oNE", ["first"]), + ("baz", ["first", "second"]), + ("sec ond", ["second"]), + ("three", ["third"]), + ("albumflex:foo", ["first", "second"]), + ("artist::t.+r", ["third"]), + ("artist:thrEE", ["third"]), + ("artists::eleven", ["first"]), + ("artists::one", ["first", "third"]), + ("ArTiST:three", ["third"]), + ("comments:caf\xe9", ["third"]), + ("comp:true", ["first", "second"]), + ("comp:false", ["third"]), + ("genre:=rock", ["first"]), + ("genre:=Rock", ["second"]), + ('genre:="Hard Rock"', ["third"]), + ('genre:=~"hard rock"', ["third"]), + ("genre:=~rock", ["first", "second"]), + ('genre:="hard rock"', []), + ("popebear", []), + ("pope:bear", []), + ("singleton:true", ["third"]), + ("singleton:1", ["third"]), + ("singleton:false", ["first", "second"]), + ("singleton:0", ["first", "second"]), + ("title:ond", ["second"]), + ("title::sec", ["second"]), + ("year:2001", ["first"]), + ("year:2000..2002", ["first", "second"]), + ("xyzzy:nonsense", []), + ], + ) + def test_get_query(self, lib, q, expected_titles): + assert {i.title for i in lib.items(q)} == set(expected_titles) + + @pytest.mark.parametrize( + "q, expected_titles", + [ + (BooleanQuery("comp", True), ("third",)), + (DateQuery("added", "2000-01-01"), ("first", "second", "third")), + (FalseQuery(), ("first", "second", "third")), + (MatchQuery("year", "2003"), ("first", "second")), + (NoneQuery("rg_track_gain"), ()), + (NumericQuery("year", "2001..2002"), ("third",)), + ( + AndQuery( + [BooleanQuery("comp", True), NumericQuery("year", "2002")] + ), + ("first", "third"), + ), + ( + OrQuery( + [BooleanQuery("comp", True), NumericQuery("year", "2002")] + ), + ("third",), + ), + (RegexpQuery("artist", "^t"), ("first",)), + (SubstringQuery("album", "ba"), ("third",)), + (TrueQuery(), ()), + ], + ) + def test_query_logic(self, lib, q, expected_titles): + def get_results(*args): + return {i.title for i in lib.items(*args)} + + # not(a and b) <-> not(a) or not(b) + not_q = NotQuery(q) + not_q_results = get_results(not_q) + assert not_q_results == set(expected_titles) + + # assert using OrQuery, AndQuery + q_or = OrQuery([q, not_q]) + + q_and = AndQuery([q, not_q]) + assert get_results(q_or) == {"first", "second", "third"} + assert get_results(q_and) == set() + + # assert manually checking the item titles + all_titles = get_results() + q_results = get_results(q) + assert q_results.union(not_q_results) == all_titles + assert q_results.intersection(not_q_results) == set() + + # round trip + not_not_q = NotQuery(not_q) + assert get_results(q) == get_results(not_not_q) + + @pytest.mark.parametrize( + "q, expected_titles", + [ + ("-artist::t.+r", ["first", "second"]), + ("-:t$", ["second", "third"]), + ("sec -bar", ["second"]), + ("sec -title:bar", ["second"]), + ("-ond", ["first", "third"]), + ("^ond", ["first", "third"]), + ("^title:sec", ["first", "third"]), + ("-title:sec", ["first", "third"]), + ], + ) + def test_negation_prefix(self, lib, q, expected_titles): + actual_titles = {i.title for i in lib.items(q)} + assert actual_titles == set(expected_titles) + + @pytest.mark.parametrize( + "make_q", + [ + partial(DateQuery, "added", "2001-01-01"), + partial(MatchQuery, "artist", "one"), + partial(NoneQuery, "rg_track_gain"), + partial(NumericQuery, "year", "2002"), + partial(StringQuery, "year", "2001"), + partial(RegexpQuery, "album", "^.a"), + partial(SubstringQuery, "title", "x"), + ], + ) + def test_fast_vs_slow(self, lib, make_q): + """Test that the results are the same regardless of the `fast` flag + for negated `FieldQuery`s. + """ + q_fast = make_q(True) + q_slow = make_q(False) + + assert list(map(dict, lib.items(q_fast))) == list( + map(dict, lib.items(q_slow)) + ) + + +class TestMatch: + @pytest.fixture(scope="class") + def item(self): + return _common.item( + album="the album", + disc=6, + genre="the genre", + year=1, + bitrate=128000, + ) + + @pytest.mark.parametrize( + "q, should_match", + [ + (RegexpQuery("album", "^the album$"), True), + (RegexpQuery("album", "^album$"), False), + (RegexpQuery("disc", "^6$"), True), + (SubstringQuery("album", "album"), True), + (SubstringQuery("album", "ablum"), False), + (SubstringQuery("disc", "6"), True), + (StringQuery("genre", "the genre"), True), + (StringQuery("genre", "THE GENRE"), True), + (StringQuery("genre", "genre"), False), + (NumericQuery("year", "1"), True), + (NumericQuery("year", "10"), False), + (NumericQuery("bitrate", "100000..200000"), True), + (NumericQuery("bitrate", "200000..300000"), False), + (NumericQuery("bitrate", "100000.."), True), + ], + ) + def test_match(self, item, q, should_match): + assert q.match(item) == should_match + assert not NotQuery(q).match(item) == should_match + + class TestPathQuery: - _p = pytest.param + """Tests for path-based querying functionality in the database system. + + Verifies that path queries correctly match items by their file paths, + handling special characters, case sensitivity, parent directories, + and path separator detection across different platforms. + """ @pytest.fixture(scope="class") def lib(self, helper): @@ -889,6 +311,7 @@ class TestPathQuery: ], ) def test_explicit(self, monkeypatch, lib, q, expected_titles): + """Test explicit path queries with different path specifications.""" monkeypatch.setattr("beets.util.case_sensitive", lambda *_: True) assert {i.title for i in lib.items(q)} == set(expected_titles) @@ -904,6 +327,7 @@ class TestPathQuery: ], ) def test_implicit(self, monkeypatch, lib, q, expected_titles): + """Test implicit path detection when queries contain path separators.""" monkeypatch.setattr( "beets.dbcore.query.PathQuery.is_path_query", lambda path: True ) @@ -920,6 +344,7 @@ class TestPathQuery: def test_case_sensitivity( self, lib, monkeypatch, case_sensitive, expected_titles ): + """Test path matching with different case sensitivity settings.""" q = "path:/a/b/c2.mp3" monkeypatch.setattr( "beets.util.case_sensitive", lambda *_: case_sensitive @@ -943,6 +368,7 @@ class TestPathQuery: ], ) def test_path_sep_detection(self, monkeypatch, tmp_path, q, is_path_query): + """Test detection of path queries based on the presence of path separators.""" monkeypatch.chdir(tmp_path) (tmp_path / "foo").mkdir() (tmp_path / "foo" / "bar").touch() @@ -950,3 +376,151 @@ class TestPathQuery: q = str(tmp_path / q[1:]) assert PathQuery.is_path_query(q) == is_path_query + + +class TestQuery: + ALBUM = "album title" + SINGLE = "singleton" + + @pytest.fixture(scope="class") + def lib(self, helper): + helper.add_album( + title=self.ALBUM, + comp=True, + flexbool=True, + bpm=120, + flexint=2, + rg_track_gain=0, + ) + helper.add_item( + title=self.SINGLE, comp=False, flexbool=False, rg_track_gain=None + ) + + with pytest.MonkeyPatch.context() as monkeypatch: + monkeypatch.setattr( + Item, + "_types", + {"flexbool": types.Boolean(), "flexint": types.Integer()}, + ) + yield helper.lib + + @pytest.mark.parametrize("query_class", [MatchQuery, StringFieldQuery]) + def test_equality(self, query_class): + assert query_class("foo", "bar") == query_class("foo", "bar") + + @pytest.mark.parametrize( + "make_q, expected_msg", + [ + (lambda: NumericQuery("year", "199a"), "not an int"), + (lambda: RegexpQuery("year", "199("), r"not a regular expression.*unterminated subpattern"), # noqa: E501 + ] + ) # fmt: skip + def test_invalid_query(self, make_q, expected_msg): + with pytest.raises(ParsingError, match=expected_msg): + make_q() + + @pytest.mark.parametrize( + "q, expected_titles", + [ + # Boolean value + _p("comp:true", {ALBUM}, id="parse-true"), + _p("flexbool:true", {ALBUM}, id="flex-parse-true"), + _p("flexbool:false", {SINGLE}, id="flex-parse-false"), + _p("flexbool:1", {ALBUM}, id="flex-parse-1"), + _p("flexbool:0", {SINGLE}, id="flex-parse-0"), + # TODO: shouldn't this match 1 / true instead? + _p("flexbool:something", {SINGLE}, id="flex-parse-true"), + # Integer value + _p("bpm:120", {ALBUM}, id="int-exact-value"), + _p("bpm:110..125", {ALBUM}, id="int-range"), + _p("flexint:2", {ALBUM}, id="int-flex"), + _p("flexint:3", set(), id="int-no-match"), + _p("bpm:12", set(), id="int-dont-match-substring"), + # None value + _p(NoneQuery("album_id"), {SINGLE}, id="none-match-singleton"), + _p(NoneQuery("rg_track_gain"), {SINGLE}, id="none-value"), + ], + ) + def test_value_type(self, lib, q, expected_titles): + assert {i.title for i in lib.items(q)} == expected_titles + + +class TestDefaultSearchFields: + @pytest.fixture(scope="class") + def lib(self, helper): + helper.add_album( + title="title", + album="album", + albumartist="albumartist", + catalognum="catalognum", + year=2001, + ) + + return helper.lib + + @pytest.mark.parametrize( + "entity, q, should_match", + [ + _p("albums", "album", True, id="album-match-album"), + _p("albums", "albumartist", True, id="album-match-albumartist"), + _p("albums", "catalognum", False, id="album-dont-match-catalognum"), + _p("items", "title", True, id="item-match-title"), + _p("items", "2001", False, id="item-dont-match-year"), + ], + ) + def test_search(self, lib, entity, q, should_match): + assert bool(getattr(lib, entity)(q)) == should_match + + +class TestRelatedQueries: + """Test album-level queries with track-level filters and vice-versa.""" + + @pytest.fixture(scope="class") + def lib(self, helper): + for album_idx in range(1, 3): + album_name = f"Album{album_idx}" + items = [ + helper.create_item( + album=album_name, title=f"{album_name} Item{idx}" + ) + for idx in range(1, 3) + ] + album = helper.lib.add_album(items) + album.artpath = f"{album_name} Artpath" + album.catalognum = "ABC" + album.store() + + return helper.lib + + @pytest.mark.parametrize( + "q, expected_titles, expected_albums", + [ + _p( + "title:Album1", + ["Album1 Item1", "Album1 Item2"], + ["Album1"], + id="match-album-with-item-field-query", + ), + _p( + "title:Item2", + ["Album1 Item2", "Album2 Item2"], + ["Album1", "Album2"], + id="match-albums-with-item-field-query", + ), + _p( + "artpath::Album1", + ["Album1 Item1", "Album1 Item2"], + ["Album1"], + id="match-items-with-album-field-query", + ), + _p( + "catalognum:ABC Album1", + ["Album1 Item1", "Album1 Item2"], + ["Album1"], + id="query-field-common-to-album-and-item", + ), + ], + ) + def test_related_query(self, lib, q, expected_titles, expected_albums): + assert {i.album for i in lib.albums(q)} == set(expected_albums) + assert {i.title for i in lib.items(q)} == set(expected_titles)