Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
127 changes: 127 additions & 0 deletions osu.Game.Tests/Visual/SongSelect/TestSceneManageCollectionsDialog.cs
Original file line number Diff line number Diff line change
Expand Up @@ -259,7 +259,7 @@
InputManager.Click(MouseButton.Left);
});

AddAssert("dialog displayed", () => dialogOverlay.CurrentDialog is DeleteCollectionDialog);

Check failure on line 262 in osu.Game.Tests/Visual/SongSelect/TestSceneManageCollectionsDialog.cs

View workflow job for this annotation

GitHub Actions / Results

osu.Game.Tests.Visual.SongSelect.TestSceneManageCollectionsDialog ► TestCollectionNotRemovedWhenDialogCancelled

Failed test found in: TestResults-Linux-MultiThreaded.trx TestResults-Linux-SingleThread.trx Error: dialog displayed
Raw output
dialog displayed
   at osu.Game.Tests.Visual.SongSelect.TestSceneManageCollectionsDialog.TestCollectionNotRemovedWhenDialogCancelled() in /home/runner/work/osu/osu/osu.Game.Tests/Visual/SongSelect/TestSceneManageCollectionsDialog.cs:line 262

AddStep("click cancellation", () =>
{
InputManager.MoveMouseTo(dialogOverlay.CurrentDialog.ChildrenOfType<PopupDialogButton>().Last());
Expand Down Expand Up @@ -375,6 +375,133 @@
assertCollectionCount(1);
}

[Test]
public void TestBatchAddButtonVisibility()
{
AddStep("add collection", () => Realm.Write(r => r.Add(new BeatmapCollection(name: "Test collection"))));

AddAssert("batch add button displayed", () =>

Check failure on line 383 in osu.Game.Tests/Visual/SongSelect/TestSceneManageCollectionsDialog.cs

View workflow job for this annotation

GitHub Actions / Results

osu.Game.Tests.Visual.SongSelect.TestSceneManageCollectionsDialog ► TestBatchAddButtonVisibility

Failed test found in: TestResults-Linux-SingleThread.trx Error: batch add button displayed
Raw output
batch add button displayed
   at osu.Game.Tests.Visual.SongSelect.TestSceneManageCollectionsDialog.TestBatchAddButtonVisibility() in /home/runner/work/osu/osu/osu.Game.Tests/Visual/SongSelect/TestSceneManageCollectionsDialog.cs:line 383

dialog.ChildrenOfType<DrawableCollectionListItem.BatchAddToCollectionButton>().Any());
}

[Test]
public void TestBatchAddButtonWithNoFilteredBeatmaps()
{
AddStep("add collection", () => Realm.Write(r => r.Add(new BeatmapCollection(name: "Test collection"))));
AddStep("set null filtered beatmaps provider", () => dialog.FilteredBeatmapsProvider = null);

AddStep("click batch add button", () =>
{
var button = dialog.ChildrenOfType<DrawableCollectionListItem.BatchAddToCollectionButton>().First();
InputManager.MoveMouseTo(button);
InputManager.Click(MouseButton.Left);
});

AddAssert("no dialog shown", () => dialogOverlay.CurrentDialog == null);

Check failure on line 400 in osu.Game.Tests/Visual/SongSelect/TestSceneManageCollectionsDialog.cs

View workflow job for this annotation

GitHub Actions / Results

osu.Game.Tests.Visual.SongSelect.TestSceneManageCollectionsDialog ► TestBatchAddButtonWithNoFilteredBeatmaps

Failed test found in: TestResults-Linux-MultiThreaded.trx TestResults-Linux-SingleThread.trx Error: no dialog shown
Raw output
no dialog shown
   at osu.Game.Tests.Visual.SongSelect.TestSceneManageCollectionsDialog.TestBatchAddButtonWithNoFilteredBeatmaps() in /home/runner/work/osu/osu/osu.Game.Tests/Visual/SongSelect/TestSceneManageCollectionsDialog.cs:line 400

}

[Test]
public void TestBatchAddAllBeatmaps()
{
BeatmapCollection collection = null!;

AddStep("add collection and set filtered beatmaps", () =>
{
Realm.Write(r => r.Add(collection = new BeatmapCollection(name: "Test collection")));
var beatmaps = beatmapManager.GetAllUsableBeatmapSets().SelectMany(s => s.Beatmaps).Take(5).ToList();
dialog.FilteredBeatmapsProvider = () => beatmaps;
});

AddStep("click batch add button", () =>
{
var button = dialog.ChildrenOfType<DrawableCollectionListItem.BatchAddToCollectionButton>().First();
InputManager.MoveMouseTo(button);
InputManager.Click(MouseButton.Left);
});

AddAssert("add dialog shown", () => dialogOverlay.CurrentDialog != null && dialogOverlay.CurrentDialog.GetType().Name == "AddFilteredResultsDialog");
AddStep("confirm add all", () =>
{
InputManager.MoveMouseTo(dialogOverlay.CurrentDialog!.ChildrenOfType<PopupDialogButton>().First());
InputManager.Click(MouseButton.Left);
});

AddUntilStep("beatmaps added to collection", () =>

Check failure on line 429 in osu.Game.Tests/Visual/SongSelect/TestSceneManageCollectionsDialog.cs

View workflow job for this annotation

GitHub Actions / Results

osu.Game.Tests.Visual.SongSelect.TestSceneManageCollectionsDialog ► TestBatchAddAllBeatmaps

Failed test found in: TestResults-Linux-MultiThreaded.trx TestResults-Linux-SingleThread.trx Error: "beatmaps added to collection" timed out
Raw output
"beatmaps added to collection" timed out
   at osu.Game.Tests.Visual.SongSelect.TestSceneManageCollectionsDialog.TestBatchAddAllBeatmaps() in /home/runner/work/osu/osu/osu.Game.Tests/Visual/SongSelect/TestSceneManageCollectionsDialog.cs:line 429

collection.BeatmapMD5Hashes.Count > 0);
}

[Test]
public void TestBatchRemoveAllBeatmaps()
{
BeatmapCollection collection = null!;

AddStep("add collection with all beatmaps", () =>
{
var beatmaps = beatmapManager.GetAllUsableBeatmapSets().SelectMany(s => s.Beatmaps).Take(5).ToList();
var hashes = beatmaps.Select(b => b.MD5Hash).Where(h => !string.IsNullOrEmpty(h)).ToList();
Realm.Write(r =>
{
r.Add(collection = new BeatmapCollection(name: "Test collection"));
foreach (string hash in hashes)
collection.BeatmapMD5Hashes.Add(hash);
});
dialog.FilteredBeatmapsProvider = () => beatmaps;
});

AddStep("click batch add button", () =>
{
var button = dialog.ChildrenOfType<DrawableCollectionListItem.BatchAddToCollectionButton>().First();

Check failure on line 453 in osu.Game.Tests/Visual/SongSelect/TestSceneManageCollectionsDialog.cs

View workflow job for this annotation

GitHub Actions / Results

osu.Game.Tests.Visual.SongSelect.TestSceneManageCollectionsDialog ► TestBatchRemoveAllBeatmaps

Failed test found in: TestResults-Linux-SingleThread.trx Error: System.InvalidOperationException : Sequence contains no elements
Raw output
System.InvalidOperationException : Sequence contains no elements
   at System.Linq.ThrowHelper.ThrowNoElementsException()
   at System.Linq.Enumerable.First[TSource](IEnumerable`1 source)
   at osu.Game.Tests.Visual.SongSelect.TestSceneManageCollectionsDialog.<>c__DisplayClass26_0.<TestBatchRemoveAllBeatmaps>b__1() in /home/runner/work/osu/osu/osu.Game.Tests/Visual/SongSelect/TestSceneManageCollectionsDialog.cs:line 453
   at osu.Framework.Testing.Drawables.Steps.SingleStepButton.clickAction()
   at osu.Framework.Testing.Drawables.Steps.StepButton.PerformStep(Boolean userTriggered)
   at osu.Framework.Testing.TestScene.runNextStep(Action onCompletion, Action`2 onError, Func`2 stopCondition)
--- End of stack trace from previous location ---
   at osu.Framework.Testing.TestSceneTestRunner.TestRunner.RunTestBlocking(TestScene test)
   at osu.Game.Tests.Visual.OsuTestScene.OsuTestSceneTestRunner.RunTestBlocking(TestScene test) in /home/runner/work/osu/osu/osu.Game/Tests/Visual/OsuTestScene.cs:line 586
   at osu.Framework.Testing.TestScene.UseTestSceneRunnerAttribute.AfterTest(ITest test)
   at NUnit.Framework.Internal.Commands.TestActionCommand.<>c__DisplayClass0_0.<.ctor>b__1(TestExecutionContext context)
   at NUnit.Framework.Internal.Commands.BeforeAndAfterTestCommand.<>c__DisplayClass1_0.<Execute>b__1()
   at NUnit.Framework.Internal.Commands.DelegatingTestCommand.RunTestMethodInThreadAbortSafeZone(TestExecutionContext context, Action action)
InputManager.MoveMouseTo(button);
InputManager.Click(MouseButton.Left);
});

AddAssert("remove dialog shown", () => dialogOverlay.CurrentDialog != null && dialogOverlay.CurrentDialog.GetType().Name == "RemoveFilteredResultsDialog");

Check failure on line 458 in osu.Game.Tests/Visual/SongSelect/TestSceneManageCollectionsDialog.cs

View workflow job for this annotation

GitHub Actions / Results

osu.Game.Tests.Visual.SongSelect.TestSceneManageCollectionsDialog ► TestBatchRemoveAllBeatmaps

Failed test found in: TestResults-Linux-MultiThreaded.trx Error: remove dialog shown
Raw output
remove dialog shown
   at osu.Game.Tests.Visual.SongSelect.TestSceneManageCollectionsDialog.TestBatchRemoveAllBeatmaps() in /home/runner/work/osu/osu/osu.Game.Tests/Visual/SongSelect/TestSceneManageCollectionsDialog.cs:line 458

AddStep("confirm remove all", () =>
{
InputManager.MoveMouseTo(dialogOverlay.CurrentDialog!.ChildrenOfType<PopupDialogButton>().First());
InputManager.Click(MouseButton.Left);
});

AddUntilStep("beatmaps removed from collection", () =>
collection.BeatmapMD5Hashes.Count == 0);
}

[Test]
public void TestBatchAddPartialOverlap()
{
BeatmapCollection collection;

AddStep("add collection with some beatmaps", () =>
{
var allBeatmaps = beatmapManager.GetAllUsableBeatmapSets().SelectMany(s => s.Beatmaps).Take(10).ToList();
var halfBeatmaps = allBeatmaps.Take(5).ToList();
var hashes = halfBeatmaps.Select(b => b.MD5Hash).Where(h => !string.IsNullOrEmpty(h)).ToList();
Realm.Write(r =>
{
r.Add(collection = new BeatmapCollection(name: "Test collection"));
foreach (string hash in hashes)
collection.BeatmapMD5Hashes.Add(hash);
});
dialog.FilteredBeatmapsProvider = () => allBeatmaps;
});

AddStep("click batch add button", () =>
{
var button = dialog.ChildrenOfType<DrawableCollectionListItem.BatchAddToCollectionButton>().First();

Check failure on line 490 in osu.Game.Tests/Visual/SongSelect/TestSceneManageCollectionsDialog.cs

View workflow job for this annotation

GitHub Actions / Results

osu.Game.Tests.Visual.SongSelect.TestSceneManageCollectionsDialog ► TestBatchAddPartialOverlap

Failed test found in: TestResults-Linux-SingleThread.trx Error: System.InvalidOperationException : Sequence contains no elements
Raw output
System.InvalidOperationException : Sequence contains no elements
   at System.Linq.ThrowHelper.ThrowNoElementsException()
   at System.Linq.Enumerable.First[TSource](IEnumerable`1 source)
   at osu.Game.Tests.Visual.SongSelect.TestSceneManageCollectionsDialog.<>c__DisplayClass27_0.<TestBatchAddPartialOverlap>b__1() in /home/runner/work/osu/osu/osu.Game.Tests/Visual/SongSelect/TestSceneManageCollectionsDialog.cs:line 490
   at osu.Framework.Testing.Drawables.Steps.SingleStepButton.clickAction()
   at osu.Framework.Testing.Drawables.Steps.StepButton.PerformStep(Boolean userTriggered)
   at osu.Framework.Testing.TestScene.runNextStep(Action onCompletion, Action`2 onError, Func`2 stopCondition)
--- End of stack trace from previous location ---
   at osu.Framework.Testing.TestSceneTestRunner.TestRunner.RunTestBlocking(TestScene test)
   at osu.Game.Tests.Visual.OsuTestScene.OsuTestSceneTestRunner.RunTestBlocking(TestScene test) in /home/runner/work/osu/osu/osu.Game/Tests/Visual/OsuTestScene.cs:line 586
   at osu.Framework.Testing.TestScene.UseTestSceneRunnerAttribute.AfterTest(ITest test)
   at NUnit.Framework.Internal.Commands.TestActionCommand.<>c__DisplayClass0_0.<.ctor>b__1(TestExecutionContext context)
   at NUnit.Framework.Internal.Commands.BeforeAndAfterTestCommand.<>c__DisplayClass1_0.<Execute>b__1()
   at NUnit.Framework.Internal.Commands.DelegatingTestCommand.RunTestMethodInThreadAbortSafeZone(TestExecutionContext context, Action action)
InputManager.MoveMouseTo(button);
InputManager.Click(MouseButton.Left);
});

AddAssert("partial overlap dialog shown", () => dialogOverlay.CurrentDialog != null && dialogOverlay.CurrentDialog.GetType().Name == "PartialOverlapFilteredResultsDialog");

Check failure on line 495 in osu.Game.Tests/Visual/SongSelect/TestSceneManageCollectionsDialog.cs

View workflow job for this annotation

GitHub Actions / Results

osu.Game.Tests.Visual.SongSelect.TestSceneManageCollectionsDialog ► TestBatchAddPartialOverlap

Failed test found in: TestResults-Linux-MultiThreaded.trx Error: partial overlap dialog shown
Raw output
partial overlap dialog shown
   at osu.Game.Tests.Visual.SongSelect.TestSceneManageCollectionsDialog.TestBatchAddPartialOverlap() in /home/runner/work/osu/osu/osu.Game.Tests/Visual/SongSelect/TestSceneManageCollectionsDialog.cs:line 495

AddStep("cancel operation", () =>
{
InputManager.MoveMouseTo(dialogOverlay.CurrentDialog!.ChildrenOfType<PopupDialogButton>().Last());
InputManager.Click(MouseButton.Left);
});

AddAssert("dialog closed", () => dialogOverlay.CurrentDialog == null);
}

private void assertCollectionCount(int count)
=> AddUntilStep($"{count} collections shown", () => dialog.ChildrenOfType<DrawableCollectionListItem>().Count(i => i.IsPresent) == count + 1); // +1 for placeholder

Expand Down
210 changes: 210 additions & 0 deletions osu.Game/Collections/BatchAddToCollectionHandler.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. 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 osu.Framework.Graphics.Sprites;
using osu.Game.Beatmaps;
using osu.Game.Database;
using osu.Game.Overlays.Dialog;

namespace osu.Game.Collections
{
internal static partial class BatchAddToCollectionHandler
{
public enum BatchAddOperation
{
Added,
Removed,
}

public readonly record struct BatchAddResult(Guid CollectionId, BatchAddOperation Operation, int Count);

public static event Action<BatchAddResult>? OperationCompleted;

private static readonly Dictionary<Guid, (BatchAddResult Result, DateTimeOffset Timestamp)> recent_results = new Dictionary<Guid, (BatchAddResult Result, DateTimeOffset Timestamp)>();
private static readonly object recent_results_lock = new object();
private static readonly TimeSpan recent_result_lifetime = TimeSpan.FromSeconds(6);

public static bool TryGetRecentResult(Guid collectionId, out BatchAddResult result)
{
lock (recent_results_lock)
{
cleanupExpiredResults();

if (recent_results.TryGetValue(collectionId, out var entry))
{
result = entry.Result;
return true;
}
}

result = default;
return false;
}

public static void RequestSaveToCollection(
Live<BeatmapCollection> collection,
Func<IEnumerable<BeatmapInfo>>? filteredBeatmapsProvider,
Action<PopupDialog> showDialog)
{
if (filteredBeatmapsProvider == null)
return;

var hashes = filteredBeatmapsProvider().Select(b => b.MD5Hash)
.Where(h => !string.IsNullOrEmpty(h))
.Distinct()
.ToList();

if (hashes.Count == 0)
return;

var existing = collection.PerformRead(c => c.BeatmapMD5Hashes.ToList());
var intersection = existing.Intersect(hashes).ToList();
int overlapCount = intersection.Count;

if (overlapCount == hashes.Count)
{
showDialog(new RemoveFilteredResultsDialog(
onRemove: () => runHashRemoval(collection, intersection, BatchAddOperation.Removed)));
return;
}

if (overlapCount > 0)
{
var toAdd = hashes.Except(existing).ToList();
var toRemove = intersection;

showDialog(new PartialOverlapFilteredResultsDialog(
missingCount: toAdd.Count,
duplicateCount: toRemove.Count,
onAddDifference: () => runHashAddition(collection, toAdd, BatchAddOperation.Added),
onRemoveIntersection: () => runHashRemoval(collection, toRemove, BatchAddOperation.Removed)));
return;
}

string collectionName = collection.PerformRead(c => c.Name);

showDialog(new AddFilteredResultsDialog(
collectionName,
hashes.Count,
onAddAll: () => runHashAddition(collection, hashes, BatchAddOperation.Added)));
}

private static void runHashAddition(Live<BeatmapCollection> collection, IReadOnlyList<string> hashes, BatchAddOperation operation)
{
if (hashes.Count == 0)
return;

Task.Run(() => collection.PerformWrite(c =>
{
int affected = 0;

foreach (string hash in hashes)
{
if (c.BeatmapMD5Hashes.Contains(hash))
continue;

c.BeatmapMD5Hashes.Add(hash);
affected++;
}

if (affected > 0)
notifyOperationCompleted(c.ID, operation, affected);
}));
}

private static void runHashRemoval(Live<BeatmapCollection> collection, IReadOnlyList<string> hashes, BatchAddOperation operation)
{
Task.Run(() => collection.PerformWrite(c =>
{
int affected = 0;

foreach (string hash in hashes)
{
if (c.BeatmapMD5Hashes.Remove(hash))
affected++;
}

if (affected > 0)
notifyOperationCompleted(c.ID, operation, affected);
}));
}

private static void notifyOperationCompleted(Guid collectionId, BatchAddOperation operation, int count)
{
var result = new BatchAddResult(collectionId, operation, count);

lock (recent_results_lock)
{
recent_results[collectionId] = (result, DateTimeOffset.UtcNow);
cleanupExpiredResults();
}

OperationCompleted?.Invoke(result);
}

private static void cleanupExpiredResults()
{
var now = DateTimeOffset.UtcNow;

foreach (var entry in recent_results.ToArray())
{
if (now - entry.Value.Timestamp > recent_result_lifetime)
recent_results.Remove(entry.Key);
}
}

private partial class AddFilteredResultsDialog : DangerousActionDialog
{
public AddFilteredResultsDialog(string collectionName, int beatmapCount, Action onAddAll)
{
Icon = FontAwesome.Solid.Check;
HeaderText = "Add all visible beatmaps to collection";
BodyText = $"Add {beatmapCount:#,0} beatmaps to \"{collectionName}\"?";
DangerousAction = onAddAll;
}
}

private partial class RemoveFilteredResultsDialog : DangerousActionDialog
{
public RemoveFilteredResultsDialog(Action onRemove)
{
Icon = FontAwesome.Solid.Trash;
HeaderText = "Remove all visible beatmaps from collection";
BodyText = "The collection already contains all the visible beatmaps, do you want to remove these beatmaps from the collection?";
DangerousAction = onRemove;
}
}

private partial class PartialOverlapFilteredResultsDialog : DangerousActionDialog
{
public PartialOverlapFilteredResultsDialog(int missingCount, int duplicateCount, Action onAddDifference, Action onRemoveIntersection)
{
Icon = FontAwesome.Solid.Question;
HeaderText = "Add or remove visible beatmaps";
BodyText = $"{missingCount:#,0} of the visible beatmaps are missing from this collection."
+ $"\n{duplicateCount:#,0} of the visible beatmaps already exist in this collection.";
Buttons = new PopupDialogButton[]
{
new PopupDialogDangerousButton
{
Text = "Add missing beatmaps",
Action = onAddDifference,
},
new PopupDialogDangerousButton
{
Text = "Remove existing beatmaps",
Action = onRemoveIntersection,
},
new PopupDialogCancelButton
{
Text = "Cancel"
}
};
}
}
}
}
Loading
Loading