From d34e040b4e16e68df37c365711cd873a542fa772 Mon Sep 17 00:00:00 2001 From: Salman Alshamrani Date: Sat, 3 May 2025 02:35:25 +0300 Subject: [PATCH] Add test coverage for song select filtering --- .../TestSceneSongSelectFiltering.cs | 336 ++++++++++++++++++ osu.Game/Graphics/Carousel/Carousel.cs | 2 +- 2 files changed, 337 insertions(+), 1 deletion(-) create mode 100644 osu.Game.Tests/Visual/SongSelectV2/TestSceneSongSelectFiltering.cs diff --git a/osu.Game.Tests/Visual/SongSelectV2/TestSceneSongSelectFiltering.cs b/osu.Game.Tests/Visual/SongSelectV2/TestSceneSongSelectFiltering.cs new file mode 100644 index 0000000000..806604cd63 --- /dev/null +++ b/osu.Game.Tests/Visual/SongSelectV2/TestSceneSongSelectFiltering.cs @@ -0,0 +1,336 @@ +// 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.Linq; +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Extensions.ObjectExtensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Cursor; +using osu.Framework.Input; +using osu.Framework.Platform; +using osu.Framework.Screens; +using osu.Framework.Testing; +using osu.Game.Beatmaps; +using osu.Game.Configuration; +using osu.Game.Database; +using osu.Game.Graphics.UserInterface; +using osu.Game.Overlays; +using osu.Game.Overlays.Toolbar; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Mania.Mods; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Osu; +using osu.Game.Rulesets.Osu.Mods; +using osu.Game.Screens; +using osu.Game.Screens.Footer; +using osu.Game.Screens.Menu; +using osu.Game.Screens.Select.Filter; +using osu.Game.Screens.SelectV2; +using osu.Game.Tests.Resources; + +namespace osu.Game.Tests.Visual.SongSelectV2 +{ + public partial class TestSceneSongSelectFiltering : ScreenTestScene + { + private BeatmapManager manager = null!; + private RulesetStore rulesets = null!; + private MusicController music = null!; + private OsuConfigManager config = null!; + + private SoloSongSelect songSelect = null!; + private BeatmapCarousel carousel => songSelect.ChildrenOfType().Single(); + + private FilterControl filter => songSelect.ChildrenOfType().Single(); + private ShearedFilterTextBox filterTextBox => songSelect.ChildrenOfType().Single(); + private int filterOperationsCount; + + [Cached] + private readonly ScreenFooter screenFooter; + + [Cached] + private readonly OsuLogo logo; + + [Cached(typeof(INotificationOverlay))] + private readonly INotificationOverlay notificationOverlay = new NotificationOverlay(); + + public TestSceneSongSelectFiltering() + { + Children = new Drawable[] + { + new PopoverContainer + { + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + new Toolbar + { + State = { Value = Visibility.Visible }, + }, + screenFooter = new ScreenFooter + { + OnBack = () => Stack.CurrentScreen.Exit(), + }, + logo = new OsuLogo + { + Alpha = 0f, + }, + }, + }, + }; + + Stack.Padding = new MarginPadding { Top = Toolbar.HEIGHT }; + } + + [BackgroundDependencyLoader] + private void load(GameHost host) + { + RealmDetachedBeatmapStore beatmapStore; + + // These DI caches are required to ensure for interactive runs this test scene doesn't nuke all user beatmaps in the local install. + // At a point we have isolated interactive test runs enough, this can likely be removed. + Dependencies.Cache(rulesets = new RealmRulesetStore(Realm)); + Dependencies.Cache(Realm); + Dependencies.Cache(manager = new BeatmapManager(LocalStorage, Realm, null, Audio, Resources, host, Beatmap.Default)); + Dependencies.CacheAs(beatmapStore = new RealmDetachedBeatmapStore()); + + Dependencies.Cache(music = new MusicController()); + + // required to get bindables attached + Add(music); + Add(beatmapStore); + + Dependencies.Cache(config = new OsuConfigManager(LocalStorage)); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + Stack.ScreenPushed += updateFooter; + Stack.ScreenExited += updateFooter; + } + + public override void SetUpSteps() + { + base.SetUpSteps(); + + AddStep("reset defaults", () => + { + Ruleset.Value = new OsuRuleset().RulesetInfo; + + Beatmap.SetDefault(); + SelectedMods.SetDefault(); + + config.SetValue(OsuSetting.SongSelectSortingMode, SortMode.Title); + config.SetValue(OsuSetting.SongSelectGroupingMode, GroupMode.All); + + songSelect = null!; + filterOperationsCount = 0; + }); + + AddStep("delete all beatmaps", () => manager.Delete()); + } + + [Test] + public void TestSingleFilterOnEnter() + { + importBeatmapForRuleset(0); + importBeatmapForRuleset(0); + + loadSongSelect(); + + AddAssert("filter count is 0", () => filterOperationsCount, () => Is.EqualTo(0)); + } + + [Test] + public void TestNoFilterOnSimpleResume() + { + importBeatmapForRuleset(0); + importBeatmapForRuleset(0); + + loadSongSelect(); + + AddStep("push child screen", () => Stack.Push(new TestSceneOsuScreenStack.TestScreen("test child"))); + waitForSuspension(); + + AddStep("return", () => songSelect.MakeCurrent()); + AddUntilStep("wait for current", () => songSelect.IsCurrentScreen()); + AddAssert("filter count is 0", () => filterOperationsCount, () => Is.EqualTo(0)); + } + + [Test] + public void TestFilterOnResumeAfterChange() + { + importBeatmapForRuleset(0); + importBeatmapForRuleset(0); + + AddStep("change convert setting", () => config.SetValue(OsuSetting.ShowConvertedBeatmaps, false)); + + loadSongSelect(); + + AddStep("push child screen", () => Stack.Push(new TestSceneOsuScreenStack.TestScreen("test child"))); + waitForSuspension(); + + AddStep("change convert setting", () => config.SetValue(OsuSetting.ShowConvertedBeatmaps, true)); + + AddStep("return", () => songSelect.MakeCurrent()); + AddUntilStep("wait for current", () => songSelect.IsCurrentScreen()); + AddAssert("filter count is 1", () => filterOperationsCount, () => Is.EqualTo(1)); + } + + [Test] + public void TestSorting() + { + loadSongSelect(); + addManyTestMaps(); + + // TODO: old test has this step, but there doesn't seem to be any purpose for it. + // AddUntilStep("random map selected", () => Beatmap.Value != defaultBeatmap); + + AddStep(@"Sort by Artist", () => config.SetValue(OsuSetting.SongSelectSortingMode, SortMode.Artist)); + AddStep(@"Sort by Title", () => config.SetValue(OsuSetting.SongSelectSortingMode, SortMode.Title)); + AddStep(@"Sort by Author", () => config.SetValue(OsuSetting.SongSelectSortingMode, SortMode.Author)); + AddStep(@"Sort by DateAdded", () => config.SetValue(OsuSetting.SongSelectSortingMode, SortMode.DateAdded)); + AddStep(@"Sort by BPM", () => config.SetValue(OsuSetting.SongSelectSortingMode, SortMode.BPM)); + AddStep(@"Sort by Length", () => config.SetValue(OsuSetting.SongSelectSortingMode, SortMode.Length)); + AddStep(@"Sort by Difficulty", () => config.SetValue(OsuSetting.SongSelectSortingMode, SortMode.Difficulty)); + AddStep(@"Sort by Source", () => config.SetValue(OsuSetting.SongSelectSortingMode, SortMode.Source)); + } + + [Test] + public void TestCutInFilterTextBox() + { + loadSongSelect(); + + AddStep("set filter text", () => filterTextBox.Current.Value = "nonono"); + AddStep("select all", () => InputManager.Keys(PlatformAction.SelectAll)); + AddStep("press ctrl/cmd-x", () => InputManager.Keys(PlatformAction.Cut)); + + AddAssert("filter text cleared", () => filterTextBox.Current.Value, () => Is.Empty); + } + + [Test] + public void TestNonFilterableModChange() + { + importBeatmapForRuleset(0); + + loadSongSelect(); + + // Mod that is guaranteed to never re-filter. + AddStep("add non-filterable mod", () => SelectedMods.Value = new Mod[] { new OsuModCinema() }); + AddAssert("filter count is 0", () => filterOperationsCount, () => Is.EqualTo(0)); + + // Removing the mod should still not re-filter. + AddStep("remove non-filterable mod", () => SelectedMods.Value = Array.Empty()); + AddAssert("filter count is 0", () => filterOperationsCount, () => Is.EqualTo(0)); + } + + [Test] + public void TestFilterableModChange() + { + importBeatmapForRuleset(3); + + loadSongSelect(); + + // Change to mania ruleset. + AddStep("filter to mania ruleset", () => Ruleset.Value = rulesets.AvailableRulesets.First(r => r.OnlineID == 3)); + AddAssert("filter count is 1", () => filterOperationsCount, () => Is.EqualTo(1)); + + // Apply a mod, but this should NOT re-filter because there's no search text. + AddStep("add filterable mod", () => SelectedMods.Value = new Mod[] { new ManiaModKey3() }); + AddAssert("filter count is 1", () => filterOperationsCount, () => Is.EqualTo(1)); + + // Set search text. Should re-filter. + AddStep("set search text to match mods", () => filterTextBox.Current.Value = "keys=3"); + AddAssert("filter count is 2", () => filterOperationsCount, () => Is.EqualTo(2)); + + // Change filterable mod. Should re-filter. + AddStep("change new filterable mod", () => SelectedMods.Value = new Mod[] { new ManiaModKey5() }); + AddAssert("filter count is 3", () => filterOperationsCount, () => Is.EqualTo(3)); + + // Add non-filterable mod. Should NOT re-filter. + AddStep("apply non-filterable mod", () => SelectedMods.Value = new Mod[] { new ManiaModNoFail(), new ManiaModKey5() }); + AddAssert("filter count is 3", () => filterOperationsCount, () => Is.EqualTo(3)); + + // Remove filterable mod. Should re-filter. + AddStep("remove filterable mod", () => SelectedMods.Value = new Mod[] { new ManiaModNoFail() }); + AddAssert("filter count is 4", () => filterOperationsCount, () => Is.EqualTo(4)); + + // Remove non-filterable mod. Should NOT re-filter. + AddStep("remove non-filterable mod", () => SelectedMods.Value = Array.Empty()); + AddAssert("filter count is 4", () => filterOperationsCount, () => Is.EqualTo(4)); + + // Add filterable mod. Should re-filter. + AddStep("add filterable mod", () => SelectedMods.Value = new Mod[] { new ManiaModKey3() }); + AddAssert("filter count is 5", () => filterOperationsCount, () => Is.EqualTo(5)); + } + + private void loadSongSelect() + { + AddStep("load screen", () => Stack.Push(songSelect = new SoloSongSelect())); + AddUntilStep("wait for load", () => Stack.CurrentScreen == songSelect && songSelect.IsLoaded); + AddStep("hook events", () => + { + filterOperationsCount = 0; + filter.CriteriaChanged += _ => filterOperationsCount++; + }); + } + + private void importBeatmapForRuleset(int rulesetId) + { + int beatmapsCount = 0; + + AddStep($"import test map for ruleset {rulesetId}", () => + { + beatmapsCount = songSelect.IsNull() ? 0 : carousel.Filters.OfType().Single().SetItems.Count; + manager.Import(TestResources.CreateTestBeatmapSetInfo(3, rulesets.AvailableRulesets.Where(r => r.OnlineID == rulesetId).ToArray())); + }); + + // This is specifically for cases where the add is happening post song select load. + // For cases where song select is null, the assertions are provided by the load checks. + AddUntilStep("wait for imported to arrive in carousel", () => songSelect.IsNull() || carousel.Filters.OfType().Single().SetItems.Count > beatmapsCount); + } + + private void changeRuleset(int rulesetId) + { + AddStep($"change ruleset to {rulesetId}", () => Ruleset.Value = rulesets.AvailableRulesets.First(r => r.OnlineID == rulesetId)); + } + + /// + /// Imports test beatmap sets to show in the carousel. + /// + /// + /// The exact count of difficulties to create for each beatmap set. + /// A value causes the count of difficulties to be selected randomly. + /// + private void addManyTestMaps(int? difficultyCountPerSet = null) + { + AddStep("import test maps", () => + { + var usableRulesets = rulesets.AvailableRulesets.Where(r => r.OnlineID != 2).ToArray(); + + for (int i = 0; i < 10; i++) + manager.Import(TestResources.CreateTestBeatmapSetInfo(difficultyCountPerSet, usableRulesets)); + }); + } + + private void waitForSuspension() => AddUntilStep("wait for not current", () => !songSelect.AsNonNull().IsCurrentScreen()); + + private void updateFooter(IScreen? _, IScreen? newScreen) + { + if (newScreen is IOsuScreen osuScreen && osuScreen.ShowFooter) + { + screenFooter.Show(); + screenFooter.SetButtons(osuScreen.CreateFooterButtons()); + } + else + { + screenFooter.Hide(); + screenFooter.SetButtons(Array.Empty()); + } + } + } +} diff --git a/osu.Game/Graphics/Carousel/Carousel.cs b/osu.Game/Graphics/Carousel/Carousel.cs index 8d8289422b..45cf8a8205 100644 --- a/osu.Game/Graphics/Carousel/Carousel.cs +++ b/osu.Game/Graphics/Carousel/Carousel.cs @@ -68,7 +68,7 @@ namespace osu.Game.Graphics.Carousel public int ItemsTracked => Items.Count; /// - /// The number of carousel items currently in rotation for display. + /// The items currently in rotation for display. /// public int DisplayableItems => carouselItems?.Count ?? 0;