Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
ppy
GitHub Repository: ppy/osu
Path: blob/master/osu.Game.Tests/Visual/Gameplay/TestScenePlayerLoader.cs
4480 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.

#nullable disable

using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using NUnit.Framework;
using osu.Framework.Allocation;
using osu.Framework.Audio;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Screens;
using osu.Framework.Testing;
using osu.Framework.Utils;
using osu.Game.Beatmaps;
using osu.Game.Configuration;
using osu.Game.Graphics.UserInterface;
using osu.Game.Online.API;
using osu.Game.Online.API.Requests;
using osu.Game.Online.API.Requests.Responses;
using osu.Game.Online.Leaderboards;
using osu.Game.Overlays;
using osu.Game.Overlays.Notifications;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Osu;
using osu.Game.Rulesets.Osu.Mods;
using osu.Game.Rulesets.Scoring;
using osu.Game.Scoring;
using osu.Game.Screens.Menu;
using osu.Game.Screens.Play;
using osu.Game.Screens.Play.PlayerSettings;
using osu.Game.Utils;
using osuTK;
using osuTK.Input;

namespace osu.Game.Tests.Visual.Gameplay
{
    public partial class TestScenePlayerLoader : ScreenTestScene
    {
        private TestPlayerLoader loader;
        private TestPlayer player;

        private bool? epilepsyWarning;
        private BeatmapOnlineStatus? onlineStatus;

        [Resolved]
        private AudioManager audioManager { get; set; }

        [Resolved]
        private SessionStatics sessionStatics { get; set; }

        [Resolved]
        private OsuConfigManager config { get; set; }

        [Cached(typeof(INotificationOverlay))]
        private readonly NotificationOverlay notificationOverlay;

        [Cached]
        private readonly VolumeOverlay volumeOverlay;

        [Cached]
        private readonly OsuLogo logo;

        [Cached(typeof(BatteryInfo))]
        private readonly LocalBatteryInfo batteryInfo = new LocalBatteryInfo();

        [Cached]
        private readonly LeaderboardManager leaderboardManager;

        private readonly ChangelogOverlay changelogOverlay;

        private double savedTrackVolume;
        private double savedMasterVolume;
        private bool savedMutedState;

        public TestScenePlayerLoader()
        {
            AddRange(new Drawable[]
            {
                leaderboardManager = new LeaderboardManager(),
                notificationOverlay = new NotificationOverlay
                {
                    Anchor = Anchor.TopRight,
                    Origin = Anchor.TopRight,
                },
                volumeOverlay = new VolumeOverlay
                {
                    Anchor = Anchor.TopLeft,
                    Origin = Anchor.TopLeft,
                },
                changelogOverlay = new ChangelogOverlay(),
                logo = new OsuLogo
                {
                    Anchor = Anchor.BottomRight,
                    Origin = Anchor.BottomRight,
                    Scale = new Vector2(0.5f),
                    Position = new Vector2(128f),
                },
            });
        }

        [SetUp]
        public void Setup() => Schedule(() =>
        {
            player = null;
            epilepsyWarning = null;
            onlineStatus = null;
        });

        [SetUpSteps]
        public override void SetUpSteps()
        {
            base.SetUpSteps();

            AddStep("read all notifications", () =>
            {
                notificationOverlay.Show();
                notificationOverlay.Hide();
            });

            AddUntilStep("wait for no notifications", () => notificationOverlay.UnreadCount.Value, () => Is.EqualTo(0));
        }

        /// <summary>
        /// Sets the input manager child to a new test player loader container instance.
        /// </summary>
        /// <param name="interactive">If the test player should behave like the production one.</param>
        /// <param name="beforeLoadAction">An action to run before player load but after bindable leases are returned.</param>
        private void resetPlayer(bool interactive, Action beforeLoadAction = null)
        {
            beforeLoadAction?.Invoke();

            prepareBeatmap();

            LoadScreen(loader = new TestPlayerLoader(() => player = new TestPlayer(interactive, interactive)));
        }

