Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
ppy
GitHub Repository: ppy/osu
Path: blob/master/osu.Game.Tests/Visual/SongSelectV2/TestSceneSongSelect.cs
4397 views
// Copyright (c) ppy Pty Ltd <[email protected]>. 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.Extensions;
using osu.Framework.Graphics.Containers;
using osu.Framework.Screens;
using osu.Framework.Testing;
using osu.Game.Beatmaps;
using osu.Game.Configuration;
using osu.Game.Online.API;
using osu.Game.Overlays.Dialog;
using osu.Game.Overlays.Mods;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Osu;
using osu.Game.Rulesets.Osu.Mods;
using osu.Game.Scoring;
using osu.Game.Screens.Menu;
using osu.Game.Screens.Play;
using osu.Game.Screens.Ranking;
using osu.Game.Screens.Select;
using osu.Game.Screens.Select.Leaderboards;
using osu.Game.Screens.SelectV2;
using osu.Game.Tests.Resources;
using osuTK.Input;
using BeatmapCarousel = osu.Game.Screens.SelectV2.BeatmapCarousel;
using FooterButtonMods = osu.Game.Screens.SelectV2.FooterButtonMods;
using FooterButtonOptions = osu.Game.Screens.SelectV2.FooterButtonOptions;
using FooterButtonRandom = osu.Game.Screens.SelectV2.FooterButtonRandom;

namespace osu.Game.Tests.Visual.SongSelectV2
{
    public partial class TestSceneSongSelect : SongSelectTestScene
    {
        [Test]
        public void TestResultsScreenWhenClickingLeaderboardScore()
        {
            LoadSongSelect();
            ImportBeatmapForRuleset(0);

            AddAssert("beatmap imported", () => Beatmaps.GetAllUsableBeatmapSets().Any(), () => Is.True);

            AddAssert("beatmap selected", () => !Beatmap.IsDefault);

            AddStep("import score", () =>
            {
                var beatmapInfo = Beatmaps.GetAllUsableBeatmapSets().Single().Beatmaps.First();
                ScoreManager.Import(new ScoreInfo
                {
                    Hash = Guid.NewGuid().ToString(),
                    BeatmapHash = beatmapInfo.Hash,
                    BeatmapInfo = beatmapInfo,
                    Ruleset = new OsuRuleset().RulesetInfo,
                    User = new GuestUser(),
                });
            });

            AddStep("select ranking tab", () =>
            {
                InputManager.MoveMouseTo(SongSelect.ChildrenOfType<BeatmapDetailsArea.WedgeSelector<BeatmapDetailsArea.Header.Selection>>().Last());
                InputManager.Click(MouseButton.Left);
            });

            // probably should be done via dropdown menu instead of forcing this way?
            AddStep("set local scope", () =>
            {
                var current = LeaderboardManager.CurrentCriteria!;
                LeaderboardManager.FetchWithCriteria(current with
                {
                    Scope = BeatmapLeaderboardScope.Local,
                });
            });

            AddUntilStep("wait for score panel", () => SongSelect.ChildrenOfType<BeatmapLeaderboardScore>().Any());
            AddStep("click score panel", () =>
            {
                InputManager.MoveMouseTo(SongSelect.ChildrenOfType<BeatmapLeaderboardScore>().Single());
                InputManager.Click(MouseButton.Left);
            });
            AddUntilStep("wait for results screen", () => Stack.CurrentScreen is ResultsScreen);
        }

        [Test]
        public void TestSingleFilterWhenEntering()
        {
            ImportBeatmapForRuleset(0);
            LoadSongSelect();

            AddAssert("single filter", () => Carousel.FilterCount, () => Is.EqualTo(1));
        }

