diff --git a/Source/_Tests/LibationUiBase.Tests/TrackedQueueTests.cs b/Source/_Tests/LibationUiBase.Tests/TrackedQueueTests.cs new file mode 100644 index 00000000..4adc4e01 --- /dev/null +++ b/Source/_Tests/LibationUiBase.Tests/TrackedQueueTests.cs @@ -0,0 +1,198 @@ +using System.Collections.Specialized; + +namespace LibationUiBase.Tests; + +/// +/// TrackedQueue<T> 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. +/// +[TestClass] +public class TrackedQueueTests +{ + private sealed class Book(string id) + { + public string Id { get; } = id; + public override string ToString() => Id; + } + + private static TrackedQueue QueueOf(params Book[] books) + { + var queue = new TrackedQueue(); + queue.Enqueue(books); + return queue; + } + + private static List<(object? Item, int OldIndex, int NewIndex)> RecordMoves(TrackedQueue 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(); + 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(); + 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 and re-queued. ClearCurrent() 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()); + } +}