Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
ppy
GitHub Repository: ppy/osu
Path: blob/master/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapCarouselUpdateHandling.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.Testing;
using osu.Framework.Utils;
using osu.Game.Beatmaps;
using osu.Game.Database;
using osu.Game.Extensions;
using osu.Game.Graphics.Containers;
using osu.Game.Graphics.Sprites;
using osu.Game.Screens.Select.Filter;
using osu.Game.Screens.SelectV2;
using osu.Game.Tests.Resources;

namespace osu.Game.Tests.Visual.SongSelectV2
{
    [TestFixture]
    public partial class TestSceneBeatmapCarouselUpdateHandling : BeatmapCarouselTestScene
    {
        private BeatmapSetInfo baseTestBeatmap = null!;

        private const int initial_filter_count = 3;

        [SetUpSteps]
        public void SetUpSteps()
        {
            RemoveAllBeatmaps();
            CreateCarousel();
            WaitForFiltering();
            AddStep("add beatmap", () =>
            {
                var beatmap = CreateTestBeatmapSetInfo(3, false);
                Realm.Write(r => r.Add(beatmap, update: true));
                BeatmapSets.Add(beatmap.Detach());
            });
            WaitForFiltering();
            AddStep("generate and add test beatmap", () =>
            {
                baseTestBeatmap = TestResources.CreateTestBeatmapSetInfo(3);

                var metadata = new BeatmapMetadata
                {
                    Artist = "update test",
                    Title = "beatmap",
                };

                foreach (var b in baseTestBeatmap.Beatmaps)
                    b.Metadata = metadata;

                Realm.Write(r => r.Add(baseTestBeatmap, update: true));
                BeatmapSets.Add(baseTestBeatmap.Detach());
            });
            WaitForFiltering();

            AddAssert("filter count correct", () => Carousel.FilterCount, () => Is.EqualTo(initial_filter_count));
        }

        [Test]
        public void TestBeatmapSetUpdatedNoop()
        {
            List<Panel> originalDrawables = new List<Panel>();

            AddStep("store drawable references", () =>
            {
                originalDrawables.Clear();
                originalDrawables.AddRange(Carousel.ChildrenOfType<Panel>().ToList());
            });

            AddStep("update beatmap with same reference", () => BeatmapSets.ReplaceRange(1, 1, [baseTestBeatmap]));

            WaitForFiltering();
            AddAssert("drawables unchanged", () => Carousel.ChildrenOfType<Panel>(), () => Is.EqualTo(originalDrawables));
        }

        [TestCase(true)]
        [TestCase(false)]
        public void TestScrollPositionMaintainedWhenSetUpdated(bool difficultySort)
        {
            if (difficultySort)
            {
                SortAndGroupBy(SortMode.Difficulty, GroupMode.Difficulty);
                assertDidFilter(1);
            }

            Panel panel = null!;

            AddStep("find panel", () => panel = Carousel.ChildrenOfType<Panel>().First(p => p.Item != null && p.ChildrenOfType<OsuSpriteText>().Any(t => t.Text.ToString() == "beatmap")));

            AddStep("select panel", () => panel.TriggerClick());

            AddStep("scroll to end", () =>
            {
                // must trigger a user scroll so that carousel doesn't follow the selection.
                InputManager.MoveMouseTo(Carousel);
                InputManager.ScrollVerticalBy(-1000);
            });

            AddUntilStep("is scrolled to end", () => Carousel.ChildrenOfType<UserTrackingScrollContainer>().Single().IsScrolledToEnd());

            updateBeatmap(b =>
            {
                // hash will be updated when important metadata changes, such as title, difficulty, author etc.
                b.Hash = "new hash";
                b.Metadata = new BeatmapMetadata
                {
                    Artist = "updated test",
                    Title = $"beatmap {RNG.Next().ToString()}"
                };
            });

            assertDidFilter(difficultySort ? 2 : 1);
            WaitForFiltering();

            AddAssert("scroll is still at end", () => Carousel.ChildrenOfType<UserTrackingScrollContainer>().Single().IsScrolledToEnd());
        }