        [Test]
        public void TestCookieDoesNothingIfNothingSelected()
        {
            var screensPushed = new List<IScreen>();

            LoadSongSelect();
            AddStep("subscribe to screen pushed", () => Stack.ScreenPushed += onScreenPushed);
            AddStep("click osu! cookie", () =>
            {
                InputManager.MoveMouseTo(this.ChildrenOfType<OsuLogo>().Single());
                InputManager.Click(MouseButton.Left);
            });
            AddAssert("no screens pushed", () => screensPushed, () => Is.Empty);
            AddStep("unsubscribe from screen pushed", () => Stack.ScreenPushed -= onScreenPushed);

            void onScreenPushed(IScreen lastScreen, IScreen newScreen) => screensPushed.Add(lastScreen);
        }

        [Test]
        public void TestInvalidRulesetDoesNotEnterGameplay()
        {
            var screensPushed = new List<IScreen>();

            ImportBeatmapForRuleset(0);
            ImportBeatmapForRuleset(1);

            LoadSongSelect();
            AddStep("subscribe to screen pushed", () => Stack.ScreenPushed += onScreenPushed);

            AddStep("change ruleset to taiko", () => Ruleset.Value = Rulesets.AvailableRulesets.Single(r => r.OnlineID == 1));

            AddStep("disable converts", () => Config.SetValue(OsuSetting.ShowConvertedBeatmaps, false));

            AddUntilStep("wait for taiko beatmap selected", () => Beatmap.Value.BeatmapInfo.Ruleset.OnlineID, () => Is.EqualTo(1));

            AddStep("change ruleset back and start gameplay immediately", () =>
            {
                Ruleset.Value = Rulesets.AvailableRulesets.Single(r => r.OnlineID == 0);

                InputManager.MoveMouseTo(this.ChildrenOfType<OsuLogo>().Single());
                InputManager.Click(MouseButton.Left);
            });

            AddAssert("no screens pushed", () => screensPushed, () => Is.Empty);
            AddStep("unsubscribe from screen pushed", () => Stack.ScreenPushed -= onScreenPushed);

            AddUntilStep("wait for osu beatmap selected", () => Beatmap.Value.BeatmapInfo.Ruleset.OnlineID, () => Is.EqualTo(0));

            void onScreenPushed(IScreen lastScreen, IScreen newScreen) => screensPushed.Add(lastScreen);
        }

        #region Hotkeys

        [Test]
        public void TestDeleteHotkey()
        {
            LoadSongSelect();

            ImportBeatmapForRuleset(0);

            AddAssert("beatmap imported", () => Beatmaps.GetAllUsableBeatmapSets().Any(), () => Is.True);
            AddAssert("beatmap selected", () => !Beatmap.IsDefault);

            AddStep("press shift-delete", () =>
            {
                InputManager.PressKey(Key.ShiftLeft);
                InputManager.Key(Key.Delete);
                InputManager.ReleaseKey(Key.ShiftLeft);
            });

            AddUntilStep("delete dialog shown", () => DialogOverlay.CurrentDialog, Is.InstanceOf<BeatmapDeleteDialog>);
            AddStep("confirm deletion", () => DialogOverlay.CurrentDialog!.PerformAction<PopupDialogDangerousButton>());

            AddAssert("beatmap set deleted", () => Beatmaps.GetAllUsableBeatmapSets().Any(), () => Is.False);
        }

        [Test]
        public void TestClearModsViaModButtonRightClick()
        {
            LoadSongSelect();

            AddStep("select NC", () => SelectedMods.Value = new[] { new OsuModNightcore() });
            AddAssert("mods selected", () => SelectedMods.Value, () => Has.Count.EqualTo(1));
            AddStep("right click mod button", () =>
            {
                InputManager.MoveMouseTo(ScreenFooter.ChildrenOfType<FooterButtonMods>().Single());
                InputManager.Click(MouseButton.Right);
            });
            AddAssert("not mods selected", () => SelectedMods.Value, () => Has.Count.EqualTo(0));
        }

