diff --git a/osu.Game.Tests/NonVisual/Filtering/FilterMatchingTest.cs b/osu.Game.Tests/NonVisual/Filtering/FilterMatchingTest.cs index d3f957131e..db76782350 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; @@ -373,6 +375,167 @@ 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)); + } + + [Test] + 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[] { 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 })] + [TestCase("status!=r,l", new[] { 1, 2, 4 })] + 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 2fd594b0e9..9968647cb2 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() { diff --git a/osu.Game/Screens/Select/FilterCriteria.cs b/osu.Game/Screens/Select/FilterCriteria.cs index b241b1764e..9ac22d90c4 100644 --- a/osu.Game/Screens/Select/FilterCriteria.cs +++ b/osu.Game/Screens/Select/FilterCriteria.cs @@ -154,29 +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) - return false; - - if (comparison == 0 && !IsLowerInclusive) - return false; + lowerRangeSatisfied = comparison > 0 || (comparison == 0 && IsLowerInclusive); } if (Max != null) { int comparison = Comparer.Default.Compare(value, Max.Value); - - if (comparison > 0) - return false; - - if (comparison == 0 && !IsUpperInclusive) - return false; + upperRangeSatisfied = comparison < 0 || (comparison == 0 && IsUpperInclusive); } - return true; + bool result = lowerRangeSatisfied && upperRangeSatisfied; + if (InvertRange) + result = !result; + return result; } public T? Min; @@ -184,6 +180,11 @@ namespace osu.Game.Screens.Select public bool IsLowerInclusive; public bool IsUpperInclusive; + /// + /// When , the meaning of this filter is inverted, i.e. it will exclude items that satisfy this range. + /// + public bool InvertRange; + public bool Equals(OptionalRange other) => EqualityComparer.Default.Equals(Min, other.Min) && EqualityComparer.Default.Equals(Max, other.Max) @@ -206,22 +207,37 @@ 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 value.Contains(SearchTerm, StringComparison.OrdinalIgnoreCase); + result = value.Contains(SearchTerm, StringComparison.OrdinalIgnoreCase); + break; case MatchMode.IsolatedPhrase: - return 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 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; } + /// + /// When , the meaning of this filter is inverted, i.e. it will exclude items which match . + /// + 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 9bcbfc5cef..7d66a61884 100644 --- a/osu.Game/Screens/Select/FilterQueryParser.cs +++ b/osu.Game/Screens/Select/FilterQueryParser.cs @@ -76,6 +76,8 @@ namespace osu.Game.Screens.Select return false; // Unplayed beatmaps are filtered on DateTimeOffset.MinValue. + if (op == Operator.NotEqual) + played = !played; if (played) { @@ -232,6 +234,10 @@ namespace osu.Game.Screens.Select { switch (op) { + case Operator.NotEqual: + textFilter.ExcludeTerm = true; + goto case Operator.Equal; + case Operator.Equal: textFilter.SearchTerm = value; return true; @@ -259,14 +265,22 @@ namespace osu.Game.Screens.Select private static bool tryUpdateCriteriaRange(ref FilterCriteria.OptionalRange range, Operator op, float value, float tolerance = 0.05f) { + range.InvertRange = false; + switch (op) { 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: @@ -307,11 +321,17 @@ namespace osu.Game.Screens.Select private static bool tryUpdateCriteriaRange(ref FilterCriteria.OptionalRange range, Operator op, double value, double tolerance = 0.05) { + range.InvertRange = false; + switch (op) { default: return false; + case Operator.NotEqual: + range.InvertRange = true; + goto case Operator.Equal; + case Operator.Equal: range.Min = value - tolerance; range.Max = value + tolerance; @@ -380,17 +400,30 @@ 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); + parsedValues.Add(parsedValue); } + + if (op == Operator.Equal) + { + matchingValues.UnionWith(parsedValues); + } + else if (op == Operator.NotEqual) + { + matchingValues.UnionWith(Enum.GetValues()); + matchingValues.ExceptWith(parsedValues); + } + else + return false; } else { @@ -425,6 +458,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; } @@ -443,6 +480,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; @@ -523,13 +564,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 @@ -682,6 +725,7 @@ namespace osu.Game.Screens.Select try { DateTimeOffset dateTimeOffset; + dateRange.InvertRange = false; switch (op) { @@ -737,6 +781,11 @@ namespace osu.Game.Screens.Select dateTimeOffset = dateTimeOffsetFromDateOnly(year.Value, month.Value, day.Value).AddDays(1); return tryUpdateCriteriaRange(ref dateRange, Operator.GreaterOrEqual, dateTimeOffset); + case Operator.NotEqual: + + dateRange.InvertRange = true; + goto case Operator.Equal; + case Operator.Equal: DateTimeOffset minDateTimeOffset;