        private void prepareBeatmap()
        {
            var workingBeatmap = CreateWorkingBeatmap(new OsuRuleset().RulesetInfo);

            // Add intro time to test quick retry skipping (TestQuickRetry).
            workingBeatmap.Beatmap.AudioLeadIn = 60000;

            // Set up data for testing disclaimer display.
            workingBeatmap.Beatmap.EpilepsyWarning = epilepsyWarning ?? false;
            workingBeatmap.BeatmapInfo.Status = onlineStatus ?? BeatmapOnlineStatus.Ranked;

            Beatmap.Value = workingBeatmap;

            foreach (var mod in SelectedMods.Value.OfType<IApplicableToTrack>())
                mod.ApplyToTrack(Beatmap.Value.Track);
        }

        [Test]
        public void TestEarlyExitBeforePlayerConstruction()
        {
            AddStep("load dummy beatmap", () => resetPlayer(false, () => SelectedMods.Value = new[] { new OsuModNightcore() }));
            AddUntilStep("wait for current", () => loader.IsCurrentScreen());
            AddStep("exit loader", () => loader.Exit());
            AddUntilStep("wait for not current", () => !loader.IsCurrentScreen());
            AddAssert("player did not load", () => player == null);
            AddUntilStep("player disposed", () => loader.DisposalTask == null);
            AddAssert("mod rate still applied", () => Beatmap.Value.Track.Rate != 1);
        }

        /// <summary>
        /// When <see cref="PlayerLoader"/> exits early, it has to wait for the player load task
        /// to complete before running disposal on player. This previously caused an issue where mod
        /// speed adjustments were undone too late, causing cross-screen pollution.
        /// </summary>
        [Test]
        public void TestEarlyExitAfterPlayerConstruction()
        {
            AddStep("load dummy beatmap", () => resetPlayer(false, () => SelectedMods.Value = new[] { new OsuModNightcore() }));
            AddUntilStep("wait for current", () => loader.IsCurrentScreen());
            AddAssert("mod rate applied", () => Beatmap.Value.Track.Rate != 1);
            AddUntilStep("wait for non-null player", () => player != null);
            AddStep("exit loader", () => loader.Exit());
            AddUntilStep("wait for not current", () => !loader.IsCurrentScreen());
            AddAssert("player did not load", () => !player.IsLoaded);
            AddUntilStep("player disposed", () => loader.DisposalTask?.IsCompleted == true);
            AddAssert("mod rate still applied", () => Beatmap.Value.Track.Rate != 1);
        }

        [Test]
        public void TestBlockLoadViaMouseMovement()
        {
            AddStep("load dummy beatmap", () => resetPlayer(false));
            AddUntilStep("wait for current", () => loader.IsCurrentScreen());

            AddUntilStep("wait for load ready", () =>
            {
                moveMouse();
                return player?.LoadState == LoadState.Ready;
            });

            AddRepeatStep("move mouse", moveMouse, 20);

            AddAssert("loader still active", () => loader.IsCurrentScreen());
            AddUntilStep("loads after idle", () => !loader.IsCurrentScreen());

            void moveMouse()
            {
                notificationOverlay.State.Value = Visibility.Hidden;

                InputManager.MoveMouseTo(
                    loader.VisualSettings.ScreenSpaceDrawQuad.TopLeft
                    + (loader.VisualSettings.ScreenSpaceDrawQuad.BottomRight - loader.VisualSettings.ScreenSpaceDrawQuad.TopLeft)
                    * RNG.NextSingle());
            }
        }

        [Test]
        public void TestLoadNotBlockedViaArbitraryFocus()
        {
            AddStep("load dummy beatmap", () => resetPlayer(false));
            AddUntilStep("wait for current", () => loader.IsCurrentScreen());

            AddUntilStep("click settings slider", () =>
            {
                InputManager.MoveMouseTo(loader.ChildrenOfType<OsuSliderBar<float>>().First());
                InputManager.Click(MouseButton.Left);

                return InputManager.FocusedDrawable is OsuSliderBar<float>;
            });

            AddUntilStep("wait for load ready", () => player?.LoadState == LoadState.Ready);
            AddUntilStep("loads", () => !loader.IsCurrentScreen());
        }