        [Test]
        public void TestSpeedChange()
        {
            LoadSongSelect();
            AddStep("clear mods", () => SelectedMods.Value = Array.Empty<Mod>());

            decreaseModSpeed();
            AddAssert("half time activated at 0.95x", () => SelectedMods.Value.OfType<ModHalfTime>().Single().SpeedChange.Value, () => Is.EqualTo(0.95).Within(0.005));

            decreaseModSpeed();
            AddAssert("half time speed changed to 0.9x", () => SelectedMods.Value.OfType<ModHalfTime>().Single().SpeedChange.Value, () => Is.EqualTo(0.9).Within(0.005));

            increaseModSpeed();
            AddAssert("half time speed changed to 0.95x", () => SelectedMods.Value.OfType<ModHalfTime>().Single().SpeedChange.Value, () => Is.EqualTo(0.95).Within(0.005));

            increaseModSpeed();
            AddAssert("no mods selected", () => SelectedMods.Value.Count == 0);

            increaseModSpeed();
            AddAssert("double time activated at 1.05x", () => SelectedMods.Value.OfType<ModDoubleTime>().Single().SpeedChange.Value, () => Is.EqualTo(1.05).Within(0.005));

            increaseModSpeed();
            AddAssert("double time speed changed to 1.1x", () => SelectedMods.Value.OfType<ModDoubleTime>().Single().SpeedChange.Value, () => Is.EqualTo(1.1).Within(0.005));

            decreaseModSpeed();
            AddAssert("double time speed changed to 1.05x", () => SelectedMods.Value.OfType<ModDoubleTime>().Single().SpeedChange.Value, () => Is.EqualTo(1.05).Within(0.005));

            OsuModNightcore nc = new OsuModNightcore
            {
                SpeedChange = { Value = 1.05 }
            };
            AddStep("select NC", () => SelectedMods.Value = new[] { nc });

            increaseModSpeed();
            AddAssert("nightcore speed changed to 1.1x", () => SelectedMods.Value.OfType<ModNightcore>().Single().SpeedChange.Value, () => Is.EqualTo(1.1).Within(0.005));

            decreaseModSpeed();
            AddAssert("nightcore speed changed to 1.05x", () => SelectedMods.Value.OfType<ModNightcore>().Single().SpeedChange.Value, () => Is.EqualTo(1.05).Within(0.005));

            decreaseModSpeed();
            AddAssert("no mods selected", () => SelectedMods.Value.Count == 0);

            decreaseModSpeed();
            AddAssert("daycore activated at 0.95x", () => SelectedMods.Value.OfType<ModDaycore>().Single().SpeedChange.Value, () => Is.EqualTo(0.95).Within(0.005));

            decreaseModSpeed();
            AddAssert("daycore activated at 0.95x", () => SelectedMods.Value.OfType<ModDaycore>().Single().SpeedChange.Value, () => Is.EqualTo(0.9).Within(0.005));

            increaseModSpeed();
            AddAssert("daycore activated at 0.95x", () => SelectedMods.Value.OfType<ModDaycore>().Single().SpeedChange.Value, () => Is.EqualTo(0.95).Within(0.005));

            OsuModDoubleTime dt = new OsuModDoubleTime
            {
                SpeedChange = { Value = 1.02 },
                AdjustPitch = { Value = true },
            };
            AddStep("select DT", () => SelectedMods.Value = new[] { dt });

            decreaseModSpeed();
            AddAssert("half time activated at 0.97x", () => SelectedMods.Value.OfType<ModHalfTime>().Single().SpeedChange.Value, () => Is.EqualTo(0.97).Within(0.005));
            AddAssert("adjust pitch preserved", () => SelectedMods.Value.OfType<ModHalfTime>().Single().AdjustPitch.Value, () => Is.True);

            OsuModHalfTime ht = new OsuModHalfTime
            {
                SpeedChange = { Value = 0.97 },
                AdjustPitch = { Value = true },
            };
            Mod[] modlist = { ht, new OsuModHardRock(), new OsuModHidden() };
            AddStep("select HT+HD", () => SelectedMods.Value = modlist);

            increaseModSpeed();
            AddAssert("double time activated at 1.02x", () => SelectedMods.Value.OfType<ModDoubleTime>().Single().SpeedChange.Value, () => Is.EqualTo(1.02).Within(0.005));
            AddAssert("double time activated at 1.02x", () => SelectedMods.Value.OfType<ModDoubleTime>().Single().AdjustPitch.Value, () => Is.True);
            AddAssert("HD still enabled", () => SelectedMods.Value.OfType<ModHidden>().SingleOrDefault(), () => Is.Not.Null);
            AddAssert("HR still enabled", () => SelectedMods.Value.OfType<ModHardRock>().SingleOrDefault(), () => Is.Not.Null);

            AddStep("select WU", () => SelectedMods.Value = new[] { new ModWindUp() });
            increaseModSpeed();
            AddAssert("windup still active", () => SelectedMods.Value.First() is ModWindUp);

            AddStep("select AS", () => SelectedMods.Value = new[] { new ModAdaptiveSpeed() });
            increaseModSpeed();
            AddAssert("adaptive speed still active", () => SelectedMods.Value.First() is ModAdaptiveSpeed);

            OsuModDoubleTime dtWithAdjustPitch = new OsuModDoubleTime
            {
                SpeedChange = { Value = 1.05 },
                AdjustPitch = { Value = true },
            };
            AddStep("select DT x1.05", () => SelectedMods.Value = new[] { dtWithAdjustPitch });

            decreaseModSpeed();
            AddAssert("no mods selected", () => SelectedMods.Value.Count == 0);

            decreaseModSpeed();
            AddAssert("half time activated at 0.95x", () => SelectedMods.Value.OfType<ModHalfTime>().Single().SpeedChange.Value, () => Is.EqualTo(0.95).Within(0.005));
            AddAssert("half time has adjust pitch active", () => SelectedMods.Value.OfType<ModHalfTime>().Single().AdjustPitch.Value, () => Is.True);

            AddStep("turn off adjust pitch", () => SelectedMods.Value.OfType<ModHalfTime>().Single().AdjustPitch.Value = false);

            increaseModSpeed();
            AddAssert("no mods selected", () => SelectedMods.Value.Count == 0);

            increaseModSpeed();
            AddAssert("double time activated at 1.05x", () => SelectedMods.Value.OfType<ModDoubleTime>().Single().SpeedChange.Value, () => Is.EqualTo(1.05).Within(0.005));
            AddAssert("double time has adjust pitch inactive", () => SelectedMods.Value.OfType<ModDoubleTime>().Single().AdjustPitch.Value, () => Is.False);

            void increaseModSpeed() => AddStep("increase mod speed", () =>
            {
                InputManager.PressKey(Key.ControlLeft);
                InputManager.Key(Key.Up);
                InputManager.ReleaseKey(Key.ControlLeft);
            });

            void decreaseModSpeed() => AddStep("decrease mod speed", () =>
            {
                InputManager.PressKey(Key.ControlLeft);
                InputManager.Key(Key.Down);
                InputManager.ReleaseKey(Key.ControlLeft);
            });
        }

