Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
ppy
GitHub Repository: ppy/osu
Path: blob/master/osu.Game.Tests/Visual/SongSelectV2/TestSceneSongSelectCurrentSelectionInvalidated.cs
4412 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.Testing;
using osu.Game.Beatmaps;
using osu.Game.Configuration;
using osu.Game.Screens.Select.Filter;
using osu.Game.Screens.SelectV2;
using osuTK.Input;

namespace osu.Game.Tests.Visual.SongSelectV2
{
    /// <summary>
    /// The fallback behaviour guaranteed by SongSelect is that a random selection will happen in worst case scenario.
    /// Every case we're testing here is expected to have a *custom behaviour* – engaging and overriding this random selection fallback.
    ///
    /// The scenarios we care abouts are:
    /// - Ruleset change (select another difficulty from same set for the new ruleset, if possible).
    /// - Beatmap difficulty hidden (select closest valid difficulty from same set)
    /// - Beatmap set deleted (select closest valid beatmap post-deletion)
    ///
    /// We are working with 5 sets, each with 3 difficulties (all osu! ruleset).
    /// </summary>
    public partial class TestSceneSongSelectCurrentSelectionInvalidated : SongSelectTestScene
    {
        private BeatmapInfo? selectedBeatmap => Carousel.CurrentBeatmap;
        private BeatmapSetInfo? selectedBeatmapSet => selectedBeatmap?.BeatmapSet;

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

            for (int i = 0; i < 5; i++)
                ImportBeatmapForRuleset(0);

            LoadSongSelect();
        }

        [Test]
        public void TestRulesetChange()
        {
            AddStep("disable converts", () => Config.SetValue(OsuSetting.ShowConvertedBeatmaps, false));

            ImportBeatmapForRuleset(0, 1);
            ImportBeatmapForRuleset(0, 1);
            ImportBeatmapForRuleset(0, 2);
            waitForFiltering(5);

            ChangeRuleset(1);
            waitForFiltering(6);

            BeatmapInfo? initiallySelected = null;
            AddAssert("carousel beatmap is taiko", () => (initiallySelected = selectedBeatmap)?.Ruleset.OnlineID, () => Is.EqualTo(1));
            AddUntilStep("global beatmap is taiko", () => Beatmap.Value.BeatmapInfo.Ruleset.OnlineID, () => Is.EqualTo(1));

            ChangeRuleset(0);
            waitForFiltering(7);
            AddAssert("carousel beatmap is osu", () => selectedBeatmap?.Ruleset.OnlineID, () => Is.EqualTo(0));
            AddUntilStep("global beatmap is osu", () => Beatmap.Value.BeatmapInfo.Ruleset.OnlineID, () => Is.EqualTo(0));
            AddAssert("carousel beatmap is same set as original", () => selectedBeatmap?.BeatmapSet, () => Is.EqualTo(initiallySelected!.BeatmapSet));

            ChangeRuleset(1);
            waitForFiltering(8);
            AddAssert("carousel beatmap is taiko", () => selectedBeatmap?.Ruleset.OnlineID, () => Is.EqualTo(1));
            AddUntilStep("global beatmap is taiko", () => Beatmap.Value.BeatmapInfo.Ruleset.OnlineID, () => Is.EqualTo(1));
            AddAssert("carousel beatmap is same set as original", () => selectedBeatmap?.BeatmapSet, () => Is.EqualTo(initiallySelected!.BeatmapSet));

            ChangeRuleset(2);
            waitForFiltering(9);
            AddAssert("carousel beatmap is catch", () => selectedBeatmap?.Ruleset.OnlineID, () => Is.EqualTo(2));
            AddUntilStep("global beatmap is catch", () => Beatmap.Value.BeatmapInfo.Ruleset.OnlineID, () => Is.EqualTo(2));
            AddAssert("carousel beatmap is different set", () => selectedBeatmap?.BeatmapSet, () => Is.Not.EqualTo(initiallySelected!.BeatmapSet));
        }