        [Test]
        public void TestBlockLoadViaOverlayFocus()
        {
            AddStep("load dummy beatmap", () => resetPlayer(false));
            AddUntilStep("wait for current", () => loader.IsCurrentScreen());

            AddStep("show focused overlay", () => changelogOverlay.Show());
            AddUntilStep("overlay visible", () => changelogOverlay.IsPresent);

            AddUntilStep("wait for load ready", () => player?.LoadState == LoadState.Ready);
            AddRepeatStep("twiddle thumbs", () => { }, 20);

            AddAssert("loader still active", () => loader.IsCurrentScreen());

            AddStep("hide overlay", () => changelogOverlay.Hide());
            AddUntilStep("loads after idle", () => !loader.IsCurrentScreen());
        }

        [Test]
        public void TestLoadNotBlockedOnOsuLogo()
        {
            AddStep("load dummy beatmap", () => resetPlayer(false));
            AddUntilStep("wait for current", () => loader.IsCurrentScreen());

            AddUntilStep("wait for load ready", () =>
            {
                moveMouse();
                return player?.LoadState == LoadState.Ready;
            });

            // move mouse in logo while waiting for load to still proceed (it shouldn't be blocked when hovering logo).
            AddUntilStep("move mouse in logo", () =>
            {
                moveMouse();
                return !loader.IsCurrentScreen();
            });

            void moveMouse()
            {
                notificationOverlay.State.Value = Visibility.Hidden;

                InputManager.MoveMouseTo(
                    logo.ScreenSpaceDrawQuad.TopLeft
                    + (logo.ScreenSpaceDrawQuad.BottomRight - logo.ScreenSpaceDrawQuad.TopLeft)
                    * RNG.NextSingle(0.3f, 0.7f));
            }
        }

        [Test]
        public void TestLoadContinuation()
        {
            SlowLoadPlayer slowPlayer = null;

            AddStep("load slow dummy beatmap", () =>
            {
                prepareBeatmap();
                slowPlayer = new SlowLoadPlayer(false, false);
                LoadScreen(loader = new TestPlayerLoader(() => slowPlayer));
            });

            AddStep("schedule slow load", () => Scheduler.AddDelayed(() => slowPlayer.AllowLoad.Set(), 5000));

            AddUntilStep("wait for player to be current", () => slowPlayer.IsCurrentScreen());
        }

        [Test]
        public void TestModReinstantiation()
        {
            TestMod gameMod = null;
            TestMod playerMod1 = null;
            TestMod playerMod2 = null;

            AddStep("load player", () => { resetPlayer(true, () => SelectedMods.Value = new[] { gameMod = new TestMod() }); });

            AddUntilStep("wait for loader to become current", () => loader.IsCurrentScreen());
            AddStep("mouse in centre", () => InputManager.MoveMouseTo(loader.ScreenSpaceDrawQuad.Centre));
            AddUntilStep("wait for player to be current", () => player.IsCurrentScreen());
            AddStep("retrieve mods", () => playerMod1 = (TestMod)player.GameplayState.Mods.Single());
            AddAssert("game mods not applied", () => gameMod.Applied == false);
            AddAssert("player mods applied", () => playerMod1.Applied);

            AddStep("restart player", () =>
            {
                var lastPlayer = player;
                player = null;
                lastPlayer.Restart();
            });

            AddUntilStep("wait for player to be current", () => player.IsCurrentScreen());
            AddStep("retrieve mods", () => playerMod2 = (TestMod)player.GameplayState.Mods.Single());
            AddAssert("game mods not applied", () => gameMod.Applied == false);
            AddAssert("player has different mods", () => playerMod1 != playerMod2);
            AddAssert("player mods applied", () => playerMod2.Applied);
        }

        [Test]
        public void TestModDisplayChanges()
        {
            var testMod = new TestMod();

            AddStep("load player", () => resetPlayer(true));

            AddUntilStep("wait for loader to become current", () => loader.IsCurrentScreen());
            AddStep("set test mod in loader", () => loader.Mods.Value = new[] { testMod });
            AddAssert("test mod is displayed", () => (TestMod)loader.DisplayedMods.Single() == testMod);
        }