        /// <summary>
        /// Last played and rank achieved may have changed, so we want to make sure filtering runs on resume to song select.
        /// </summary>
        [Test]
        public void TestFilteringRunsAfterReturningFromGameplay()
        {
            AddStep("import actual beatmap", () => Beatmaps.Import(TestResources.GetQuickTestBeatmapForImport()).WaitSafely());

            LoadSongSelect();

            AddUntilStep("wait for filtered", () => SongSelect.ChildrenOfType<BeatmapCarousel>().Single().FilterCount, () => Is.EqualTo(1));

            AddStep("enter gameplay", () => InputManager.Key(Key.Enter));

            AddUntilStep("wait for player", () => Stack.CurrentScreen is Player);
            AddUntilStep("wait for fail", () => ((Player)Stack.CurrentScreen).GameplayState.HasFailed);

            AddStep("exit gameplay", () => Stack.CurrentScreen.Exit());

            AddUntilStep("wait for song select", () => Stack.CurrentScreen is Screens.SelectV2.SongSelect);
            AddUntilStep("wait for filtered", () => SongSelect.ChildrenOfType<BeatmapCarousel>().Single().FilterCount, () => Is.EqualTo(2));
        }

        [Test]
        public void TestAutoplayShortcut()
        {
            ImportBeatmapForRuleset(0);

            LoadSongSelect();
            AddStep("press right", () => InputManager.Key(Key.Right)); // press right to select in carousel, also remove.
            AddAssert("beatmap selected", () => !Beatmap.IsDefault);

            AddStep("press ctrl+enter", () =>
            {
                InputManager.PressKey(Key.ControlLeft);
                InputManager.Key(Key.Enter);
                InputManager.ReleaseKey(Key.ControlLeft);
            });

            AddUntilStep("wait for player", () => Stack.CurrentScreen is PlayerLoader);

            AddAssert("autoplay selected", () => SongSelect.Mods.Value.Single() is ModAutoplay);

            AddUntilStep("wait for return to ss", () => SongSelect.IsCurrentScreen());

            AddAssert("no mods selected", () => SongSelect.Mods.Value.Count == 0);
        }

