From 3162478172512757d6547b2e0cf98aadcc90085b Mon Sep 17 00:00:00 2001 From: Valerus9 Date: Fri, 8 Aug 2025 12:47:07 +0200 Subject: [PATCH 01/16] Implement not equal operator Added blank lines --- osu.Game/Screens/Select/FilterCriteria.cs | 24 ++++- osu.Game/Screens/Select/FilterQueryParser.cs | 98 +++++++++++++++++++- 2 files changed, 115 insertions(+), 7 deletions(-) diff --git a/osu.Game/Screens/Select/FilterCriteria.cs b/osu.Game/Screens/Select/FilterCriteria.cs index ce7d624e2a..25101d65f2 100644 --- a/osu.Game/Screens/Select/FilterCriteria.cs +++ b/osu.Game/Screens/Select/FilterCriteria.cs @@ -158,10 +158,10 @@ namespace osu.Game.Screens.Select { int comparison = Comparer.Default.Compare(value, Min.Value); - if (comparison < 0) + if (comparison < 0 && !ExcludeIfInRange) return false; - if (comparison == 0 && !IsLowerInclusive) + if (comparison == 0 && !IsLowerInclusive && !ExcludeIfInRange) return false; } @@ -169,10 +169,25 @@ namespace osu.Game.Screens.Select { int comparison = Comparer.Default.Compare(value, Max.Value); - if (comparison > 0) + if (comparison > 0 && !ExcludeIfInRange) return false; - if (comparison == 0 && !IsUpperInclusive) + if (comparison == 0 && !IsUpperInclusive && !ExcludeIfInRange) + return false; + } + + if (Min != null && Max != null) + { + int minComparison = Comparer.Default.Compare(value, Min.Value); + int maxComparison = Comparer.Default.Compare(value, Max.Value); + + if (minComparison > 0 && maxComparison < 0 && ExcludeIfInRange) + return false; + + if (minComparison == 0 && IsLowerInclusive && ExcludeIfInRange) + return false; + + if (maxComparison == 0 && IsUpperInclusive && ExcludeIfInRange) return false; } @@ -183,6 +198,7 @@ namespace osu.Game.Screens.Select public T? Max; public bool IsLowerInclusive; public bool IsUpperInclusive; + public bool ExcludeIfInRange; public bool Equals(OptionalRange other) => EqualityComparer.Default.Equals(Min, other.Min) diff --git a/osu.Game/Screens/Select/FilterQueryParser.cs b/osu.Game/Screens/Select/FilterQueryParser.cs index 36afd8fb72..b647416113 100644 --- a/osu.Game/Screens/Select/FilterQueryParser.cs +++ b/osu.Game/Screens/Select/FilterQueryParser.cs @@ -256,6 +256,8 @@ namespace osu.Game.Screens.Select private static bool tryUpdateCriteriaRange(ref FilterCriteria.OptionalRange range, Operator op, float value, float tolerance = 0.05f) { + range.ExcludeIfInRange = false; + switch (op) { default: @@ -281,6 +283,14 @@ namespace osu.Game.Screens.Select case Operator.LessOrEqual: range.Max = value + tolerance; break; + + case Operator.NotEqual: + range.Min = value - tolerance; + range.Max = value + tolerance; + range.ExcludeIfInRange = true; + if (tolerance == 0) + range.IsLowerInclusive = range.IsUpperInclusive = true; + break; } return true; @@ -304,6 +314,8 @@ namespace osu.Game.Screens.Select private static bool tryUpdateCriteriaRange(ref FilterCriteria.OptionalRange range, Operator op, double value, double tolerance = 0.05) { + range.ExcludeIfInRange = false; + switch (op) { default: @@ -335,6 +347,14 @@ namespace osu.Game.Screens.Select if (tolerance == 0) range.IsUpperInclusive = true; break; + + case Operator.NotEqual: + range.Min = value - tolerance; + range.Max = value + tolerance; + range.ExcludeIfInRange = true; + if (tolerance == 0) + range.IsLowerInclusive = range.IsUpperInclusive = true; + break; } return true; @@ -389,6 +409,40 @@ namespace osu.Game.Screens.Select matchingValues.Add(parsedValue); } } + else if (op == Operator.NotEqual && filterValue.Contains(',')) + { + string[] splitValues = filterValue.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + + var allDefinedValues = Enum.GetValues(); + HashSet excludedValues = new HashSet(); + + foreach (string splitValue in splitValues) + { + if (!tryParseEnum(splitValue, out var parsedValue)) + return false; + + excludedValues.Add(parsedValue); + } + + foreach (var definedValue in allDefinedValues) + { + bool isExcludedValue = false; + + foreach (var excludedValue in excludedValues) + { + int compareResult = Comparer.Default.Compare(definedValue, excludedValue); + + if (compareResult == 0) + { + isExcludedValue = true; + break; + } + } + + if (!isExcludedValue) + matchingValues.Add(definedValue); + } + } else { if (!tryParseEnum(filterValue, out var pivotValue)) @@ -422,6 +476,10 @@ namespace osu.Game.Screens.Select if (compareResult > 0) matchingValues.Add(val); break; + case Operator.NotEqual: + if (compareResult != 0) matchingValues.Add(val); + break; + default: return false; } @@ -435,6 +493,8 @@ namespace osu.Game.Screens.Select private static bool tryUpdateCriteriaRange(ref FilterCriteria.OptionalRange range, Operator op, T value) where T : struct { + range.ExcludeIfInRange = false; + switch (op) { default: @@ -465,6 +525,13 @@ namespace osu.Game.Screens.Select range.IsUpperInclusive = true; range.Max = value; break; + + case Operator.NotEqual: + range.IsLowerInclusive = range.IsUpperInclusive = true; + range.Min = value; + range.Max = value; + range.ExcludeIfInRange = true; + break; } return true; @@ -679,6 +746,8 @@ namespace osu.Game.Screens.Select try { DateTimeOffset dateTimeOffset; + DateTimeOffset minDateTimeOffset; + DateTimeOffset maxDateTimeOffset; switch (op) { @@ -736,9 +805,6 @@ namespace osu.Game.Screens.Select case Operator.Equal: - DateTimeOffset minDateTimeOffset; - DateTimeOffset maxDateTimeOffset; - if (month == null) { month = 1; @@ -763,6 +829,32 @@ namespace osu.Game.Screens.Select return tryUpdateCriteriaRange(ref dateRange, Operator.GreaterOrEqual, minDateTimeOffset) && tryUpdateCriteriaRange(ref dateRange, Operator.Less, maxDateTimeOffset); + case Operator.NotEqual: + + if (month == null) + { + month = 1; + day = 1; + minDateTimeOffset = dateTimeOffsetFromDateOnly(year.Value, month.Value, day.Value); + maxDateTimeOffset = dateTimeOffsetFromDateOnly(year.Value, month.Value, day.Value).AddYears(1); + return tryUpdateCriteriaRange(ref dateRange, Operator.Less, minDateTimeOffset) + || tryUpdateCriteriaRange(ref dateRange, Operator.GreaterOrEqual, maxDateTimeOffset); + } + + if (day == null) + { + day = 1; + minDateTimeOffset = dateTimeOffsetFromDateOnly(year.Value, month.Value, day.Value); + maxDateTimeOffset = dateTimeOffsetFromDateOnly(year.Value, month.Value, day.Value).AddMonths(1); + return tryUpdateCriteriaRange(ref dateRange, Operator.Less, minDateTimeOffset) + || tryUpdateCriteriaRange(ref dateRange, Operator.GreaterOrEqual, maxDateTimeOffset); + } + + minDateTimeOffset = dateTimeOffsetFromDateOnly(year.Value, month.Value, day.Value); + maxDateTimeOffset = dateTimeOffsetFromDateOnly(year.Value, month.Value, day.Value).AddDays(1); + return tryUpdateCriteriaRange(ref dateRange, Operator.Less, minDateTimeOffset) + || tryUpdateCriteriaRange(ref dateRange, Operator.GreaterOrEqual, maxDateTimeOffset); + default: return false; } From 1a18aac5159ecf1a2880feb96582efe98b421f88 Mon Sep 17 00:00:00 2001 From: Valerus9 Date: Fri, 8 Aug 2025 12:47:19 +0200 Subject: [PATCH 02/16] Add test coverage --- .../NonVisual/Filtering/FilterQueryParserTest.cs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/osu.Game.Tests/NonVisual/Filtering/FilterQueryParserTest.cs b/osu.Game.Tests/NonVisual/Filtering/FilterQueryParserTest.cs index 578698b724..f162a3ea7b 100644 --- a/osu.Game.Tests/NonVisual/Filtering/FilterQueryParserTest.cs +++ b/osu.Game.Tests/NonVisual/Filtering/FilterQueryParserTest.cs @@ -294,6 +294,16 @@ namespace osu.Game.Tests.NonVisual.Filtering Assert.That(filterCriteria.OnlineStatus.Values, Is.Empty); } + [Test] + public void TestPartialStatusNotMatch() + { + const string query = "status!=r"; + var filterCriteria = new FilterCriteria(); + FilterQueryParser.ApplyQueries(filterCriteria, query); + Assert.IsNotEmpty(filterCriteria.OnlineStatus.Values); + Assert.That(filterCriteria.OnlineStatus.Values, Does.Not.Contain(BeatmapOnlineStatus.Ranked)); + } + [Test] public void TestApplyEqualStatusQueryWithMultipleValues() { From 3a78cfc6271f79d642bbcce881aaf42d4f4c9b0c Mon Sep 17 00:00:00 2001 From: Valerus9 Date: Fri, 8 Aug 2025 13:36:36 +0200 Subject: [PATCH 03/16] Rename ExludeIfInRange to InvertRange, add xmldox to InvertRange --- osu.Game/Screens/Select/FilterCriteria.cs | 19 +++++++++++-------- osu.Game/Screens/Select/FilterQueryParser.cs | 12 ++++++------ 2 files changed, 17 insertions(+), 14 deletions(-) diff --git a/osu.Game/Screens/Select/FilterCriteria.cs b/osu.Game/Screens/Select/FilterCriteria.cs index 25101d65f2..2a301dbcda 100644 --- a/osu.Game/Screens/Select/FilterCriteria.cs +++ b/osu.Game/Screens/Select/FilterCriteria.cs @@ -158,10 +158,10 @@ namespace osu.Game.Screens.Select { int comparison = Comparer.Default.Compare(value, Min.Value); - if (comparison < 0 && !ExcludeIfInRange) + if (comparison < 0 && !InvertRange) return false; - if (comparison == 0 && !IsLowerInclusive && !ExcludeIfInRange) + if (comparison == 0 && !IsLowerInclusive && !InvertRange) return false; } @@ -169,10 +169,10 @@ namespace osu.Game.Screens.Select { int comparison = Comparer.Default.Compare(value, Max.Value); - if (comparison > 0 && !ExcludeIfInRange) + if (comparison > 0 && !InvertRange) return false; - if (comparison == 0 && !IsUpperInclusive && !ExcludeIfInRange) + if (comparison == 0 && !IsUpperInclusive && !InvertRange) return false; } @@ -181,13 +181,13 @@ namespace osu.Game.Screens.Select int minComparison = Comparer.Default.Compare(value, Min.Value); int maxComparison = Comparer.Default.Compare(value, Max.Value); - if (minComparison > 0 && maxComparison < 0 && ExcludeIfInRange) + if (minComparison > 0 && maxComparison < 0 && InvertRange) return false; - if (minComparison == 0 && IsLowerInclusive && ExcludeIfInRange) + if (minComparison == 0 && IsLowerInclusive && InvertRange) return false; - if (maxComparison == 0 && IsUpperInclusive && ExcludeIfInRange) + if (maxComparison == 0 && IsUpperInclusive && InvertRange) return false; } @@ -198,7 +198,10 @@ namespace osu.Game.Screens.Select public T? Max; public bool IsLowerInclusive; public bool IsUpperInclusive; - public bool ExcludeIfInRange; + /// + /// If true, only outside of MaxValue and MinValue will return true; + /// + public bool InvertRange; public bool Equals(OptionalRange other) => EqualityComparer.Default.Equals(Min, other.Min) diff --git a/osu.Game/Screens/Select/FilterQueryParser.cs b/osu.Game/Screens/Select/FilterQueryParser.cs index b647416113..8652331419 100644 --- a/osu.Game/Screens/Select/FilterQueryParser.cs +++ b/osu.Game/Screens/Select/FilterQueryParser.cs @@ -256,7 +256,7 @@ namespace osu.Game.Screens.Select private static bool tryUpdateCriteriaRange(ref FilterCriteria.OptionalRange range, Operator op, float value, float tolerance = 0.05f) { - range.ExcludeIfInRange = false; + range.InvertRange = false; switch (op) { @@ -287,7 +287,7 @@ namespace osu.Game.Screens.Select case Operator.NotEqual: range.Min = value - tolerance; range.Max = value + tolerance; - range.ExcludeIfInRange = true; + range.InvertRange = true; if (tolerance == 0) range.IsLowerInclusive = range.IsUpperInclusive = true; break; @@ -314,7 +314,7 @@ namespace osu.Game.Screens.Select private static bool tryUpdateCriteriaRange(ref FilterCriteria.OptionalRange range, Operator op, double value, double tolerance = 0.05) { - range.ExcludeIfInRange = false; + range.InvertRange = false; switch (op) { @@ -351,7 +351,7 @@ namespace osu.Game.Screens.Select case Operator.NotEqual: range.Min = value - tolerance; range.Max = value + tolerance; - range.ExcludeIfInRange = true; + range.InvertRange = true; if (tolerance == 0) range.IsLowerInclusive = range.IsUpperInclusive = true; break; @@ -493,7 +493,7 @@ namespace osu.Game.Screens.Select private static bool tryUpdateCriteriaRange(ref FilterCriteria.OptionalRange range, Operator op, T value) where T : struct { - range.ExcludeIfInRange = false; + range.InvertRange = false; switch (op) { @@ -530,7 +530,7 @@ namespace osu.Game.Screens.Select range.IsLowerInclusive = range.IsUpperInclusive = true; range.Min = value; range.Max = value; - range.ExcludeIfInRange = true; + range.InvertRange = true; break; } From cc970ffd8069ea37b9febe60de15f8041b67b415 Mon Sep 17 00:00:00 2001 From: Valerus9 Date: Fri, 8 Aug 2025 13:41:59 +0200 Subject: [PATCH 04/16] Simplify logic of IsInRange() --- osu.Game/Screens/Select/FilterCriteria.cs | 39 ++++++----------------- 1 file changed, 10 insertions(+), 29 deletions(-) diff --git a/osu.Game/Screens/Select/FilterCriteria.cs b/osu.Game/Screens/Select/FilterCriteria.cs index 2a301dbcda..c223c291ee 100644 --- a/osu.Game/Screens/Select/FilterCriteria.cs +++ b/osu.Game/Screens/Select/FilterCriteria.cs @@ -154,44 +154,25 @@ namespace osu.Game.Screens.Select public bool IsInRange(T value) { + bool lowerRangeSatisfied = true; + bool upperRangeSatisfied = true; + if (Min != null) { int comparison = Comparer.Default.Compare(value, Min.Value); - - if (comparison < 0 && !InvertRange) - return false; - - if (comparison == 0 && !IsLowerInclusive && !InvertRange) - return false; + lowerRangeSatisfied = comparison > 0 || (comparison == 0 && IsLowerInclusive); } if (Max != null) { int comparison = Comparer.Default.Compare(value, Max.Value); - - if (comparison > 0 && !InvertRange) - return false; - - if (comparison == 0 && !IsUpperInclusive && !InvertRange) - return false; + upperRangeSatisfied = comparison < 0 || (comparison == 0 && IsUpperInclusive); } - if (Min != null && Max != null) - { - int minComparison = Comparer.Default.Compare(value, Min.Value); - int maxComparison = Comparer.Default.Compare(value, Max.Value); - - if (minComparison > 0 && maxComparison < 0 && InvertRange) - return false; - - if (minComparison == 0 && IsLowerInclusive && InvertRange) - return false; - - if (maxComparison == 0 && IsUpperInclusive && InvertRange) - return false; - } - - return true; + bool result = lowerRangeSatisfied && upperRangeSatisfied; + if (InvertRange) + result = !result; + return result; } public T? Min; @@ -199,7 +180,7 @@ namespace osu.Game.Screens.Select public bool IsLowerInclusive; public bool IsUpperInclusive; /// - /// If true, only outside of MaxValue and MinValue will return true; + /// If true, only outside of MaxValue and MinValue will return true /// public bool InvertRange; From 02f835dbc37c60ae9cd1b853dd2ee1f372b7e62c Mon Sep 17 00:00:00 2001 From: Valerus9 Date: Fri, 8 Aug 2025 13:56:59 +0200 Subject: [PATCH 05/16] Added FilterMatchTest for InvertRange --- .../NonVisual/Filtering/FilterMatchingTest.cs | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/osu.Game.Tests/NonVisual/Filtering/FilterMatchingTest.cs b/osu.Game.Tests/NonVisual/Filtering/FilterMatchingTest.cs index eeca60a314..62486d8d5b 100644 --- a/osu.Game.Tests/NonVisual/Filtering/FilterMatchingTest.cs +++ b/osu.Game.Tests/NonVisual/Filtering/FilterMatchingTest.cs @@ -146,6 +146,30 @@ namespace osu.Game.Tests.NonVisual.Filtering Assert.AreEqual(!inclusive, carouselItem.Filtered.Value); } + [Test] + [TestCase(true)] + [TestCase(false)] + public void TestCriteriaMatchingInvertedRange(bool inverted) + { + var exampleBeatmapInfo = getExampleBeatmap(); + var criteria = new FilterCriteria + { + Ruleset = new RulesetInfo { OnlineID = 6 }, + AllowConvertedBeatmaps = true, + StarDifficulty = new FilterCriteria.OptionalRange + { + Max = 4.0d, + Min = 4.0d, + IsLowerInclusive = true, + IsUpperInclusive = true, + InvertRange = inverted + } + }; + var carouselItem = new CarouselBeatmap(exampleBeatmapInfo); + carouselItem.Filter(criteria); + Assert.AreEqual(inverted, carouselItem.Filtered.Value); + } + [Test] [TestCase("artist", false)] [TestCase("artist title author", false)] From 997b8a0bba66492ec646dafb06f0a603dc624686 Mon Sep 17 00:00:00 2001 From: Valerus9 Date: Tue, 12 Aug 2025 14:48:43 +0200 Subject: [PATCH 06/16] Add tests coverage for filters with not equal --- .../Filtering/FilterQueryParserTest.cs | 193 ++++++++++++++++++ osu.Game/Screens/Select/FilterCriteria.cs | 8 +- osu.Game/Screens/Select/FilterQueryParser.cs | 34 +-- 3 files changed, 219 insertions(+), 16 deletions(-) diff --git a/osu.Game.Tests/NonVisual/Filtering/FilterQueryParserTest.cs b/osu.Game.Tests/NonVisual/Filtering/FilterQueryParserTest.cs index f162a3ea7b..ad266432fe 100644 --- a/osu.Game.Tests/NonVisual/Filtering/FilterQueryParserTest.cs +++ b/osu.Game.Tests/NonVisual/Filtering/FilterQueryParserTest.cs @@ -521,6 +521,199 @@ namespace osu.Game.Tests.NonVisual.Filtering Assert.That(visibleBeatmaps, Is.EqualTo(expectedBeatmapIndexes)); } + [TestCase("title!=Title", new[] { 2, 4, 6 })] + [TestCase("title!=\"Title1\"", new[] { 2, 3, 4, 5, 6 })] + [TestCase("title!=\"Title1\"!", new[] { 2, 3, 4, 5, 6 })] + [TestCase("artist!=artist", new int[] { })] + [TestCase("artist!=\"artist2\"", new[] { 1, 2, 4, 6 })] + [TestCase("artist!=\"artist2\"!", new[] { 1, 2, 4, 6 })] + [TestCase("diff!=Diff", new[] { 2, 5 })] + [TestCase("diff!=\"Diff1\"", new[] { 1, 2, 3, 4, 5, 6 })] + [TestCase("diff!=\"Diff1\"!", new[] { 1, 2, 3, 4, 5, 6 })] + public void TestNotEqualSearchForTextFilters(string query, int[] expectedBeatmapIndexes) + { + var carouselBeatmaps = (((string title, string difficultyName, string artist)[])new[] + { + ("Title1", "Diff1", "artist2"), + ("Title1", "Diff2", "artist1"), + ("My[Favourite]Song", "Expert", "artist1"), + ("Title", "My Favourite Diff", "artist2"), + ("Another One", "diff ]with [[ brackets]]]", "artist3"), + ("Diff in title", "a", "artist2"), + ("a", "Diff in diff", "artist3") + }).Select(info => new CarouselBeatmap(new BeatmapInfo + { + Metadata = new BeatmapMetadata + { + Title = info.title, + Artist = info.artist + }, + DifficultyName = info.difficultyName + + })).ToList(); + + var criteria = new FilterCriteria(); + + FilterQueryParser.ApplyQueries(criteria, query); + carouselBeatmaps.ForEach(b => b.Filter(criteria)); + + int[] visibleBeatmaps = carouselBeatmaps + .Where(b => !b.Filtered.Value) + .Select(b => carouselBeatmaps.IndexOf(b)).ToArray(); + + Assert.That(visibleBeatmaps, Is.EqualTo(expectedBeatmapIndexes)); + } + + [TestCase("ar!=5", new[] { 0, 2, 3, 5 })] + [TestCase("cs!=7", new[] { 0, 2, 3, 6 })] + [TestCase("od!=3", new[] { 0, 2, 4, 6 })] + [TestCase("hp!=6", new[] { 0, 1, 3, 5, 6 })] + [TestCase("star!=1.78", new[] { 0, 2, 3, 5, 6 })] + [TestCase("bpm!=144", new[] { 0, 1, 3, 5 })] + [TestCase("length!=120", new[] { 2, 3, 4, 6 })] + public void TestNotEqualSearchForNumberFilters(string query, int[] expectedBeatmapIndexes) + { + var carouselBeatmaps = (((float ar, float cs, float od, float hp, double star, double bpm, double length)[])new[] + { + (10.0f, 5.0f, 1.0f, 6.5f, 2.78, 100.0, 120000.0), + (5.0f, 7.0f, 3.0f, 8.0f, 1.78, 244.0, 120000.0), + (5.5f, 7.5f, 4.0f, 6.0f, 1.55, 144.0, 60000.0), + (6.0f, 2.0f, 3.0f, 7.0f, 3.78, 774.0, 440000.0), + (5.0f, 7.0f, 4.0f, 6.0f, 1.78, 144.0, 310000.0), + (5.8f, 7.0f, 3.0f, 6.5f, 1.55, 344.0, 120000.0), + (5.0f, 3.0f, 7.0f, 10.0f, 2.78, 144.0, 260000.0) + }).Select(info => new CarouselBeatmap(new BeatmapInfo + { + Difficulty = new BeatmapDifficulty{ + ApproachRate = info.ar, + OverallDifficulty = info.od, + DrainRate = info.hp, + CircleSize = info.cs + }, + BPM = info.bpm, + StarRating = info.star, + Length = info.length + + })).ToList(); + + var criteria = new FilterCriteria(); + + FilterQueryParser.ApplyQueries(criteria, query); + carouselBeatmaps.ForEach(b => b.Filter(criteria)); + + int[] visibleBeatmaps = carouselBeatmaps + .Where(b => !b.Filtered.Value) + .Select(b => carouselBeatmaps.IndexOf(b)).ToArray(); + + Assert.That(visibleBeatmaps, Is.EqualTo(expectedBeatmapIndexes)); + } + + [TestCase("status!=ranked", new[] { 1, 2, 4, 5 })] + [TestCase("status!=r", new[] { 1, 2, 4, 5 })] + [TestCase("status!=loved", new[] { 0, 1, 2, 3, 4, 6 })] + [TestCase("status!=l", new[] { 0, 1, 2, 3, 4, 6 })] + public void TestNotEqualSearchForEnumFilter(string query, int[] expectedBeatmapIndexes) + { + var carouselBeatmaps = (new BeatmapOnlineStatus[] + { + BeatmapOnlineStatus.Ranked, + BeatmapOnlineStatus.Qualified, + BeatmapOnlineStatus.Approved, + BeatmapOnlineStatus.Ranked, + BeatmapOnlineStatus.Approved, + BeatmapOnlineStatus.Loved, + BeatmapOnlineStatus.Ranked + }).Select(info => new CarouselBeatmap(new BeatmapInfo + { + Status = info + + })).ToList(); + + var criteria = new FilterCriteria(); + + FilterQueryParser.ApplyQueries(criteria, query); + carouselBeatmaps.ForEach(b => b.Filter(criteria)); + + int[] visibleBeatmaps = carouselBeatmaps + .Where(b => !b.Filtered.Value) + .Select(b => carouselBeatmaps.IndexOf(b)).ToArray(); + + Assert.That(visibleBeatmaps, Is.EqualTo(expectedBeatmapIndexes)); + } + + //played + [TestCase("played!=1", new[] { 1, 4, 5 })] + [TestCase("played!=0", new[] { 0, 2, 3, 6, 7 })] + public void TestNotEqualSearchForBooleanFilter(string query, int[] expectedBeatmapIndexes) + { + var carouselBeatmaps = (new DateTimeOffset?[] + { + new DateTime(2012, 10, 21), + null, + new DateTime(2012, 11, 12), + new DateTime(2013, 2, 13), + null, + null, + new DateTime(2014, 1, 15), + new DateTime(2014, 11, 16), + }).Select(info => new CarouselBeatmap(new BeatmapInfo + { + LastPlayed = info + })).ToList(); + + var criteria = new FilterCriteria(); + + FilterQueryParser.ApplyQueries(criteria, query); + carouselBeatmaps.ForEach(b => b.Filter(criteria)); + + int[] visibleBeatmaps = carouselBeatmaps + .Where(b => !b.Filtered.Value) + .Select(b => carouselBeatmaps.IndexOf(b)).ToArray(); + + Assert.That(visibleBeatmaps, Is.EqualTo(expectedBeatmapIndexes)); + } + + //submitted, ranked + [TestCase("ranked!=2012", new[] { 3, 4, 5, 6, 7 })] + [TestCase("ranked!=2012.11", new[] { 0, 1, 3, 4, 5, 6, 7 })] + [TestCase("ranked!=2012.10.21", new[] { 1, 2, 3, 4, 5, 6, 7 })] + [TestCase("submitted!=2012", new[] { 3, 4, 5, 6, 7 })] + [TestCase("submitted!=2012.11", new[] { 0, 1, 3, 4, 5, 6, 7 })] + [TestCase("submitted!=2012.10.21", new[] { 1, 2, 3, 4, 5, 6, 7 })] + public void TestNotEqualSearchForDateFilter(string query, int[] expectedBeatmapIndexes) + { + var carouselBeatmaps = (new DateTime[] + { + new DateTime(2012, 10, 21), + new DateTime(2012, 10, 11), + new DateTime(2012, 11, 12), + new DateTime(2013, 2, 13), + new DateTime(2013, 2, 13), + new DateTime(2013, 3, 14), + new DateTime(2014, 1, 15), + new DateTime(2014, 11, 16), + }).Select(info => new CarouselBeatmap(new BeatmapInfo + { + BeatmapSet = new BeatmapSetInfo + { + DateRanked = new DateTimeOffset(info), + DateSubmitted = new DateTimeOffset(info), + } + + })).ToList(); + + var criteria = new FilterCriteria(); + + FilterQueryParser.ApplyQueries(criteria, query); + carouselBeatmaps.ForEach(b => b.Filter(criteria)); + + int[] visibleBeatmaps = carouselBeatmaps + .Where(b => !b.Filtered.Value) + .Select(b => carouselBeatmaps.IndexOf(b)).ToArray(); + + Assert.That(visibleBeatmaps, Is.EqualTo(expectedBeatmapIndexes)); + } + [Test] public void TestApplySourceQueries() { diff --git a/osu.Game/Screens/Select/FilterCriteria.cs b/osu.Game/Screens/Select/FilterCriteria.cs index c223c291ee..e298dcfa52 100644 --- a/osu.Game/Screens/Select/FilterCriteria.cs +++ b/osu.Game/Screens/Select/FilterCriteria.cs @@ -212,16 +212,16 @@ namespace osu.Game.Screens.Select case MatchMode.Substring: // Note that we are using ordinal here to avoid performance issues caused by globalisation concerns. // See https://github.com/ppy/osu/issues/11571 / https://github.com/dotnet/docs/issues/18423. - return value.Contains(SearchTerm, StringComparison.OrdinalIgnoreCase); + return InvertSearch != value.Contains(SearchTerm, StringComparison.OrdinalIgnoreCase); case MatchMode.IsolatedPhrase: - return Regex.IsMatch(value, $@"(^|\s){Regex.Escape(searchTerm)}($|\s)", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + return InvertSearch != Regex.IsMatch(value, $@"(^|\s){Regex.Escape(searchTerm)}($|\s)", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); case MatchMode.FullPhrase: - return CultureInfo.InvariantCulture.CompareInfo.Compare(value, searchTerm, CompareOptions.OrdinalIgnoreCase) == 0; + return InvertSearch != (CultureInfo.InvariantCulture.CompareInfo.Compare(value, searchTerm, CompareOptions.OrdinalIgnoreCase) == 0); } } - + public bool InvertSearch; private string searchTerm; public string SearchTerm diff --git a/osu.Game/Screens/Select/FilterQueryParser.cs b/osu.Game/Screens/Select/FilterQueryParser.cs index 8652331419..a516fc2dc4 100644 --- a/osu.Game/Screens/Select/FilterQueryParser.cs +++ b/osu.Game/Screens/Select/FilterQueryParser.cs @@ -76,7 +76,8 @@ namespace osu.Game.Screens.Select return false; // Unplayed beatmaps are filtered on DateTimeOffset.MinValue. - + if (op == Operator.NotEqual) + played = !played; if (played) { criteria.LastPlayed.Min = DateTimeOffset.MinValue; @@ -233,6 +234,11 @@ namespace osu.Game.Screens.Select textFilter.SearchTerm = value; return true; + case Operator.NotEqual: + textFilter.InvertSearch = true; + textFilter.SearchTerm = value; + return true; + default: return false; } @@ -493,7 +499,6 @@ namespace osu.Game.Screens.Select private static bool tryUpdateCriteriaRange(ref FilterCriteria.OptionalRange range, Operator op, T value) where T : struct { - range.InvertRange = false; switch (op) { @@ -587,13 +592,15 @@ namespace osu.Game.Screens.Select { switch (op) { + case Operator.NotEqual: case Operator.Equal: - // an equality filter is difficult to define for support here. + // an equality or inequality filter is difficult to define for support here. // if "3 months 2 days ago" means a single concrete time instant, such a filter is basically useless. // if it means a range of 24 hours, then that is annoying to write and also comes with its own implications // (does it mean "time instant 3 months 2 days ago, within 12 hours of tolerance either direction"? // does it mean "the full calendar day, from midnight to midnight, 3 months 2 days ago"?) // as such, for simplicity, just refuse to support this. + // same applies to inequality, but instead 24 hours would be need to be left out return false; // for the remaining operators, since the value provided to this function is an "ago" type value @@ -748,6 +755,7 @@ namespace osu.Game.Screens.Select DateTimeOffset dateTimeOffset; DateTimeOffset minDateTimeOffset; DateTimeOffset maxDateTimeOffset; + dateRange.InvertRange = false; switch (op) { @@ -831,14 +839,16 @@ namespace osu.Game.Screens.Select case Operator.NotEqual: + dateRange.InvertRange = true; + if (month == null) { month = 1; day = 1; minDateTimeOffset = dateTimeOffsetFromDateOnly(year.Value, month.Value, day.Value); maxDateTimeOffset = dateTimeOffsetFromDateOnly(year.Value, month.Value, day.Value).AddYears(1); - return tryUpdateCriteriaRange(ref dateRange, Operator.Less, minDateTimeOffset) - || tryUpdateCriteriaRange(ref dateRange, Operator.GreaterOrEqual, maxDateTimeOffset); + return tryUpdateCriteriaRange(ref dateRange, Operator.GreaterOrEqual, minDateTimeOffset) + && tryUpdateCriteriaRange(ref dateRange, Operator.Less, maxDateTimeOffset); } if (day == null) @@ -846,14 +856,14 @@ namespace osu.Game.Screens.Select day = 1; minDateTimeOffset = dateTimeOffsetFromDateOnly(year.Value, month.Value, day.Value); maxDateTimeOffset = dateTimeOffsetFromDateOnly(year.Value, month.Value, day.Value).AddMonths(1); - return tryUpdateCriteriaRange(ref dateRange, Operator.Less, minDateTimeOffset) - || tryUpdateCriteriaRange(ref dateRange, Operator.GreaterOrEqual, maxDateTimeOffset); + return tryUpdateCriteriaRange(ref dateRange, Operator.GreaterOrEqual, minDateTimeOffset) + && tryUpdateCriteriaRange(ref dateRange, Operator.Less, maxDateTimeOffset); } - minDateTimeOffset = dateTimeOffsetFromDateOnly(year.Value, month.Value, day.Value); - maxDateTimeOffset = dateTimeOffsetFromDateOnly(year.Value, month.Value, day.Value).AddDays(1); - return tryUpdateCriteriaRange(ref dateRange, Operator.Less, minDateTimeOffset) - || tryUpdateCriteriaRange(ref dateRange, Operator.GreaterOrEqual, maxDateTimeOffset); + minDateTimeOffset = dateTimeOffsetFromDateOnly(year.Value, month.Value, day.Value).AddDays(-1); + maxDateTimeOffset = dateTimeOffsetFromDateOnly(year.Value, month.Value, day.Value); + return tryUpdateCriteriaRange(ref dateRange, Operator.GreaterOrEqual, minDateTimeOffset) + && tryUpdateCriteriaRange(ref dateRange, Operator.Less, maxDateTimeOffset); default: return false; @@ -862,7 +872,7 @@ namespace osu.Game.Screens.Select catch (ArgumentOutOfRangeException) { return false; - } } } + } } From bd90ef1bf45bfca88418f455215138c2925d2482 Mon Sep 17 00:00:00 2001 From: Valerus9 Date: Fri, 8 Aug 2025 14:41:44 +0200 Subject: [PATCH 07/16] Fix code quality Fix code quality 2 code quality fix 3 Fix code quality 4 Add empty line --- .../Filtering/FilterQueryParserTest.cs | 20 ++++++------------- osu.Game/Screens/Select/FilterCriteria.cs | 2 ++ osu.Game/Screens/Select/FilterQueryParser.cs | 4 ++-- 3 files changed, 10 insertions(+), 16 deletions(-) diff --git a/osu.Game.Tests/NonVisual/Filtering/FilterQueryParserTest.cs b/osu.Game.Tests/NonVisual/Filtering/FilterQueryParserTest.cs index ad266432fe..8003719998 100644 --- a/osu.Game.Tests/NonVisual/Filtering/FilterQueryParserTest.cs +++ b/osu.Game.Tests/NonVisual/Filtering/FilterQueryParserTest.cs @@ -549,11 +549,8 @@ namespace osu.Game.Tests.NonVisual.Filtering Artist = info.artist }, DifficultyName = info.difficultyName - })).ToList(); - var criteria = new FilterCriteria(); - FilterQueryParser.ApplyQueries(criteria, query); carouselBeatmaps.ForEach(b => b.Filter(criteria)); @@ -584,7 +581,8 @@ namespace osu.Game.Tests.NonVisual.Filtering (5.0f, 3.0f, 7.0f, 10.0f, 2.78, 144.0, 260000.0) }).Select(info => new CarouselBeatmap(new BeatmapInfo { - Difficulty = new BeatmapDifficulty{ + Difficulty = new BeatmapDifficulty + { ApproachRate = info.ar, OverallDifficulty = info.od, DrainRate = info.hp, @@ -593,7 +591,6 @@ namespace osu.Game.Tests.NonVisual.Filtering BPM = info.bpm, StarRating = info.star, Length = info.length - })).ToList(); var criteria = new FilterCriteria(); @@ -614,7 +611,7 @@ namespace osu.Game.Tests.NonVisual.Filtering [TestCase("status!=l", new[] { 0, 1, 2, 3, 4, 6 })] public void TestNotEqualSearchForEnumFilter(string query, int[] expectedBeatmapIndexes) { - var carouselBeatmaps = (new BeatmapOnlineStatus[] + var carouselBeatmaps = new[] { BeatmapOnlineStatus.Ranked, BeatmapOnlineStatus.Qualified, @@ -623,14 +620,12 @@ namespace osu.Game.Tests.NonVisual.Filtering BeatmapOnlineStatus.Approved, BeatmapOnlineStatus.Loved, BeatmapOnlineStatus.Ranked - }).Select(info => new CarouselBeatmap(new BeatmapInfo + }.Select(info => new CarouselBeatmap(new BeatmapInfo { Status = info - })).ToList(); var criteria = new FilterCriteria(); - FilterQueryParser.ApplyQueries(criteria, query); carouselBeatmaps.ForEach(b => b.Filter(criteria)); @@ -682,7 +677,7 @@ namespace osu.Game.Tests.NonVisual.Filtering [TestCase("submitted!=2012.10.21", new[] { 1, 2, 3, 4, 5, 6, 7 })] public void TestNotEqualSearchForDateFilter(string query, int[] expectedBeatmapIndexes) { - var carouselBeatmaps = (new DateTime[] + var carouselBeatmaps = new[] { new DateTime(2012, 10, 21), new DateTime(2012, 10, 11), @@ -692,18 +687,15 @@ namespace osu.Game.Tests.NonVisual.Filtering new DateTime(2013, 3, 14), new DateTime(2014, 1, 15), new DateTime(2014, 11, 16), - }).Select(info => new CarouselBeatmap(new BeatmapInfo + }.Select(info => new CarouselBeatmap(new BeatmapInfo { BeatmapSet = new BeatmapSetInfo { DateRanked = new DateTimeOffset(info), DateSubmitted = new DateTimeOffset(info), } - })).ToList(); - var criteria = new FilterCriteria(); - FilterQueryParser.ApplyQueries(criteria, query); carouselBeatmaps.ForEach(b => b.Filter(criteria)); diff --git a/osu.Game/Screens/Select/FilterCriteria.cs b/osu.Game/Screens/Select/FilterCriteria.cs index e298dcfa52..06fa80f1f2 100644 --- a/osu.Game/Screens/Select/FilterCriteria.cs +++ b/osu.Game/Screens/Select/FilterCriteria.cs @@ -179,6 +179,7 @@ namespace osu.Game.Screens.Select public T? Max; public bool IsLowerInclusive; public bool IsUpperInclusive; + /// /// If true, only outside of MaxValue and MinValue will return true /// @@ -221,6 +222,7 @@ namespace osu.Game.Screens.Select return InvertSearch != (CultureInfo.InvariantCulture.CompareInfo.Compare(value, searchTerm, CompareOptions.OrdinalIgnoreCase) == 0); } } + public bool InvertSearch; private string searchTerm; diff --git a/osu.Game/Screens/Select/FilterQueryParser.cs b/osu.Game/Screens/Select/FilterQueryParser.cs index a516fc2dc4..c74790a4cb 100644 --- a/osu.Game/Screens/Select/FilterQueryParser.cs +++ b/osu.Game/Screens/Select/FilterQueryParser.cs @@ -78,6 +78,7 @@ namespace osu.Game.Screens.Select // Unplayed beatmaps are filtered on DateTimeOffset.MinValue. if (op == Operator.NotEqual) played = !played; + if (played) { criteria.LastPlayed.Min = DateTimeOffset.MinValue; @@ -499,7 +500,6 @@ namespace osu.Game.Screens.Select private static bool tryUpdateCriteriaRange(ref FilterCriteria.OptionalRange range, Operator op, T value) where T : struct { - switch (op) { default: @@ -872,7 +872,7 @@ namespace osu.Game.Screens.Select catch (ArgumentOutOfRangeException) { return false; + } } } - } } From e31b2c08fdb4e786736ec9273db0f1291948e83f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Wed, 13 Aug 2025 11:25:14 +0200 Subject: [PATCH 08/16] Fix tests - Move them to the correct class. They were exercising filter matching, not parsing. - Remove a bunch of unreadable tuple stuff that was mostly obfuscating the readability without actually improving test coverage. - Make tests fail everywhere rather than on CI only. They were failing because they were written in a way that was implicitly dependent on the local computer's timezone. --- .../NonVisual/Filtering/FilterMatchingTest.cs | 161 +++++++++++++++ .../Filtering/FilterQueryParserTest.cs | 185 ------------------ 2 files changed, 161 insertions(+), 185 deletions(-) diff --git a/osu.Game.Tests/NonVisual/Filtering/FilterMatchingTest.cs b/osu.Game.Tests/NonVisual/Filtering/FilterMatchingTest.cs index 62486d8d5b..74ef8168c5 100644 --- a/osu.Game.Tests/NonVisual/Filtering/FilterMatchingTest.cs +++ b/osu.Game.Tests/NonVisual/Filtering/FilterMatchingTest.cs @@ -1,7 +1,9 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; using System.Collections.Generic; +using System.Linq; using NUnit.Framework; using osu.Framework.Bindables; using osu.Game.Beatmaps; @@ -361,6 +363,165 @@ namespace osu.Game.Tests.NonVisual.Filtering Assert.AreEqual(matchCustomCriteria == false, carouselItem.Filtered.Value); } + [TestCase("title!=Title", new[] { 2, 4, 6 })] + [TestCase("title!=\"Title1\"", new[] { 2, 3, 4, 5, 6 })] + [TestCase("title!=\"Title1\"!", new[] { 2, 3, 4, 5, 6 })] + public void TestNotEqualSearchForTextFilters(string query, int[] expectedBeatmapIndexes) + { + string[] titles = + [ + "Title1", + "Title1", + "My[Favourite]Song", + "Title", + "Another One", + "Diff in title", + "a", + ]; + + var carouselBeatmaps = titles.Select(title => new CarouselBeatmap(new BeatmapInfo + { + Metadata = new BeatmapMetadata + { + Title = title, + }, + })).ToList(); + + var criteria = new FilterCriteria(); + FilterQueryParser.ApplyQueries(criteria, query); + carouselBeatmaps.ForEach(b => b.Filter(criteria)); + + int[] visibleBeatmaps = carouselBeatmaps + .Where(b => !b.Filtered.Value) + .Select(b => carouselBeatmaps.IndexOf(b)).ToArray(); + Assert.That(visibleBeatmaps, Is.EqualTo(expectedBeatmapIndexes)); + } + + public void TestNotEqualSearchForNumberFilters() + { + double[] starRatings = + [ + 2.78, + 1.78, + 1.55, + 3.78, + 1.78, + 1.55, + 2.78 + ]; + + var carouselBeatmaps = starRatings.Select(starRating => new CarouselBeatmap(new BeatmapInfo + { + StarRating = starRating, + })).ToList(); + + var criteria = new FilterCriteria(); + + FilterQueryParser.ApplyQueries(criteria, "star!=1.78"); + carouselBeatmaps.ForEach(b => b.Filter(criteria)); + + int[] visibleBeatmaps = carouselBeatmaps + .Where(b => !b.Filtered.Value) + .Select(b => carouselBeatmaps.IndexOf(b)).ToArray(); + + Assert.That(visibleBeatmaps, Is.EqualTo(new int[] { 0, 2, 3, 5, 6 })); + } + + [TestCase("status!=ranked", new[] { 1, 2, 4, 5 })] + [TestCase("status!=r", new[] { 1, 2, 4, 5 })] + [TestCase("status!=loved", new[] { 0, 1, 2, 3, 4, 6 })] + [TestCase("status!=l", new[] { 0, 1, 2, 3, 4, 6 })] + public void TestNotEqualSearchForEnumFilter(string query, int[] expectedBeatmapIndexes) + { + var carouselBeatmaps = new[] + { + BeatmapOnlineStatus.Ranked, + BeatmapOnlineStatus.Qualified, + BeatmapOnlineStatus.Approved, + BeatmapOnlineStatus.Ranked, + BeatmapOnlineStatus.Approved, + BeatmapOnlineStatus.Loved, + BeatmapOnlineStatus.Ranked + }.Select(info => new CarouselBeatmap(new BeatmapInfo + { + Status = info + })).ToList(); + + var criteria = new FilterCriteria(); + FilterQueryParser.ApplyQueries(criteria, query); + carouselBeatmaps.ForEach(b => b.Filter(criteria)); + + int[] visibleBeatmaps = carouselBeatmaps + .Where(b => !b.Filtered.Value) + .Select(b => carouselBeatmaps.IndexOf(b)).ToArray(); + + Assert.That(visibleBeatmaps, Is.EqualTo(expectedBeatmapIndexes)); + } + + [TestCase("played!=1", new[] { 1, 4, 5 })] + [TestCase("played!=0", new[] { 0, 2, 3, 6, 7 })] + public void TestNotEqualSearchForBooleanFilter(string query, int[] expectedBeatmapIndexes) + { + var carouselBeatmaps = (new DateTimeOffset?[] + { + new DateTimeOffset(2012, 10, 21, 12, 13, 24, TimeSpan.Zero), + null, + new DateTimeOffset(2012, 11, 12, 23, 10, 13, TimeSpan.Zero), + new DateTimeOffset(2013, 2, 13, 11, 43, 23, TimeSpan.Zero), + null, + null, + new DateTimeOffset(2014, 1, 15, 20, 13, 24, TimeSpan.Zero), + new DateTimeOffset(2014, 11, 16, 0, 13, 23, TimeSpan.Zero), + }).Select(lastPlayed => new CarouselBeatmap(new BeatmapInfo + { + LastPlayed = lastPlayed + })).ToList(); + + var criteria = new FilterCriteria(); + + FilterQueryParser.ApplyQueries(criteria, query); + carouselBeatmaps.ForEach(b => b.Filter(criteria)); + + int[] visibleBeatmaps = carouselBeatmaps + .Where(b => !b.Filtered.Value) + .Select(b => carouselBeatmaps.IndexOf(b)).ToArray(); + + Assert.That(visibleBeatmaps, Is.EqualTo(expectedBeatmapIndexes)); + } + + [TestCase("ranked!=2012", new[] { 3, 4, 5, 6, 7 })] + [TestCase("ranked!=2012.11", new[] { 0, 1, 3, 4, 5, 6, 7 })] + [TestCase("ranked!=2012.10.21", new[] { 1, 2, 3, 4, 5, 6, 7 })] + public void TestNotEqualSearchForDateFilter(string query, int[] expectedBeatmapIndexes) + { + var carouselBeatmaps = new[] + { + new DateTimeOffset(2012, 10, 21, 13, 42, 13, TimeSpan.Zero), + new DateTimeOffset(2012, 10, 11, 2, 33, 43, TimeSpan.Zero), + new DateTimeOffset(2012, 11, 12, 10, 22, 32, TimeSpan.Zero), + new DateTimeOffset(2013, 2, 13, 5, 19, 0, TimeSpan.Zero), + new DateTimeOffset(2013, 2, 13, 11, 23, 35, TimeSpan.Zero), + new DateTimeOffset(2013, 3, 14, 9, 9, 1, TimeSpan.Zero), + new DateTimeOffset(2014, 1, 15, 10, 5, 0, TimeSpan.Zero), + new DateTimeOffset(2014, 11, 16, 23, 27, 0, TimeSpan.Zero), + }.Select(dateRanked => new CarouselBeatmap(new BeatmapInfo + { + BeatmapSet = new BeatmapSetInfo + { + DateRanked = dateRanked, + } + })).ToList(); + var criteria = new FilterCriteria(); + FilterQueryParser.ApplyQueries(criteria, query); + carouselBeatmaps.ForEach(b => b.Filter(criteria)); + + int[] visibleBeatmaps = carouselBeatmaps + .Where(b => !b.Filtered.Value) + .Select(b => carouselBeatmaps.IndexOf(b)).ToArray(); + + Assert.That(visibleBeatmaps, Is.EqualTo(expectedBeatmapIndexes)); + } + private class CustomCriteria : IRulesetFilterCriteria { private readonly bool match; diff --git a/osu.Game.Tests/NonVisual/Filtering/FilterQueryParserTest.cs b/osu.Game.Tests/NonVisual/Filtering/FilterQueryParserTest.cs index 8003719998..f162a3ea7b 100644 --- a/osu.Game.Tests/NonVisual/Filtering/FilterQueryParserTest.cs +++ b/osu.Game.Tests/NonVisual/Filtering/FilterQueryParserTest.cs @@ -521,191 +521,6 @@ namespace osu.Game.Tests.NonVisual.Filtering Assert.That(visibleBeatmaps, Is.EqualTo(expectedBeatmapIndexes)); } - [TestCase("title!=Title", new[] { 2, 4, 6 })] - [TestCase("title!=\"Title1\"", new[] { 2, 3, 4, 5, 6 })] - [TestCase("title!=\"Title1\"!", new[] { 2, 3, 4, 5, 6 })] - [TestCase("artist!=artist", new int[] { })] - [TestCase("artist!=\"artist2\"", new[] { 1, 2, 4, 6 })] - [TestCase("artist!=\"artist2\"!", new[] { 1, 2, 4, 6 })] - [TestCase("diff!=Diff", new[] { 2, 5 })] - [TestCase("diff!=\"Diff1\"", new[] { 1, 2, 3, 4, 5, 6 })] - [TestCase("diff!=\"Diff1\"!", new[] { 1, 2, 3, 4, 5, 6 })] - public void TestNotEqualSearchForTextFilters(string query, int[] expectedBeatmapIndexes) - { - var carouselBeatmaps = (((string title, string difficultyName, string artist)[])new[] - { - ("Title1", "Diff1", "artist2"), - ("Title1", "Diff2", "artist1"), - ("My[Favourite]Song", "Expert", "artist1"), - ("Title", "My Favourite Diff", "artist2"), - ("Another One", "diff ]with [[ brackets]]]", "artist3"), - ("Diff in title", "a", "artist2"), - ("a", "Diff in diff", "artist3") - }).Select(info => new CarouselBeatmap(new BeatmapInfo - { - Metadata = new BeatmapMetadata - { - Title = info.title, - Artist = info.artist - }, - DifficultyName = info.difficultyName - })).ToList(); - var criteria = new FilterCriteria(); - FilterQueryParser.ApplyQueries(criteria, query); - carouselBeatmaps.ForEach(b => b.Filter(criteria)); - - int[] visibleBeatmaps = carouselBeatmaps - .Where(b => !b.Filtered.Value) - .Select(b => carouselBeatmaps.IndexOf(b)).ToArray(); - - Assert.That(visibleBeatmaps, Is.EqualTo(expectedBeatmapIndexes)); - } - - [TestCase("ar!=5", new[] { 0, 2, 3, 5 })] - [TestCase("cs!=7", new[] { 0, 2, 3, 6 })] - [TestCase("od!=3", new[] { 0, 2, 4, 6 })] - [TestCase("hp!=6", new[] { 0, 1, 3, 5, 6 })] - [TestCase("star!=1.78", new[] { 0, 2, 3, 5, 6 })] - [TestCase("bpm!=144", new[] { 0, 1, 3, 5 })] - [TestCase("length!=120", new[] { 2, 3, 4, 6 })] - public void TestNotEqualSearchForNumberFilters(string query, int[] expectedBeatmapIndexes) - { - var carouselBeatmaps = (((float ar, float cs, float od, float hp, double star, double bpm, double length)[])new[] - { - (10.0f, 5.0f, 1.0f, 6.5f, 2.78, 100.0, 120000.0), - (5.0f, 7.0f, 3.0f, 8.0f, 1.78, 244.0, 120000.0), - (5.5f, 7.5f, 4.0f, 6.0f, 1.55, 144.0, 60000.0), - (6.0f, 2.0f, 3.0f, 7.0f, 3.78, 774.0, 440000.0), - (5.0f, 7.0f, 4.0f, 6.0f, 1.78, 144.0, 310000.0), - (5.8f, 7.0f, 3.0f, 6.5f, 1.55, 344.0, 120000.0), - (5.0f, 3.0f, 7.0f, 10.0f, 2.78, 144.0, 260000.0) - }).Select(info => new CarouselBeatmap(new BeatmapInfo - { - Difficulty = new BeatmapDifficulty - { - ApproachRate = info.ar, - OverallDifficulty = info.od, - DrainRate = info.hp, - CircleSize = info.cs - }, - BPM = info.bpm, - StarRating = info.star, - Length = info.length - })).ToList(); - - var criteria = new FilterCriteria(); - - FilterQueryParser.ApplyQueries(criteria, query); - carouselBeatmaps.ForEach(b => b.Filter(criteria)); - - int[] visibleBeatmaps = carouselBeatmaps - .Where(b => !b.Filtered.Value) - .Select(b => carouselBeatmaps.IndexOf(b)).ToArray(); - - Assert.That(visibleBeatmaps, Is.EqualTo(expectedBeatmapIndexes)); - } - - [TestCase("status!=ranked", new[] { 1, 2, 4, 5 })] - [TestCase("status!=r", new[] { 1, 2, 4, 5 })] - [TestCase("status!=loved", new[] { 0, 1, 2, 3, 4, 6 })] - [TestCase("status!=l", new[] { 0, 1, 2, 3, 4, 6 })] - public void TestNotEqualSearchForEnumFilter(string query, int[] expectedBeatmapIndexes) - { - var carouselBeatmaps = new[] - { - BeatmapOnlineStatus.Ranked, - BeatmapOnlineStatus.Qualified, - BeatmapOnlineStatus.Approved, - BeatmapOnlineStatus.Ranked, - BeatmapOnlineStatus.Approved, - BeatmapOnlineStatus.Loved, - BeatmapOnlineStatus.Ranked - }.Select(info => new CarouselBeatmap(new BeatmapInfo - { - Status = info - })).ToList(); - - var criteria = new FilterCriteria(); - FilterQueryParser.ApplyQueries(criteria, query); - carouselBeatmaps.ForEach(b => b.Filter(criteria)); - - int[] visibleBeatmaps = carouselBeatmaps - .Where(b => !b.Filtered.Value) - .Select(b => carouselBeatmaps.IndexOf(b)).ToArray(); - - Assert.That(visibleBeatmaps, Is.EqualTo(expectedBeatmapIndexes)); - } - - //played - [TestCase("played!=1", new[] { 1, 4, 5 })] - [TestCase("played!=0", new[] { 0, 2, 3, 6, 7 })] - public void TestNotEqualSearchForBooleanFilter(string query, int[] expectedBeatmapIndexes) - { - var carouselBeatmaps = (new DateTimeOffset?[] - { - new DateTime(2012, 10, 21), - null, - new DateTime(2012, 11, 12), - new DateTime(2013, 2, 13), - null, - null, - new DateTime(2014, 1, 15), - new DateTime(2014, 11, 16), - }).Select(info => new CarouselBeatmap(new BeatmapInfo - { - LastPlayed = info - })).ToList(); - - var criteria = new FilterCriteria(); - - FilterQueryParser.ApplyQueries(criteria, query); - carouselBeatmaps.ForEach(b => b.Filter(criteria)); - - int[] visibleBeatmaps = carouselBeatmaps - .Where(b => !b.Filtered.Value) - .Select(b => carouselBeatmaps.IndexOf(b)).ToArray(); - - Assert.That(visibleBeatmaps, Is.EqualTo(expectedBeatmapIndexes)); - } - - //submitted, ranked - [TestCase("ranked!=2012", new[] { 3, 4, 5, 6, 7 })] - [TestCase("ranked!=2012.11", new[] { 0, 1, 3, 4, 5, 6, 7 })] - [TestCase("ranked!=2012.10.21", new[] { 1, 2, 3, 4, 5, 6, 7 })] - [TestCase("submitted!=2012", new[] { 3, 4, 5, 6, 7 })] - [TestCase("submitted!=2012.11", new[] { 0, 1, 3, 4, 5, 6, 7 })] - [TestCase("submitted!=2012.10.21", new[] { 1, 2, 3, 4, 5, 6, 7 })] - public void TestNotEqualSearchForDateFilter(string query, int[] expectedBeatmapIndexes) - { - var carouselBeatmaps = new[] - { - new DateTime(2012, 10, 21), - new DateTime(2012, 10, 11), - new DateTime(2012, 11, 12), - new DateTime(2013, 2, 13), - new DateTime(2013, 2, 13), - new DateTime(2013, 3, 14), - new DateTime(2014, 1, 15), - new DateTime(2014, 11, 16), - }.Select(info => new CarouselBeatmap(new BeatmapInfo - { - BeatmapSet = new BeatmapSetInfo - { - DateRanked = new DateTimeOffset(info), - DateSubmitted = new DateTimeOffset(info), - } - })).ToList(); - var criteria = new FilterCriteria(); - FilterQueryParser.ApplyQueries(criteria, query); - carouselBeatmaps.ForEach(b => b.Filter(criteria)); - - int[] visibleBeatmaps = carouselBeatmaps - .Where(b => !b.Filtered.Value) - .Select(b => carouselBeatmaps.IndexOf(b)).ToArray(); - - Assert.That(visibleBeatmaps, Is.EqualTo(expectedBeatmapIndexes)); - } - [Test] public void TestApplySourceQueries() { From 7a2b032b00e4a261341701aa15049a3d4aaa1a3d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Wed, 13 Aug 2025 11:34:54 +0200 Subject: [PATCH 09/16] Fix date range filter being inexplicably implemented a little different than the equality filter Why??? --- osu.Game/Screens/Select/FilterQueryParser.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/osu.Game/Screens/Select/FilterQueryParser.cs b/osu.Game/Screens/Select/FilterQueryParser.cs index c74790a4cb..c76997efe4 100644 --- a/osu.Game/Screens/Select/FilterQueryParser.cs +++ b/osu.Game/Screens/Select/FilterQueryParser.cs @@ -860,8 +860,8 @@ namespace osu.Game.Screens.Select && tryUpdateCriteriaRange(ref dateRange, Operator.Less, maxDateTimeOffset); } - minDateTimeOffset = dateTimeOffsetFromDateOnly(year.Value, month.Value, day.Value).AddDays(-1); - maxDateTimeOffset = dateTimeOffsetFromDateOnly(year.Value, month.Value, day.Value); + minDateTimeOffset = dateTimeOffsetFromDateOnly(year.Value, month.Value, day.Value); + maxDateTimeOffset = dateTimeOffsetFromDateOnly(year.Value, month.Value, day.Value).AddDays(1); return tryUpdateCriteriaRange(ref dateRange, Operator.GreaterOrEqual, minDateTimeOffset) && tryUpdateCriteriaRange(ref dateRange, Operator.Less, maxDateTimeOffset); From b356e3bee37ef887f0d4acc1cb0ad3c172173f6f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Wed, 13 Aug 2025 11:42:26 +0200 Subject: [PATCH 10/16] Reduce code duplication --- osu.Game/Screens/Select/FilterQueryParser.cs | 78 ++++++-------------- 1 file changed, 23 insertions(+), 55 deletions(-) diff --git a/osu.Game/Screens/Select/FilterQueryParser.cs b/osu.Game/Screens/Select/FilterQueryParser.cs index c76997efe4..14feed1cd1 100644 --- a/osu.Game/Screens/Select/FilterQueryParser.cs +++ b/osu.Game/Screens/Select/FilterQueryParser.cs @@ -231,12 +231,11 @@ namespace osu.Game.Screens.Select { switch (op) { - case Operator.Equal: - textFilter.SearchTerm = value; - return true; - case Operator.NotEqual: textFilter.InvertSearch = true; + goto case Operator.Equal; + + case Operator.Equal: textFilter.SearchTerm = value; return true; @@ -270,9 +269,15 @@ namespace osu.Game.Screens.Select default: return false; + case Operator.NotEqual: + range.InvertRange = true; + goto case Operator.Equal; + case Operator.Equal: range.Min = value - tolerance; range.Max = value + tolerance; + if (tolerance == 0) + range.IsLowerInclusive = range.IsUpperInclusive = true; break; case Operator.Greater: @@ -290,14 +295,6 @@ namespace osu.Game.Screens.Select case Operator.LessOrEqual: range.Max = value + tolerance; break; - - case Operator.NotEqual: - range.Min = value - tolerance; - range.Max = value + tolerance; - range.InvertRange = true; - if (tolerance == 0) - range.IsLowerInclusive = range.IsUpperInclusive = true; - break; } return true; @@ -328,6 +325,10 @@ namespace osu.Game.Screens.Select default: return false; + case Operator.NotEqual: + range.InvertRange = true; + goto case Operator.Equal; + case Operator.Equal: range.Min = value - tolerance; range.Max = value + tolerance; @@ -354,14 +355,6 @@ namespace osu.Game.Screens.Select if (tolerance == 0) range.IsUpperInclusive = true; break; - - case Operator.NotEqual: - range.Min = value - tolerance; - range.Max = value + tolerance; - range.InvertRange = true; - if (tolerance == 0) - range.IsLowerInclusive = range.IsUpperInclusive = true; - break; } return true; @@ -505,6 +498,10 @@ namespace osu.Game.Screens.Select default: return false; + case Operator.NotEqual: + range.InvertRange = true; + goto case Operator.Equal; + case Operator.Equal: range.IsLowerInclusive = range.IsUpperInclusive = true; range.Min = value; @@ -530,13 +527,6 @@ namespace osu.Game.Screens.Select range.IsUpperInclusive = true; range.Max = value; break; - - case Operator.NotEqual: - range.IsLowerInclusive = range.IsUpperInclusive = true; - range.Min = value; - range.Max = value; - range.InvertRange = true; - break; } return true; @@ -753,8 +743,6 @@ namespace osu.Game.Screens.Select try { DateTimeOffset dateTimeOffset; - DateTimeOffset minDateTimeOffset; - DateTimeOffset maxDateTimeOffset; dateRange.InvertRange = false; switch (op) @@ -811,35 +799,15 @@ namespace osu.Game.Screens.Select dateTimeOffset = dateTimeOffsetFromDateOnly(year.Value, month.Value, day.Value).AddDays(1); return tryUpdateCriteriaRange(ref dateRange, Operator.GreaterOrEqual, dateTimeOffset); - case Operator.Equal: - - if (month == null) - { - month = 1; - day = 1; - minDateTimeOffset = dateTimeOffsetFromDateOnly(year.Value, month.Value, day.Value); - maxDateTimeOffset = dateTimeOffsetFromDateOnly(year.Value, month.Value, day.Value).AddYears(1); - return tryUpdateCriteriaRange(ref dateRange, Operator.GreaterOrEqual, minDateTimeOffset) - && tryUpdateCriteriaRange(ref dateRange, Operator.Less, maxDateTimeOffset); - } - - if (day == null) - { - day = 1; - minDateTimeOffset = dateTimeOffsetFromDateOnly(year.Value, month.Value, day.Value); - maxDateTimeOffset = dateTimeOffsetFromDateOnly(year.Value, month.Value, day.Value).AddMonths(1); - return tryUpdateCriteriaRange(ref dateRange, Operator.GreaterOrEqual, minDateTimeOffset) - && tryUpdateCriteriaRange(ref dateRange, Operator.Less, maxDateTimeOffset); - } - - minDateTimeOffset = dateTimeOffsetFromDateOnly(year.Value, month.Value, day.Value); - maxDateTimeOffset = dateTimeOffsetFromDateOnly(year.Value, month.Value, day.Value).AddDays(1); - return tryUpdateCriteriaRange(ref dateRange, Operator.GreaterOrEqual, minDateTimeOffset) - && tryUpdateCriteriaRange(ref dateRange, Operator.Less, maxDateTimeOffset); - case Operator.NotEqual: dateRange.InvertRange = true; + goto case Operator.Equal; + + case Operator.Equal: + + DateTimeOffset minDateTimeOffset; + DateTimeOffset maxDateTimeOffset; if (month == null) { From 8be6b1ad2093b1d1ba8d50356a4d5cbb29ca1fd4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Wed, 13 Aug 2025 11:45:17 +0200 Subject: [PATCH 11/16] Fix code quality --- osu.Game.Tests/NonVisual/Filtering/FilterMatchingTest.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/osu.Game.Tests/NonVisual/Filtering/FilterMatchingTest.cs b/osu.Game.Tests/NonVisual/Filtering/FilterMatchingTest.cs index 74ef8168c5..31d23b11ee 100644 --- a/osu.Game.Tests/NonVisual/Filtering/FilterMatchingTest.cs +++ b/osu.Game.Tests/NonVisual/Filtering/FilterMatchingTest.cs @@ -424,7 +424,7 @@ namespace osu.Game.Tests.NonVisual.Filtering .Where(b => !b.Filtered.Value) .Select(b => carouselBeatmaps.IndexOf(b)).ToArray(); - Assert.That(visibleBeatmaps, Is.EqualTo(new int[] { 0, 2, 3, 5, 6 })); + Assert.That(visibleBeatmaps, Is.EqualTo(new[] { 0, 2, 3, 5, 6 })); } [TestCase("status!=ranked", new[] { 1, 2, 4, 5 })] From da69cdfa2a16c4a12b1322554e1c71d01b281917 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Wed, 13 Aug 2025 11:45:56 +0200 Subject: [PATCH 12/16] Refactor string filter not-equal implementation to not make my eyes bleed --- osu.Game/Screens/Select/FilterCriteria.cs | 19 +++++++++++++++---- osu.Game/Screens/Select/FilterQueryParser.cs | 2 +- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/osu.Game/Screens/Select/FilterCriteria.cs b/osu.Game/Screens/Select/FilterCriteria.cs index 06fa80f1f2..8e94cf5b8e 100644 --- a/osu.Game/Screens/Select/FilterCriteria.cs +++ b/osu.Game/Screens/Select/FilterCriteria.cs @@ -207,23 +207,34 @@ namespace osu.Game.Screens.Select if (string.IsNullOrEmpty(value)) return false; + bool result; + switch (MatchMode) { default: case MatchMode.Substring: // Note that we are using ordinal here to avoid performance issues caused by globalisation concerns. // See https://github.com/ppy/osu/issues/11571 / https://github.com/dotnet/docs/issues/18423. - return InvertSearch != value.Contains(SearchTerm, StringComparison.OrdinalIgnoreCase); + result = value.Contains(SearchTerm, StringComparison.OrdinalIgnoreCase); + break; case MatchMode.IsolatedPhrase: - return InvertSearch != Regex.IsMatch(value, $@"(^|\s){Regex.Escape(searchTerm)}($|\s)", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + result = Regex.IsMatch(value, $@"(^|\s){Regex.Escape(searchTerm)}($|\s)", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + break; case MatchMode.FullPhrase: - return InvertSearch != (CultureInfo.InvariantCulture.CompareInfo.Compare(value, searchTerm, CompareOptions.OrdinalIgnoreCase) == 0); + result = CultureInfo.InvariantCulture.CompareInfo.Compare(value, searchTerm, CompareOptions.OrdinalIgnoreCase) == 0; + break; } + + if (ExcludeTerm) + result = !result; + + return result; } - public bool InvertSearch; + public bool ExcludeTerm; + private string searchTerm; public string SearchTerm diff --git a/osu.Game/Screens/Select/FilterQueryParser.cs b/osu.Game/Screens/Select/FilterQueryParser.cs index 14feed1cd1..609a60188b 100644 --- a/osu.Game/Screens/Select/FilterQueryParser.cs +++ b/osu.Game/Screens/Select/FilterQueryParser.cs @@ -232,7 +232,7 @@ namespace osu.Game.Screens.Select switch (op) { case Operator.NotEqual: - textFilter.InvertSearch = true; + textFilter.ExcludeTerm = true; goto case Operator.Equal; case Operator.Equal: From bab696f744e37b479d9d2fed52a8ae2128608452 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Wed, 13 Aug 2025 11:47:12 +0200 Subject: [PATCH 13/16] Remove redundant test --- .../NonVisual/Filtering/FilterMatchingTest.cs | 25 +------------------ 1 file changed, 1 insertion(+), 24 deletions(-) diff --git a/osu.Game.Tests/NonVisual/Filtering/FilterMatchingTest.cs b/osu.Game.Tests/NonVisual/Filtering/FilterMatchingTest.cs index 31d23b11ee..d2953a59db 100644 --- a/osu.Game.Tests/NonVisual/Filtering/FilterMatchingTest.cs +++ b/osu.Game.Tests/NonVisual/Filtering/FilterMatchingTest.cs @@ -148,30 +148,6 @@ namespace osu.Game.Tests.NonVisual.Filtering Assert.AreEqual(!inclusive, carouselItem.Filtered.Value); } - [Test] - [TestCase(true)] - [TestCase(false)] - public void TestCriteriaMatchingInvertedRange(bool inverted) - { - var exampleBeatmapInfo = getExampleBeatmap(); - var criteria = new FilterCriteria - { - Ruleset = new RulesetInfo { OnlineID = 6 }, - AllowConvertedBeatmaps = true, - StarDifficulty = new FilterCriteria.OptionalRange - { - Max = 4.0d, - Min = 4.0d, - IsLowerInclusive = true, - IsUpperInclusive = true, - InvertRange = inverted - } - }; - var carouselItem = new CarouselBeatmap(exampleBeatmapInfo); - carouselItem.Filter(criteria); - Assert.AreEqual(inverted, carouselItem.Filtered.Value); - } - [Test] [TestCase("artist", false)] [TestCase("artist title author", false)] @@ -397,6 +373,7 @@ namespace osu.Game.Tests.NonVisual.Filtering Assert.That(visibleBeatmaps, Is.EqualTo(expectedBeatmapIndexes)); } + [Test] public void TestNotEqualSearchForNumberFilters() { double[] starRatings = From 844704212d9ad43a3f85bd7f8e3fef6539b13f2f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Wed, 13 Aug 2025 11:50:35 +0200 Subject: [PATCH 14/16] Improve xmldoc --- osu.Game/Screens/Select/FilterCriteria.cs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/osu.Game/Screens/Select/FilterCriteria.cs b/osu.Game/Screens/Select/FilterCriteria.cs index 8e94cf5b8e..b0add6c52b 100644 --- a/osu.Game/Screens/Select/FilterCriteria.cs +++ b/osu.Game/Screens/Select/FilterCriteria.cs @@ -181,7 +181,7 @@ namespace osu.Game.Screens.Select public bool IsUpperInclusive; /// - /// If true, only outside of MaxValue and MinValue will return true + /// When , the meaning of this filter is inverted, i.e. it will exclude items that satisfy this range. /// public bool InvertRange; @@ -233,6 +233,9 @@ namespace osu.Game.Screens.Select return result; } + /// + /// When , the meaning of this filter is inverted, i.e. it will exclude items which match . + /// public bool ExcludeTerm; private string searchTerm; From 0475fe321597e9bd1795062ac596735c0526b10f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Wed, 13 Aug 2025 11:54:52 +0200 Subject: [PATCH 15/16] Simplify multi-valued not-equal enum filter implementation (and cover with tests) --- .../NonVisual/Filtering/FilterMatchingTest.cs | 1 + osu.Game/Screens/Select/FilterQueryParser.cs | 43 +++++-------------- 2 files changed, 11 insertions(+), 33 deletions(-) diff --git a/osu.Game.Tests/NonVisual/Filtering/FilterMatchingTest.cs b/osu.Game.Tests/NonVisual/Filtering/FilterMatchingTest.cs index d2953a59db..f3f820cb07 100644 --- a/osu.Game.Tests/NonVisual/Filtering/FilterMatchingTest.cs +++ b/osu.Game.Tests/NonVisual/Filtering/FilterMatchingTest.cs @@ -408,6 +408,7 @@ namespace osu.Game.Tests.NonVisual.Filtering [TestCase("status!=r", new[] { 1, 2, 4, 5 })] [TestCase("status!=loved", new[] { 0, 1, 2, 3, 4, 6 })] [TestCase("status!=l", new[] { 0, 1, 2, 3, 4, 6 })] + [TestCase("status!=r,l", new[] { 1, 2, 4 })] public void TestNotEqualSearchForEnumFilter(string query, int[] expectedBeatmapIndexes) { var carouselBeatmaps = new[] diff --git a/osu.Game/Screens/Select/FilterQueryParser.cs b/osu.Game/Screens/Select/FilterQueryParser.cs index 609a60188b..602beb2daf 100644 --- a/osu.Game/Screens/Select/FilterQueryParser.cs +++ b/osu.Game/Screens/Select/FilterQueryParser.cs @@ -397,50 +397,27 @@ namespace osu.Game.Screens.Select { var matchingValues = new HashSet(); - if (op == Operator.Equal && filterValue.Contains(',')) + if (filterValue.Contains(',')) { string[] splitValues = filterValue.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + HashSet parsedValues = new HashSet(); foreach (string splitValue in splitValues) { if (!tryParseEnum(splitValue, out var parsedValue)) return false; - matchingValues.Add(parsedValue); - } - } - else if (op == Operator.NotEqual && filterValue.Contains(',')) - { - string[] splitValues = filterValue.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); - - var allDefinedValues = Enum.GetValues(); - HashSet excludedValues = new HashSet(); - - foreach (string splitValue in splitValues) - { - if (!tryParseEnum(splitValue, out var parsedValue)) - return false; - - excludedValues.Add(parsedValue); + parsedValues.Add(parsedValue); } - foreach (var definedValue in allDefinedValues) + if (op == Operator.Equal) { - bool isExcludedValue = false; - - foreach (var excludedValue in excludedValues) - { - int compareResult = Comparer.Default.Compare(definedValue, excludedValue); - - if (compareResult == 0) - { - isExcludedValue = true; - break; - } - } - - if (!isExcludedValue) - matchingValues.Add(definedValue); + matchingValues.UnionWith(parsedValues); + } + else if (op == Operator.NotEqual) + { + matchingValues.UnionWith(Enum.GetValues()); + matchingValues.ExceptWith(parsedValues); } } else From 5a9592a769b768624f99752c9e28788633c394ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Fri, 15 Aug 2025 11:12:10 +0200 Subject: [PATCH 16/16] Fix incorrect parsing --- osu.Game/Screens/Select/FilterQueryParser.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/osu.Game/Screens/Select/FilterQueryParser.cs b/osu.Game/Screens/Select/FilterQueryParser.cs index 602beb2daf..ba973bd9b9 100644 --- a/osu.Game/Screens/Select/FilterQueryParser.cs +++ b/osu.Game/Screens/Select/FilterQueryParser.cs @@ -419,6 +419,8 @@ namespace osu.Game.Screens.Select matchingValues.UnionWith(Enum.GetValues()); matchingValues.ExceptWith(parsedValues); } + else + return false; } else {