        /// <summary>
        /// Make sure that deleting all sets doesn't hit some weird edge case / crash.
        /// </summary>
        [TestCase(SortMode.Title)]
        [TestCase(SortMode.Artist)]
        [TestCase(SortMode.Difficulty)]
        public void TestDeleteAllSets(SortMode sortMode)
        {
            int filterCount = sortMode != SortMode.Title ? 2 : 1;

            SortBy(sortMode);
            waitForFiltering(filterCount);

            BeatmapSetInfo deletedSet = null!;

            for (int i = 0; i < 4; i++)
            {
                AddStep("delete selected", () => Beatmaps.Delete(deletedSet = selectedBeatmapSet!));
                waitForFiltering(filterCount + 1 + i);
                selectionChangedFrom(() => deletedSet);
            }

            // The carousel still holds an invalid selection after the final deletion. Probably fine?
            AddStep("delete selected", () => Beatmaps.Delete(deletedSet = selectedBeatmapSet!));
            AddUntilStep("wait for no global selection", () => Beatmap.IsDefault, () => Is.True);
        }

        [Test]
        public void DifficultiesGrouped_DeleteSet_SelectsAdjacent()
        {
            SortAndGroupBy(SortMode.Difficulty, GroupMode.Difficulty);
            waitForFiltering(2);

            makePanelSelected<PanelGroupStarDifficulty>(2);
            makePanelSelected<PanelBeatmapStandalone>(3);

            // Deleting second-last, should select last
            BeatmapSetInfo deletedSet = null!;
            AddStep("delete selected", () => Beatmaps.Delete(deletedSet = selectedBeatmapSet!));
            waitForFiltering(3);

            selectionChangedFrom(() => deletedSet);
            assertPanelSelected<PanelBeatmapStandalone>(3);

            // Deleting last, should select previous
            AddStep("delete selected", () => Beatmaps.Delete(deletedSet = selectedBeatmapSet!));
            waitForFiltering(4);

            selectionChangedFrom(() => deletedSet);
            assertPanelSelected<PanelBeatmapStandalone>(2);
        }

        [TestCase(SortMode.Title)]
        [TestCase(SortMode.Artist)]
        public void SetsGrouped_DeleteSet_SelectsAdjacent(SortMode sortMode)
        {
            int filterCount = sortMode != SortMode.Title ? 2 : 1;

            SortBy(sortMode);
            waitForFiltering(filterCount);

            makePanelSelected<PanelBeatmapSet>(3);

            // Deleting second-last, should select last
            BeatmapSetInfo deletedSet = null!;
            AddStep("delete selected", () => Beatmaps.Delete(deletedSet = selectedBeatmapSet!));
            waitForFiltering(filterCount + 1);

            selectionChangedFrom(() => deletedSet);
            assertPanelSelected<PanelBeatmapSet>(3);
            assertPanelSelected<PanelBeatmap>(0);

            // Deleting last, should select previous
            AddStep("delete selected", () => Beatmaps.Delete(deletedSet = selectedBeatmapSet!));
            waitForFiltering(filterCount + 2);

            selectionChangedFrom(() => deletedSet);
            assertPanelSelected<PanelBeatmapSet>(2);
            assertPanelSelected<PanelBeatmap>(0);
        }

        // Same scenario as the test case above, but where selected difficulty before deletion is not first index in the expanded set.
        // Basically ensures that the reselection is running `RequestRecommendedSelection` and not just relying on indices.
        [TestCase(SortMode.Title)]
        [TestCase(SortMode.Artist)]
        public void SetsGrouped_DeleteSet_SelectsNextSetRecommendedDifficulty(SortMode sortMode)
        {
            int filterCount = sortMode != SortMode.Title ? 2 : 1;

            SortBy(sortMode);
            waitForFiltering(filterCount);

            makePanelSelected<PanelBeatmapSet>(2);
            makePanelSelected<PanelBeatmap>(2);

            AddUntilStep("wait for beatmap to be selected", () => selectedBeatmapSet != null);

            BeatmapSetInfo deletedSet = null!;
            AddStep("delete selected", () => Beatmaps.Delete(deletedSet = selectedBeatmapSet!));
            waitForFiltering(++filterCount);

            selectionChangedFrom(() => deletedSet);
            assertPanelSelected<PanelBeatmapSet>(2);
            assertPanelSelected<PanelBeatmap>(0);
        }

