using DataLayer;
using LibationFileManager;
using LibationUiBase.Forms;
using LibationUiBase.ProcessQueue;
using System.Collections.Concurrent;
namespace LibationUiBase.Tests;
///
/// Covers the dispatch loop itself: the capacity cap, the enqueue signal, and the abort drain.
///
/// All of the risk parallel downloads added lives in that loop, and none of it was reachable from a
/// test while the only way to run a book was to download one.
/// is the seam: every book here finishes when this test says so and never touches the network, the
/// database or the disk. The loop is the real one.
///
///
[TestClass]
[DoNotParallelize]
public class ProcessQueueDispatchTests
{
private static readonly TimeSpan Patience = TimeSpan.FromSeconds(10);
[TestInitialize]
public void Initialize()
{
// ProcessQueueViewModel raises property changes through the current SynchronizationContext.
SynchronizationContext.SetSynchronizationContext(new SynchronizationContext());
Configuration.CreateMockInstance();
MessageBoxBase.ShowAsyncImpl = (_, _, _, _, _, _, _) => Task.FromResult(DialogResult.OK);
}
[TestCleanup]
public void Cleanup()
{
MessageBoxBase.ShowAsyncImpl = null!;
Configuration.RestoreSingletonInstance();
}
///
/// A book with no processables attached, so IncludesBookDownload is false and the daily
/// limit gate returns immediately without querying the download history.
///
///
/// Must be called before the test's first await. ReactiveObject captures the current
/// SynchronizationContext in its constructor, and after an await the test has resumed on a
/// thread-pool thread where there is none.
///
private static ProcessBookViewModel Book(string asin)
{
var contributor = Contributor.GetEmpty();
var book = new Book(new AudibleProductId(asin), asin, null, null, 1, ContentType.Product, [contributor], [contributor], "us");
return new ProcessBookViewModel(new LibraryBook(book, new DateTime(2026, 8, 10), "account"), Configuration.Instance);
}
///
/// Hands out a gate per book so a test can decide, from outside, exactly when each one finishes
/// and in what order. Also records how many were running at the same moment, which is the only
/// way to observe the capacity cap.
///
private sealed class FakeBooks
{
private readonly ConcurrentDictionary> gates = new();
private readonly object countLock = new();
private int running;
public int HighWaterMark { get; private set; }
public ConcurrentQueue Started { get; } = new();
public Task Handle(ProcessBookViewModel book)
{
var asin = book.LibraryBook.Book.AudibleProductId.ToString()!;
// Count first, then publish. WaitForStarted polls Started, so enqueuing before the
// counter moves lets a test wake up in the gap and assert a HighWaterMark that is one
// short of the books it just waited for.
lock (countLock)
{
running++;
if (running > HighWaterMark)
HighWaterMark = running;
}
Started.Enqueue(asin);
return GateFor(asin).Task.ContinueWith(t =>
{
lock (countLock) running--;
// The real ProcessOneAsync records its own outcome on the book, and the queue loop reads
// it back afterwards. A fake that only returned the value would leave Result unset.
book.Result = t.Result;
return t.Result;
});
}
private TaskCompletionSource GateFor(string asin)
=> gates.GetOrAdd(asin, _ => new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously));
public void Finish(string asin, ProcessBookResult result = ProcessBookResult.Success)
=> GateFor(asin).TrySetResult(result);
public void FinishAll(params string[] asins)
{
foreach (var asin in asins)
Finish(asin);
}
/// Waits for books to have entered the handler.
public async Task WaitForStarted(int count)
{
var deadline = DateTime.UtcNow + Patience;
while (Started.Count < count)
{
if (DateTime.UtcNow > deadline)
Assert.Fail($"Only {Started.Count} of {count} books started within {Patience.TotalSeconds}s.");
await Task.Delay(15);
}
}
}
///
/// The setting under test. Machine capability is pinned to the same number, because the loop
/// clamps the setting by it: left to , a test asking for
/// three books at once would quietly start two on a small CI runner, wait out its patience and
/// fail - having measured the runner rather than the loop.
///
private static (ProcessQueueViewModel Queue, FakeBooks Books) NewQueue(int atOnce)
{
var books = new FakeBooks();
var queue = new ProcessQueueViewModel { MaxConcurrentDownloads = atOnce, MachineCeilingOverride = atOnce };
queue.ProcessBookHandler = books.Handle;
return (queue, books);
}
private static async Task RunToCompletion(ProcessQueueViewModel queue)
{
var runner = queue.QueueRunner;
Assert.IsNotNull(runner, "The queue loop never started.");
var finished = await Task.WhenAny(runner, Task.Delay(Patience));
Assert.AreSame(runner, finished, $"The queue loop did not finish within {Patience.TotalSeconds}s.");
await runner;
}
[TestMethod]
public async Task the_loop_starts_no_more_books_than_the_concurrency_setting_allows()
{
var (queue, books) = NewQueue(atOnce: 2);
queue.AddToQueue([Book("A"), Book("B"), Book("C"), Book("D")]);
// Two start; C and D must wait for a slot rather than all four going at once.
await books.WaitForStarted(2);
await Task.Delay(100);
Assert.AreEqual(2, books.Started.Count, "A third book started while the queue was at capacity.");
books.FinishAll("A", "B");
await books.WaitForStarted(4);
books.FinishAll("C", "D");
await RunToCompletion(queue);
Assert.AreEqual(2, books.HighWaterMark, "More books ran at once than the setting allows.");
Assert.AreEqual(4, queue.Queue.Completed.Count);
}
[TestMethod]
public async Task lowering_the_setting_mid_run_does_not_start_more_books_until_the_extra_ones_finish()
{
var (queue, books) = NewQueue(atOnce: 3);
queue.AddToQueue([Book("A"), Book("B"), Book("C"), Book("D")]);
await books.WaitForStarted(3);
queue.MaxConcurrentDownloads = 1;
books.Finish("A");
// Down to two running, which is still over the new cap, so D stays put.
await Task.Delay(150);
Assert.AreEqual(3, books.Started.Count, "A book started while the queue was still over its lowered cap.");
books.FinishAll("B", "C");
await books.WaitForStarted(4);
books.Finish("D");
await RunToCompletion(queue);
Assert.AreEqual(4, queue.Queue.Completed.Count);
}
[TestMethod]
public async Task books_queued_after_the_loop_starts_fill_the_free_slots_without_waiting_for_one_to_finish()
{
var (queue, books) = NewQueue(atOnce: 3);
ProcessBookViewModel a = Book("A"), b = Book("B"), c = Book("C");
// One book, so the loop ends up parked with two slots free and nothing queued. Without the
// enqueue signal it would sit on the active task and pick the new books up one at a time as
// that one finished, instead of waking on the arrival.
queue.AddToQueue([a]);
await books.WaitForStarted(1);
queue.AddToQueue([b, c]);
await books.WaitForStarted(3);
Assert.AreEqual(3, books.HighWaterMark, "Newly queued books did not fill the free slots.");
books.FinishAll("A", "B", "C");
await RunToCompletion(queue);
Assert.AreEqual(3, queue.Queue.Completed.Count);
}
[TestMethod]
public async Task a_book_queued_while_the_loop_is_finishing_is_still_picked_up()
{
var (queue, books) = NewQueue(atOnce: 1);
ProcessBookViewModel a = Book("A"), b = Book("B");
queue.AddToQueue([a]);
await books.WaitForStarted(1);
// Racing the loop's exit: the wait captured before the queue is inspected is what stops this
// book from being stranded by arriving in the gap.
books.Finish("A");
queue.AddToQueue([b]);
await books.WaitForStarted(2);
books.Finish("B");
await RunToCompletion(queue);
Assert.AreEqual(2, queue.Queue.Completed.Count, "A book queued as the loop wound down was stranded.");
}
[TestMethod]
public async Task an_abort_clears_the_queue_and_the_loop_still_finishes_cleanly()
{
// One slot on purpose. With more, the aborting book and another finishing together let the
// loop take a third book off the queue before the abort has cleared it - a real window, but a
// tiny one, and not what this test is about.
var (queue, books) = NewQueue(atOnce: 1);
queue.AddToQueue([Book("A"), Book("B"), Book("C")]);
await books.WaitForStarted(1);
books.Finish("A", ProcessBookResult.FailedAbort);
// The loop has to come back rather than dying inside the drain, and B and C must never start.
await RunToCompletion(queue);
Assert.AreEqual(1, books.Started.Count, "A queued book started after the abort.");
// The queue itself, not the view model's QueuedCount mirror. That mirror arrives through the
// posted path, and the bare SynchronizationContext installed in TestInitialize posts to the
// thread pool - so reading it here raced delivery and failed most runs. A is the only book
// left on the board: it completed, and the abort cleared B and C without starting them.
Assert.AreEqual(1, queue.Queue.Count, "The abort left books on the queue.");
Assert.IsFalse(queue.ProgressBarVisible, "The loop exited without clearing the progress bar.");
}
[TestMethod]
public async Task only_one_book_reports_an_abort_and_the_rest_report_cancelled()
{
var (queue, books) = NewQueue(atOnce: 3);
var a = Book("A");
var b = Book("B");
var c = Book("C");
queue.AddToQueue([a, b, c]);
await books.WaitForStarted(3);
// No dialog was answered here - this is Bad Book set to Abort in settings, where every book
// that fails aborts on its own account. Nobody is the one the user was asked about, so the
// first book through tears the queue down and keeps the abort; the others were cancelled by it.
Assert.IsNull(queue.BadBookSession.AbortOriginator);
books.Finish("A", ProcessBookResult.FailedAbort);
books.Finish("B", ProcessBookResult.FailedAbort);
books.Finish("C", ProcessBookResult.FailedAbort);
await RunToCompletion(queue);
var aborted = new[] { a, b, c }.Count(x => x.Result is ProcessBookResult.FailedAbort);
var cancelled = new[] { a, b, c }.Count(x => x.Result is ProcessBookResult.Cancelled);
Assert.AreEqual(1, aborted, "More than one book claimed the abort.");
Assert.AreEqual(2, cancelled, "Books that inherited the abort should report as cancelled.");
}
[TestMethod]
public async Task the_book_the_user_aborted_reports_the_abort_whichever_book_tears_the_queue_down()
{
var (queue, books) = NewQueue(atOnce: 3);
var a = Book("A");
var b = Book("B");
var c = Book("C");
queue.AddToQueue([a, b, c]);
await books.WaitForStarted(3);
// C is the book the user was looking at when they answered Abort. A and B inherit that answer
// through the session override, which is what puts all three here reporting FailedAbort.
queue.BadBookSession.AbortOriginator = c;
// A finishes first and so claims the teardown. Nothing below depends on it winning - that race
// is what made the old status arbitrary - but this is the ordering that used to leave the row
// the user actually aborted saying "Cancelled" while A's said "Error, Abort".
books.Finish("A", ProcessBookResult.FailedAbort);
await Task.Delay(50);
books.Finish("B", ProcessBookResult.FailedAbort);
books.Finish("C", ProcessBookResult.FailedAbort);
await RunToCompletion(queue);
Assert.AreEqual(ProcessBookResult.FailedAbort, c.Result, "The book the user aborted did not report the abort.");
Assert.AreEqual(ProcessBookResult.Cancelled, a.Result, "A book that inherited the abort reported it as its own.");
Assert.AreEqual(ProcessBookResult.Cancelled, b.Result, "A book that inherited the abort reported it as its own.");
}
[TestMethod]
public async Task a_book_that_throws_is_logged_and_the_rest_of_the_queue_still_finishes()
{
var books = new FakeBooks();
var queue = new ProcessQueueViewModel { MaxConcurrentDownloads = 2 };
queue.ProcessBookHandler = book
=> book.LibraryBook.Book.AudibleProductId.ToString() == "BOOM"
? Task.FromException(new InvalidOperationException("Queue empty."))
: books.Handle(book);
queue.AddToQueue([Book("BOOM"), Book("A")]);
await books.WaitForStarted(1);
books.Finish("A");
// The faulted task is observed on the way out. Before this, it took the loop out through its
// outer catch and the remaining books ran on unsupervised.
await RunToCompletion(queue);
Assert.IsFalse(queue.ProgressBarVisible, "The loop died rather than finishing.");
}
[TestMethod]
public async Task cancel_all_empties_the_queue_and_lets_the_loop_finish()
{
var (queue, books) = NewQueue(atOnce: 2);
queue.AddToQueue([Book("A"), Book("B"), Book("C"), Book("D")]);
await books.WaitForStarted(2);
var cancelling = queue.CancelAllAsync();
books.FinishAll("A", "B");
await cancelling;
await RunToCompletion(queue);
// See the abort test: QueuedCount is the posted mirror and races delivery. A and B are the
// only books left on the board; Cancel All took C and D off it before either could start.
Assert.AreEqual(2, queue.Queue.Count, "Cancel All left books queued.");
Assert.AreEqual(2, books.Started.Count, "Cancel All did not stop new books from starting.");
}
[TestMethod]
public async Task a_machine_smaller_than_the_setting_holds_the_loop_down_without_changing_the_setting()
{
var books = new FakeBooks();
var queue = new ProcessQueueViewModel { MaxConcurrentDownloads = 8, MachineCeilingOverride = 2 };
queue.ProcessBookHandler = books.Handle;
queue.AddToQueue([Book("A"), Book("B"), Book("C"), Book("D")]);
await books.WaitForStarted(2);
await Task.Delay(100);
Assert.AreEqual(2, books.Started.Count, "The machine ceiling did not hold the loop down.");
// The whole point of clamping here rather than on the way in: what the user asked for
// survives being opened on a machine that cannot deliver it.
Assert.AreEqual(8, queue.MaxConcurrentDownloads, "The stored setting was rewritten to what the machine could manage.");
books.FinishAll("A", "B");
await books.WaitForStarted(4);
books.FinishAll("C", "D");
await RunToCompletion(queue);
Assert.AreEqual(2, books.HighWaterMark, "More books ran at once than the machine allows.");
}
[TestMethod]
public async Task cancelling_a_book_with_nothing_running_still_records_the_cancellation()
{
var book = Book("A");
Assert.IsFalse(book.CancellationRequested);
// Nothing has started, so there is no step to cancel - and that is the case that matters. A
// book held at the daily download limit is in exactly this state, and the gate reads this to
// decide whether to resume it. Recording it on the book rather than on the queue is what
// stops a later AddToQueue withdrawing the cancellation while the book is still parked.
await book.CancelAsync();
Assert.IsTrue(book.CancellationRequested);
}
[TestMethod]
public void the_hint_says_what_the_machine_will_do_and_is_silent_when_it_can_keep_up()
{
var queue = new ProcessQueueViewModel { MaxConcurrentDownloads = 8, MachineCeilingOverride = 2 };
Assert.AreEqual("(2 at a time)", queue.ConcurrencyHint);
// Nothing to say once the machine can deliver what was asked for.
queue.MachineCeilingOverride = 10;
Assert.IsNull(queue.ConcurrencyHint);
}
}