        [Test]
        public void TestMutedNotificationLowMusicVolume()
        {
            addVolumeSteps("master and music volumes", () =>
            {
                audioManager.Volume.Value = 0.6;
                audioManager.VolumeTrack.Value = 0.01;
            }, () => Precision.AlmostEquals(audioManager.Volume.Value, 0.6) && Precision.AlmostEquals(audioManager.VolumeTrack.Value, 0.5));
        }

        [Test]
        public void TestMutedNotificationLowMasterVolume()
        {
            addVolumeSteps("master and music volumes", () =>
            {
                audioManager.Volume.Value = 0.01;
                audioManager.VolumeTrack.Value = 0.6;
            }, () => Precision.AlmostEquals(audioManager.Volume.Value, 0.5) && Precision.AlmostEquals(audioManager.VolumeTrack.Value, 0.6));
        }

        [Test]
        public void TestMutedNotificationMuteButton()
        {
            addVolumeSteps("mute button", () =>
            {
                // Importantly, in the case the volume is muted but the user has a volume level set, it should be retained.
                audioManager.Volume.Value = 0.5;
                audioManager.VolumeTrack.Value = 0.5;
                volumeOverlay.IsMuted.Value = true;
            }, () => !volumeOverlay.IsMuted.Value && audioManager.Volume.Value == 0.5 && audioManager.VolumeTrack.Value == 0.5);
        }

        [Test]
        public void TestLeaderboardForciblyRefetchedOnRestart([Values] bool quickRestart)
        {
            int leaderboardRequestsHandled = 0;
            AddStep("set up request handling", () => ((DummyAPIAccess)API).HandleRequest = req =>
            {
                switch (req)
                {
                    case GetScoresRequest getScores:
                        leaderboardRequestsHandled++;
                        getScores.TriggerSuccess(new APIScoresCollection { Scores = [] });
                        return true;

                    default:
                        return false;
                }
            });

            AddStep("load player", () => resetPlayer(true));

            AddUntilStep("wait for loader to become current", () => loader.IsCurrentScreen());
            AddUntilStep("wait for player to be current", () => player.IsCurrentScreen());
            AddAssert("leaderboard fetched once", () => leaderboardRequestsHandled, () => Is.EqualTo(1));

            AddStep("restart player", () =>
            {
                var lastPlayer = player;
                player = null;
                lastPlayer.Restart(quickRestart);
            });

            AddUntilStep("wait for player to be current", () => player.IsCurrentScreen());

            if (quickRestart)
                AddAssert("leaderboard not refetched", () => leaderboardRequestsHandled, () => Is.EqualTo(1));
            else
                AddAssert("leaderboard fetched twice", () => leaderboardRequestsHandled, () => Is.EqualTo(2));
        }

        /// <remarks>
        /// Created for avoiding copy pasting code for the same steps.
        /// </remarks>
        /// <param name="volumeName">What part of the volume system is checked</param>
        /// <param name="beforeLoad">The action to be invoked to set the volume before loading</param>
        /// <param name="assert">The function to be invoked and checked</param>
        private void addVolumeSteps(string volumeName, Action beforeLoad, Func<bool> assert)
        {
            AddStep("reset notification lock", () => sessionStatics.GetBindable<bool>(Static.MutedAudioNotificationShownOnce).Value = false);

            AddStep("load player", () => resetPlayer(false, beforeLoad));
            AddUntilStep("wait for player", () => player?.LoadState == LoadState.Ready);

            saveVolumes();

            AddAssert("check for notification", () => notificationOverlay.UnreadCount.Value, () => Is.EqualTo(1));

            clickNotification();

            AddAssert("check " + volumeName, assert);

            restoreVolumes();

            AddUntilStep("wait for player load", () => player.IsLoaded);
        }

        [TestCase(true)]
        [TestCase(false)]
        public void TestEpilepsyWarning(bool warning)
        {
            saveVolumes();
            setFullVolume();

            AddStep("enable storyboards", () => config.SetValue(OsuSetting.ShowStoryboard, true));
            AddStep("change epilepsy warning", () => epilepsyWarning = warning);
            AddStep("load dummy beatmap", () => resetPlayer(false));

            AddUntilStep("wait for current", () => loader.IsCurrentScreen());

            AddAssert($"epilepsy warning {(warning ? "present" : "absent")}", () => this.ChildrenOfType<PlayerLoaderDisclaimer>().Count(), () => Is.EqualTo(warning ? 1 : 0));

            restoreVolumes();
        }