        [Test]
        public void TestBeatmapSetMetadataUpdated()
        {
            PanelBeatmapSet panel = null!;

            var metadata = new BeatmapMetadata
            {
                Artist = "updated test",
                Title = "new beatmap title",
            };

            List<Panel> originalDrawables = new List<Panel>();

            AddStep("store drawable references", () =>
            {
                originalDrawables.Clear();
                originalDrawables.AddRange(Carousel.ChildrenOfType<Panel>().ToList());
            });

            AddStep("find panel", () => panel = Carousel.ChildrenOfType<PanelBeatmapSet>().Single(p => p.ChildrenOfType<OsuSpriteText>().Any(t => t.Text.ToString() == "beatmap")));

            updateBeatmap(b =>
            {
                b.Metadata = metadata;
                // hash will be updated when important metadata changes, such as title, difficulty, author etc.
                b.Hash = "new hash";
            });

            assertDidFilter();
            WaitForFiltering();

            AddAssert("drawables unchanged", () => Carousel.ChildrenOfType<Panel>(), () => Is.EqualTo(originalDrawables));

            AddAssert("title updated", () => panel.ChildrenOfType<OsuSpriteText>().Any(t => t.Text.ToString() == metadata.Title));
        }

        [Test]
        public void TestOnlineStatusUpdated()
        {
            List<Panel> originalDrawables = new List<Panel>();

            AddStep("store drawable references", () =>
            {
                originalDrawables.Clear();
                originalDrawables.AddRange(Carousel.ChildrenOfType<Panel>().ToList());
            });

            updateBeatmap(b => b.Status = BeatmapOnlineStatus.Graveyard);

            assertDidFilter();
            WaitForFiltering();

            AddAssert("drawables unchanged", () => Carousel.ChildrenOfType<Panel>(), () => Is.EqualTo(originalDrawables));
        }

        [Test]
        public void TestNoUpdateTriggeredOnUserTagsChange()
        {
            var metadata = new BeatmapMetadata
            {
                Artist = "updated test",
                Title = "new beatmap title",
                UserTags = { "hi" }
            };

            updateBeatmap(b => b.Metadata = metadata);
            assertDidNotFilter();
        }

        [TestCase(false, false)]
        [TestCase(false, true)]
        [TestCase(true, false)]
        [TestCase(true, true)]
        public void TestSelectionHeld(bool difficultySort, bool hashChanged)
        {
            SelectNextSet();

            if (difficultySort)
            {
                SortAndGroupBy(SortMode.Difficulty, GroupMode.Difficulty);
                assertDidFilter(1);
            }

            WaitForSetSelection(1, 0);
            AddAssert("selection is updateable beatmap", () => Carousel.CurrentBeatmap, () => Is.EqualTo(baseTestBeatmap.Beatmaps[0]));
            AddAssert("visible panel is updateable beatmap", () => (GetSelectedPanel()?.Item?.Model as GroupedBeatmap)?.Beatmap, () => Is.EqualTo(baseTestBeatmap.Beatmaps[0]));

            updateBeatmap(b =>
            {
                if (hashChanged)
                    b.Hash = "new hash";
            });

            int baseFilterCount = difficultySort ? 1 : 0;

            if (hashChanged)
                assertDidFilter(baseFilterCount + 1);
            else
                assertDidFilter(baseFilterCount);

            WaitForFiltering();

            AddAssert("selection is updateable beatmap", () => Carousel.CurrentBeatmap, () => Is.EqualTo(baseTestBeatmap.Beatmaps[0]));
            AddAssert("visible panel is updateable beatmap", () => (GetSelectedPanel()?.Item?.Model as GroupedBeatmap)?.Beatmap, () => Is.EqualTo(baseTestBeatmap.Beatmaps[0]));
        }

