// Copyright (c) ppy Pty Ltd . 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.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(); private readonly ChangelogOverlay changelogOverlay; private double savedTrackVolume; private double savedMasterVolume; private bool savedMutedState; public TestScenePlayerLoader() { AddRange(new Drawable[] { 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)); } /// /// Sets the input manager child to a new test player loader container instance. /// /// If the test player should behave like the production one. /// An action to run before player load but after bindable leases are returned. 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.BeatmapInfo.AudioLeadIn = 60000; // Set up data for testing disclaimer display. workingBeatmap.BeatmapInfo.EpilepsyWarning = epilepsyWarning ?? false; workingBeatmap.BeatmapInfo.Status = onlineStatus ?? BeatmapOnlineStatus.Ranked; Beatmap.Value = workingBeatmap; foreach (var mod in SelectedMods.Value.OfType()) 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); } /// /// When 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. /// [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 TestBlockLoadViaFocus() { 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); } /// /// Created for avoiding copy pasting code for the same steps. /// /// What part of the volume system is checked /// The action to be invoked to set the volume before loading /// The function to be invoked and checked private void addVolumeSteps(string volumeName, Action beforeLoad, Func assert) { AddStep("reset notification lock", () => sessionStatics.GetBindable(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().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().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().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().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(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().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().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 DisplayedMods => MetadataInfo.Mods.Value; public TestPlayerLoader(Func 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(); } } /// /// Mutable dummy BatteryInfo class for /// /// 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; } } } }