        [Test]
        public void TestEpilepsyWarningWithDisabledStoryboard()
        {
            saveVolumes();
            setFullVolume();

            AddStep("disable storyboards", () => config.SetValue(OsuSetting.ShowStoryboard, false));
            AddStep("change epilepsy warning", () => epilepsyWarning = true);
            AddStep("load dummy beatmap", () => resetPlayer(false));

            AddUntilStep("wait for current", () => loader.IsCurrentScreen());

            AddUntilStep("epilepsy warning absent", () => this.ChildrenOfType<PlayerLoaderDisclaimer>().Single().Alpha, () => Is.Zero);

            restoreVolumes();
        }

        [TestCase(BeatmapOnlineStatus.Loved, 1)]
        [TestCase(BeatmapOnlineStatus.Qualified, 1)]
        [TestCase(BeatmapOnlineStatus.Graveyard, 0)]
        public void TestStatusWarning(BeatmapOnlineStatus status, int expectedDisclaimerCount)
        {
            saveVolumes();
            setFullVolume();

            AddStep("enable storyboards", () => config.SetValue(OsuSetting.ShowStoryboard, true));
            AddStep("disable epilepsy warning", () => epilepsyWarning = false);
            AddStep("set beatmap status", () => onlineStatus = status);
            AddStep("load dummy beatmap", () => resetPlayer(false));

            AddUntilStep("wait for current", () => loader.IsCurrentScreen());

            AddAssert($"disclaimer count is {expectedDisclaimerCount}", () => this.ChildrenOfType<PlayerLoaderDisclaimer>().Count(), () => Is.EqualTo(expectedDisclaimerCount));

            restoreVolumes();
        }

        [Test]
        public void TestCombinedWarnings()
        {
            saveVolumes();
            setFullVolume();

            AddStep("enable storyboards", () => config.SetValue(OsuSetting.ShowStoryboard, true));
            AddStep("disable epilepsy warning", () => epilepsyWarning = true);
            AddStep("set beatmap status", () => onlineStatus = BeatmapOnlineStatus.Loved);
            AddStep("load dummy beatmap", () => resetPlayer(false));

            AddUntilStep("wait for current", () => loader.IsCurrentScreen());

            AddAssert("disclaimer count is 2", () => this.ChildrenOfType<PlayerLoaderDisclaimer>().Count(), () => Is.EqualTo(2));

            restoreVolumes();
        }

        [TestCase(true, 1.0, false)] // on battery, above cutoff --> no warning
        [TestCase(false, 0.1, false)] // not on battery, below cutoff --> no warning
        [TestCase(true, 0.25, true)] // on battery, at cutoff --> warning
        [TestCase(true, null, false)] // on battery, level unknown --> no warning
        public void TestLowBatteryNotification(bool onBattery, double? chargeLevel, bool shouldWarn)
        {
            AddStep("reset notification lock", () => sessionStatics.GetBindable<bool>(Static.LowBatteryNotificationShownOnce).Value = false);

            // set charge status and level
            AddStep("load player", () => resetPlayer(false, () =>
            {
                batteryInfo.SetOnBattery(onBattery);
                batteryInfo.SetChargeLevel(chargeLevel);
            }));
            AddUntilStep("wait for player", () => player?.LoadState == LoadState.Ready);

            if (shouldWarn)
                clickNotification();
            else
                AddAssert("notification not triggered", () => notificationOverlay.UnreadCount.Value == 0);

            AddUntilStep("wait for player load", () => player.IsLoaded);
        }

        private void restoreVolumes()
        {
            AddStep("restore previous volumes", () =>
            {
                audioManager.VolumeTrack.Value = savedTrackVolume;
                audioManager.Volume.Value = savedMasterVolume;
                volumeOverlay.IsMuted.Value = savedMutedState;
            });
        }

        private void setFullVolume()
        {
            AddStep("set volumes to 100%", () =>
            {
                audioManager.VolumeTrack.Value = 1;
                audioManager.Volume.Value = 1;
                volumeOverlay.IsMuted.Value = false;
            });
        }