        [Test] // Checks that we keep selection based on online ID where possible.
        public void TestSelectionHeldDifficultyNameChanged()
        {
            SelectNextSet();

            WaitForSetSelection(1, 0);
            AddAssert("selection is updateable beatmap", () => Carousel.CurrentBeatmap, () => Is.EqualTo(baseTestBeatmap.Beatmaps[0]));
            AddAssert("visible panel is updateable beatmap", () => (GetSelectedPanel()?.Item?.Model as GroupedBeatmap)?.Beatmap, () => Is.EqualTo(baseTestBeatmap.Beatmaps[0]));

            updateBeatmap(b => b.DifficultyName = "new name");
            assertDidFilter();
            WaitForFiltering();

            AddAssert("selection is updateable beatmap", () => Carousel.CurrentBeatmap, () => Is.EqualTo(baseTestBeatmap.Beatmaps[0]));
            AddAssert("visible panel is updateable beatmap", () => (GetSelectedPanel()?.Item?.Model as GroupedBeatmap)?.Beatmap, () => Is.EqualTo(baseTestBeatmap.Beatmaps[0]));
        }

        [Test] // Checks that we fallback to keeping selection based on difficulty name.
        public void TestSelectionHeldDifficultyOnlineIDChanged()
        {
            SelectNextSet();

            WaitForSetSelection(1, 0);
            AddAssert("selection is updateable beatmap", () => Carousel.CurrentBeatmap, () => Is.EqualTo(baseTestBeatmap.Beatmaps[0]));
            AddAssert("visible panel is updateable beatmap", () => (GetSelectedPanel()?.Item?.Model as GroupedBeatmap)?.Beatmap, () => Is.EqualTo(baseTestBeatmap.Beatmaps[0]));

            updateBeatmap(b => b.OnlineID = b.OnlineID + 1);
            assertDidFilter();
            WaitForFiltering();

            AddAssert("selection is updateable beatmap", () => Carousel.CurrentBeatmap, () => Is.EqualTo(baseTestBeatmap.Beatmaps[0]));
            AddAssert("visible panel is updateable beatmap", () => (GetSelectedPanel()?.Item?.Model as GroupedBeatmap)?.Beatmap, () => Is.EqualTo(baseTestBeatmap.Beatmaps[0]));
        }

        [Test] // Checks that we don't crash if there exists a difficulty with the same online ID as the selected difficulty.
        public void TestDifferentDifficultiesWithSameOnlineID()
        {
            SelectNextSet();

            WaitForSetSelection(1, 0);
            AddAssert("selection is updateable beatmap", () => Carousel.CurrentBeatmap, () => Is.EqualTo(baseTestBeatmap.Beatmaps[0]));
            AddAssert("visible panel is updateable beatmap", () => (GetSelectedPanel()?.Item?.Model as GroupedBeatmap)?.Beatmap, () => Is.EqualTo(baseTestBeatmap.Beatmaps[0]));

            // Add another difficulty with same online ID.
            updateBeatmap(null, bs =>
            {
                var newBeatmap = createBeatmap(bs);
                newBeatmap.OnlineID = baseTestBeatmap.Beatmaps[0].OnlineID;
                bs.Beatmaps.Add(newBeatmap);
            });

            WaitForFiltering();

            AddAssert("selection is updateable beatmap", () => Carousel.CurrentBeatmap, () => Is.EqualTo(baseTestBeatmap.Beatmaps[0]));
            AddAssert("visible panel is updateable beatmap", () => (GetSelectedPanel()?.Item?.Model as GroupedBeatmap)?.Beatmap, () => Is.EqualTo(baseTestBeatmap.Beatmaps[0]));
        }

