From 073b1810b3145e61347c01bc81fa8cb672bfeb1c Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Wed, 14 May 2025 18:46:03 +0900 Subject: [PATCH] Add test coverage for beatmap carousel v2 sort support --- .../BeatmapCarouselFilterSortingTest.cs | 180 ++++++++++++++++++ .../SongSelectV2/BeatmapCarouselTestScene.cs | 6 +- .../TestSceneBeatmapCarouselUpdateHandling.cs | 119 ++++++++++++ 3 files changed, 302 insertions(+), 3 deletions(-) create mode 100644 osu.Game.Tests/Visual/SongSelectV2/BeatmapCarouselFilterSortingTest.cs diff --git a/osu.Game.Tests/Visual/SongSelectV2/BeatmapCarouselFilterSortingTest.cs b/osu.Game.Tests/Visual/SongSelectV2/BeatmapCarouselFilterSortingTest.cs new file mode 100644 index 0000000000..868abf9583 --- /dev/null +++ b/osu.Game.Tests/Visual/SongSelectV2/BeatmapCarouselFilterSortingTest.cs @@ -0,0 +1,180 @@ +// Copyright (c) ppy Pty Ltd . 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 System.Threading; +using System.Threading.Tasks; +using NUnit.Framework; +using osu.Framework.Extensions.IEnumerableExtensions; +using osu.Game.Beatmaps; +using osu.Game.Graphics.Carousel; +using osu.Game.Screens.Select; +using osu.Game.Screens.Select.Filter; +using osu.Game.Screens.SelectV2; +using osu.Game.Tests.Resources; + +namespace osu.Game.Tests.Visual.SongSelectV2 +{ + [TestFixture] + public partial class BeatmapCarouselFilterSortingTest + { + [Test] + public async Task TestSorting() + { + List beatmapSets = new List(); + + const string zzz_lowercase = "zzzzz"; + const string zzz_uppercase = "ZZZZZ"; + const int diff_count = 5; + + for (int i = 0; i < 20; i++) + { + var set = TestResources.CreateTestBeatmapSetInfo(diff_count); + + if (i == 4) + set.Beatmaps.ForEach(b => b.Metadata.Artist = zzz_uppercase); + + if (i == 8) + set.Beatmaps.ForEach(b => b.Metadata.Artist = zzz_lowercase); + + if (i == 12) + set.Beatmaps.ForEach(b => b.Metadata.Author.Username = zzz_uppercase); + + if (i == 16) + set.Beatmaps.ForEach(b => b.Metadata.Author.Username = zzz_lowercase); + + beatmapSets.Add(set); + } + + var results = await runSorting(SortMode.Author, beatmapSets); + + Assert.That(results.Last().Metadata.Author.Username, Is.EqualTo(zzz_uppercase)); + Assert.That(results.SkipLast(diff_count).Last().Metadata.Author.Username, Is.EqualTo(zzz_lowercase)); + + results = await runSorting(SortMode.Artist, beatmapSets); + + Assert.That(results.Last().Metadata.Artist, Is.EqualTo(zzz_uppercase)); + Assert.That(results.SkipLast(diff_count).Last().Metadata.Artist, Is.EqualTo(zzz_lowercase)); + } + + [Test] + public async Task TestSortingDateSubmitted() + { + List beatmapSets = new List(); + + const string zzz_string = "zzzzz"; + + for (int i = 0; i < 10; i++) + { + var set = TestResources.CreateTestBeatmapSetInfo(5); + + // A total of 6 sets have date submitted (4 don't) + // A total of 5 sets have artist string (3 of which also have date submitted) + + if (i >= 2 && i < 8) // i = 2, 3, 4, 5, 6, 7 have submitted date + set.DateSubmitted = DateTimeOffset.Now.AddMinutes(i); + if (i < 5) // i = 0, 1, 2, 3, 4 have matching string + set.Beatmaps.ForEach(b => b.Metadata.Artist = zzz_string); + + set.Beatmaps.ForEach(b => b.Metadata.Title = $"submitted: {set.DateSubmitted}"); + + beatmapSets.Add(set); + } + + var results = await runSorting(SortMode.DateSubmitted, beatmapSets); + + Assert.That(results.Count(), Is.EqualTo(50)); + + Assert.That(results.Reverse().TakeWhile(b => b.BeatmapSet!.DateSubmitted == null).Count(), Is.EqualTo(20), () => "missing dates should be at the end"); + 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 TestSortByArtistUsesTitleAsTiebreaker() + { + List beatmapSets = new List(); + + const int diff_count = 5; + + for (int i = 0; i < 20; i++) + { + var set = TestResources.CreateTestBeatmapSetInfo(diff_count); + + if (i == 4) + { + set.Beatmaps.ForEach(b => + { + b.Metadata.Artist = "ZZZ"; + b.Metadata.Title = "AAA"; + }); + } + + if (i == 8) + { + set.Beatmaps.ForEach(b => + { + b.Metadata.Artist = "ZZZ"; + b.Metadata.Title = "ZZZ"; + }); + } + + beatmapSets.Add(set); + } + + var results = await runSorting(SortMode.Artist, beatmapSets); + + Assert.That(() => + { + var lastItem = results.Last(); + return lastItem.Metadata.Artist == "ZZZ" && lastItem.Metadata.Title == "ZZZ"; + }); + + Assert.That(() => + { + var secondLastItem = results.SkipLast(diff_count).Last(); + return secondLastItem.Metadata.Artist == "ZZZ" && secondLastItem.Metadata.Title == "AAA"; + }); + } + + /// + /// Ensures stability is maintained on different sort modes for items with equal properties. + /// + [Test] + public async Task TestSortingStabilityDateAdded() + { + List beatmapSets = new List(); + + for (int i = 0; i < 10; i++) + { + var set = TestResources.CreateTestBeatmapSetInfo(); + + set.DateAdded = DateTimeOffset.FromUnixTimeSeconds(i); + + // only need to set the first as they are a shared reference. + var beatmap = set.Beatmaps.First(); + + beatmap.Metadata.Artist = "a"; + beatmap.Metadata.Title = "b"; + + beatmapSets.Add(set); + } + + var results = await runSorting(SortMode.Title, beatmapSets); + + Assert.That(results.Select(b => b.BeatmapSet!.DateAdded), Is.Ordered.Descending); + + results = await runSorting(SortMode.Artist, beatmapSets); + + Assert.That(results.Select(b => b.BeatmapSet!.DateAdded), Is.Ordered.Descending); + } + + private static async Task> runSorting(SortMode sort, List beatmapSets) + { + var sorter = new BeatmapCarouselFilterSorting(() => new FilterCriteria { Sort = sort }); + var carouselItems = await sorter.Run(beatmapSets.SelectMany(s => s.Beatmaps.Select(b => new CarouselItem(b))), CancellationToken.None); + return carouselItems.Select(ci => ci.Model).OfType(); + } + } +} diff --git a/osu.Game.Tests/Visual/SongSelectV2/BeatmapCarouselTestScene.cs b/osu.Game.Tests/Visual/SongSelectV2/BeatmapCarouselTestScene.cs index 8c842b726e..9bcaded69b 100644 --- a/osu.Game.Tests/Visual/SongSelectV2/BeatmapCarouselTestScene.cs +++ b/osu.Game.Tests/Visual/SongSelectV2/BeatmapCarouselTestScene.cs @@ -140,12 +140,12 @@ namespace osu.Game.Tests.Visual.SongSelectV2 SortBy(SortMode.Title); } - protected void SortBy(SortMode mode) => ApplyToFilter($"sort by {mode.ToString().ToLowerInvariant()}", c => c.Sort = mode); - protected void GroupBy(GroupMode mode) => ApplyToFilter($"group by {mode.ToString().ToLowerInvariant()}", c => c.Group = mode); + protected void SortBy(SortMode mode) => ApplyToFilter($"sort by {mode.GetDescription().ToLowerInvariant()}", c => c.Sort = mode); + protected void GroupBy(GroupMode mode) => ApplyToFilter($"group by {mode.GetDescription().ToLowerInvariant()}", c => c.Group = mode); protected void SortAndGroupBy(SortMode sort, GroupMode group) { - ApplyToFilter($"sort by {sort.ToString().ToLowerInvariant()} & group by {group.ToString().ToLowerInvariant()}", c => + ApplyToFilter($"sort by {sort.GetDescription().ToLowerInvariant()} & group by {group.GetDescription().ToLowerInvariant()}", c => { c.Sort = sort; c.Group = group; diff --git a/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapCarouselUpdateHandling.cs b/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapCarouselUpdateHandling.cs index b9a468d580..206c32725e 100644 --- a/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapCarouselUpdateHandling.cs +++ b/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapCarouselUpdateHandling.cs @@ -8,6 +8,7 @@ using NUnit.Framework; using osu.Framework.Testing; using osu.Game.Beatmaps; using osu.Game.Extensions; +using osu.Game.Screens.Select.Filter; using osu.Game.Screens.SelectV2; using osu.Game.Tests.Resources; @@ -130,6 +131,124 @@ namespace osu.Game.Tests.Visual.SongSelectV2 AddAssert("visible panel is updateable beatmap", () => GetSelectedPanel()?.Item?.Model, () => Is.EqualTo(baseTestBeatmap.Beatmaps[0])); } + /// + /// Ensures stability is maintained on different sort modes while an item is removed and then immediately re-added. + /// + [Test] + public void TestSortingStabilityWithRemovedAndReaddedItem() + { + RemoveAllBeatmaps(); + + const int diff_count = 5; + + AddStep("Populate beatmap sets", () => + { + for (int i = 0; i < 3; i++) + { + var set = TestResources.CreateTestBeatmapSetInfo(diff_count); + + // only need to set the first as they are a shared reference. + var beatmap = set.Beatmaps.First(); + + beatmap.Metadata.Artist = "same artist"; + beatmap.Metadata.Title = "same title"; + + // testing the case where DateAdded happens to equal (quite rare). + set.DateAdded = DateTimeOffset.UnixEpoch; + + BeatmapSets.Add(set); + } + }); + + BeatmapSetInfo removedBeatmap = null!; + Guid[] originalOrder = null!; + + SortBy(SortMode.Artist); + WaitForFiltering(); + + AddAssert("Items in descending added order", () => Carousel.PostFilterBeatmaps.Select(b => b.BeatmapSet!.DateAdded), () => Is.Ordered.Descending); + AddStep("Save order", () => originalOrder = Carousel.PostFilterBeatmaps.Select(b => b.ID).ToArray()); + + AddStep("Remove item", () => + { + removedBeatmap = BeatmapSets[1]; + BeatmapSets.RemoveAt(1); + }); + AddStep("Re-add item", () => BeatmapSets.Insert(1, removedBeatmap)); + WaitForFiltering(); + + AddAssert("Order didn't change", () => Carousel.PostFilterBeatmaps.Select(b => b.ID), () => Is.EqualTo(originalOrder)); + + SortBy(SortMode.Title); + WaitForFiltering(); + + AddAssert("Order didn't change", () => Carousel.PostFilterBeatmaps.Select(b => b.ID), () => Is.EqualTo(originalOrder)); + } + + /// + /// Ensures stability is maintained on different sort modes while a new item is added to the carousel. + /// + [Test] + public void TestSortingStabilityWithNewItems() + { + RemoveAllBeatmaps(); + + const int diff_count = 5; + + AddStep("Populate beatmap sets", () => + { + for (int i = 0; i < 3; i++) + { + var set = TestResources.CreateTestBeatmapSetInfo(diff_count); + + // only need to set the first as they are a shared reference. + var beatmap = set.Beatmaps.First(); + + beatmap.Metadata.Artist = "same artist"; + beatmap.Metadata.Title = "same title"; + + // testing the case where DateAdded happens to equal (quite rare). + set.DateAdded = DateTimeOffset.UnixEpoch; + + BeatmapSets.Add(set); + } + }); + + Guid[] originalOrder = null!; + + SortBy(SortMode.Artist); + WaitForFiltering(); + + AddAssert("Items in descending added order", () => Carousel.PostFilterBeatmaps.Select(b => b.BeatmapSet!.DateAdded), () => Is.Ordered.Descending); + AddStep("Save order", () => originalOrder = Carousel.PostFilterBeatmaps.Select(b => b.ID).ToArray()); + + AddStep("Add new item", () => + { + var set = TestResources.CreateTestBeatmapSetInfo(); + + // only need to set the first as they are a shared reference. + var beatmap = set.Beatmaps.First(); + + beatmap.Metadata.Artist = "same artist"; + beatmap.Metadata.Title = "same title"; + + set.DateAdded = DateTimeOffset.FromUnixTimeSeconds(1); + + BeatmapSets.Add(set); + + // add set to expected ordering + originalOrder = set.Beatmaps.Select(b => b.ID).Concat(originalOrder).ToArray(); + }); + WaitForFiltering(); + + AddAssert("Order didn't change", () => Carousel.PostFilterBeatmaps.Select(b => b.ID), () => Is.EqualTo(originalOrder)); + + SortBy(SortMode.Title); + WaitForFiltering(); + + AddAssert("Order didn't change", () => Carousel.PostFilterBeatmaps.Select(b => b.ID), () => Is.EqualTo(originalOrder)); + } + private void updateBeatmap(Action? updateBeatmap = null, Action? updateSet = null) { AddStep("update beatmap with different reference", () =>