        private void saveVolumes()
        {
            AddStep("save previous volumes", () =>
            {
                savedTrackVolume = audioManager.VolumeTrack.Value;
                savedMasterVolume = audioManager.Volume.Value;
                savedMutedState = volumeOverlay.IsMuted.Value;
            });
        }

        [Test]
        public void TestQuickRetry()
        {
            TestPlayer getCurrentPlayer() => loader.CurrentPlayer as TestPlayer;
            bool checkSkipButtonVisible() => player.ChildrenOfType<SkipOverlay>().FirstOrDefault()?.IsButtonVisible == true;

            TestPlayer previousPlayer = null;

            AddStep("load dummy beatmap", () => resetPlayer(false));

            AddUntilStep("wait for current", () => getCurrentPlayer()?.IsCurrentScreen() == true);
            AddStep("store previous player", () => previousPlayer = getCurrentPlayer());

            AddStep("Restart map normally", () => getCurrentPlayer().Restart());
            AddUntilStep("wait for load", () => getCurrentPlayer()?.LoadedBeatmapSuccessfully == true);

            AddUntilStep("restart completed", () => getCurrentPlayer() != null && getCurrentPlayer() != previousPlayer);
            AddStep("store previous player", () => previousPlayer = getCurrentPlayer());

            AddUntilStep("skip button visible", checkSkipButtonVisible);

            AddStep("press quick retry key", () => InputManager.PressKey(Key.Tilde));
            AddUntilStep("restart completed", () => getCurrentPlayer() != null && getCurrentPlayer() != previousPlayer);
            AddStep("release quick retry key", () => InputManager.ReleaseKey(Key.Tilde));

            AddUntilStep("wait for player", () => getCurrentPlayer()?.LoadState >= LoadState.Ready);

            AddUntilStep("time reached zero", () => getCurrentPlayer()?.GameplayClockContainer.CurrentTime > 0);
            AddUntilStep("skip button not visible", () => !checkSkipButtonVisible());
        }

        private void clickNotification()
        {
            Notification notification = null;

            AddUntilStep("wait for notification", () => (notification = notificationOverlay.ChildrenOfType<Notification>().FirstOrDefault()) != null);
            AddStep("open notification overlay", () => notificationOverlay.Show());
            AddStep("click notification", () => notification.TriggerClick());
        }

        private partial class TestPlayerLoader : PlayerLoader
        {
            public new VisualSettings VisualSettings => base.VisualSettings;

            public new Task DisposalTask => base.DisposalTask;

            public IReadOnlyList<Mod> DisplayedMods => MetadataInfo.Mods.Value;

            public TestPlayerLoader(Func<Player> createPlayer)
                : base(createPlayer)
            {
            }
        }

        private class TestMod : OsuModDoubleTime, IApplicableToScoreProcessor
        {
            public bool Applied { get; private set; }

            public void ApplyToScoreProcessor(ScoreProcessor scoreProcessor)
            {
                Applied = true;
            }

            public ScoreRank AdjustRank(ScoreRank rank, double accuracy) => rank;
        }

        protected partial class SlowLoadPlayer : TestPlayer
        {
            public readonly ManualResetEventSlim AllowLoad = new ManualResetEventSlim(false);

            public SlowLoadPlayer(bool allowPause = true, bool showResults = true)
                : base(allowPause, showResults)
            {
            }

            [BackgroundDependencyLoader]
            private void load()
            {
                if (!AllowLoad.Wait(TimeSpan.FromSeconds(10)))
                    throw new TimeoutException();
            }
        }

        /// <summary>
        /// Mutable dummy BatteryInfo class for <see cref="TestScenePlayerLoader.TestLowBatteryNotification"/>
        /// </summary>
        /// <inheritdoc/>
        private class LocalBatteryInfo : BatteryInfo
        {
            private bool onBattery;
            private double? chargeLevel;

            public override bool OnBattery => onBattery;

            public override double? ChargeLevel => chargeLevel;

            public void SetOnBattery(bool value)
            {
                onBattery = value;
            }

            public void SetChargeLevel(double? value)
            {
                chargeLevel = value;
            }
        }
    }
}