1
0
mirror of https://github.com/ppy/osu.git synced 2025-01-22 17:12:54 +08:00

Merge pull request #24058 from peppy/full-term-exact-match

Add support for matching full terms at song select using suffixed `!`
This commit is contained in:
Dean Herbert 2023-06-28 12:14:15 +09:00 committed by GitHub
commit 1d4380cfd0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 111 additions and 18 deletions

View File

@ -169,6 +169,9 @@ namespace osu.Game.Tests.NonVisual.Filtering
[TestCase("\"tags to\"", true)] [TestCase("\"tags to\"", true)]
[TestCase("\"version\"", false)] [TestCase("\"version\"", false)]
[TestCase("\"an auteur\"", true)] [TestCase("\"an auteur\"", true)]
[TestCase("\"Artist\"!", true)]
[TestCase("\"The Artist\"!", false)]
[TestCase("\"the artist\"!", false)]
[TestCase("\"\\\"", true)] // nasty case, covers properly escaping user input in underlying regex. [TestCase("\"\\\"", true)] // nasty case, covers properly escaping user input in underlying regex.
public void TestCriteriaMatchingExactTerms(string terms, bool filtered) public void TestCriteriaMatchingExactTerms(string terms, bool filtered)
{ {
@ -234,6 +237,9 @@ namespace osu.Game.Tests.NonVisual.Filtering
[TestCase("the artist AND then something else", true)] [TestCase("the artist AND then something else", true)]
[TestCase("unicode too", false)] [TestCase("unicode too", false)]
[TestCase("unknown", true)] [TestCase("unknown", true)]
[TestCase("\"Artist\"!", true)]
[TestCase("\"The Artist\"!", false)]
[TestCase("\"the artist\"!", false)]
public void TestCriteriaMatchingArtist(string artistName, bool filtered) public void TestCriteriaMatchingArtist(string artistName, bool filtered)
{ {
var exampleBeatmapInfo = getExampleBeatmap(); var exampleBeatmapInfo = getExampleBeatmap();

View File

@ -26,26 +26,58 @@ namespace osu.Game.Tests.NonVisual.Filtering
[Test] [Test]
public void TestApplyQueriesBareWordsWithExactMatch() public void TestApplyQueriesBareWordsWithExactMatch()
{ {
const string query = "looking for \"a beatmap\" like \"this\""; const string query = "looking for \"a beatmap\"! like \"this\"";
var filterCriteria = new FilterCriteria(); var filterCriteria = new FilterCriteria();
FilterQueryParser.ApplyQueries(filterCriteria, query); FilterQueryParser.ApplyQueries(filterCriteria, query);
Assert.AreEqual("looking for \"a beatmap\" like \"this\"", filterCriteria.SearchText); Assert.AreEqual("looking for \"a beatmap\"! like \"this\"", filterCriteria.SearchText);
Assert.AreEqual(5, filterCriteria.SearchTerms.Length); Assert.AreEqual(5, filterCriteria.SearchTerms.Length);
Assert.That(filterCriteria.SearchTerms[0].SearchTerm, Is.EqualTo("a beatmap")); Assert.That(filterCriteria.SearchTerms[0].SearchTerm, Is.EqualTo("a beatmap"));
Assert.That(filterCriteria.SearchTerms[0].Exact, Is.True); Assert.That(filterCriteria.SearchTerms[0].MatchMode, Is.EqualTo(FilterCriteria.MatchMode.FullPhrase));
Assert.That(filterCriteria.SearchTerms[1].SearchTerm, Is.EqualTo("this")); Assert.That(filterCriteria.SearchTerms[1].SearchTerm, Is.EqualTo("this"));
Assert.That(filterCriteria.SearchTerms[1].Exact, Is.True); Assert.That(filterCriteria.SearchTerms[1].MatchMode, Is.EqualTo(FilterCriteria.MatchMode.IsolatedPhrase));
Assert.That(filterCriteria.SearchTerms[2].SearchTerm, Is.EqualTo("looking")); Assert.That(filterCriteria.SearchTerms[2].SearchTerm, Is.EqualTo("looking"));
Assert.That(filterCriteria.SearchTerms[2].Exact, Is.False); Assert.That(filterCriteria.SearchTerms[2].MatchMode, Is.EqualTo(FilterCriteria.MatchMode.Substring));
Assert.That(filterCriteria.SearchTerms[3].SearchTerm, Is.EqualTo("for")); Assert.That(filterCriteria.SearchTerms[3].SearchTerm, Is.EqualTo("for"));
Assert.That(filterCriteria.SearchTerms[3].Exact, Is.False); Assert.That(filterCriteria.SearchTerms[3].MatchMode, Is.EqualTo(FilterCriteria.MatchMode.Substring));
Assert.That(filterCriteria.SearchTerms[4].SearchTerm, Is.EqualTo("like")); Assert.That(filterCriteria.SearchTerms[4].SearchTerm, Is.EqualTo("like"));
Assert.That(filterCriteria.SearchTerms[4].Exact, Is.False); Assert.That(filterCriteria.SearchTerms[4].MatchMode, Is.EqualTo(FilterCriteria.MatchMode.Substring));
}
[Test]
public void TestApplyFullPhraseQueryWithExclamationPointInTerm()
{
const string query = "looking for \"circles!\"!";
var filterCriteria = new FilterCriteria();
FilterQueryParser.ApplyQueries(filterCriteria, query);
Assert.AreEqual("looking for \"circles!\"!", filterCriteria.SearchText);
Assert.AreEqual(3, filterCriteria.SearchTerms.Length);
Assert.That(filterCriteria.SearchTerms[0].SearchTerm, Is.EqualTo("circles!"));
Assert.That(filterCriteria.SearchTerms[0].MatchMode, Is.EqualTo(FilterCriteria.MatchMode.FullPhrase));
Assert.That(filterCriteria.SearchTerms[1].SearchTerm, Is.EqualTo("looking"));
Assert.That(filterCriteria.SearchTerms[1].MatchMode, Is.EqualTo(FilterCriteria.MatchMode.Substring));
Assert.That(filterCriteria.SearchTerms[2].SearchTerm, Is.EqualTo("for"));
Assert.That(filterCriteria.SearchTerms[2].MatchMode, Is.EqualTo(FilterCriteria.MatchMode.Substring));
}
[Test]
public void TestApplyBrokenFullPhraseQuery()
{
const string query = "\"!";
var filterCriteria = new FilterCriteria();
FilterQueryParser.ApplyQueries(filterCriteria, query);
Assert.AreEqual("\"!", filterCriteria.SearchText);
Assert.AreEqual(1, filterCriteria.SearchTerms.Length);
Assert.That(filterCriteria.SearchTerms[0].SearchTerm, Is.EqualTo("!"));
Assert.That(filterCriteria.SearchTerms[0].MatchMode, Is.EqualTo(FilterCriteria.MatchMode.IsolatedPhrase));
} }
/* /*
@ -260,7 +292,7 @@ namespace osu.Game.Tests.NonVisual.Filtering
Assert.AreEqual("find me songs with please", filterCriteria.SearchText.Trim()); Assert.AreEqual("find me songs with please", filterCriteria.SearchText.Trim());
Assert.AreEqual(5, filterCriteria.SearchTerms.Length); Assert.AreEqual(5, filterCriteria.SearchTerms.Length);
Assert.AreEqual("a certain title", filterCriteria.Title.SearchTerm); Assert.AreEqual("a certain title", filterCriteria.Title.SearchTerm);
Assert.That(filterCriteria.Title.Exact, Is.True); Assert.That(filterCriteria.Title.MatchMode, Is.EqualTo(FilterCriteria.MatchMode.IsolatedPhrase));
} }
[Test] [Test]
@ -272,7 +304,7 @@ namespace osu.Game.Tests.NonVisual.Filtering
Assert.AreEqual("find me songs by please", filterCriteria.SearchText.Trim()); Assert.AreEqual("find me songs by please", filterCriteria.SearchText.Trim());
Assert.AreEqual(5, filterCriteria.SearchTerms.Length); Assert.AreEqual(5, filterCriteria.SearchTerms.Length);
Assert.AreEqual("singer", filterCriteria.Artist.SearchTerm); Assert.AreEqual("singer", filterCriteria.Artist.SearchTerm);
Assert.That(filterCriteria.Artist.Exact, Is.False); Assert.That(filterCriteria.Artist.MatchMode, Is.EqualTo(FilterCriteria.MatchMode.Substring));
} }
[Test] [Test]
@ -284,7 +316,19 @@ namespace osu.Game.Tests.NonVisual.Filtering
Assert.AreEqual("really like yes", filterCriteria.SearchText.Trim()); Assert.AreEqual("really like yes", filterCriteria.SearchText.Trim());
Assert.AreEqual(3, filterCriteria.SearchTerms.Length); Assert.AreEqual(3, filterCriteria.SearchTerms.Length);
Assert.AreEqual("name with space", filterCriteria.Artist.SearchTerm); Assert.AreEqual("name with space", filterCriteria.Artist.SearchTerm);
Assert.That(filterCriteria.Artist.Exact, Is.True); Assert.That(filterCriteria.Artist.MatchMode, Is.EqualTo(FilterCriteria.MatchMode.IsolatedPhrase));
}
[Test]
public void TestApplyArtistQueriesWithSpacesFullPhrase()
{
const string query = "artist=\"The Only One\"!";
var filterCriteria = new FilterCriteria();
FilterQueryParser.ApplyQueries(filterCriteria, query);
Assert.That(filterCriteria.SearchText.Trim(), Is.Empty);
Assert.AreEqual(0, filterCriteria.SearchTerms.Length);
Assert.AreEqual("The Only One", filterCriteria.Artist.SearchTerm);
Assert.That(filterCriteria.Artist.MatchMode, Is.EqualTo(FilterCriteria.MatchMode.FullPhrase));
} }
[Test] [Test]

View File

@ -3,6 +3,7 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Globalization;
using System.Linq; using System.Linq;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using osu.Game.Beatmaps; using osu.Game.Beatmaps;
@ -63,7 +64,7 @@ namespace osu.Game.Screens.Select
string remainingText = value; string remainingText = value;
// First handle quoted segments to ensure we keep inline spaces in exact matches. // 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(searchText, "(\"[^\"]+\"[!]?)"))
{ {
terms.Add(new OptionalTextFilter { SearchTerm = quotedSegment.Value }); terms.Add(new OptionalTextFilter { SearchTerm = quotedSegment.Value });
remainingText = remainingText.Replace(quotedSegment.Value, string.Empty); remainingText = remainingText.Replace(quotedSegment.Value, string.Empty);
@ -139,7 +140,7 @@ namespace osu.Game.Screens.Select
{ {
public bool HasFilter => !string.IsNullOrEmpty(SearchTerm); public bool HasFilter => !string.IsNullOrEmpty(SearchTerm);
public bool Exact { get; private set; } public MatchMode MatchMode { get; private set; }
public bool Matches(string value) public bool Matches(string value)
{ {
@ -150,10 +151,18 @@ namespace osu.Game.Screens.Select
if (string.IsNullOrEmpty(value)) if (string.IsNullOrEmpty(value))
return false; return false;
if (Exact) switch (MatchMode)
{
default:
case MatchMode.Substring:
return value.Contains(SearchTerm, StringComparison.InvariantCultureIgnoreCase);
case MatchMode.IsolatedPhrase:
return Regex.IsMatch(value, $@"(^|\s){Regex.Escape(searchTerm)}($|\s)", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); return Regex.IsMatch(value, $@"(^|\s){Regex.Escape(searchTerm)}($|\s)", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
return value.Contains(SearchTerm, StringComparison.InvariantCultureIgnoreCase); case MatchMode.FullPhrase:
return CultureInfo.InvariantCulture.CompareInfo.Compare(value, searchTerm, CompareOptions.IgnoreCase) == 0;
}
} }
private string searchTerm; private string searchTerm;
@ -163,12 +172,46 @@ namespace osu.Game.Screens.Select
get => searchTerm; get => searchTerm;
set set
{ {
searchTerm = value.Trim('"'); searchTerm = value;
Exact = searchTerm != value;
if (searchTerm.StartsWith('\"'))
{
// length check ensures that the quote character in the `StartsWith()` check above and the `EndsWith()` check below is not the same character.
if (searchTerm.EndsWith("\"!", StringComparison.Ordinal) && searchTerm.Length >= 3)
{
searchTerm = searchTerm.TrimEnd('!').Trim('\"');
MatchMode = MatchMode.FullPhrase;
}
else
{
searchTerm = searchTerm.Trim('\"');
MatchMode = MatchMode.IsolatedPhrase;
}
}
else
MatchMode = MatchMode.Substring;
} }
} }
public bool Equals(OptionalTextFilter other) => SearchTerm == other.SearchTerm; public bool Equals(OptionalTextFilter other) => SearchTerm == other.SearchTerm;
} }
public enum MatchMode
{
/// <summary>
/// Match using a simple "contains" substring match.
/// </summary>
Substring,
/// <summary>
/// Match for the search phrase being isolated by spaces, or at the start or end of the text.
/// </summary>
IsolatedPhrase,
/// <summary>
/// Match for the search phrase matching the full text in completion.
/// </summary>
FullPhrase,
}
} }
} }

View File

@ -16,7 +16,7 @@ namespace osu.Game.Screens.Select
public static class FilterQueryParser public static class FilterQueryParser
{ {
private static readonly Regex query_syntax_regex = new Regex( private static readonly Regex query_syntax_regex = new Regex(
@"\b(?<key>\w+)(?<op>(:|=|(>|<)(:|=)?))(?<value>("".*"")|(\S*))", @"\b(?<key>\w+)(?<op>(:|=|(>|<)(:|=)?))(?<value>("".*""[!]?)|(\S*))",
RegexOptions.Compiled | RegexOptions.IgnoreCase); RegexOptions.Compiled | RegexOptions.IgnoreCase);
internal static void ApplyQueries(FilterCriteria criteria, string query) internal static void ApplyQueries(FilterCriteria criteria, string query)