From 793d1bf9706dcbf6b26363b3085283898bc3033c Mon Sep 17 00:00:00 2001 From: Pasi4K5 Date: Mon, 25 Sep 2023 12:52:49 +0200 Subject: [PATCH 01/15] Add ability to search for difficulty names --- .../Select/Carousel/CarouselBeatmap.cs | 2 + osu.Game/Screens/Select/FilterCriteria.cs | 47 ++++++++++++------- osu.Game/Screens/Select/FilterQueryParser.cs | 19 ++++++++ 3 files changed, 50 insertions(+), 18 deletions(-) diff --git a/osu.Game/Screens/Select/Carousel/CarouselBeatmap.cs b/osu.Game/Screens/Select/Carousel/CarouselBeatmap.cs index 6917bd1da2..f433e71cc3 100644 --- a/osu.Game/Screens/Select/Carousel/CarouselBeatmap.cs +++ b/osu.Game/Screens/Select/Carousel/CarouselBeatmap.cs @@ -64,6 +64,8 @@ namespace osu.Game.Screens.Select.Carousel if (!match) return false; + match &= criteria.DifficultySearchTerms.All(term => term.Matches(BeatmapInfo.DifficultyName)); + if (criteria.SearchTerms.Length > 0) { var searchableTerms = BeatmapInfo.GetSearchableTerms(); diff --git a/osu.Game/Screens/Select/FilterCriteria.cs b/osu.Game/Screens/Select/FilterCriteria.cs index a2ae114126..aa3909fa0e 100644 --- a/osu.Game/Screens/Select/FilterCriteria.cs +++ b/osu.Game/Screens/Select/FilterCriteria.cs @@ -46,6 +46,7 @@ namespace osu.Game.Screens.Select }; public OptionalTextFilter[] SearchTerms = Array.Empty(); + public OptionalTextFilter[] DifficultySearchTerms = Array.Empty(); public RulesetInfo? Ruleset; public bool AllowConvertedBeatmaps; @@ -64,24 +65,7 @@ namespace osu.Game.Screens.Select { searchText = value; - List terms = new List(); - - string remainingText = value; - - // First handle quoted segments to ensure we keep inline spaces in exact matches. - foreach (Match quotedSegment in Regex.Matches(searchText, "(\"[^\"]+\"[!]?)")) - { - terms.Add(new OptionalTextFilter { SearchTerm = quotedSegment.Value }); - remainingText = remainingText.Replace(quotedSegment.Value, string.Empty); - } - - // Then handle the rest splitting on any spaces. - terms.AddRange(remainingText.Split(' ', StringSplitOptions.RemoveEmptyEntries).Select(s => new OptionalTextFilter - { - SearchTerm = s - })); - - SearchTerms = terms.ToArray(); + SearchTerms = getTermsFromSearchText(value); SearchNumber = null; @@ -90,6 +74,11 @@ namespace osu.Game.Screens.Select } } + public string DifficultySearchText + { + set => DifficultySearchTerms = getTermsFromSearchText(value); + } + /// /// Hashes from the to filter to. /// @@ -97,6 +86,28 @@ namespace osu.Game.Screens.Select public IRulesetFilterCriteria? RulesetCriteria { get; set; } + private static OptionalTextFilter[] getTermsFromSearchText(string searchText) + { + List terms = new List(); + + string remainingText = searchText; + + // First handle quoted segments to ensure we keep inline spaces in exact matches. + foreach (Match quotedSegment in Regex.Matches(searchText, "(\"[^\"]+\"[!]?)")) + { + terms.Add(new OptionalTextFilter { SearchTerm = quotedSegment.Value }); + remainingText = remainingText.Replace(quotedSegment.Value, string.Empty); + } + + // Then handle the rest splitting on any spaces. + terms.AddRange(remainingText.Split(' ', StringSplitOptions.RemoveEmptyEntries).Select(s => new OptionalTextFilter + { + SearchTerm = s + })); + + return terms.ToArray(); + } + public struct OptionalRange : IEquatable> where T : struct { diff --git a/osu.Game/Screens/Select/FilterQueryParser.cs b/osu.Game/Screens/Select/FilterQueryParser.cs index 1238173b41..797493189d 100644 --- a/osu.Game/Screens/Select/FilterQueryParser.cs +++ b/osu.Game/Screens/Select/FilterQueryParser.cs @@ -19,8 +19,27 @@ namespace osu.Game.Screens.Select @"\b(?\w+)(?(:|=|(>|<)(:|=)?))(?("".*""[!]?)|(\S*))", RegexOptions.Compiled | RegexOptions.IgnoreCase); + private static readonly Regex difficulty_query_syntax_regex = new Regex( + @"(\s|^)((\[(?>\[(?)|[^[\]]+|\](?<-level>))*(?(level)(?!))\](\s|$))|(\[.*))", + RegexOptions.Compiled); + internal static void ApplyQueries(FilterCriteria criteria, string query) { + foreach (Match match in difficulty_query_syntax_regex.Matches(query)) + { + // Trim the first character because it's always '[' (ignoring spaces) + string cleanDifficultyQuery = match.Value.Trim(' ')[1..]; + + if (cleanDifficultyQuery.EndsWith(']')) + cleanDifficultyQuery = cleanDifficultyQuery[..^1]; + + criteria.DifficultySearchText = cleanDifficultyQuery; + + // Insert whitespace if necessary so that the words before and after the difficulty query aren't joined together. + bool insertWhitespace = match.Value.StartsWith(' ') && match.Value.EndsWith(' '); + query = query.Replace(match.Value, insertWhitespace ? " " : ""); + } + foreach (Match match in query_syntax_regex.Matches(query)) { string key = match.Groups["key"].Value.ToLowerInvariant(); From ab57cbf6f5a1d438963469f4dea27974755559e7 Mon Sep 17 00:00:00 2001 From: Pasi4K5 Date: Mon, 25 Sep 2023 12:53:17 +0200 Subject: [PATCH 02/15] Add `TestSceneDifficultySearch` --- .../SongSelect/TestSceneDifficultySearch.cs | 71 +++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 osu.Game.Tests/SongSelect/TestSceneDifficultySearch.cs diff --git a/osu.Game.Tests/SongSelect/TestSceneDifficultySearch.cs b/osu.Game.Tests/SongSelect/TestSceneDifficultySearch.cs new file mode 100644 index 0000000000..63963d1101 --- /dev/null +++ b/osu.Game.Tests/SongSelect/TestSceneDifficultySearch.cs @@ -0,0 +1,71 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using System.Linq; +using NUnit.Framework; +using osu.Game.Beatmaps; +using osu.Game.Screens.Select; +using osu.Game.Screens.Select.Carousel; +using osu.Game.Tests.Visual; + +namespace osu.Game.Tests.SongSelect +{ + public partial class TestSceneDifficultySearch : OsuTestScene + { + private static readonly (string title, string difficultyName)[] beatmaps = + { + ("Title1", "Diff1"), + ("Title1", "Diff2"), + ("My[Favourite]Song", "Expert"), + ("Title", "My Favourite Diff"), + ("Another One", "diff ]with [[] brackets]]]"), + }; + + [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 })] + [TestCase("[Diff]", new[] { 0, 1, 3, 4 })] + [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 })] + public void TestDifficultySearch(string query, int[] expectedBeatmapIndexes) + { + var carouselBeatmaps = createCarouselBeatmaps().ToList(); + + AddStep("filter beatmaps", () => + { + var criteria = new FilterCriteria(); + FilterQueryParser.ApplyQueries(criteria, query); + carouselBeatmaps.ForEach(b => b.Filter(criteria)); + }); + + AddAssert("filtered correctly", () => carouselBeatmaps.All(b => + { + int index = carouselBeatmaps.IndexOf(b); + + bool filtered = b.Filtered.Value; + + return filtered != expectedBeatmapIndexes.Contains(index); + })); + } + + private static IEnumerable createCarouselBeatmaps() + { + return beatmaps.Select(info => new CarouselBeatmap(new BeatmapInfo + { + Metadata = new BeatmapMetadata + { + Title = info.title + }, + DifficultyName = info.difficultyName + })); + } + } +} From 74896fd6b2f271dfd5ec01c01aa04526a72125c3 Mon Sep 17 00:00:00 2001 From: Pasi4K5 Date: Mon, 25 Sep 2023 13:38:47 +0200 Subject: [PATCH 03/15] Fix multiple difficulty search queries not working in some cases. --- osu.Game/Screens/Select/FilterQueryParser.cs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/osu.Game/Screens/Select/FilterQueryParser.cs b/osu.Game/Screens/Select/FilterQueryParser.cs index 797493189d..58e25adb5b 100644 --- a/osu.Game/Screens/Select/FilterQueryParser.cs +++ b/osu.Game/Screens/Select/FilterQueryParser.cs @@ -25,8 +25,12 @@ namespace osu.Game.Screens.Select internal static void ApplyQueries(FilterCriteria criteria, string query) { - foreach (Match match in difficulty_query_syntax_regex.Matches(query)) + while (true) { + var match = difficulty_query_syntax_regex.Matches(query).FirstOrDefault(); + + if (match is null) break; + // Trim the first character because it's always '[' (ignoring spaces) string cleanDifficultyQuery = match.Value.Trim(' ')[1..]; From 23c20ca5f44c6eb1a56ce5f6c932dd951e5945b5 Mon Sep 17 00:00:00 2001 From: Pasi4K5 Date: Fri, 29 Sep 2023 13:44:11 +0200 Subject: [PATCH 04/15] Add xmldocs --- osu.Game/Screens/Select/FilterCriteria.cs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/osu.Game/Screens/Select/FilterCriteria.cs b/osu.Game/Screens/Select/FilterCriteria.cs index aa3909fa0e..1bcff601e5 100644 --- a/osu.Game/Screens/Select/FilterCriteria.cs +++ b/osu.Game/Screens/Select/FilterCriteria.cs @@ -46,6 +46,10 @@ namespace osu.Game.Screens.Select }; public OptionalTextFilter[] SearchTerms = Array.Empty(); + + /// + /// Search terms that are used for searching difficulty names. + /// public OptionalTextFilter[] DifficultySearchTerms = Array.Empty(); public RulesetInfo? Ruleset; @@ -74,6 +78,10 @@ namespace osu.Game.Screens.Select } } + /// + /// Extracts the search terms from the provided + /// and stores them in . + /// public string DifficultySearchText { set => DifficultySearchTerms = getTermsFromSearchText(value); From c927f90a4834ccf2d5f7c8108feeebfb6ebbfd66 Mon Sep 17 00:00:00 2001 From: Pasi4K5 Date: Fri, 29 Sep 2023 13:44:42 +0200 Subject: [PATCH 05/15] Replace regex with a custom algorithm and update test scene. --- .../SongSelect/TestSceneDifficultySearch.cs | 5 +- osu.Game/Screens/Select/FilterQueryParser.cs | 90 ++++++++++++++----- 2 files changed, 71 insertions(+), 24 deletions(-) diff --git a/osu.Game.Tests/SongSelect/TestSceneDifficultySearch.cs b/osu.Game.Tests/SongSelect/TestSceneDifficultySearch.cs index 63963d1101..f013d5c19a 100644 --- a/osu.Game.Tests/SongSelect/TestSceneDifficultySearch.cs +++ b/osu.Game.Tests/SongSelect/TestSceneDifficultySearch.cs @@ -19,7 +19,7 @@ namespace osu.Game.Tests.SongSelect ("Title1", "Diff2"), ("My[Favourite]Song", "Expert"), ("Title", "My Favourite Diff"), - ("Another One", "diff ]with [[] brackets]]]"), + ("Another One", "diff ]with [[ brackets]]]"), }; [TestCase("[1]", new[] { 0 })] @@ -34,7 +34,8 @@ namespace osu.Game.Tests.SongSelect [TestCase("Title1 [Diff]", new[] { 0, 1 })] [TestCase("Title1[Diff]", new int[] { })] [TestCase("[diff ]with]", new[] { 4 })] - [TestCase("[diff ]with [[] brackets]]]]", new[] { 4 })] + [TestCase("[diff ]with [[ brackets]]]]", new[] { 4 })] + [TestCase("[diff] another [brackets]", new[] { 4 })] public void TestDifficultySearch(string query, int[] expectedBeatmapIndexes) { var carouselBeatmaps = createCarouselBeatmaps().ToList(); diff --git a/osu.Game/Screens/Select/FilterQueryParser.cs b/osu.Game/Screens/Select/FilterQueryParser.cs index 58e25adb5b..08fe86713a 100644 --- a/osu.Game/Screens/Select/FilterQueryParser.cs +++ b/osu.Game/Screens/Select/FilterQueryParser.cs @@ -19,30 +19,9 @@ namespace osu.Game.Screens.Select @"\b(?\w+)(?(:|=|(>|<)(:|=)?))(?("".*""[!]?)|(\S*))", RegexOptions.Compiled | RegexOptions.IgnoreCase); - private static readonly Regex difficulty_query_syntax_regex = new Regex( - @"(\s|^)((\[(?>\[(?)|[^[\]]+|\](?<-level>))*(?(level)(?!))\](\s|$))|(\[.*))", - RegexOptions.Compiled); - internal static void ApplyQueries(FilterCriteria criteria, string query) { - while (true) - { - var match = difficulty_query_syntax_regex.Matches(query).FirstOrDefault(); - - if (match is null) break; - - // Trim the first character because it's always '[' (ignoring spaces) - string cleanDifficultyQuery = match.Value.Trim(' ')[1..]; - - if (cleanDifficultyQuery.EndsWith(']')) - cleanDifficultyQuery = cleanDifficultyQuery[..^1]; - - criteria.DifficultySearchText = cleanDifficultyQuery; - - // Insert whitespace if necessary so that the words before and after the difficulty query aren't joined together. - bool insertWhitespace = match.Value.StartsWith(' ') && match.Value.EndsWith(' '); - query = query.Replace(match.Value, insertWhitespace ? " " : ""); - } + criteria.DifficultySearchText = extractDifficultySearchText(ref query); foreach (Match match in query_syntax_regex.Matches(query)) { @@ -57,6 +36,73 @@ namespace osu.Game.Screens.Select criteria.SearchText = query; } + /// + /// Extracts and returns the difficulty search text between square brackets. + /// + /// The search query. The difficulty search text will be removed from the query. + /// The difficulty search text (without the square brackets). + private static string extractDifficultySearchText(ref string query) + { + var openingBracketIndexes = new List(); + var closingBracketIndexes = new List(); + + populateIndexLists(ref query); + + return performExtraction(ref query); + + void populateIndexLists(ref string query) + { + bool currentlyBetweenBrackets = false; + + for (int i = 0; i < query.Length; i++) + { + switch (query[i]) + { + case '[' when !currentlyBetweenBrackets && (i == 0 || query[i - 1] == ' '): + currentlyBetweenBrackets = true; + openingBracketIndexes.Add(i + 1); + break; + + case ']' when currentlyBetweenBrackets && (i == query.Length - 1 || query[i + 1] == ' '): + currentlyBetweenBrackets = false; + closingBracketIndexes.Add(i); + break; + } + } + + if (currentlyBetweenBrackets) + { + // If there is no "]" closing the current difficulty search query, append it. + query += ']'; + closingBracketIndexes.Add(query.Length - 1); + } + } + + string performExtraction(ref string query) + { + var searchTexts = new List(); + string originalQuery = query; + + for (int i = 0; i < openingBracketIndexes.Count; i++) + { + int startIndex = openingBracketIndexes[i]; + int endIndex = closingBracketIndexes[i]; + + string searchText = originalQuery[startIndex..endIndex]; + + searchTexts.Add(searchText); + + query = query + .Replace($" [{searchText}]", "") + .Replace($"[{searchText}] ", "") + .Replace($"[{searchText}]", "") + .Replace($" [{searchText}] ", " "); + } + + return string.Join(' ', searchTexts); + } + } + private static bool tryParseKeywordCriteria(FilterCriteria criteria, string key, string value, Operator op) { switch (key) From c5339f440e4a3c968a62235fc439b01539536dc4 Mon Sep 17 00:00:00 2001 From: Pasi4K5 Date: Mon, 23 Oct 2023 23:30:54 +0200 Subject: [PATCH 06/15] Apply code review suggestions --- osu.Game/Screens/Select/FilterQueryParser.cs | 40 +++++++++++--------- 1 file changed, 23 insertions(+), 17 deletions(-) diff --git a/osu.Game/Screens/Select/FilterQueryParser.cs b/osu.Game/Screens/Select/FilterQueryParser.cs index 08fe86713a..33ddd08e09 100644 --- a/osu.Game/Screens/Select/FilterQueryParser.cs +++ b/osu.Game/Screens/Select/FilterQueryParser.cs @@ -46,11 +46,11 @@ namespace osu.Game.Screens.Select var openingBracketIndexes = new List(); var closingBracketIndexes = new List(); - populateIndexLists(ref query); + populateIndexLists(query); return performExtraction(ref query); - void populateIndexLists(ref string query) + void populateIndexLists(string query) { bool currentlyBetweenBrackets = false; @@ -58,24 +58,25 @@ namespace osu.Game.Screens.Select { switch (query[i]) { - case '[' when !currentlyBetweenBrackets && (i == 0 || query[i - 1] == ' '): - currentlyBetweenBrackets = true; - openingBracketIndexes.Add(i + 1); + case '[': + if (!currentlyBetweenBrackets && (i == 0 || query[i - 1] == ' ')) + { + currentlyBetweenBrackets = true; + openingBracketIndexes.Add(i + 1); + } + break; - case ']' when currentlyBetweenBrackets && (i == query.Length - 1 || query[i + 1] == ' '): - currentlyBetweenBrackets = false; - closingBracketIndexes.Add(i); + case ']': + if (currentlyBetweenBrackets && (i == query.Length - 1 || query[i + 1] == ' ')) + { + currentlyBetweenBrackets = false; + closingBracketIndexes.Add(i); + } + break; } } - - if (currentlyBetweenBrackets) - { - // If there is no "]" closing the current difficulty search query, append it. - query += ']'; - closingBracketIndexes.Add(query.Length - 1); - } } string performExtraction(ref string query) @@ -86,7 +87,10 @@ namespace osu.Game.Screens.Select for (int i = 0; i < openingBracketIndexes.Count; i++) { int startIndex = openingBracketIndexes[i]; - int endIndex = closingBracketIndexes[i]; + + int endIndex = closingBracketIndexes.Count > 0 + ? closingBracketIndexes[Math.Min(i, closingBracketIndexes.Count - 1)] + : query.Length; string searchText = originalQuery[startIndex..endIndex]; @@ -96,7 +100,9 @@ namespace osu.Game.Screens.Select .Replace($" [{searchText}]", "") .Replace($"[{searchText}] ", "") .Replace($"[{searchText}]", "") - .Replace($" [{searchText}] ", " "); + .Replace($" [{searchText}] ", " ") + .Replace($" [{searchText}", "") + .Replace($"[{searchText}", ""); } return string.Join(' ', searchTexts); From 383d7153788152c5dd6cdc4abe1149e03bfb3d7c Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Tue, 24 Oct 2023 14:59:21 +0900 Subject: [PATCH 07/15] Fix incorrectly named test --- .../{TestSceneDifficultySearch.cs => DifficultySearchTest.cs} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename osu.Game.Tests/SongSelect/{TestSceneDifficultySearch.cs => DifficultySearchTest.cs} (97%) diff --git a/osu.Game.Tests/SongSelect/TestSceneDifficultySearch.cs b/osu.Game.Tests/SongSelect/DifficultySearchTest.cs similarity index 97% rename from osu.Game.Tests/SongSelect/TestSceneDifficultySearch.cs rename to osu.Game.Tests/SongSelect/DifficultySearchTest.cs index f013d5c19a..770e40d672 100644 --- a/osu.Game.Tests/SongSelect/TestSceneDifficultySearch.cs +++ b/osu.Game.Tests/SongSelect/DifficultySearchTest.cs @@ -11,7 +11,7 @@ using osu.Game.Tests.Visual; namespace osu.Game.Tests.SongSelect { - public partial class TestSceneDifficultySearch : OsuTestScene + public partial class DifficultySearchTest : OsuTestScene { private static readonly (string title, string difficultyName)[] beatmaps = { From b16ece32f42373a0dde565a830f19b414e21966e Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Tue, 24 Oct 2023 15:14:30 +0900 Subject: [PATCH 08/15] Put tests in more correct place --- .../Filtering/FilterQueryParserTest.cs | 50 +++++++++++++ .../SongSelect/DifficultySearchTest.cs | 72 ------------------- 2 files changed, 50 insertions(+), 72 deletions(-) delete mode 100644 osu.Game.Tests/SongSelect/DifficultySearchTest.cs diff --git a/osu.Game.Tests/NonVisual/Filtering/FilterQueryParserTest.cs b/osu.Game.Tests/NonVisual/Filtering/FilterQueryParserTest.cs index ce95e921b9..d453954ae0 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,54 @@ 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 })] + [TestCase("[Diff]", new[] { 0, 1, 3, 4 })] + [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] another [brackets]", new[] { 4 })] + 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]]]"), + }).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)); + + Assert.That(carouselBeatmaps.All(b => + { + int index = carouselBeatmaps.IndexOf(b); + + bool shouldBeVisible = expectedBeatmapIndexes.Contains(index); + bool isVisible = !b.Filtered.Value; + + return isVisible == shouldBeVisible; + })); + } + private class CustomFilterCriteria : IRulesetFilterCriteria { public string? CustomValue { get; set; } diff --git a/osu.Game.Tests/SongSelect/DifficultySearchTest.cs b/osu.Game.Tests/SongSelect/DifficultySearchTest.cs deleted file mode 100644 index 770e40d672..0000000000 --- a/osu.Game.Tests/SongSelect/DifficultySearchTest.cs +++ /dev/null @@ -1,72 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using System.Collections.Generic; -using System.Linq; -using NUnit.Framework; -using osu.Game.Beatmaps; -using osu.Game.Screens.Select; -using osu.Game.Screens.Select.Carousel; -using osu.Game.Tests.Visual; - -namespace osu.Game.Tests.SongSelect -{ - public partial class DifficultySearchTest : OsuTestScene - { - private static readonly (string title, string difficultyName)[] beatmaps = - { - ("Title1", "Diff1"), - ("Title1", "Diff2"), - ("My[Favourite]Song", "Expert"), - ("Title", "My Favourite Diff"), - ("Another One", "diff ]with [[ brackets]]]"), - }; - - [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 })] - [TestCase("[Diff]", new[] { 0, 1, 3, 4 })] - [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] another [brackets]", new[] { 4 })] - public void TestDifficultySearch(string query, int[] expectedBeatmapIndexes) - { - var carouselBeatmaps = createCarouselBeatmaps().ToList(); - - AddStep("filter beatmaps", () => - { - var criteria = new FilterCriteria(); - FilterQueryParser.ApplyQueries(criteria, query); - carouselBeatmaps.ForEach(b => b.Filter(criteria)); - }); - - AddAssert("filtered correctly", () => carouselBeatmaps.All(b => - { - int index = carouselBeatmaps.IndexOf(b); - - bool filtered = b.Filtered.Value; - - return filtered != expectedBeatmapIndexes.Contains(index); - })); - } - - private static IEnumerable createCarouselBeatmaps() - { - return beatmaps.Select(info => new CarouselBeatmap(new BeatmapInfo - { - Metadata = new BeatmapMetadata - { - Title = info.title - }, - DifficultyName = info.difficultyName - })); - } - } -} From 794c3a2473024aa8119cd708acfa4fddebd10dc8 Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Tue, 24 Oct 2023 15:22:27 +0900 Subject: [PATCH 09/15] Add a couple more tests for sanity --- .../NonVisual/Filtering/FilterQueryParserTest.cs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/osu.Game.Tests/NonVisual/Filtering/FilterQueryParserTest.cs b/osu.Game.Tests/NonVisual/Filtering/FilterQueryParserTest.cs index d453954ae0..499300ebf8 100644 --- a/osu.Game.Tests/NonVisual/Filtering/FilterQueryParserTest.cs +++ b/osu.Game.Tests/NonVisual/Filtering/FilterQueryParserTest.cs @@ -390,14 +390,16 @@ namespace osu.Game.Tests.NonVisual.Filtering [TestCase("My[Favourite]", new[] { 2 })] [TestCase("My[Favourite]Song", new[] { 2 })] [TestCase("Favourite]", new[] { 2 })] - [TestCase("[Diff", new[] { 0, 1, 3, 4 })] - [TestCase("[Diff]", new[] { 0, 1, 3, 4 })] + [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] another [brackets]", new[] { 4 })] + [TestCase("[Diff in title]", new int[] { })] + [TestCase("[Diff in diff]", new int[] { 6 })] public void TestDifficultySearch(string query, int[] expectedBeatmapIndexes) { var carouselBeatmaps = (((string title, string difficultyName)[])new[] @@ -407,6 +409,8 @@ namespace osu.Game.Tests.NonVisual.Filtering ("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 From 6865d8894de02cf68466d809875e45508d839bb1 Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Tue, 24 Oct 2023 16:01:32 +0900 Subject: [PATCH 10/15] Simplify implementation and remove unsupported test coverage --- .../Filtering/FilterQueryParserTest.cs | 1 - .../Select/Carousel/CarouselBeatmap.cs | 4 +- osu.Game/Screens/Select/FilterCriteria.cs | 71 +++++++++--------- osu.Game/Screens/Select/FilterQueryParser.cs | 75 ------------------- 4 files changed, 36 insertions(+), 115 deletions(-) diff --git a/osu.Game.Tests/NonVisual/Filtering/FilterQueryParserTest.cs b/osu.Game.Tests/NonVisual/Filtering/FilterQueryParserTest.cs index 499300ebf8..508251f046 100644 --- a/osu.Game.Tests/NonVisual/Filtering/FilterQueryParserTest.cs +++ b/osu.Game.Tests/NonVisual/Filtering/FilterQueryParserTest.cs @@ -397,7 +397,6 @@ namespace osu.Game.Tests.NonVisual.Filtering [TestCase("Title1[Diff]", new int[] { })] [TestCase("[diff ]with]", new[] { 4 })] [TestCase("[diff ]with [[ brackets]]]]", new[] { 4 })] - [TestCase("[diff] another [brackets]", new[] { 4 })] [TestCase("[Diff in title]", new int[] { })] [TestCase("[Diff in diff]", new int[] { 6 })] public void TestDifficultySearch(string query, int[] expectedBeatmapIndexes) diff --git a/osu.Game/Screens/Select/Carousel/CarouselBeatmap.cs b/osu.Game/Screens/Select/Carousel/CarouselBeatmap.cs index f433e71cc3..ff781db680 100644 --- a/osu.Game/Screens/Select/Carousel/CarouselBeatmap.cs +++ b/osu.Game/Screens/Select/Carousel/CarouselBeatmap.cs @@ -59,13 +59,13 @@ 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) || + criteria.DifficultyName.Matches(BeatmapInfo.Metadata.TitleUnicode); match &= !criteria.UserStarDifficulty.HasFilter || criteria.UserStarDifficulty.IsInRange(BeatmapInfo.StarRating); if (!match) return false; - match &= criteria.DifficultySearchTerms.All(term => term.Matches(BeatmapInfo.DifficultyName)); - if (criteria.SearchTerms.Length > 0) { var searchableTerms = BeatmapInfo.GetSearchableTerms(); diff --git a/osu.Game/Screens/Select/FilterCriteria.cs b/osu.Game/Screens/Select/FilterCriteria.cs index 1bcff601e5..5666f9d1d4 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 { @@ -47,11 +48,6 @@ namespace osu.Game.Screens.Select public OptionalTextFilter[] SearchTerms = Array.Empty(); - /// - /// Search terms that are used for searching difficulty names. - /// - public OptionalTextFilter[] DifficultySearchTerms = Array.Empty(); - public RulesetInfo? Ruleset; public bool AllowConvertedBeatmaps; @@ -69,7 +65,39 @@ namespace osu.Game.Screens.Select { searchText = value; - SearchTerms = getTermsFromSearchText(value); + List terms = new List(); + + 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 trimmer 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(value, "(\"[^\"]+\"[!]?)")) + { + terms.Add(new OptionalTextFilter { SearchTerm = quotedSegment.Value }); + remainingText = remainingText.Replace(quotedSegment.Value, string.Empty); + } + + // Then handle the rest splitting on any spaces. + terms.AddRange(remainingText.Split(' ', StringSplitOptions.RemoveEmptyEntries).Select(s => new OptionalTextFilter + { + SearchTerm = s + })); + + SearchTerms = terms.ToArray(); SearchNumber = null; @@ -78,15 +106,6 @@ namespace osu.Game.Screens.Select } } - /// - /// Extracts the search terms from the provided - /// and stores them in . - /// - public string DifficultySearchText - { - set => DifficultySearchTerms = getTermsFromSearchText(value); - } - /// /// Hashes from the to filter to. /// @@ -94,28 +113,6 @@ namespace osu.Game.Screens.Select public IRulesetFilterCriteria? RulesetCriteria { get; set; } - private static OptionalTextFilter[] getTermsFromSearchText(string searchText) - { - List terms = new List(); - - string remainingText = searchText; - - // First handle quoted segments to ensure we keep inline spaces in exact matches. - foreach (Match quotedSegment in Regex.Matches(searchText, "(\"[^\"]+\"[!]?)")) - { - terms.Add(new OptionalTextFilter { SearchTerm = quotedSegment.Value }); - remainingText = remainingText.Replace(quotedSegment.Value, string.Empty); - } - - // Then handle the rest splitting on any spaces. - terms.AddRange(remainingText.Split(' ', StringSplitOptions.RemoveEmptyEntries).Select(s => new OptionalTextFilter - { - SearchTerm = s - })); - - return terms.ToArray(); - } - public struct OptionalRange : IEquatable> where T : struct { diff --git a/osu.Game/Screens/Select/FilterQueryParser.cs b/osu.Game/Screens/Select/FilterQueryParser.cs index 33ddd08e09..1238173b41 100644 --- a/osu.Game/Screens/Select/FilterQueryParser.cs +++ b/osu.Game/Screens/Select/FilterQueryParser.cs @@ -21,8 +21,6 @@ namespace osu.Game.Screens.Select internal static void ApplyQueries(FilterCriteria criteria, string query) { - criteria.DifficultySearchText = extractDifficultySearchText(ref query); - foreach (Match match in query_syntax_regex.Matches(query)) { string key = match.Groups["key"].Value.ToLowerInvariant(); @@ -36,79 +34,6 @@ namespace osu.Game.Screens.Select criteria.SearchText = query; } - /// - /// Extracts and returns the difficulty search text between square brackets. - /// - /// The search query. The difficulty search text will be removed from the query. - /// The difficulty search text (without the square brackets). - private static string extractDifficultySearchText(ref string query) - { - var openingBracketIndexes = new List(); - var closingBracketIndexes = new List(); - - populateIndexLists(query); - - return performExtraction(ref query); - - void populateIndexLists(string query) - { - bool currentlyBetweenBrackets = false; - - for (int i = 0; i < query.Length; i++) - { - switch (query[i]) - { - case '[': - if (!currentlyBetweenBrackets && (i == 0 || query[i - 1] == ' ')) - { - currentlyBetweenBrackets = true; - openingBracketIndexes.Add(i + 1); - } - - break; - - case ']': - if (currentlyBetweenBrackets && (i == query.Length - 1 || query[i + 1] == ' ')) - { - currentlyBetweenBrackets = false; - closingBracketIndexes.Add(i); - } - - break; - } - } - } - - string performExtraction(ref string query) - { - var searchTexts = new List(); - string originalQuery = query; - - for (int i = 0; i < openingBracketIndexes.Count; i++) - { - int startIndex = openingBracketIndexes[i]; - - int endIndex = closingBracketIndexes.Count > 0 - ? closingBracketIndexes[Math.Min(i, closingBracketIndexes.Count - 1)] - : query.Length; - - string searchText = originalQuery[startIndex..endIndex]; - - searchTexts.Add(searchText); - - query = query - .Replace($" [{searchText}]", "") - .Replace($"[{searchText}] ", "") - .Replace($"[{searchText}]", "") - .Replace($" [{searchText}] ", " ") - .Replace($" [{searchText}", "") - .Replace($"[{searchText}", ""); - } - - return string.Join(' ', searchTexts); - } - } - private static bool tryParseKeywordCriteria(FilterCriteria criteria, string key, string value, Operator op) { switch (key) From fcb366af4db6736e4786c705de7a3cee59dc0e1d Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Tue, 24 Oct 2023 16:09:05 +0900 Subject: [PATCH 11/15] Add `diff=` support for more advanced usages --- .../NonVisual/Filtering/FilterQueryParserTest.cs | 16 ++++++++-------- osu.Game/Screens/Select/FilterQueryParser.cs | 3 +++ 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/osu.Game.Tests/NonVisual/Filtering/FilterQueryParserTest.cs b/osu.Game.Tests/NonVisual/Filtering/FilterQueryParserTest.cs index 508251f046..74d47b43af 100644 --- a/osu.Game.Tests/NonVisual/Filtering/FilterQueryParserTest.cs +++ b/osu.Game.Tests/NonVisual/Filtering/FilterQueryParserTest.cs @@ -399,6 +399,10 @@ namespace osu.Game.Tests.NonVisual.Filtering [TestCase("[diff ]with [[ brackets]]]]", new[] { 4 })] [TestCase("[Diff in title]", new int[] { })] [TestCase("[Diff in diff]", new int[] { 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[] @@ -424,15 +428,11 @@ namespace osu.Game.Tests.NonVisual.Filtering FilterQueryParser.ApplyQueries(criteria, query); carouselBeatmaps.ForEach(b => b.Filter(criteria)); - Assert.That(carouselBeatmaps.All(b => - { - int index = carouselBeatmaps.IndexOf(b); + int[] visibleBeatmaps = carouselBeatmaps + .Where(b => !b.Filtered.Value) + .Select(b => carouselBeatmaps.IndexOf(b)).ToArray(); - bool shouldBeVisible = expectedBeatmapIndexes.Contains(index); - bool isVisible = !b.Filtered.Value; - - return isVisible == shouldBeVisible; - })); + Assert.That(visibleBeatmaps, Is.EqualTo(expectedBeatmapIndexes)); } private class CustomFilterCriteria : IRulesetFilterCriteria 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; } From b4cc12ab5aee4682359f874548f937793a075d68 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Tue, 24 Oct 2023 10:00:58 +0200 Subject: [PATCH 12/15] Fix formatting --- osu.Game.Tests/NonVisual/Filtering/FilterQueryParserTest.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/osu.Game.Tests/NonVisual/Filtering/FilterQueryParserTest.cs b/osu.Game.Tests/NonVisual/Filtering/FilterQueryParserTest.cs index 74d47b43af..bfdd5f92a2 100644 --- a/osu.Game.Tests/NonVisual/Filtering/FilterQueryParserTest.cs +++ b/osu.Game.Tests/NonVisual/Filtering/FilterQueryParserTest.cs @@ -402,7 +402,7 @@ namespace osu.Game.Tests.NonVisual.Filtering [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[] {})] + [TestCase("diff=!\"Diff\"", new int[] { })] public void TestDifficultySearch(string query, int[] expectedBeatmapIndexes) { var carouselBeatmaps = (((string title, string difficultyName)[])new[] From abfe2ce0fe28c8efc99ef622a6724fe399bb4ca2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Tue, 24 Oct 2023 10:05:20 +0200 Subject: [PATCH 13/15] Fix comment --- osu.Game/Screens/Select/FilterCriteria.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/osu.Game/Screens/Select/FilterCriteria.cs b/osu.Game/Screens/Select/FilterCriteria.cs index 5666f9d1d4..812a16c484 100644 --- a/osu.Game/Screens/Select/FilterCriteria.cs +++ b/osu.Game/Screens/Select/FilterCriteria.cs @@ -73,7 +73,7 @@ namespace osu.Game.Screens.Select // 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 trimmer post-match. + // and is trimmed post-match. foreach (Match quotedSegment in Regex.Matches(value, "(^|\\s)\\[(.*)(\\]\\s|$)")) { DifficultyName = new OptionalTextFilter From c9161a7bfcbf09a9ce577e174c5867475e21476d Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Tue, 24 Oct 2023 17:32:01 +0900 Subject: [PATCH 14/15] Remove incorrect copy paste fail --- osu.Game/Screens/Select/Carousel/CarouselBeatmap.cs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/osu.Game/Screens/Select/Carousel/CarouselBeatmap.cs b/osu.Game/Screens/Select/Carousel/CarouselBeatmap.cs index ff781db680..1d40862df7 100644 --- a/osu.Game/Screens/Select/Carousel/CarouselBeatmap.cs +++ b/osu.Game/Screens/Select/Carousel/CarouselBeatmap.cs @@ -59,9 +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) || - criteria.DifficultyName.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; From 966decb0089631032a5eb29a1f547faf16e83cbc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Tue, 24 Oct 2023 11:25:37 +0200 Subject: [PATCH 15/15] Remove redundant explicit array type specification --- osu.Game.Tests/NonVisual/Filtering/FilterQueryParserTest.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/osu.Game.Tests/NonVisual/Filtering/FilterQueryParserTest.cs b/osu.Game.Tests/NonVisual/Filtering/FilterQueryParserTest.cs index bfdd5f92a2..739a72df08 100644 --- a/osu.Game.Tests/NonVisual/Filtering/FilterQueryParserTest.cs +++ b/osu.Game.Tests/NonVisual/Filtering/FilterQueryParserTest.cs @@ -398,7 +398,7 @@ namespace osu.Game.Tests.NonVisual.Filtering [TestCase("[diff ]with]", new[] { 4 })] [TestCase("[diff ]with [[ brackets]]]]", new[] { 4 })] [TestCase("[Diff in title]", new int[] { })] - [TestCase("[Diff in diff]", new int[] { 6 })] + [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 })]