diff --git a/osu.Game.Tests/Visual/SongSelect/BeatmapCarouselFilterSortingTest.cs b/osu.Game.Tests/Visual/SongSelect/BeatmapCarouselFilterSortingTest.cs index d6729d2141..857fa2b887 100644 --- a/osu.Game.Tests/Visual/SongSelect/BeatmapCarouselFilterSortingTest.cs +++ b/osu.Game.Tests/Visual/SongSelect/BeatmapCarouselFilterSortingTest.cs @@ -90,6 +90,42 @@ namespace osu.Game.Tests.Visual.SongSelect Assert.That(results.TakeWhile(b => b.BeatmapSet!.DateSubmitted != null).Count(), Is.EqualTo(30), () => "non-missing dates should be at the start"); } + [Test] + public async Task TestSortByBpmUsesTitleAsTiebreaker() + { + List beatmapSets = []; + + // 2 sets with same BPM but different titles + const int diff_count = 1; + { + var set = TestResources.CreateTestBeatmapSetInfo(diff_count); + set.DateAdded = new DateTimeOffset(2025, 6, 11, 10, 0, 0, TimeSpan.Zero); + set.Beatmaps.ForEach(b => + { + b.ID = Guid.Parse("00000000-0000-0000-0000-000000000000"); + b.BPM = 175; + b.Metadata.Title = "ZZZ"; + }); + beatmapSets.Add(set); + } + { + var set = TestResources.CreateTestBeatmapSetInfo(diff_count); + set.DateAdded = new DateTimeOffset(2025, 6, 10, 10, 0, 0, TimeSpan.Zero); + set.Beatmaps.ForEach(b => + { + b.ID = Guid.Parse("ffffffff-ffff-ffff-ffff-ffffffffffff"); + b.BPM = 175; + b.Metadata.Title = "AAA"; + }); + beatmapSets.Add(set); + } + + var results = (await runSorting(SortMode.BPM, beatmapSets)).ToList(); + + Assert.That(results[0].Metadata.Title, Is.EqualTo("AAA")); + Assert.That(results[1].Metadata.Title, Is.EqualTo("ZZZ")); + } + [Test] public async Task TestSortByArtistUsesTitleAsTiebreaker() { diff --git a/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapCarouselNoGrouping.cs b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapCarouselNoGrouping.cs index 1ff87712de..ae3d920289 100644 --- a/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapCarouselNoGrouping.cs +++ b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapCarouselNoGrouping.cs @@ -323,15 +323,16 @@ namespace osu.Game.Tests.Visual.SongSelect SelectNextSet(); // both sets have a difficulty with 0.00* star rating. - // in the case of a tie when sorting, the first tie-breaker is `DateAdded` descending, which will pick the last set added (see `TestResources.CreateTestBeatmapSetInfo()`). - WaitForSetSelection(1, 0); + // in the case of a tie when sorting, the first tie-breaker is `Title` ascending, which will pick the first set added as the title contains the set ID + // (see `TestResources.CreateTestBeatmapSetInfo()`). + WaitForSetSelection(0, 0); SelectNextSet(); - WaitForSetSelection(0, 0); + WaitForSetSelection(1, 0); SelectNextPanel(); Select(); - WaitForSetSelection(1, 1); + WaitForSetSelection(0, 1); } [Test] diff --git a/osu.Game/Screens/Select/BeatmapCarouselFilterSorting.cs b/osu.Game/Screens/Select/BeatmapCarouselFilterSorting.cs index 60e94299b2..5aa3698e9e 100644 --- a/osu.Game/Screens/Select/BeatmapCarouselFilterSorting.cs +++ b/osu.Game/Screens/Select/BeatmapCarouselFilterSorting.cs @@ -115,12 +115,15 @@ namespace osu.Game.Screens.Select throw new ArgumentOutOfRangeException(); } - // If the initial sort could not differentiate, attempt to use DateAdded to order sets in a stable fashion. + // If the initial sort could not differentiate, attempt to use Title then DateAdded to order sets in a stable fashion. + if (comparison == 0) + comparison = OrdinalSortByCaseStringComparer.DEFAULT.Compare(a.BeatmapSet!.Metadata.Title, b.BeatmapSet!.Metadata.Title); + // The directionality of this matches the current SortMode.DateAdded, but we may want to reconsider if that becomes a user decision (ie. asc / desc). if (comparison == 0) comparison = b.BeatmapSet!.DateAdded.CompareTo(a.BeatmapSet!.DateAdded); - // If DateAdded fails to break the tie, fallback to our internal GUID for stability. + // If both failed to break the tie, fallback to our internal GUID for stability. // This basically means it's a stable random sort. if (comparison == 0) comparison = b.BeatmapSet!.ID.CompareTo(a.BeatmapSet!.ID);