        [Test] // Checks that we don't crash if there exists a difficulty with the same name as the selected difficulty.
        public void TestDifferentDifficultiesWithSameName()
        {
            SelectNextSet();

            WaitForSetSelection(1, 0);
            AddAssert("selection is updateable beatmap", () => Carousel.CurrentBeatmap, () => Is.EqualTo(baseTestBeatmap.Beatmaps[0]));
            AddAssert("visible panel is updateable beatmap", () => (GetSelectedPanel()?.Item?.Model as GroupedBeatmap)?.Beatmap, () => Is.EqualTo(baseTestBeatmap.Beatmaps[0]));

            // Remove original selected difficulty, and add two difficulties with same name as selection.
            updateBeatmap(null, bs =>
            {
                string selectedName = bs.Beatmaps[0].DifficultyName;
                Realm.Write(r => r.Remove(r.Find<BeatmapInfo>(bs.Beatmaps[0].ID)!));
                bs.Beatmaps.RemoveAt(0);

                var newBeatmap = createBeatmap(bs);
                newBeatmap.ID = Guid.NewGuid();
                newBeatmap.DifficultyName = selectedName;
                newBeatmap.OnlineID = -1;
                bs.Beatmaps.Add(newBeatmap);

                newBeatmap = createBeatmap(bs);
                newBeatmap.ID = Guid.NewGuid();
                newBeatmap.DifficultyName = selectedName;
                newBeatmap.OnlineID = -1;
                bs.Beatmaps.Add(newBeatmap);
            });

            WaitForFiltering();

            AddAssert("selection is updateable beatmap", () => Carousel.CurrentBeatmap, () => Is.EqualTo(BeatmapSets[1].Beatmaps[2]));
            AddAssert("visible panel is updateable beatmap", () => (GetSelectedPanel()?.Item?.Model as GroupedBeatmap)?.Beatmap, () => Is.EqualTo(BeatmapSets[1].Beatmaps[2]));
        }

        /// <summary>
        /// Ensures stability is maintained on different sort modes while an item is removed and then immediately re-added.
        /// </summary>
        [Test]
        public void TestSortingStabilityWithRemovedAndReaddedItem()
        {
            RemoveAllBeatmaps();

            const int diff_count = 5;

            AddStep("Populate beatmap sets", () =>
            {
                for (int i = 0; i < 3; i++)
                {
                    var set = TestResources.CreateTestBeatmapSetInfo(diff_count);

                    // only need to set the first as they are a shared reference.
                    var beatmap = set.Beatmaps.First();

                    beatmap.Metadata.Artist = "same artist";
                    beatmap.Metadata.Title = "same title";

                    // testing the case where DateAdded happens to equal (quite rare).
                    set.DateAdded = DateTimeOffset.UnixEpoch;

                    BeatmapSets.Add(set);
                }
            });

            BeatmapSetInfo removedBeatmap = null!;
            Guid[] originalOrder = null!;

            SortBy(SortMode.Artist);

            AddAssert("Items in descending added order", () => Carousel.PostFilterBeatmaps.Select(b => b.BeatmapSet!.DateAdded), () => Is.Ordered.Descending);
            AddStep("Save order", () => originalOrder = Carousel.PostFilterBeatmaps.Select(b => b.ID).ToArray());

            AddStep("Remove item", () =>
            {
                removedBeatmap = BeatmapSets[1];
                BeatmapSets.RemoveAt(1);
            });
            AddStep("Re-add item", () => BeatmapSets.Insert(1, removedBeatmap));
            WaitForFiltering();

            AddAssert("Order didn't change", () => Carousel.PostFilterBeatmaps.Select(b => b.ID), () => Is.EqualTo(originalOrder));

            SortBy(SortMode.Title);

            AddAssert("Order didn't change", () => Carousel.PostFilterBeatmaps.Select(b => b.ID), () => Is.EqualTo(originalOrder));
        }