        [Test]
        public void TestAutoplayShortcutKeepsAutoplayIfSelectedAlready()
        {
            ImportBeatmapForRuleset(0);

            LoadSongSelect();
            AddStep("press right", () => InputManager.Key(Key.Right)); // press right to select in carousel, also remove.
            AddAssert("beatmap selected", () => !Beatmap.IsDefault);

            ChangeMods(new OsuModAutoplay());

            AddStep("press ctrl+enter", () =>
            {
                InputManager.PressKey(Key.ControlLeft);
                InputManager.Key(Key.Enter);
                InputManager.ReleaseKey(Key.ControlLeft);
            });

            AddUntilStep("wait for player", () => Stack.CurrentScreen is PlayerLoader);

            AddAssert("autoplay selected", () => SongSelect.Mods.Value.Single() is ModAutoplay);

            AddUntilStep("wait for return to ss", () => SongSelect.IsCurrentScreen());

            AddAssert("autoplay still selected", () => SongSelect.Mods.Value.Single() is ModAutoplay);
        }

        [Test]
        public void TestAutoplayShortcutReturnsInitialModsOnExit()
        {
            ImportBeatmapForRuleset(0);

            LoadSongSelect();
            AddStep("press right", () => InputManager.Key(Key.Right)); // press right to select in carousel, also remove.
            AddAssert("beatmap selected", () => !Beatmap.IsDefault);

            ChangeMods(new OsuModRelax());

            AddStep("press ctrl+enter", () =>
            {
                InputManager.PressKey(Key.ControlLeft);
                InputManager.Key(Key.Enter);
                InputManager.ReleaseKey(Key.ControlLeft);
            });

            AddUntilStep("wait for player", () => Stack.CurrentScreen is PlayerLoader);

            AddAssert("only autoplay selected", () => SongSelect.Mods.Value.Single() is ModAutoplay);

            AddUntilStep("wait for return to ss", () => SongSelect.IsCurrentScreen());

            AddAssert("relax returned", () => SongSelect.Mods.Value.Single() is ModRelax);
        }

        [Test]
        public void TestModSelectCannotBeOpenedAfterConfirmingSelection()
        {
            ImportBeatmapForRuleset(0);

            LoadSongSelect();
            AddStep("press right", () => InputManager.Key(Key.Right)); // press right to select in carousel, also remove.
            AddAssert("beatmap selected", () => !Beatmap.IsDefault);

            ChangeMods(new OsuModAutoplay());

            AddStep("press ctrl+enter", () =>
            {
                InputManager.PressKey(Key.ControlLeft);
                InputManager.Key(Key.Enter);
                InputManager.ReleaseKey(Key.ControlLeft);
            });
            AddStep("press F1", () => InputManager.PressKey(Key.F1));
            AddAssert("mod select not visible", () => this.ChildrenOfType<ModSelectOverlay>().Single().State.Value, () => Is.EqualTo(Visibility.Hidden));

            AddUntilStep("wait for player", () => Stack.CurrentScreen is PlayerLoader);
            AddAssert("osu! cookie visible", () => this.ChildrenOfType<OsuLogo>().Single().Alpha, () => Is.Not.Zero);
        }

