mirror of
https://github.com/rmcrackan/Libation.git
synced 2026-09-20 11:32:25 -04:00
Address trash recovery and regression coverage review follow-ups
This commit is contained in:
1 parent
84c55a69d6
commit
2b5d439d8f
7 files changed
+178
-56
No files matched your search
@@ -129,25 +129,15 @@ public class TrashBinViewModel : ViewModelBase
|
||||
public async Task PermanentlyDeleteCheckedAsync()
|
||||
=> await ChangeCheckedBooksAsync(books => books.PermanentlyDeleteBooksAsync(), "Could not permanently delete books.");
|
||||
|
||||
private async Task ChangeCheckedBooksAsync(Func<LibraryBook[], Task<int>> operation, string message)
|
||||
{
|
||||
ControlsEnabled = false;
|
||||
try
|
||||
private Task ChangeCheckedBooksAsync(Func<LibraryBook[], Task<int>> operation, string message)
|
||||
=> LibationUiBase.LibraryOperation.RunAsync(async () =>
|
||||
{
|
||||
var selection = GetCheckedBooks().ToArray();
|
||||
var qtyChanges = await operation(selection);
|
||||
if (qtyChanges > 0)
|
||||
await ReloadAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await MessageBox.ShowAdminAlert(null, message, "Trash bin operation failed", ex);
|
||||
}
|
||||
finally
|
||||
{
|
||||
ControlsEnabled = true;
|
||||
}
|
||||
}
|
||||
}, ex => MessageBox.ShowAdminAlert(null, message, "Trash bin operation failed", ex),
|
||||
enabled => ControlsEnabled = enabled);
|
||||
|
||||
private static List<LibraryBook> GetDeletedLibraryBooks()
|
||||
{
|
||||
|
||||
@@ -756,15 +756,7 @@ public partial class ProductsDisplay : UserControl
|
||||
}
|
||||
|
||||
#endregion
|
||||
private async Task RunLibraryOperationAsync(Func<Task> operation, string message)
|
||||
{
|
||||
try
|
||||
{
|
||||
await operation();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await MessageBox.ShowAdminAlert(this.GetParentWindow() as Window, message, "Library operation failed", ex);
|
||||
}
|
||||
}
|
||||
private Task RunLibraryOperationAsync(Func<Task> operation, string message)
|
||||
=> LibationUiBase.LibraryOperation.RunAsync(operation,
|
||||
ex => MessageBox.ShowAdminAlert(this.GetParentWindow() as Window, message, "Library operation failed", ex));
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace LibationUiBase;
|
||||
|
||||
/// <summary>Await UI operations, report their failures, and always restore disabled controls.</summary>
|
||||
public static class LibraryOperation
|
||||
{
|
||||
public static async Task RunAsync(Func<Task> operation, Func<Exception, Task> reportError, Action<bool>? setControlsEnabled = null)
|
||||
{
|
||||
try
|
||||
{
|
||||
setControlsEnabled?.Invoke(false);
|
||||
await operation();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await reportError(ex);
|
||||
}
|
||||
finally
|
||||
{
|
||||
setControlsEnabled?.Invoke(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -78,29 +78,25 @@ public partial class TrashBinDialog : Form
|
||||
}
|
||||
|
||||
private async void permanentlyDeleteBtn_Click(object sender, EventArgs e)
|
||||
{
|
||||
setControlsEnabled(false);
|
||||
|
||||
var qtyChanges = await GetCheckedBooks().PermanentlyDeleteBooksAsync();
|
||||
if (qtyChanges > 0)
|
||||
Reload();
|
||||
|
||||
setControlsEnabled(true);
|
||||
}
|
||||
=> await ChangeCheckedBooksAsync(books => books.PermanentlyDeleteBooksAsync(), "Could not permanently delete books.");
|
||||
|
||||
private async void restoreBtn_Click(object sender, EventArgs e)
|
||||
{
|
||||
setControlsEnabled(false);
|
||||
=> await ChangeCheckedBooksAsync(books => books.RestoreBooksAsync(), "Could not restore books.");
|
||||
|
||||
var qtyChanges = await GetCheckedBooks().RestoreBooksAsync();
|
||||
if (qtyChanges > 0)
|
||||
Reload();
|
||||
|
||||
setControlsEnabled(true);
|
||||
}
|
||||
private Task ChangeCheckedBooksAsync(Func<LibraryBook[], Task<int>> operation, string message)
|
||||
=> LibationUiBase.LibraryOperation.RunAsync(async () =>
|
||||
{
|
||||
var selection = GetCheckedBooks().ToArray();
|
||||
if (await operation(selection) > 0)
|
||||
Reload();
|
||||
}, ex =>
|
||||
{
|
||||
MessageBoxLib.ShowAdminAlert(this, message, "Trash bin operation failed", ex);
|
||||
return Task.CompletedTask;
|
||||
}, setControlsEnabled);
|
||||
|
||||
private void setControlsEnabled(bool enabled)
|
||||
=> Invoke(() => productsGrid1.Enabled = restoreBtn.Enabled = permanentlyDeleteBtn.Enabled = everythingCb.Enabled = enabled);
|
||||
=> Invoke(() => productsGrid1.Enabled = restoreBtn.Enabled = permanentlyDeleteBtn.Enabled = everythingCb.Enabled = audiblePlusCb.Enabled = enabled);
|
||||
|
||||
private void textBox1_KeyDown(object sender, KeyEventArgs e)
|
||||
{
|
||||
|
||||
@@ -480,15 +480,10 @@ public partial class ProductsDisplay : UserControl
|
||||
{
|
||||
RemovableCountChanged?.Invoke(sender, productsGrid.GetAllBookEntries().Count(lbe => lbe.Remove is true));
|
||||
}
|
||||
private async Task RunLibraryOperationAsync(Func<Task> operation, string message)
|
||||
{
|
||||
try
|
||||
{
|
||||
await operation();
|
||||
}
|
||||
catch (Exception ex)
|
||||
private Task RunLibraryOperationAsync(Func<Task> operation, string message)
|
||||
=> LibationUiBase.LibraryOperation.RunAsync(operation, ex =>
|
||||
{
|
||||
MessageBoxLib.ShowAdminAlert(this, message, "Library operation failed", ex);
|
||||
}
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
}
|
||||
@@ -28,6 +28,18 @@ public class TrashOperationsTests
|
||||
var book = new Book(new AudibleProductId("B0TRASHTEST"), "Trash test", "", "Description", 600,
|
||||
ContentType.Product, [new Contributor("Author")], [new Contributor("Narrator")], "us");
|
||||
context.LibraryBooks.Add(new LibraryBook(book, DateTime.UtcNow, "original-account"));
|
||||
foreach (var deleted in new[] { false, true })
|
||||
{
|
||||
var id = deleted ? "B0UNSELECTEDTRASH" : "B0UNSELECTED";
|
||||
var untouched = new Book(new AudibleProductId(id), id, "", "Untouched description", 600,
|
||||
ContentType.Product, [new Contributor(id + " author")], [new Contributor(id + " narrator")], "us");
|
||||
context.LibraryBooks.Add(new LibraryBook(untouched, new DateTime(2020, 1, 1), "untouched-account")
|
||||
{
|
||||
IsDeleted = deleted,
|
||||
AbsentFromLastScan = true,
|
||||
IsAudiblePlus = true
|
||||
});
|
||||
}
|
||||
context.SaveChanges();
|
||||
}
|
||||
|
||||
@@ -47,6 +59,24 @@ public class TrashOperationsTests
|
||||
return context.GetLibraryBook_Flat_NoTracking("B0TRASHTEST")!;
|
||||
}
|
||||
|
||||
private static void AssertUnselectedUnchanged()
|
||||
{
|
||||
using var context = DbContexts.GetContext();
|
||||
foreach (var deleted in new[] { false, true })
|
||||
{
|
||||
var id = deleted ? "B0UNSELECTEDTRASH" : "B0UNSELECTED";
|
||||
var row = context.LibraryBooks.Single(lb => lb.Book.AudibleProductId == id);
|
||||
Assert.AreEqual(deleted, row.IsDeleted);
|
||||
Assert.AreEqual("untouched-account", row.Account);
|
||||
Assert.AreEqual(new DateTime(2020, 1, 1), row.DateAdded);
|
||||
Assert.IsTrue(row.AbsentFromLastScan);
|
||||
Assert.IsTrue(row.IsAudiblePlus);
|
||||
var book = context.Books.Single(b => b.AudibleProductId == id);
|
||||
Assert.AreEqual(id, book.Title);
|
||||
Assert.AreEqual("Untouched description", book.Description);
|
||||
}
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task Remove_and_restore_duplicate_instances_preserve_newer_database_fields()
|
||||
{
|
||||
@@ -55,7 +85,7 @@ public class TrashOperationsTests
|
||||
Assert.AreNotSame(first, second);
|
||||
using (var context = DbContexts.GetContext())
|
||||
{
|
||||
var current = context.LibraryBooks.Single();
|
||||
var current = context.LibraryBooks.Single(lb => lb.Book.AudibleProductId == "B0TRASHTEST");
|
||||
current.SetAccount("updated-account");
|
||||
current.AbsentFromLastScan = true;
|
||||
context.SaveChanges();
|
||||
@@ -63,16 +93,18 @@ public class TrashOperationsTests
|
||||
|
||||
LibraryBook[] selection = [first, second];
|
||||
Assert.IsTrue(await selection.RemoveBooksAsync() > 0);
|
||||
AssertUnselectedUnchanged();
|
||||
Assert.IsFalse(first.IsDeleted, "Do not mutate detached UI objects before refreshing the library.");
|
||||
using (var context = DbContexts.GetContext())
|
||||
{
|
||||
var current = context.LibraryBooks.Single();
|
||||
var current = context.LibraryBooks.Single(lb => lb.Book.AudibleProductId == "B0TRASHTEST");
|
||||
Assert.IsTrue(current.IsDeleted);
|
||||
Assert.AreEqual("updated-account", current.Account);
|
||||
Assert.IsTrue(current.AbsentFromLastScan);
|
||||
}
|
||||
|
||||
Assert.IsTrue(await selection.RestoreBooksAsync() > 0);
|
||||
AssertUnselectedUnchanged();
|
||||
var restored = Read();
|
||||
Assert.IsFalse(restored.IsDeleted);
|
||||
Assert.AreEqual("updated-account", restored.Account);
|
||||
@@ -85,8 +117,27 @@ public class TrashOperationsTests
|
||||
LibraryBook[] selection = [Read(), Read()];
|
||||
Assert.AreNotSame(selection[0], selection[1]);
|
||||
Assert.IsTrue(await selection.PermanentlyDeleteBooksAsync() > 0);
|
||||
AssertUnselectedUnchanged();
|
||||
using var context = DbContexts.GetContext();
|
||||
Assert.AreEqual(0, context.LibraryBooks.Count());
|
||||
Assert.AreEqual(0, context.Books.Count());
|
||||
Assert.AreEqual(2, context.LibraryBooks.Count());
|
||||
Assert.AreEqual(2, context.Books.Count());
|
||||
Assert.IsFalse(context.LibraryBooks.Any(lb => lb.Book.AudibleProductId == "B0TRASHTEST"));
|
||||
Assert.IsFalse(context.Books.Any(b => b.AudibleProductId == "B0TRASHTEST"));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task Repeated_operations_and_missing_rows_are_no_ops()
|
||||
{
|
||||
LibraryBook[] selection = [Read(), Read()];
|
||||
Assert.AreEqual(0, await selection.RestoreBooksAsync());
|
||||
Assert.IsTrue(await selection.RemoveBooksAsync() > 0);
|
||||
Assert.AreEqual(0, await selection.RemoveBooksAsync());
|
||||
Assert.IsTrue(await selection.RestoreBooksAsync() > 0);
|
||||
Assert.AreEqual(0, await selection.RestoreBooksAsync());
|
||||
Assert.IsTrue(await selection.PermanentlyDeleteBooksAsync() > 0);
|
||||
Assert.AreEqual(0, await selection.PermanentlyDeleteBooksAsync());
|
||||
Assert.AreEqual(0, await selection.RemoveBooksAsync());
|
||||
Assert.AreEqual(0, await selection.RestoreBooksAsync());
|
||||
AssertUnselectedUnchanged();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
namespace LibationUiBase.Tests;
|
||||
|
||||
[TestClass]
|
||||
public class LibraryOperationTests
|
||||
{
|
||||
[TestMethod]
|
||||
public async Task Delayed_failure_is_observed_and_controls_recover_after_alert_completes()
|
||||
{
|
||||
var operation = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var alertShown = new TaskCompletionSource<Exception>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var dismissAlert = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var controls = new List<bool>();
|
||||
var failure = new InvalidOperationException("database failed");
|
||||
var pending = LibraryOperation.RunAsync(() => operation.Task, ex =>
|
||||
{
|
||||
alertShown.SetResult(ex);
|
||||
return dismissAlert.Task;
|
||||
}, controls.Add);
|
||||
|
||||
CollectionAssert.AreEqual(new[] { false }, controls);
|
||||
Assert.IsFalse(pending.IsCompleted);
|
||||
operation.SetException(failure);
|
||||
Assert.AreSame(failure, await alertShown.Task.WaitAsync(TimeSpan.FromSeconds(5)));
|
||||
Assert.IsFalse(pending.IsCompleted, "Keep the operation active until the user dismisses the alert.");
|
||||
dismissAlert.SetResult();
|
||||
await pending.WaitAsync(TimeSpan.FromSeconds(5));
|
||||
CollectionAssert.AreEqual(new[] { false, true }, controls);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task Status_operation_failure_is_reported_without_faulting_the_returned_task()
|
||||
{
|
||||
var failure = new InvalidOperationException("status failed");
|
||||
Exception? reported = null;
|
||||
await LibraryOperation.RunAsync(() => Task.FromException(failure), ex =>
|
||||
{
|
||||
reported = ex;
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
Assert.AreSame(failure, reported);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task Synchronous_failure_and_failed_alert_still_restore_controls()
|
||||
{
|
||||
var controls = new List<bool>();
|
||||
var alertFailure = new InvalidOperationException("alert failed");
|
||||
var result = LibraryOperation.RunAsync(() => throw new Exception("selection failed"),
|
||||
_ => Task.FromException(alertFailure), controls.Add);
|
||||
try
|
||||
{
|
||||
await result;
|
||||
Assert.Fail("The alert failure must propagate.");
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
Assert.AreSame(alertFailure, ex);
|
||||
}
|
||||
CollectionAssert.AreEqual(new[] { false, true }, controls);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task Successful_operation_restores_controls_without_alert()
|
||||
{
|
||||
var controls = new List<bool>();
|
||||
await LibraryOperation.RunAsync(() => Task.CompletedTask, _ =>
|
||||
{
|
||||
Assert.Fail("No alert should be shown.");
|
||||
return Task.CompletedTask;
|
||||
}, controls.Add);
|
||||
CollectionAssert.AreEqual(new[] { false, true }, controls);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user