1
0
mirror of https://github.com/ppy/osu.git synced 2026-05-23 22:44:54 +08:00

Merge pull request #34568 from Valerus9/notequaloperator

Support not equal operator in song select search
This commit is contained in:
Dean Herbert
2025-08-15 23:38:32 +09:00
committed by GitHub
Unverified
4 changed files with 257 additions and 19 deletions
@@ -1,7 +1,9 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. 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;
@@ -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()
{
+32 -16
View File
@@ -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<T>.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<T>.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;
/// <summary>
/// When <see langword="true"/>, the meaning of this filter is inverted, i.e. it will <i>exclude</i> items that satisfy this range.
/// </summary>
public bool InvertRange;
public bool Equals(OptionalRange<T> other)
=> EqualityComparer<T?>.Default.Equals(Min, other.Min)
&& EqualityComparer<T?>.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;
}
/// <summary>
/// When <see langword="true"/>, the meaning of this filter is inverted, i.e. it will <i>exclude</i> items which match <see cref="SearchTerm"/>.
/// </summary>
public bool ExcludeTerm;
private string searchTerm;
public string SearchTerm
+52 -3
View File
@@ -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<float> 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<double> 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<T>();
if (op == Operator.Equal && filterValue.Contains(','))
if (filterValue.Contains(','))
{
string[] splitValues = filterValue.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
HashSet<T> parsedValues = new HashSet<T>();
foreach (string splitValue in splitValues)
{
if (!tryParseEnum<T>(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<T>());
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;