Path: blob/master/osu.Game.Tests/Visual/SongSelectV2/BeatmapCarouselTestScene.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 System.Threading.Tasks; using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Extensions; using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Framework.Testing; using osu.Framework.Utils; using osu.Game.Beatmaps; using osu.Game.Collections; using osu.Game.Database; using osu.Game.Graphics; using osu.Game.Graphics.Carousel; using osu.Game.Graphics.Containers; using osu.Game.Overlays; using osu.Game.Scoring; using osu.Game.Screens.Select; using osu.Game.Screens.Select.Filter; using osu.Game.Screens.SelectV2; using osu.Game.Tests.Beatmaps; using osu.Game.Tests.Resources; using osuTK; using osuTK.Graphics; using osuTK.Input; using BeatmapCarousel = osu.Game.Screens.SelectV2.BeatmapCarousel; namespace osu.Game.Tests.Visual.SongSelectV2 { public abstract partial class BeatmapCarouselTestScene : OsuManualInputManagerTestScene { protected readonly Stack<BeatmapSetInfo> BeatmapSetRequestedSelections = new Stack<BeatmapSetInfo>(); protected readonly Stack<BeatmapInfo> BeatmapRequestedSelections = new Stack<BeatmapInfo>(); protected readonly BindableList<BeatmapSetInfo> BeatmapSets = new BindableList<BeatmapSetInfo>(); protected TestBeatmapCarousel Carousel = null!; protected bool RetainSelection { get; set; } protected OsuScrollContainer<Drawable> Scroll => Carousel.ChildrenOfType<OsuScrollContainer<Drawable>>().Single(); [Cached(typeof(BeatmapStore))] private BeatmapStore store; [Cached] private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Aquamarine); public Func<IEnumerable<BeatmapInfo>, BeatmapInfo>? BeatmapRecommendationFunction { get; set; } private OsuTextFlowContainer stats = null!; private int beatmapCount; protected int NewItemsPresentedInvocationCount; protected BeatmapCarouselTestScene() { store = new TestBeatmapStore { BeatmapSets = { BindTarget = BeatmapSets } }; BeatmapSets.BindCollectionChanged((_, _) => beatmapCount = BeatmapSets.Sum(s => s.Beatmaps.Count)); Scheduler.AddDelayed(updateStats, 100, true); } [BackgroundDependencyLoader] private void load() { Dependencies.Cache(Realm); } protected void CreateCarousel(bool retainSelection = false) { AddStep("create components", () => { BeatmapRequestedSelections.Clear(); BeatmapSetRequestedSelections.Clear(); BeatmapRecommendationFunction = null; NewItemsPresentedInvocationCount = 0; GroupedBeatmap? previousSelection = retainSelection ? Carousel.CurrentGroupedBeatmap : null; Box topBox; Children = new Drawable[] { new GridContainer { RelativeSizeAxes = Axes.Both, ColumnDimensions = new[] { new Dimension(GridSizeMode.Relative, 1), }, RowDimensions = new[] { new Dimension(GridSizeMode.Absolute, 200), new Dimension(), new Dimension(GridSizeMode.Absolute, 200), }, Content = new[] { new Drawable[] { topBox = new Box { Anchor = Anchor.Centre, Origin = Anchor.Centre, Colour = Color4.Cyan, RelativeSizeAxes = Axes.Both, Alpha = 0.4f, }, }, new Drawable[] { Carousel = new TestBeatmapCarousel { CurrentGroupedBeatmap = previousSelection, NewItemsPresented = _ => NewItemsPresentedInvocationCount++, RequestSelection = b => { BeatmapRequestedSelections.Push(b.Beatmap); Carousel.CurrentGroupedBeatmap = b; }, RequestRecommendedSelection = groupedBeatmaps => { var recommendedBeatmap = BeatmapRecommendationFunction?.Invoke(groupedBeatmaps.Select(gb => gb.Beatmap)) ?? groupedBeatmaps.First().Beatmap; var recommendedGroupedBeatmap = groupedBeatmaps.First(gb => gb.Beatmap.Equals(recommendedBeatmap)); BeatmapSetRequestedSelections.Push(recommendedBeatmap.BeatmapSet!); Carousel.CurrentGroupedBeatmap = recommendedGroupedBeatmap; }, BleedTop = 50, BleedBottom = 50, Anchor = Anchor.Centre, Origin = Anchor.Centre, Width = 800, RelativeSizeAxes = Axes.Y, }, }, new[] { new Box { Anchor = Anchor.Centre, Origin = Anchor.Centre, Colour = Color4.Cyan, RelativeSizeAxes = Axes.Both, Alpha = 0.4f, }, topBox.CreateProxy(), } } }, stats = new OsuTextFlowContainer { AutoSizeAxes = Axes.Both, Padding = new MarginPadding(10), TextAnchor = Anchor.CentreLeft, }, }; // Prefer title sorting so that order of carousel panels match order of BeatmapSets bindable. Carousel.Filter(new FilterCriteria { Sort = SortMode.Title }); }); } protected void SortBy(SortMode mode) => ApplyToFilterAndWaitForFilter($"sort by {mode.GetDescription().ToLowerInvariant()}", c => c.Sort = mode); protected void GroupBy(GroupMode mode) => ApplyToFilterAndWaitForFilter($"group by {mode.GetDescription().ToLowerInvariant()}", c => c.Group = mode); protected void SortAndGroupBy(SortMode sort, GroupMode group) { ApplyToFilterAndWaitForFilter($"sort by {sort.GetDescription().ToLowerInvariant()} & group by {group.GetDescription().ToLowerInvariant()}", c => { c.Sort = sort; c.Group = group; }); } protected void ApplyToFilterAndWaitForFilter(string description, Action<FilterCriteria>? apply) { AddStep(description, () => { var criteria = Carousel.Criteria ?? new FilterCriteria(); apply?.Invoke(criteria); Carousel.Filter(criteria); }); WaitForFiltering(); } protected void WaitForDrawablePanels() => AddUntilStep("drawable panels loaded", () => Carousel.ChildrenOfType<ICarouselPanel>().Count(), () => Is.GreaterThan(0)); protected void WaitForFiltering() => AddUntilStep("filtering finished", () => Carousel.IsFiltering, () => Is.False); protected void WaitForScrolling() => AddUntilStep("scroll finished", () => Scroll.Current, () => Is.EqualTo(Scroll.Target)); protected void ToggleGroupCollapse() => AddStep("toggle group collapse", () => { InputManager.PressKey(Key.ShiftLeft); InputManager.Key(Key.Enter); InputManager.ReleaseKey(Key.ShiftLeft); }); protected void SelectNextGroup() => AddStep("select next group", () => { InputManager.PressKey(Key.ShiftLeft); InputManager.Key(Key.Right); InputManager.ReleaseKey(Key.ShiftLeft); }); protected void SelectPrevGroup() => AddStep("select prev group", () => { InputManager.PressKey(Key.ShiftLeft); InputManager.Key(Key.Left); InputManager.ReleaseKey(Key.ShiftLeft); }); protected void SelectNextPanel() => AddStep("select next panel", () => InputManager.Key(Key.Down)); protected void SelectPrevPanel() => AddStep("select prev panel", () => InputManager.Key(Key.Up)); protected void SelectNextSet() => AddStep("select next set", () => InputManager.Key(Key.Right)); protected void SelectPrevSet() => AddStep("select prev set", () => InputManager.Key(Key.Left)); protected void SelectRandomSet() => AddStep("select random set", () => Carousel.NextRandom()); protected void Select() => AddStep("select", () => InputManager.Key(Key.Enter)); protected void CheckNoSelection() => AddAssert("has no selection", () => Carousel.CurrentGroupedBeatmap, () => Is.Null); protected void CheckHasSelection() => AddAssert("has selection", () => Carousel.CurrentGroupedBeatmap, () => Is.Not.Null); protected void CheckRequestPresentCount(int expected) => AddAssert($"check present count is {expected}", () => Carousel.RequestPresentBeatmapCount, () => Is.EqualTo(expected)); protected void CheckActivationCount(int expected) => AddAssert($"check activation count is {expected}", () => Carousel.ActivationCount, () => Is.EqualTo(expected)); protected void CheckDisplayedBeatmapsCount(int expected) { AddAssert($"{expected} diffs displayed", () => Carousel.MatchedBeatmapsCount, () => Is.EqualTo(expected)); } protected void CheckDisplayedBeatmapSetsCount(int expected) { AddAssert($"{expected} sets displayed", () => { var groupingFilter = Carousel.Filters.OfType<BeatmapCarouselFilterGrouping>().Single(); // Using groupingFilter.SetItems.Count alone doesn't work. // When sorting by difficulty, there can be more than one set panel for the same set displayed. return groupingFilter.SetItems.Sum(s => s.Value.Count(i => i.Model is GroupedBeatmapSet)); }, () => Is.EqualTo(expected)); } protected void CheckDisplayedGroupsCount(int expected) { AddAssert($"{expected} groups displayed", () => { var groupingFilter = Carousel.Filters.OfType<BeatmapCarouselFilterGrouping>().Single(); return groupingFilter.GroupItems.Count; }, () => Is.EqualTo(expected)); } protected ICarouselPanel? GetSelectedPanel() => Carousel.ChildrenOfType<ICarouselPanel>().SingleOrDefault(p => p.Selected.Value); protected ICarouselPanel? GetKeyboardSelectedPanel() => Carousel.ChildrenOfType<ICarouselPanel>().SingleOrDefault(p => p.KeyboardSelected.Value); protected void WaitForExpandedGroup(int group) { AddUntilStep($"group {group} is expanded", () => { var groupingFilter = Carousel.Filters.OfType<BeatmapCarouselFilterGrouping>().Single(); GroupDefinition g = groupingFilter.GroupItems.Keys.ElementAt(group); // offset by one because the group itself is included in the items list. CarouselItem item = groupingFilter.GroupItems[g].ElementAt(0); return item.Model is GroupDefinition def && def == Carousel.ExpandedGroup; }); } protected void WaitForBeatmapSelection(int group, int panel) { AddUntilStep($"selected is group{group} panel{panel}", () => { var groupingFilter = Carousel.Filters.OfType<BeatmapCarouselFilterGrouping>().Single(); GroupDefinition? groupDefinition = groupingFilter.GroupItems.Keys.ElementAtOrDefault(group); if (groupDefinition == null) return false; // offset by one because the group itself is included in the items list. CarouselItem item = groupingFilter.GroupItems[groupDefinition].ElementAt(panel + 1); return Carousel.CurrentGroupedBeatmap?.Equals(item.Model as GroupedBeatmap) == true; }); } protected void WaitForSetSelection(int set, int? diff = null) { if (diff != null) { AddUntilStep($"selected is set{set} diff{diff.Value}", () => Carousel.CurrentBeatmap, () => Is.EqualTo(BeatmapSets[set].Beatmaps[diff.Value])); } else { AddUntilStep($"selected is set{set}", () => BeatmapSets[set].Beatmaps.Contains(Carousel.CurrentBeatmap!)); } } protected IEnumerable<T> GetVisiblePanels<T>() where T : Drawable { return Carousel.ChildrenOfType<UserTrackingScrollContainer>().Single() .ChildrenOfType<T>() .Where(p => ((ICarouselPanel)p).Item?.IsVisible == true) .OrderBy(p => p.Y); } protected void ClickVisiblePanel<T>(int index) where T : Drawable { AddStep($"click panel at index {index}", () => { Carousel.ChildrenOfType<UserTrackingScrollContainer>().Single() .ChildrenOfType<T>() .Where(p => ((ICarouselPanel)p).Item?.IsVisible == true) .OrderBy(p => p.Y) .ElementAt(index) .ChildrenOfType<Panel>().Single() .TriggerClick(); }); } protected void ClickVisiblePanelWithOffset<T>(int index, Vector2 positionOffsetFromCentre) where T : Drawable { AddStep($"move mouse to panel {index} with offset {positionOffsetFromCentre}", () => { var panel = Carousel.ChildrenOfType<UserTrackingScrollContainer>().Single() .ChildrenOfType<T>() .Where(p => ((ICarouselPanel)p).Item?.IsVisible == true) .OrderBy(p => p.Y) .ElementAt(index); InputManager.MoveMouseTo(panel.ScreenSpaceDrawQuad.Centre + panel.ToScreenSpace(positionOffsetFromCentre) - panel.ToScreenSpace(Vector2.Zero)); }); AddStep("click", () => InputManager.Click(MouseButton.Left)); } /// <summary> /// Add requested beatmap sets count to list. /// </summary> /// <param name="count">The count of beatmap sets to add.</param> /// <param name="fixedDifficultiesPerSet">If not null, the number of difficulties per set. If null, randomised difficulty count will be used.</param> /// <param name="randomMetadata">Whether to randomise the metadata to make groupings more uniform.</param> protected void AddBeatmaps(int count, int? fixedDifficultiesPerSet = null, bool randomMetadata = false) => AddStep($"add {count} beatmaps{(randomMetadata ? " with random data" : "")}", () => { var beatmaps = new List<BeatmapSetInfo>(); for (int i = 0; i < count; i++) beatmaps.Add(CreateTestBeatmapSetInfo(fixedDifficultiesPerSet, randomMetadata)); BeatmapSets.AddRange(beatmaps); }); protected static BeatmapSetInfo CreateTestBeatmapSetInfo(int? fixedDifficultiesPerSet, bool randomMetadata) { var beatmapSetInfo = TestResources.CreateTestBeatmapSetInfo(fixedDifficultiesPerSet ?? RNG.Next(1, 4)); if (randomMetadata) { char randomCharacter = getRandomCharacter(); var metadata = new BeatmapMetadata { // Create random metadata, then we can check if sorting works based on these Artist = $"{randomCharacter}ome Artist " + RNG.Next(0, 9), Title = $"{randomCharacter}ome Song (set id {beatmapSetInfo.OnlineID:000}) {Guid.NewGuid()}", Author = { Username = $"{randomCharacter}ome Guy " + RNG.Next(0, 9) }, }; foreach (var beatmap in beatmapSetInfo.Beatmaps) beatmap.Metadata = metadata.DeepClone(); } return beatmapSetInfo; } private static long randomCharPointer; private static char getRandomCharacter() { const string chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz*"; return chars[(int)((randomCharPointer++ / 2) % chars.Length)]; } protected void RemoveAllBeatmaps() => AddStep("clear all beatmaps", () => BeatmapSets.Clear()); protected void RemoveFirstBeatmap() => AddStep("remove first beatmap", () => { if (BeatmapSets.Count == 0) return; BeatmapSets.Remove(BeatmapSets.First()); }); private void updateStats() { if (Carousel.IsNull()) return; stats.Clear(); createHeader("beatmap store"); stats.AddParagraph($""" sets: {BeatmapSets.Count} beatmaps: {beatmapCount} """); createHeader("carousel"); stats.AddParagraph($""" filtering: {Carousel.IsFiltering} (total {Carousel.FilterCount} times) tracked: {Carousel.ItemsTracked} displayable: {Carousel.DisplayableItems} displayed: {Carousel.VisibleItems} selected: {Carousel.CurrentGroupedBeatmap} """); void createHeader(string text) { stats.AddParagraph(string.Empty); stats.AddParagraph(text, cp => { cp.Font = cp.Font.With(size: 18, weight: FontWeight.Bold); }); } } public partial class TestBeatmapCarousel : BeatmapCarousel { public int ActivationCount { get; private set; } public int RequestPresentBeatmapCount { get; private set; } public int FilterDelay = 0; public IEnumerable<BeatmapInfo> PostFilterBeatmaps = null!; public BeatmapInfo? SelectedBeatmapInfo => (CurrentSelection as GroupedBeatmap)?.Beatmap; public BeatmapSetInfo? SelectedBeatmapSet => SelectedBeatmapInfo?.BeatmapSet; public new GroupedBeatmapSet? ExpandedBeatmapSet => base.ExpandedBeatmapSet; public new GroupDefinition? ExpandedGroup => base.ExpandedGroup; public Func<List<BeatmapCollection>> AllCollections { get; set; } = () => []; public Func<FilterCriteria, Dictionary<Guid, ScoreRank>> BeatmapInfoGuidToTopRankMapping { get; set; } = _ => new Dictionary<Guid, ScoreRank>(); public TestBeatmapCarousel() { RequestPresentBeatmap = _ => RequestPresentBeatmapCount++; } protected override void HandleItemActivated(CarouselItem item) { ActivationCount++; base.HandleItemActivated(item); } protected override async Task<IEnumerable<CarouselItem>> FilterAsync(bool clearExistingPanels = false) { var items = await base.FilterAsync(clearExistingPanels).ConfigureAwait(true); if (FilterDelay != 0) await Task.Delay(FilterDelay).ConfigureAwait(true); PostFilterBeatmaps = items.Select(i => i.Model).OfType<GroupedBeatmap>().Select(i => i.Beatmap); return items; } protected override List<BeatmapCollection> GetAllCollections() => AllCollections.Invoke(); protected override Dictionary<Guid, ScoreRank> GetBeatmapInfoGuidToTopRankMapping(FilterCriteria criteria) => BeatmapInfoGuidToTopRankMapping.Invoke(criteria); } } }