diff --git a/Source/_Tests/LibationUiBase.Tests/ProcessQueueDispatchTests.cs b/Source/_Tests/LibationUiBase.Tests/ProcessQueueDispatchTests.cs
new file mode 100644
index 00000000..324fa3ee
--- /dev/null
+++ b/Source/_Tests/LibationUiBase.Tests/ProcessQueueDispatchTests.cs
@@ -0,0 +1,303 @@
+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()!;
+ Started.Enqueue(asin);
+
+ lock (countLock)
+ {
+ running++;
+ if (running > HighWaterMark)
+ HighWaterMark = running;
+ }
+
+ 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);
+ }
+ }
+ }
+
+ private static (ProcessQueueViewModel Queue, FakeBooks Books) NewQueue(int atOnce)
+ {
+ var books = new FakeBooks();
+ var queue = new ProcessQueueViewModel { MaxConcurrentDownloads = 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.");
+ Assert.AreEqual(0, queue.QueuedCount, "The abort left books on the queue.");
+ Assert.IsFalse(queue.ProgressBarVisible, "The loop exited without clearing the progress bar.");
+ }
+
+ [TestMethod]
+ public async Task only_the_book_that_aborted_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);
+
+ // All three inherit the abort, which is what a session-wide Abort answer produces. Only the
+ // first through gets to tear the queue down; the others were cancelled by it.
+ 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 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);
+ Assert.AreEqual(0, queue.QueuedCount, "Cancel All left books queued.");
+ Assert.AreEqual(2, books.Started.Count, "Cancel All did not stop new books from starting.");
+ }
+}