        /// <summary>
        /// Ensures stability is maintained on different sort modes while a new item is added to the carousel.
        /// </summary>
        [Test]
        public void TestSortingStabilityWithNewItems()
        {
            RemoveAllBeatmaps();

            const int diff_count = 5;

            AddStep("Populate beatmap sets", () =>
            {
                for (int i = 0; i < 3; i++)
                {
                    var set = TestResources.CreateTestBeatmapSetInfo(diff_count);

                    // only need to set the first as they are a shared reference.
                    var beatmap = set.Beatmaps.First();

                    beatmap.Metadata.Artist = "same artist";
                    beatmap.Metadata.Title = "same title";

                    // testing the case where DateAdded happens to equal (quite rare).
                    set.DateAdded = DateTimeOffset.UnixEpoch;

                    BeatmapSets.Add(set);
                }
            });

            Guid[] originalOrder = null!;

            SortBy(SortMode.Artist);

            AddAssert("Items in descending added order", () => Carousel.PostFilterBeatmaps.Select(b => b.BeatmapSet!.DateAdded), () => Is.Ordered.Descending);
            AddStep("Save order", () => originalOrder = Carousel.PostFilterBeatmaps.Select(b => b.ID).ToArray());

            AddStep("Add new item", () =>
            {
                var set = TestResources.CreateTestBeatmapSetInfo();

                // only need to set the first as they are a shared reference.
                var beatmap = set.Beatmaps.First();

                beatmap.Metadata.Artist = "same artist";
                beatmap.Metadata.Title = "same title";

                set.DateAdded = DateTimeOffset.FromUnixTimeSeconds(1);

                BeatmapSets.Add(set);

                // add set to expected ordering
                originalOrder = set.Beatmaps.Select(b => b.ID).Concat(originalOrder).ToArray();
            });
            WaitForFiltering();

            AddAssert("Order didn't change", () => Carousel.PostFilterBeatmaps.Select(b => b.ID), () => Is.EqualTo(originalOrder));

            SortBy(SortMode.Title);

            AddAssert("Order didn't change", () => Carousel.PostFilterBeatmaps.Select(b => b.ID), () => Is.EqualTo(originalOrder));
        }

        private void assertDidFilter(int count = 1) => AddAssert("did filter", () => Carousel.FilterCount, () => Is.EqualTo(initial_filter_count + count));

        private void assertDidNotFilter() => AddAssert("did not filter", () => Carousel.FilterCount, () => Is.EqualTo(initial_filter_count));

        private void updateBeatmap(Action<BeatmapInfo>? updateBeatmap = null, Action<BeatmapSetInfo>? updateSet = null)
        {
            AddStep("update beatmap with different reference", () =>
            {
                var updatedSet = new BeatmapSetInfo
                {
                    ID = baseTestBeatmap.ID,
                    OnlineID = baseTestBeatmap.OnlineID,
                    DateAdded = baseTestBeatmap.DateAdded,
                    DateSubmitted = baseTestBeatmap.DateSubmitted,
                    DateRanked = baseTestBeatmap.DateRanked,
                    Status = baseTestBeatmap.Status,
                    StatusInt = baseTestBeatmap.StatusInt,
                    DeletePending = baseTestBeatmap.DeletePending,
                    Hash = baseTestBeatmap.Hash,
                    Protected = baseTestBeatmap.Protected,
                };

                var updatedBeatmaps = baseTestBeatmap.Beatmaps.Select(b =>
                {
                    var updatedBeatmap = createBeatmap(updatedSet, b);

                    updateBeatmap?.Invoke(updatedBeatmap);

                    return updatedBeatmap;
                }).ToList();

                updatedSet.Beatmaps.AddRange(updatedBeatmaps);

                updateSet?.Invoke(updatedSet);

                int originalIndex = BeatmapSets.IndexOf(baseTestBeatmap);

                Realm.Write(r => r.Add(updatedSet, update: true));
                BeatmapSets.ReplaceRange(originalIndex, 1, [updatedSet.Detach()]);
            });
        }

        private BeatmapInfo createBeatmap(BeatmapSetInfo set, BeatmapInfo? reference = null)
        {
            reference ??= baseTestBeatmap.Beatmaps.First();

            var updatedBeatmap = new BeatmapInfo
            {
                ID = reference.ID,
                Metadata = reference.Metadata,
                Ruleset = reference.Ruleset,
                DifficultyName = reference.DifficultyName,
                BeatmapSet = set,
                Status = reference.Status,
                OnlineID = reference.OnlineID,
                Length = reference.Length,
                BPM = reference.BPM,
                Hash = reference.Hash,
                StarRating = reference.StarRating,
                MD5Hash = reference.MD5Hash,
                OnlineMD5Hash = reference.OnlineMD5Hash,
            };

            return updatedBeatmap;
        }
    }
}