        #endregion

        #region Footer

        [Test]
        public void TestFooterMods()
        {
            LoadSongSelect();

            AddStep("one mod", () => SelectedMods.Value = new List<Mod> { new OsuModHidden() });
            AddStep("two mods", () => SelectedMods.Value = new List<Mod> { new OsuModHidden(), new OsuModHardRock() });
            AddStep("three mods", () => SelectedMods.Value = new List<Mod> { new OsuModHidden(), new OsuModHardRock(), new OsuModDoubleTime() });
            AddStep("four mods", () => SelectedMods.Value = new List<Mod> { new OsuModHidden(), new OsuModHardRock(), new OsuModDoubleTime(), new OsuModClassic() });
            AddStep("five mods", () => SelectedMods.Value = new List<Mod> { new OsuModHidden(), new OsuModHardRock(), new OsuModDoubleTime(), new OsuModClassic(), new OsuModDifficultyAdjust() });

            AddStep("modified", () => SelectedMods.Value = new List<Mod> { new OsuModDoubleTime { SpeedChange = { Value = 1.2 } } });
            AddStep("modified + one", () => SelectedMods.Value = new List<Mod> { new OsuModHidden(), new OsuModDoubleTime { SpeedChange = { Value = 1.2 } } });
            AddStep("modified + two", () => SelectedMods.Value = new List<Mod> { new OsuModHidden(), new OsuModHardRock(), new OsuModDoubleTime { SpeedChange = { Value = 1.2 } } });
            AddStep("modified + three",
                () => SelectedMods.Value = new List<Mod> { new OsuModHidden(), new OsuModHardRock(), new OsuModClassic(), new OsuModDoubleTime { SpeedChange = { Value = 1.2 } } });
            AddStep("modified + four",
                () => SelectedMods.Value = new List<Mod>
                    { new OsuModHidden(), new OsuModHardRock(), new OsuModClassic(), new OsuModDifficultyAdjust(), new OsuModDoubleTime { SpeedChange = { Value = 1.2 } } });

            AddStep("clear mods", () => SelectedMods.Value = Array.Empty<Mod>());
            AddWaitStep("wait", 3);
            AddStep("one mod", () => SelectedMods.Value = new List<Mod> { new OsuModHidden() });

            AddStep("clear mods", () => SelectedMods.Value = Array.Empty<Mod>());
            AddWaitStep("wait", 3);
            AddStep("five mods", () => SelectedMods.Value = new List<Mod> { new OsuModHidden(), new OsuModHardRock(), new OsuModDoubleTime(), new OsuModClassic(), new OsuModDifficultyAdjust() });
        }

        [Test]
        public void TestFooterModOverlay()
        {
            LoadSongSelect();

            AddStep("Press F1", () =>
            {
                InputManager.MoveMouseTo(this.ChildrenOfType<FooterButtonMods>().Single());
                InputManager.Click(MouseButton.Left);
            });
            AddAssert("Overlay visible", () => this.ChildrenOfType<ModSelectOverlay>().Single().State.Value == Visibility.Visible);
            AddStep("Hide", () => this.ChildrenOfType<ModSelectOverlay>().Single().Hide());
        }

        [Test]
        public void TestFooterRandom()
        {
            LoadSongSelect();

            bool nextRandomCalled = false;
            bool previousRandomCalled = false;
            AddStep("hook events", () =>
            {
                randomButton.NextRandom = () => nextRandomCalled = true;
                randomButton.PreviousRandom = () => previousRandomCalled = true;
            });

            AddStep("press F2", () => InputManager.Key(Key.F2));
            AddAssert("next random invoked", () => nextRandomCalled && !previousRandomCalled);
        }

