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; - } } } + } }