diff --git a/osu.Game.Tests/NonVisual/Filtering/FilterQueryParserTest.cs b/osu.Game.Tests/NonVisual/Filtering/FilterQueryParserTest.cs index ce95e921b9..739a72df08 100644 --- a/osu.Game.Tests/NonVisual/Filtering/FilterQueryParserTest.cs +++ b/osu.Game.Tests/NonVisual/Filtering/FilterQueryParserTest.cs @@ -2,10 +2,12 @@ // See the LICENCE file in the repository root for full licence text. using System; +using System.Linq; using NUnit.Framework; using osu.Game.Beatmaps; using osu.Game.Rulesets.Filter; using osu.Game.Screens.Select; +using osu.Game.Screens.Select.Carousel; using osu.Game.Screens.Select.Filter; namespace osu.Game.Tests.NonVisual.Filtering @@ -382,6 +384,57 @@ namespace osu.Game.Tests.NonVisual.Filtering Assert.AreEqual("unrecognised=keyword", filterCriteria.SearchText.Trim()); } + [TestCase("[1]", new[] { 0 })] + [TestCase("[1", new[] { 0 })] + [TestCase("My[Favourite", new[] { 2 })] + [TestCase("My[Favourite]", new[] { 2 })] + [TestCase("My[Favourite]Song", new[] { 2 })] + [TestCase("Favourite]", new[] { 2 })] + [TestCase("[Diff", new[] { 0, 1, 3, 4, 6 })] + [TestCase("[Diff]", new[] { 0, 1, 3, 4, 6 })] + [TestCase("[Favourite]", new[] { 3 })] + [TestCase("Title1 [Diff]", new[] { 0, 1 })] + [TestCase("Title1[Diff]", new int[] { })] + [TestCase("[diff ]with]", new[] { 4 })] + [TestCase("[diff ]with [[ brackets]]]]", new[] { 4 })] + [TestCase("[Diff in title]", new int[] { })] + [TestCase("[Diff in diff]", new[] { 6 })] + [TestCase("diff=Diff", new[] { 0, 1, 3, 4, 6 })] + [TestCase("diff=Diff1", new[] { 0 })] + [TestCase("diff=\"Diff\"", new[] { 3, 4, 6 })] + [TestCase("diff=!\"Diff\"", new int[] { })] + public void TestDifficultySearch(string query, int[] expectedBeatmapIndexes) + { + var carouselBeatmaps = (((string title, string difficultyName)[])new[] + { + ("Title1", "Diff1"), + ("Title1", "Diff2"), + ("My[Favourite]Song", "Expert"), + ("Title", "My Favourite Diff"), + ("Another One", "diff ]with [[ brackets]]]"), + ("Diff in title", "a"), + ("a", "Diff in diff"), + }).Select(info => new CarouselBeatmap(new BeatmapInfo + { + Metadata = new BeatmapMetadata + { + Title = info.title + }, + 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)); + } + private class CustomFilterCriteria : IRulesetFilterCriteria { public string? CustomValue { get; set; } diff --git a/osu.Game/Screens/Select/Carousel/CarouselBeatmap.cs b/osu.Game/Screens/Select/Carousel/CarouselBeatmap.cs index 6917bd1da2..1d40862df7 100644 --- a/osu.Game/Screens/Select/Carousel/CarouselBeatmap.cs +++ b/osu.Game/Screens/Select/Carousel/CarouselBeatmap.cs @@ -59,7 +59,7 @@ namespace osu.Game.Screens.Select.Carousel criteria.Artist.Matches(BeatmapInfo.Metadata.ArtistUnicode); match &= !criteria.Title.HasFilter || criteria.Title.Matches(BeatmapInfo.Metadata.Title) || criteria.Title.Matches(BeatmapInfo.Metadata.TitleUnicode); - + match &= !criteria.DifficultyName.HasFilter || criteria.DifficultyName.Matches(BeatmapInfo.DifficultyName); match &= !criteria.UserStarDifficulty.HasFilter || criteria.UserStarDifficulty.IsInRange(BeatmapInfo.StarRating); if (!match) return false; diff --git a/osu.Game/Screens/Select/FilterCriteria.cs b/osu.Game/Screens/Select/FilterCriteria.cs index a2ae114126..812a16c484 100644 --- a/osu.Game/Screens/Select/FilterCriteria.cs +++ b/osu.Game/Screens/Select/FilterCriteria.cs @@ -38,6 +38,7 @@ namespace osu.Game.Screens.Select public OptionalTextFilter Creator; public OptionalTextFilter Artist; public OptionalTextFilter Title; + public OptionalTextFilter DifficultyName; public OptionalRange UserStarDifficulty = new OptionalRange { @@ -68,8 +69,23 @@ namespace osu.Game.Screens.Select string remainingText = value; + // Match either an open difficulty tag to the end of string, + // or match a closed one with a whitespace after it. + // + // To keep things simple, the closing ']' may be included in the match group, + // and is trimmed post-match. + foreach (Match quotedSegment in Regex.Matches(value, "(^|\\s)\\[(.*)(\\]\\s|$)")) + { + DifficultyName = new OptionalTextFilter + { + SearchTerm = quotedSegment.Groups[2].Value.Trim(']') + }; + + remainingText = remainingText.Replace(quotedSegment.Value, string.Empty); + } + // First handle quoted segments to ensure we keep inline spaces in exact matches. - foreach (Match quotedSegment in Regex.Matches(searchText, "(\"[^\"]+\"[!]?)")) + foreach (Match quotedSegment in Regex.Matches(value, "(\"[^\"]+\"[!]?)")) { terms.Add(new OptionalTextFilter { SearchTerm = quotedSegment.Value }); remainingText = remainingText.Replace(quotedSegment.Value, string.Empty); diff --git a/osu.Game/Screens/Select/FilterQueryParser.cs b/osu.Game/Screens/Select/FilterQueryParser.cs index 1238173b41..0d8905347b 100644 --- a/osu.Game/Screens/Select/FilterQueryParser.cs +++ b/osu.Game/Screens/Select/FilterQueryParser.cs @@ -76,6 +76,9 @@ namespace osu.Game.Screens.Select case "title": return TryUpdateCriteriaText(ref criteria.Title, op, value); + case "diff": + return TryUpdateCriteriaText(ref criteria.DifficultyName, op, value); + default: return criteria.RulesetCriteria?.TryParseCustomKeywordCriteria(key, op, value) ?? false; }