        [Test]
        public void TestHideBeatmap()
        {
            makePanelSelected<PanelBeatmapSet>(2);
            makePanelSelected<PanelBeatmap>(1);

            BeatmapInfo hiddenBeatmap = null!;

            AddStep("hide selected", () => Beatmaps.Hide(hiddenBeatmap = selectedBeatmap!));
            waitForFiltering(2);

            AddAssert("selected beatmap below", () => selectedBeatmap!.BeatmapSet, () => Is.EqualTo(hiddenBeatmap.BeatmapSet));

            AddStep("hide selected", () => Beatmaps.Hide(hiddenBeatmap = selectedBeatmap!));
            waitForFiltering(3);

            AddAssert("selected beatmap below", () => selectedBeatmap!.BeatmapSet, () => Is.EqualTo(hiddenBeatmap.BeatmapSet));
            assertPanelSelected<PanelBeatmap>(0);
        }

        [Test]
        [Explicit]
        public void TestDebounceNotBypassedOnUpdate()
        {
            BeatmapInfo? selectedBefore = null;
            BeatmapInfo? selectedBeatmapDuringDebounce = null;

            // we're testing the song select side debounce, so let's make filtering immediate
            AddStep("set filter debounce delay to zero", () => Carousel.DebounceDelay = 0);

            WaitForFiltering();

            AddUntilStep("wait for global beatmap selection", () => !Beatmap.IsDefault);

            AddStep("store selection", () => selectedBefore = Beatmap.Value.BeatmapInfo);

            AddStep("traverse to next panel and update simultaneously", () =>
            {
                InputManager.Key(Key.Right);

                Beatmaps.Delete(Beatmaps.GetAllUsableBeatmapSets().Last());

                // check selection during debounce
                Scheduler.AddDelayed(() => selectedBeatmapDuringDebounce = Beatmap.Value.BeatmapInfo, Screens.SelectV2.SongSelect.SELECTION_DEBOUNCE / 2f);
            });

            WaitForFiltering();

            AddUntilStep("wait for pre-debounce selection", () => selectedBeatmapDuringDebounce, () => Is.Not.Null);

            AddAssert("selection during debounce didn't change", () => selectedBeatmapDuringDebounce, () => Is.EqualTo(selectedBefore));

            // Due to nunit runs having limited precision this tends to fail when headless, even though you'd expect the previous step to fail.
            // Interactively, things fail as expected.
            AddUntilStep("selection has changed after debounce", () => selectedBeatmapDuringDebounce, () => Is.Not.EqualTo(Beatmap.Value.BeatmapInfo));
        }

        private void waitForFiltering(int filterCount = 1)
        {
            AddUntilStep("wait for filter count", () => Carousel.FilterCount, () => Is.EqualTo(filterCount));
            AddUntilStep("filtering finished", () => Carousel.IsFiltering, () => Is.False);
        }

        private void makePanelSelected<T>(int index)
            where T : Panel
        {
            AddStep($"click panel at index {index} if not selected", () =>
            {
                var panel = allPanels<T>().ElementAt(index).ChildrenOfType<Panel>().Single();

                // May have already been selected randomly. Don't click a second time or gameplay will start.
                if (!panel.Selected.Value)
                    panel.TriggerClick();
            });

            assertPanelSelected<T>(index);
        }

        private void selectionChangedFrom(Func<BeatmapSetInfo> deletedSet) =>
            AddUntilStep("selection changed", () => selectedBeatmapSet, () => Is.Not.EqualTo(deletedSet()));

        private void assertPanelSelected<T>(int index)
            where T : Panel
            => AddUntilStep($"selected panel at index {index}", getActivePanelIndex<T>, () => Is.EqualTo(index));

        private int getActivePanelIndex<T>()
            where T : Panel
            => allPanels<T>().ToList().FindIndex(p =>
            {
                switch (p)
                {
                    case PanelBeatmapStandalone pb:
                        return pb.Selected.Value;

                    case PanelBeatmap pb:
                        return pb.Selected.Value;

                    case Panel pbs:
                        return pbs.Expanded.Value;

                    default:
                        throw new InvalidOperationException();
                }
            });

        private IEnumerable<T> allPanels<T>()
            where T : Panel
            => Carousel.ChildrenOfType<T>().Where(p => p.Item != null).OrderBy(p => p.Y);
    }
}