        [Test]
        public void TestFooterRandomViaMouse()
        {
            LoadSongSelect();

            bool nextRandomCalled = false;
            bool previousRandomCalled = false;
            AddStep("hook events", () =>
            {
                randomButton.NextRandom = () => nextRandomCalled = true;
                randomButton.PreviousRandom = () => previousRandomCalled = true;
            });

            AddStep("click button", () =>
            {
                InputManager.MoveMouseTo(randomButton);
                InputManager.Click(MouseButton.Left);
            });
            AddAssert("next random invoked", () => nextRandomCalled && !previousRandomCalled);
        }

        [Test]
        public void TestFooterRewind()
        {
            LoadSongSelect();

            bool nextRandomCalled = false;
            bool previousRandomCalled = false;
            AddStep("hook events", () =>
            {
                randomButton.NextRandom = () => nextRandomCalled = true;
                randomButton.PreviousRandom = () => previousRandomCalled = true;
            });

            AddStep("press Shift+F2", () =>
            {
                InputManager.PressKey(Key.LShift);
                InputManager.PressKey(Key.F2);
                InputManager.ReleaseKey(Key.F2);
                InputManager.ReleaseKey(Key.LShift);
            });

            AddAssert("previous random invoked", () => previousRandomCalled && !nextRandomCalled);
        }

        [Test]
        public void TestFooterRewindViaShiftMouseLeft()
        {
            LoadSongSelect();

            bool nextRandomCalled = false;
            bool previousRandomCalled = false;
            AddStep("hook events", () =>
            {
                randomButton.NextRandom = () => nextRandomCalled = true;
                randomButton.PreviousRandom = () => previousRandomCalled = true;
            });

            AddStep("shift + click button", () =>
            {
                InputManager.PressKey(Key.LShift);
                InputManager.MoveMouseTo(randomButton);
                InputManager.Click(MouseButton.Left);
                InputManager.ReleaseKey(Key.LShift);
            });
            AddAssert("previous random invoked", () => previousRandomCalled && !nextRandomCalled);
        }

        [Test]
        public void TestFooterRewindViaMouseRight()
        {
            LoadSongSelect();

            bool nextRandomCalled = false;
            bool previousRandomCalled = false;
            AddStep("hook events", () =>
            {
                randomButton.NextRandom = () => nextRandomCalled = true;
                randomButton.PreviousRandom = () => previousRandomCalled = true;
            });

            AddStep("right click button", () =>
            {
                InputManager.MoveMouseTo(randomButton);
                InputManager.Click(MouseButton.Right);
            });
            AddAssert("previous random invoked", () => previousRandomCalled && !nextRandomCalled);
        }

        private FooterButtonRandom randomButton => ScreenFooter.ChildrenOfType<FooterButtonRandom>().Single();

        [Test]
        public void TestFooterOptions()
        {
            LoadSongSelect();

            ImportBeatmapForRuleset(0);
            AddUntilStep("options enabled", () => this.ChildrenOfType<FooterButtonOptions>().Single().Enabled.Value);

            AddStep("click", () => this.ChildrenOfType<FooterButtonOptions>().Single().TriggerClick());
            AddUntilStep("popover displayed", () => this.ChildrenOfType<FooterButtonOptions.Popover>().Any(p => p.IsPresent));
        }

        [Test]
        public void TestSelectionChangedFromProtectedToNone()
        {
            ImportBeatmapForRuleset(0);
            AddStep("set protected on import", () => Realm.Write(r => r.All<BeatmapSetInfo>().First(s => !s.DeletePending).Protected = true));

            AddStep("selected protected", () => Beatmap.Value = Beatmaps.GetWorkingBeatmap(Beatmaps.GetAllUsableBeatmapSets().First(s => s.Protected).Beatmaps.First()));

            LoadSongSelect();

            AddUntilStep("beatmap deselected", () => Beatmap.IsDefault);
        }

