// Copyright (c) ppy Pty Ltd . 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.IO; using System.Linq; using System.Text; using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Game.Beatmaps; using osu.Game.Configuration; using osu.Game.Rulesets; using osu.Game.Screens.Select; using osu.Game.Screens.Select.Carousel; using osu.Game.Screens.Select.Filter; namespace osu.Game.Tests.Visual.SongSelect { [TestFixture] public class TestSceneBeatmapCarousel : OsuTestScene { private TestBeatmapCarousel carousel; private RulesetStore rulesets; public override IReadOnlyList RequiredTypes => new[] { typeof(CarouselItem), typeof(CarouselGroup), typeof(CarouselGroupEagerSelect), typeof(CarouselBeatmap), typeof(CarouselBeatmapSet), typeof(DrawableCarouselItem), typeof(CarouselItemState), typeof(DrawableCarouselBeatmap), typeof(DrawableCarouselBeatmapSet), }; private readonly Stack selectedSets = new Stack(); private readonly HashSet eagerSelectedIDs = new HashSet(); private BeatmapInfo currentSelection => carousel.SelectedBeatmap; private const int set_count = 5; [BackgroundDependencyLoader] private void load(RulesetStore rulesets) { this.rulesets = rulesets; } /// /// Test keyboard traversal /// [Test] public void TestTraversal() { loadBeatmaps(); advanceSelection(direction: 1, diff: false); waitForSelection(1, 1); advanceSelection(direction: 1, diff: true); waitForSelection(1, 2); advanceSelection(direction: -1, diff: false); waitForSelection(set_count, 1); advanceSelection(direction: -1, diff: true); waitForSelection(set_count - 1, 3); advanceSelection(diff: false); advanceSelection(diff: false); waitForSelection(1, 2); advanceSelection(direction: -1, diff: true); advanceSelection(direction: -1, diff: true); waitForSelection(set_count, 3); } [TestCase(true)] [TestCase(false)] public void TestTraversalBeyondVisible(bool forwards) { var sets = new List(); const int total_set_count = 200; for (int i = 0; i < total_set_count; i++) sets.Add(createTestBeatmapSet(i + 1)); loadBeatmaps(sets); for (int i = 1; i < total_set_count; i += i) selectNextAndAssert(i); void selectNextAndAssert(int amount) { setSelected(forwards ? 1 : total_set_count, 1); AddStep($"{(forwards ? "Next" : "Previous")} beatmap {amount} times", () => { for (int i = 0; i < amount; i++) { carousel.SelectNext(forwards ? 1 : -1); } }); waitForSelection(forwards ? amount + 1 : total_set_count - amount); } } [Test] public void TestTraversalBeyondVisibleDifficulties() { var sets = new List(); const int total_set_count = 20; for (int i = 0; i < total_set_count; i++) sets.Add(createTestBeatmapSet(i + 1)); loadBeatmaps(sets); // Selects next set once, difficulty index doesn't change selectNextAndAssert(3, true, 2, 1); // Selects next set 16 times (50 \ 3 == 16), difficulty index changes twice (50 % 3 == 2) selectNextAndAssert(50, true, 17, 3); // Travels around the carousel thrice (200 \ 60 == 3) // continues to select 20 times (200 \ 60 == 20) // selects next set 6 times (20 \ 3 == 6) // difficulty index changes twice (20 % 3 == 2) selectNextAndAssert(200, true, 7, 3); // All same but in reverse selectNextAndAssert(3, false, 19, 3); selectNextAndAssert(50, false, 4, 1); selectNextAndAssert(200, false, 14, 1); void selectNextAndAssert(int amount, bool forwards, int expectedSet, int expectedDiff) { // Select very first or very last difficulty setSelected(forwards ? 1 : 20, forwards ? 1 : 3); AddStep($"{(forwards ? "Next" : "Previous")} difficulty {amount} times", () => { for (int i = 0; i < amount; i++) carousel.SelectNext(forwards ? 1 : -1, false); }); waitForSelection(expectedSet, expectedDiff); } } /// /// Test filtering /// [Test] public void TestFiltering() { loadBeatmaps(); // basic filtering setSelected(1, 1); AddStep("Filter", () => carousel.Filter(new FilterCriteria { SearchText = "set #3!" }, false)); checkVisibleItemCount(diff: false, count: 1); checkVisibleItemCount(diff: true, count: 3); waitForSelection(3, 1); advanceSelection(diff: true, count: 4); waitForSelection(3, 2); AddStep("Un-filter (debounce)", () => carousel.Filter(new FilterCriteria())); AddUntilStep("Wait for debounce", () => !carousel.PendingFilterTask); checkVisibleItemCount(diff: false, count: set_count); checkVisibleItemCount(diff: true, count: 3); // test filtering some difficulties (and keeping current beatmap set selected). setSelected(1, 2); AddStep("Filter some difficulties", () => carousel.Filter(new FilterCriteria { SearchText = "Normal" }, false)); waitForSelection(1, 1); AddStep("Un-filter", () => carousel.Filter(new FilterCriteria(), false)); waitForSelection(1, 1); AddStep("Filter all", () => carousel.Filter(new FilterCriteria { SearchText = "Dingo" }, false)); checkVisibleItemCount(false, 0); checkVisibleItemCount(true, 0); AddAssert("Selection is null", () => currentSelection == null); advanceSelection(true); AddAssert("Selection is null", () => currentSelection == null); advanceSelection(false); AddAssert("Selection is null", () => currentSelection == null); AddStep("Un-filter", () => carousel.Filter(new FilterCriteria(), false)); AddAssert("Selection is non-null", () => currentSelection != null); setSelected(1, 3); } [Test] public void TestFilterRange() { loadBeatmaps(); // buffer the selection setSelected(3, 2); setSelected(1, 3); AddStep("Apply a range filter", () => carousel.Filter(new FilterCriteria { SearchText = "#3", StarDifficulty = new FilterCriteria.OptionalRange { Min = 2, Max = 5.5, IsLowerInclusive = true } }, false)); // should reselect the buffered selection. waitForSelection(3, 2); } /// /// Test random non-repeating algorithm /// [Test] public void TestRandom() { loadBeatmaps(); setSelected(1, 1); nextRandom(); ensureRandomDidntRepeat(); nextRandom(); ensureRandomDidntRepeat(); nextRandom(); ensureRandomDidntRepeat(); prevRandom(); ensureRandomFetchSuccess(); prevRandom(); ensureRandomFetchSuccess(); nextRandom(); ensureRandomDidntRepeat(); nextRandom(); ensureRandomDidntRepeat(); nextRandom(); AddAssert("ensure repeat", () => selectedSets.Contains(carousel.SelectedBeatmapSet)); AddStep("Add set with 100 difficulties", () => carousel.UpdateBeatmapSet(createTestBeatmapSetWithManyDifficulties(set_count + 1))); AddStep("Filter Extra", () => carousel.Filter(new FilterCriteria { SearchText = "Extra 10" }, false)); checkInvisibleDifficultiesUnselectable(); checkInvisibleDifficultiesUnselectable(); checkInvisibleDifficultiesUnselectable(); checkInvisibleDifficultiesUnselectable(); checkInvisibleDifficultiesUnselectable(); AddStep("Un-filter", () => carousel.Filter(new FilterCriteria(), false)); } /// /// Test adding and removing beatmap sets /// [Test] public void TestAddRemove() { loadBeatmaps(); AddStep("Add new set", () => carousel.UpdateBeatmapSet(createTestBeatmapSet(set_count + 1))); AddStep("Add new set", () => carousel.UpdateBeatmapSet(createTestBeatmapSet(set_count + 2))); checkVisibleItemCount(false, set_count + 2); AddStep("Remove set", () => carousel.RemoveBeatmapSet(createTestBeatmapSet(set_count + 2))); checkVisibleItemCount(false, set_count + 1); setSelected(set_count + 1, 1); AddStep("Remove set", () => carousel.RemoveBeatmapSet(createTestBeatmapSet(set_count + 1))); checkVisibleItemCount(false, set_count); waitForSelection(set_count); } [Test] public void TestSelectionEnteringFromEmptyRuleset() { var sets = new List(); AddStep("Create beatmaps for taiko only", () => { sets.Clear(); var rulesetBeatmapSet = createTestBeatmapSet(1); var taikoRuleset = rulesets.AvailableRulesets.ElementAt(1); rulesetBeatmapSet.Beatmaps.ForEach(b => { b.Ruleset = taikoRuleset; b.RulesetID = 1; }); sets.Add(rulesetBeatmapSet); }); loadBeatmaps(sets, () => new FilterCriteria { Ruleset = rulesets.AvailableRulesets.ElementAt(0) }); AddStep("Set non-empty mode filter", () => carousel.Filter(new FilterCriteria { Ruleset = rulesets.AvailableRulesets.ElementAt(1) }, false)); AddAssert("Something is selected", () => carousel.SelectedBeatmap != null); } /// /// Test sorting /// [Test] public void TestSorting() { loadBeatmaps(); AddStep("Sort by author", () => carousel.Filter(new FilterCriteria { Sort = SortMode.Author }, false)); AddAssert("Check zzzzz is at bottom", () => carousel.BeatmapSets.Last().Metadata.AuthorString == "zzzzz"); AddStep("Sort by artist", () => carousel.Filter(new FilterCriteria { Sort = SortMode.Artist }, false)); AddAssert($"Check #{set_count} is at bottom", () => carousel.BeatmapSets.Last().Metadata.Title.EndsWith($"#{set_count}!")); } [Test] public void TestSortingStability() { var sets = new List(); for (int i = 0; i < 20; i++) { var set = createTestBeatmapSet(i); set.Metadata.Artist = "same artist"; set.Metadata.Title = "same title"; sets.Add(set); } loadBeatmaps(sets); AddStep("Sort by artist", () => carousel.Filter(new FilterCriteria { Sort = SortMode.Artist }, false)); AddAssert("Items remain in original order", () => carousel.BeatmapSets.Select((set, index) => set.ID == index).All(b => b)); AddStep("Sort by title", () => carousel.Filter(new FilterCriteria { Sort = SortMode.Title }, false)); AddAssert("Items remain in original order", () => carousel.BeatmapSets.Select((set, index) => set.ID == index).All(b => b)); } [Test] public void TestSortingWithFiltered() { List sets = new List(); for (int i = 0; i < 3; i++) { var set = createTestBeatmapSet(i); set.Beatmaps[0].StarDifficulty = 3 - i; set.Beatmaps[2].StarDifficulty = 6 + i; sets.Add(set); } loadBeatmaps(sets); AddStep("Filter to normal", () => carousel.Filter(new FilterCriteria { Sort = SortMode.Difficulty, SearchText = "Normal" }, false)); AddAssert("Check first set at end", () => carousel.BeatmapSets.First() == sets.Last()); AddAssert("Check last set at start", () => carousel.BeatmapSets.Last() == sets.First()); AddStep("Filter to insane", () => carousel.Filter(new FilterCriteria { Sort = SortMode.Difficulty, SearchText = "Insane" }, false)); AddAssert("Check first set at start", () => carousel.BeatmapSets.First() == sets.First()); AddAssert("Check last set at end", () => carousel.BeatmapSets.Last() == sets.Last()); } [Test] public void TestRemoveAll() { loadBeatmaps(); setSelected(2, 1); AddAssert("Selection is non-null", () => currentSelection != null); AddStep("Remove selected", () => carousel.RemoveBeatmapSet(carousel.SelectedBeatmapSet)); waitForSelection(2); AddStep("Remove first", () => carousel.RemoveBeatmapSet(carousel.BeatmapSets.First())); AddStep("Remove first", () => carousel.RemoveBeatmapSet(carousel.BeatmapSets.First())); waitForSelection(1); AddUntilStep("Remove all", () => { if (!carousel.BeatmapSets.Any()) return true; carousel.RemoveBeatmapSet(carousel.BeatmapSets.Last()); return false; }); checkNoSelection(); } [Test] public void TestEmptyTraversal() { loadBeatmaps(new List()); advanceSelection(direction: 1, diff: false); checkNoSelection(); advanceSelection(direction: 1, diff: true); checkNoSelection(); advanceSelection(direction: -1, diff: false); checkNoSelection(); advanceSelection(direction: -1, diff: true); checkNoSelection(); } [Test] public void TestHiding() { BeatmapSetInfo hidingSet = null; List hiddenList = new List(); AddStep("create hidden set", () => { hidingSet = createTestBeatmapSet(1); hidingSet.Beatmaps[1].Hidden = true; hiddenList.Clear(); hiddenList.Add(hidingSet); }); loadBeatmaps(hiddenList); setSelected(1, 1); checkVisibleItemCount(true, 2); advanceSelection(true); waitForSelection(1, 3); setHidden(3); waitForSelection(1, 1); setHidden(2, false); advanceSelection(true); waitForSelection(1, 2); setHidden(1); waitForSelection(1, 2); setHidden(2); checkNoSelection(); void setHidden(int diff, bool hidden = true) { AddStep((hidden ? "" : "un") + $"hide diff {diff}", () => { hidingSet.Beatmaps[diff - 1].Hidden = hidden; carousel.UpdateBeatmapSet(hidingSet); }); } } [Test] public void TestSelectingFilteredRuleset() { BeatmapSetInfo testMixed = null; createCarousel(); AddStep("add mixed ruleset beatmapset", () => { testMixed = createTestBeatmapSet(set_count + 1); for (int i = 0; i <= 2; i++) { testMixed.Beatmaps[i].Ruleset = rulesets.AvailableRulesets.ElementAt(i); testMixed.Beatmaps[i].RulesetID = i; } carousel.UpdateBeatmapSet(testMixed); }); AddStep("filter to ruleset 0", () => carousel.Filter(new FilterCriteria { Ruleset = rulesets.AvailableRulesets.ElementAt(0) }, false)); AddStep("select filtered map skipping filtered", () => carousel.SelectBeatmap(testMixed.Beatmaps[1], false)); AddAssert("unfiltered beatmap not selected", () => carousel.SelectedBeatmap.RulesetID == 0); AddStep("remove mixed set", () => { carousel.RemoveBeatmapSet(testMixed); testMixed = null; }); BeatmapSetInfo testSingle = null; AddStep("add single ruleset beatmapset", () => { testSingle = createTestBeatmapSet(set_count + 2); testSingle.Beatmaps.ForEach(b => { b.Ruleset = rulesets.AvailableRulesets.ElementAt(1); b.RulesetID = b.Ruleset.ID ?? 1; }); carousel.UpdateBeatmapSet(testSingle); }); AddStep("select filtered map skipping filtered", () => carousel.SelectBeatmap(testSingle.Beatmaps[0], false)); checkNoSelection(); AddStep("remove single ruleset set", () => carousel.RemoveBeatmapSet(testSingle)); } [Test] public void TestCarouselRemembersSelection() { List manySets = new List(); for (int i = 1; i <= 50; i++) manySets.Add(createTestBeatmapSet(i)); loadBeatmaps(manySets); advanceSelection(direction: 1, diff: false); for (int i = 0; i < 5; i++) { AddStep("Toggle non-matching filter", () => { carousel.Filter(new FilterCriteria { SearchText = Guid.NewGuid().ToString() }, false); }); AddStep("Restore no filter", () => { carousel.Filter(new FilterCriteria(), false); eagerSelectedIDs.Add(carousel.SelectedBeatmapSet.ID); }); } // always returns to same selection as long as it's available. AddAssert("Selection was remembered", () => eagerSelectedIDs.Count == 1); } [Test] public void TestRandomFallbackOnNonMatchingPrevious() { List manySets = new List(); AddStep("populate maps", () => { for (int i = 0; i < 10; i++) { var set = createTestBeatmapSet(i); foreach (var b in set.Beatmaps) { // all taiko except for first int ruleset = i > 0 ? 1 : 0; b.Ruleset = rulesets.GetRuleset(ruleset); b.RulesetID = ruleset; } manySets.Add(set); } }); loadBeatmaps(manySets); for (int i = 0; i < 10; i++) { AddStep("Reset filter", () => carousel.Filter(new FilterCriteria(), false)); AddStep("select first beatmap", () => carousel.SelectBeatmap(manySets.First().Beatmaps.First())); AddStep("Toggle non-matching filter", () => { carousel.Filter(new FilterCriteria { SearchText = Guid.NewGuid().ToString() }, false); }); AddAssert("selection lost", () => carousel.SelectedBeatmap == null); AddStep("Restore different ruleset filter", () => { carousel.Filter(new FilterCriteria { Ruleset = rulesets.GetRuleset(1) }, false); eagerSelectedIDs.Add(carousel.SelectedBeatmapSet.ID); }); AddAssert("selection changed", () => carousel.SelectedBeatmap != manySets.First().Beatmaps.First()); } AddAssert("Selection was random", () => eagerSelectedIDs.Count > 2); } [Test] public void TestFilteringByUserStarDifficulty() { BeatmapSetInfo set = null; loadBeatmaps(new List()); AddStep("add mixed difficulty set", () => { set = createTestBeatmapSet(1); set.Beatmaps.Clear(); for (int i = 1; i <= 15; i++) { set.Beatmaps.Add(new BeatmapInfo { Version = $"Stars: {i}", StarDifficulty = i, }); } carousel.UpdateBeatmapSet(set); }); AddStep("select added set", () => carousel.SelectBeatmap(set.Beatmaps[0], false)); AddStep("filter [5..]", () => carousel.Filter(new FilterCriteria { UserStarDifficulty = { Min = 5 } })); AddUntilStep("Wait for debounce", () => !carousel.PendingFilterTask); checkVisibleItemCount(true, 11); AddStep("filter to [0..7]", () => carousel.Filter(new FilterCriteria { UserStarDifficulty = { Max = 7 } })); AddUntilStep("Wait for debounce", () => !carousel.PendingFilterTask); checkVisibleItemCount(true, 7); AddStep("filter to [5..7]", () => carousel.Filter(new FilterCriteria { UserStarDifficulty = { Min = 5, Max = 7 } })); AddUntilStep("Wait for debounce", () => !carousel.PendingFilterTask); checkVisibleItemCount(true, 3); AddStep("filter [2..2]", () => carousel.Filter(new FilterCriteria { UserStarDifficulty = { Min = 2, Max = 2 } })); AddUntilStep("Wait for debounce", () => !carousel.PendingFilterTask); checkVisibleItemCount(true, 1); AddStep("filter to [0..]", () => carousel.Filter(new FilterCriteria { UserStarDifficulty = { Min = 0 } })); AddUntilStep("Wait for debounce", () => !carousel.PendingFilterTask); checkVisibleItemCount(true, 15); } private void loadBeatmaps(List beatmapSets = null, Func initialCriteria = null) { createCarousel(); if (beatmapSets == null) { beatmapSets = new List(); for (int i = 1; i <= set_count; i++) beatmapSets.Add(createTestBeatmapSet(i)); } bool changed = false; AddStep($"Load {(beatmapSets.Count > 0 ? beatmapSets.Count.ToString() : "some")} beatmaps", () => { carousel.Filter(initialCriteria?.Invoke() ?? new FilterCriteria()); carousel.BeatmapSetsChanged = () => changed = true; carousel.BeatmapSets = beatmapSets; }); AddUntilStep("Wait for load", () => changed); } private void createCarousel(Container target = null) { AddStep("Create carousel", () => { selectedSets.Clear(); eagerSelectedIDs.Clear(); (target ?? this).Child = carousel = new TestBeatmapCarousel { RelativeSizeAxes = Axes.Both, }; }); } private void ensureRandomFetchSuccess() => AddAssert("ensure prev random fetch worked", () => selectedSets.Peek() == carousel.SelectedBeatmapSet); private void waitForSelection(int set, int? diff = null) => AddUntilStep($"selected is set{set}{(diff.HasValue ? $" diff{diff.Value}" : "")}", () => { if (diff != null) return carousel.SelectedBeatmap == carousel.BeatmapSets.Skip(set - 1).First().Beatmaps.Skip(diff.Value - 1).First(); return carousel.BeatmapSets.Skip(set - 1).First().Beatmaps.Contains(carousel.SelectedBeatmap); }); private void setSelected(int set, int diff) => AddStep($"select set{set} diff{diff}", () => carousel.SelectBeatmap(carousel.BeatmapSets.Skip(set - 1).First().Beatmaps.Skip(diff - 1).First())); private void advanceSelection(bool diff, int direction = 1, int count = 1) { if (count == 1) { AddStep($"select {(direction > 0 ? "next" : "prev")} {(diff ? "diff" : "set")}", () => carousel.SelectNext(direction, !diff)); } else { AddRepeatStep($"select {(direction > 0 ? "next" : "prev")} {(diff ? "diff" : "set")}", () => carousel.SelectNext(direction, !diff), count); } } private void checkVisibleItemCount(bool diff, int count) => AddAssert($"{count} {(diff ? "diffs" : "sets")} visible", () => carousel.Items.Count(s => (diff ? s.Item is CarouselBeatmap : s.Item is CarouselBeatmapSet) && s.Item.Visible) == count); private void checkNoSelection() => AddAssert("Selection is null", () => currentSelection == null); private void nextRandom() => AddStep("select random next", () => { carousel.RandomAlgorithm.Value = RandomSelectAlgorithm.RandomPermutation; if (!selectedSets.Any() && carousel.SelectedBeatmap != null) selectedSets.Push(carousel.SelectedBeatmapSet); carousel.SelectNextRandom(); selectedSets.Push(carousel.SelectedBeatmapSet); }); private void ensureRandomDidntRepeat() => AddAssert("ensure no repeats", () => selectedSets.Distinct().Count() == selectedSets.Count); private void prevRandom() => AddStep("select random last", () => { carousel.SelectPreviousRandom(); selectedSets.Pop(); }); private bool selectedBeatmapVisible() { var currentlySelected = carousel.Items.Find(s => s.Item is CarouselBeatmap && s.Item.State.Value == CarouselItemState.Selected); if (currentlySelected == null) return true; return currentlySelected.Item.Visible; } private void checkInvisibleDifficultiesUnselectable() { nextRandom(); AddAssert("Selection is visible", selectedBeatmapVisible); } private BeatmapSetInfo createTestBeatmapSet(int id) { return new BeatmapSetInfo { ID = id, OnlineBeatmapSetID = id, Hash = new MemoryStream(Encoding.UTF8.GetBytes(Guid.NewGuid().ToString())).ComputeMD5Hash(), Metadata = new BeatmapMetadata { // Create random metadata, then we can check if sorting works based on these Artist = $"peppy{id.ToString().PadLeft(6, '0')}", Title = $"test set #{id}!", AuthorString = string.Concat(Enumerable.Repeat((char)('z' - Math.Min(25, id - 1)), 5)) }, Beatmaps = new List(new[] { new BeatmapInfo { OnlineBeatmapID = id * 10, Path = "normal.osu", Version = "Normal", StarDifficulty = 2, BaseDifficulty = new BeatmapDifficulty { OverallDifficulty = 3.5f, } }, new BeatmapInfo { OnlineBeatmapID = id * 10 + 1, Path = "hard.osu", Version = "Hard", StarDifficulty = 5, BaseDifficulty = new BeatmapDifficulty { OverallDifficulty = 5, } }, new BeatmapInfo { OnlineBeatmapID = id * 10 + 2, Path = "insane.osu", Version = "Insane", StarDifficulty = 6, BaseDifficulty = new BeatmapDifficulty { OverallDifficulty = 7, } }, }), }; } private BeatmapSetInfo createTestBeatmapSetWithManyDifficulties(int id) { var toReturn = new BeatmapSetInfo { ID = id, OnlineBeatmapSetID = id, Hash = new MemoryStream(Encoding.UTF8.GetBytes(Guid.NewGuid().ToString())).ComputeMD5Hash(), Metadata = new BeatmapMetadata { // Create random metadata, then we can check if sorting works based on these Artist = $"peppy{id.ToString().PadLeft(6, '0')}", Title = $"test set #{id}!", AuthorString = string.Concat(Enumerable.Repeat((char)('z' - Math.Min(25, id - 1)), 5)) }, Beatmaps = new List(), }; for (int b = 1; b < 101; b++) { toReturn.Beatmaps.Add(new BeatmapInfo { OnlineBeatmapID = b * 10, Path = $"extra{b}.osu", Version = $"Extra {b}", Ruleset = rulesets.GetRuleset((b - 1) % 4), StarDifficulty = 2, BaseDifficulty = new BeatmapDifficulty { OverallDifficulty = 3.5f, } }); } return toReturn; } private class TestBeatmapCarousel : BeatmapCarousel { public new List Items => base.Items; public bool PendingFilterTask => PendingFilter != null; protected override IEnumerable GetLoadableBeatmaps() => Enumerable.Empty(); } } }