Files
Libation/Source/_Tests/LibationUiBase.Tests/TrackedQueueTests.cs
T
Cursor Agentandrmcrackan d4248200d1 fix(queue): report the books an enqueue added, and where they start
Both halves of the Add notification were wrong. The index was read after the range
had been added, so it pointed past the end by the size of the batch - queueing two
books into a list of four announced them at index 6. And the parameter is IList<T>,
which does not implement the non-generic IList, so the compiler bound the
changedItem overload and the event named the list object itself as the single item
added rather than the books in it.

Both UIs survived it: WinForms discards the event and re-reads, and Avalonia
evidently falls back to re-reading too. But this class exists to give an index-based
consumer something it can follow, and this was the one notification it could not.

The list is also copied now, so the event does not hand out a reference the caller
can still mutate.

Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-24 17:52:44 +00:00

424 lines
14 KiB
C#

using System.Collections.Specialized;
using System.ComponentModel;
namespace LibationUiBase.Tests;
/// <summary>
/// TrackedQueue&lt;T&gt; is a pure data structure with no dependencies beyond System, so the ordering
/// and notification behaviour that parallel downloads depends on can be pinned down directly. Every
/// case here is reachable only when more than one item is active at a time.
/// </summary>
[TestClass]
public class TrackedQueueTests
{
private sealed class Book(string id)
{
public string Id { get; } = id;
public override string ToString() => Id;
}
private static readonly TimeSpan Patience = TimeSpan.FromSeconds(10);
/// <summary>
/// Deliberately far longer than <see cref="Patience"/>. If delivery is ever serialised by
/// something a second thread can block behind again, the test has to fail on that thread's join
/// rather than have the two time out together and pass by luck.
/// </summary>
private static readonly TimeSpan HandlerHold = TimeSpan.FromMinutes(1);
private static TrackedQueue<Book> QueueOf(params Book[] books)
{
var queue = new TrackedQueue<Book>();
queue.Enqueue(books);
return queue;
}
private static List<(object? Item, int OldIndex, int NewIndex)> RecordMoves(TrackedQueue<Book> queue)
{
var moves = new List<(object?, int, int)>();
queue.CollectionChanged += (_, e) =>
{
if (e.Action is NotifyCollectionChangedAction.Move)
moves.Add((e.NewItems?[0], e.OldStartingIndex, e.NewStartingIndex));
};
return moves;
}
[TestMethod]
public void the_second_of_two_active_books_finishing_first_reports_the_reorder()
{
Book a = new("A"), b = new("B"), c = new("C"), d = new("D");
var queue = QueueOf(a, b, c, d);
queue.TryDequeueNext(out _);
queue.TryDequeueNext(out _);
CollectionAssert.AreEqual(new[] { a, b, c, d }, queue.ToList());
var moves = RecordMoves(queue);
queue.MarkCompleted(b);
// B is now the only completed book, so it sorts ahead of A, which is still running.
CollectionAssert.AreEqual(new[] { b, a, c, d }, queue.ToList());
// Without the Move a bound list keeps painting A at row 0 and shows B's progress against it.
Assert.AreEqual(1, moves.Count);
Assert.AreSame(b, moves[0].Item);
Assert.AreEqual(1, moves[0].OldIndex);
Assert.AreEqual(0, moves[0].NewIndex);
}
[TestMethod]
public void one_book_at_a_time_reports_no_reorder()
{
Book a = new("A"), b = new("B");
var queue = QueueOf(a, b);
queue.TryDequeueNext(out _);
var moves = RecordMoves(queue);
queue.MarkCompleted(a);
// The sequential path is unchanged: the finishing book is already first, nothing moved.
CollectionAssert.AreEqual(new[] { a, b }, queue.ToList());
Assert.AreEqual(0, moves.Count);
}
[TestMethod]
public void completing_books_out_of_order_keeps_a_bound_list_in_step()
{
Book a = new("A"), b = new("B"), c = new("C");
var queue = QueueOf(a, b, c);
queue.TryDequeueNext(out _);
queue.TryDequeueNext(out _);
queue.TryDequeueNext(out _);
// A list that only ever sees CollectionChanged, as a bound UI list does.
var bound = queue.ToList();
queue.CollectionChanged += (_, e) =>
{
if (e.Action is not NotifyCollectionChangedAction.Move || e.NewItems?[0] is not Book moved)
return;
bound.RemoveAt(e.OldStartingIndex);
bound.Insert(e.NewStartingIndex, moved);
};
queue.MarkCompleted(c);
CollectionAssert.AreEqual(queue.ToList(), bound);
queue.MarkCompleted(b);
CollectionAssert.AreEqual(queue.ToList(), bound);
queue.MarkCompleted(a);
CollectionAssert.AreEqual(queue.ToList(), bound);
CollectionAssert.AreEqual(new[] { c, b, a }, bound);
}
[TestMethod]
public void completing_a_book_still_reports_the_completed_count()
{
Book a = new("A"), b = new("B");
var queue = QueueOf(a, b);
queue.TryDequeueNext(out _);
queue.TryDequeueNext(out _);
var counts = new List<int>();
queue.CompletedCountChanged += (_, count) => counts.Add(count);
queue.MarkCompleted(b);
queue.MarkCompleted(a);
CollectionAssert.AreEqual(new[] { 1, 2 }, counts);
}
[TestMethod]
public void the_queue_can_be_enumerated_while_it_is_being_mutated()
{
Book a = new("A"), b = new("B"), c = new("C");
var queue = QueueOf(a, b, c);
queue.TryDequeueNext(out _);
// Before GetAllItems snapshotted under the lock this threw
// InvalidOperationException: Collection was modified.
var seen = new List<Book>();
foreach (var book in queue)
{
queue.Enqueue([new Book("queued while reading")]);
seen.Add(book);
}
CollectionAssert.AreEqual(new[] { a, b, c }, seen);
}
[TestMethod]
public void the_active_list_is_handed_out_as_a_copy()
{
Book a = new("A"), b = new("B");
var queue = QueueOf(a, b);
queue.TryDequeueNext(out _);
var active = queue.GetActive();
queue.TryDequeueNext(out _);
// Callers iterate this while book tasks start and finish; it must not be the live list.
Assert.AreEqual(1, active.Count);
Assert.AreSame(a, active[0]);
Assert.AreEqual(2, queue.GetActive().Count);
}
[TestMethod]
public void removing_an_active_book_removes_that_book_and_not_the_first_one()
{
Book a = new("A"), b = new("B"), c = new("C");
var queue = QueueOf(a, b, c);
queue.TryDequeueNext(out _);
queue.TryDequeueNext(out _);
queue.TryDequeueNext(out _);
var removed = new List<(object? Item, int Index)>();
queue.CollectionChanged += (_, e) =>
{
if (e.Action is NotifyCollectionChangedAction.Remove)
removed.Add((e.OldItems?[0], e.OldStartingIndex));
};
queue.RemoveActive(b);
CollectionAssert.AreEqual(new[] { a, c }, queue.GetActive().ToList());
Assert.AreEqual(1, removed.Count);
Assert.AreSame(b, removed[0].Item);
Assert.AreEqual(1, removed[0].Index);
}
[TestMethod]
public void deferring_an_active_book_sends_it_to_the_back_and_leaves_the_others_running()
{
// What the daily download limit does when it holds a book back: the book being deferred is
// removed by identity and re-queued. Removing whichever was first would have dropped A, some
// other book's download.
Book a = new("A"), b = new("B"), c = new("C");
var queue = QueueOf(a, b, c);
queue.TryDequeueNext(out _);
queue.TryDequeueNext(out _);
queue.TryDequeueNext(out _);
queue.RemoveActive(b);
queue.Enqueue([b]);
CollectionAssert.AreEqual(new[] { a, c, b }, queue.ToList());
CollectionAssert.AreEqual(new[] { a, c }, queue.GetActive().ToList());
}
[TestMethod]
public void enqueuing_reports_the_books_it_added_and_where_they_start()
{
// Both halves of the Add event were wrong. The index was taken after the range was added, so
// it landed past the end by the size of the batch; and an IList<T> binds the changedItem
// overload, so the event named the list itself as the one thing added.
Book a = new("A"), b = new("B"), c = new("C"), d = new("D");
var queue = QueueOf(a, b);
queue.TryDequeueNext(out _);
queue.MarkCompleted(a);
queue.TryDequeueNext(out _);
NotifyCollectionChangedEventArgs? add = null;
queue.CollectionChanged += (_, e) =>
{
if (e.Action is NotifyCollectionChangedAction.Add)
add = e;
};
queue.Enqueue([c, d]);
Assert.IsNotNull(add);
CollectionAssert.AreEqual(new[] { c, d }, add.NewItems);
Assert.AreEqual(2, add.NewStartingIndex);
Assert.AreEqual(queue.IndexOf(c), add.NewStartingIndex);
}
[TestMethod]
public void completing_a_book_that_is_not_active_changes_nothing()
{
Book a = new("A"), b = new("B");
var queue = QueueOf(a, b);
queue.TryDequeueNext(out _);
int collectionChanges = 0;
queue.CollectionChanged += (_, _) => collectionChanges++;
var counts = new List<int>();
queue.CompletedCountChanged += (_, count) => counts.Add(count);
// B is still queued, so it has not completed. Appending it to Completed anyway would move
// Count with no CollectionChanged at all, which desynchronises a bound list silently.
queue.MarkCompleted(b);
Assert.AreEqual(2, queue.Count);
CollectionAssert.AreEqual(new[] { a, b }, queue.ToList());
Assert.AreEqual(0, collectionChanges);
Assert.AreEqual(0, counts.Count);
}
/// <summary>
/// Stands in for the UI thread. <see cref="BeginInvoke"/> only queues, so a test decides when
/// delivery happens and can assert that nothing was delivered inline.
/// </summary>
private sealed class QueuingInvoker : ISynchronizeInvoke
{
private readonly Queue<Action> _posted = new();
/// <summary>Always true, which is what makes the real invoker post rather than run inline.</summary>
public bool InvokeRequired => true;
public IAsyncResult BeginInvoke(Delegate method, object?[]? args)
{
lock (_posted)
_posted.Enqueue(() => method.DynamicInvoke(args));
return NotDelivered.Instance;
}
/// <summary>Runs what has been posted, in the order it was posted, on the calling thread.</summary>
public int Drain()
{
int delivered = 0;
while (true)
{
Action next;
lock (_posted)
{
if (_posted.Count == 0)
return delivered;
next = _posted.Dequeue();
}
next();
delivered++;
}
}
public object? EndInvoke(IAsyncResult result) => throw new NotSupportedException();
// A blocking Invoke from inside TrackedQueue's lock would deadlock against a UI thread
// waiting on that same lock. Nothing may reach this.
public object? Invoke(Delegate method, object?[]? args)
=> throw new NotSupportedException("TrackedQueue must post its notifications, never block on them.");
private sealed class NotDelivered : IAsyncResult
{
public static readonly NotDelivered Instance = new();
public object? AsyncState => null;
public WaitHandle AsyncWaitHandle => throw new NotSupportedException();
public bool CompletedSynchronously => false;
public bool IsCompleted => false;
}
}
[TestMethod]
public void notifications_are_posted_and_never_delivered_inline()
{
Book a = new("A"), b = new("B");
var invoker = new QueuingInvoker();
var queue = QueueOf(a, b);
queue.NotificationInvoker = invoker;
queue.TryDequeueNext(out _);
queue.TryDequeueNext(out _);
var moves = RecordMoves(queue);
queue.MarkCompleted(b);
// Called straight from this thread and still nothing has run. An invoker that delivered
// inline when it was already on the UI thread would let a UI-thread mutation jump ahead of
// notifications a book thread posted earlier.
Assert.AreEqual(0, moves.Count);
invoker.Drain();
Assert.AreEqual(1, moves.Count);
Assert.AreSame(b, moves[0].Item);
Assert.AreEqual(1, moves[0].OldIndex);
Assert.AreEqual(0, moves[0].NewIndex);
}
[TestMethod]
public void concurrent_completions_reach_a_bound_list_in_the_order_they_happened()
{
// The ordering this pins down is only reachable through the posted path, which is the one
// the app runs and the one the inline tests above never touch.
for (int run = 0; run < 50; run++)
{
Book[] books = [new("A"), new("B"), new("C"), new("D")];
var invoker = new QueuingInvoker();
var queue = QueueOf(books);
queue.NotificationInvoker = invoker;
for (int i = 0; i < books.Length; i++)
queue.TryDequeueNext(out _);
// A list that only ever sees CollectionChanged, as a bound UI list does.
var bound = queue.ToList();
queue.CollectionChanged += (_, e) =>
{
if (e.Action is not NotifyCollectionChangedAction.Move || e.NewItems?[0] is not Book moved)
return;
bound.RemoveAt(e.OldStartingIndex);
bound.Insert(e.NewStartingIndex, moved);
};
// Dedicated threads rather than the pool: the barrier needs all four running at once,
// and a pool that injects threads slowly would stall instead of racing.
using var allReady = new Barrier(books.Length);
var threads = books
.Select(book => new Thread(() => { allReady.SignalAndWait(); queue.MarkCompleted(book); }))
.ToArray();
foreach (var thread in threads)
thread.Start();
foreach (var thread in threads)
thread.Join();
// Delivery happens here, on one thread, exactly as the UI thread would run it.
invoker.Drain();
CollectionAssert.AreEqual(queue.ToList(), bound, $"run {run}");
}
}
[TestMethod]
public void a_mutation_is_not_blocked_behind_a_handler_that_is_still_running()
{
Book a = new("A"), b = new("B"), c = new("C");
var queue = QueueOf(a, b, c);
queue.TryDequeueNext(out _);
queue.TryDequeueNext(out _);
// No invoker, so notifications are delivered inline on the mutating thread. That is the case
// this is about: with one assigned, delivery is posted and returns immediately.
Assert.IsNull(queue.NotificationInvoker);
using var handlerRunning = new ManualResetEventSlim();
using var otherThreadDone = new ManualResetEventSlim();
int delivered = 0;
queue.CompletedCountChanged += (_, _) =>
{
Interlocked.Increment(ref delivered);
// Stands in for WinForms' RefreshDisplay, which blocks on the UI thread. While delivery
// was serialised by a lock, a book thread sat in here holding it and the UI thread's own
// mutation could never get in to release it - each waiting on the other.
handlerRunning.Set();
otherThreadDone.Wait(HandlerHold);
};
var delivering = new Thread(() => queue.MarkCompleted(a)) { IsBackground = true };
delivering.Start();
Assert.IsTrue(handlerRunning.Wait(Patience), "The handler never ran.");
var other = new Thread(() => { queue.MarkCompleted(b); otherThreadDone.Set(); }) { IsBackground = true };
other.Start();
Assert.IsTrue(other.Join(Patience), "A mutation blocked behind a delivery already in flight.");
Assert.IsTrue(delivering.Join(Patience), "The delivering thread never finished.");
// Returning without delivering is not the same as dropping it: the second thread left its
// notification for the first to pick up on its next pass, and the first did.
Assert.AreEqual(2, Volatile.Read(ref delivered), "The mutation that did not deliver its own notification lost it.");
CollectionAssert.AreEqual(new[] { a, b }, queue.Completed.ToList());
CollectionAssert.AreEqual(new[] { a, b, c }, queue.ToList());
}
}