        [Test]
        public void TestSelectionChangedFromProtectedToSomething()
        {
            ImportBeatmapForRuleset(0);
            AddStep("set protected on import", () => Realm.Write(r => r.All<BeatmapSetInfo>().First(s => !s.DeletePending).Protected = true));

            AddStep("selected protected", () => Beatmap.Value = Beatmaps.GetWorkingBeatmap(Beatmaps.GetAllUsableBeatmapSets().First(s => s.Protected).Beatmaps.First()));

            ImportBeatmapForRuleset(0);

            LoadSongSelect();

            AddUntilStep("beatmap selected", () => !Beatmap.IsDefault);
            AddUntilStep("selection not protected", () => !Beatmap.Value.BeatmapSetInfo.Protected);
        }

        [Test]
        public void TestSelectAfterDeletion()
        {
            LoadSongSelect();

            ImportBeatmapForRuleset(0);
            AddUntilStep("beatmap selected", () => !Beatmap.IsDefault);

            AddStep("delete all beatmaps", () => Beatmaps.Delete());
            AddUntilStep("beatmap not selected", () => Beatmap.IsDefault);

            AddStep("restore deleted", () => Beatmaps.UndeleteAll());
            AddUntilStep("beatmap selected", () => !Beatmap.IsDefault);
        }

        [Test]
        public void TestFooterOptionsState()
        {
            LoadSongSelect();

            ImportBeatmapForRuleset(0);

            AddUntilStep("options enabled", () => this.ChildrenOfType<FooterButtonOptions>().Single().Enabled.Value);
            AddStep("delete all beatmaps", () => Beatmaps.Delete());

            AddAssert("beatmap selected", () => !Beatmap.IsDefault);
            AddStep("select no beatmap", () => Beatmap.SetDefault());

            AddUntilStep("wait for no beatmap", () => Beatmap.IsDefault);
            AddAssert("options disabled", () => !this.ChildrenOfType<FooterButtonOptions>().Single().Enabled.Value);
        }

        /// <summary>
        /// tests that clicking the osu! logo immediately after selecting a different difficulty
        /// (before the selection debounce completes) starts the correct beatmap.
        /// this tests the fix for https://github.com/ppy/osu/issues/36074
        /// </summary>
        [Test]
        public void TestPlayCorrectBeatmapWhenSelectionNotFullyLoaded()
        {
            // import a beatmap set with multiple difficulties
            ImportBeatmapForRuleset(0);

            LoadSongSelect();

            // wait for initial beatmap to be selected
            AddUntilStep("wait for first beatmap selected", () => !Beatmap.IsDefault);

            BeatmapInfo? firstBeatmap = null;
            AddStep("store first difficulty", () => firstBeatmap = Beatmap.Value.BeatmapInfo);

            // start loading the first difficulty
            AddStep("click logo to start loading", () => this.ChildrenOfType<OsuLogo>().Single().TriggerClick());
            AddUntilStep("wait for player loader", () => Stack.CurrentScreen is PlayerLoader);

            // return to song select
            AddStep("press escape to return", () => InputManager.Key(Key.Escape));
            AddUntilStep("wait for return to song select", () => SongSelect.IsCurrentScreen());

            // press down and schedule logo click to happen shortly after (but before 150ms debounce)
            // this reproduces the race condition where Beatmap.Value hasn't updated yet
            AddStep("select next difficulty and click logo immediately", () =>
            {
                InputManager.Key(Key.Down);
                Schedule(() => this.ChildrenOfType<OsuLogo>().Single().TriggerClick());
            });

            AddUntilStep("wait for player loader", () => Stack.CurrentScreen is PlayerLoader);

            // verify we're loading the second difficulty, not the first
            // without the fix, this would fail because Beatmap.Value still has the old value
            AddAssert("player is loading second difficulty", () =>
                Beatmap.Value.BeatmapInfo.ID != firstBeatmap!.ID);

            AddUntilStep("wait for return to song select", () => SongSelect.IsCurrentScreen());
        }

        #endregion
    }
}