using DeepDrftModels.DTOs; using DeepDrftPublic.Client.Services; using Microsoft.AspNetCore.Components; namespace DeepDrftTests; /// /// Unit tests for the two-level deque play-queue orchestrator (). The queue /// is pure domain logic over the single-slot player, so it is exercised here against a recording fake /// () — no browser, no JS interop, no DI container. Coverage: PLAY- /// prepend (single + release), add-to-queue append, dormant-seed-from-player, ordered advance, /// next/previous bounds, jump, clear, current-index integrity, and auto-advance / last-track-empty on /// the player's signal. /// [TestFixture] public class QueueServiceTests { private FakeStreamingPlayer _player = null!; private QueueService _queue = null!; [SetUp] public void SetUp() { _player = new FakeStreamingPlayer(); _queue = new QueueService(); _queue.Attach(_player); } [TearDown] public void TearDown() => _queue.Dispose(); private static List Tracks(int count) => Enumerable.Range(1, count) .Select(i => new TrackDto { EntryKey = $"track-{i}", TrackName = $"Track {i}", TrackNumber = i }) .ToList(); // --- Empty-queue invariants (no regression to single-track play) --- [Test] public void NewQueue_IsEmptyWithCurrentIndexNegativeOne() { Assert.Multiple(() => { Assert.That(_queue.Items, Is.Empty); Assert.That(_queue.CurrentIndex, Is.EqualTo(-1)); Assert.That(_queue.Current, Is.Null); Assert.That(_queue.HasNext, Is.False); Assert.That(_queue.HasPrevious, Is.False); }); } [Test] public async Task NextAndPrevious_OnEmptyQueue_AreNoOpsAndDriveNoPlayback() { await _queue.Next(); await _queue.Previous(); Assert.Multiple(() => { Assert.That(_queue.CurrentIndex, Is.EqualTo(-1)); Assert.That(_player.SelectedTracks, Is.Empty); }); } // --- PlayRelease: enqueue + ordered start --- [Test] public async Task PlayRelease_LoadsTracksInOrderAndStreamsFirst() { var tracks = Tracks(3); await _queue.PlayRelease(tracks); Assert.Multiple(() => { Assert.That(_queue.Items.Select(t => t.EntryKey), Is.EqualTo(new[] { "track-1", "track-2", "track-3" })); Assert.That(_queue.CurrentIndex, Is.EqualTo(0)); Assert.That(_queue.Current!.EntryKey, Is.EqualTo("track-1")); Assert.That(_player.SelectedTracks.Single().EntryKey, Is.EqualTo("track-1")); }); } [Test] public async Task PlayRelease_WithStartIndex_StartsMidAlbumAndKeepsRemainderQueued() { var tracks = Tracks(4); await _queue.PlayRelease(tracks, startIndex: 2); Assert.Multiple(() => { Assert.That(_queue.CurrentIndex, Is.EqualTo(2)); Assert.That(_queue.Current!.EntryKey, Is.EqualTo("track-3")); Assert.That(_player.SelectedTracks.Single().EntryKey, Is.EqualTo("track-3")); Assert.That(_queue.HasNext, Is.True); Assert.That(_queue.HasPrevious, Is.True); }); } [Test] public async Task PlayRelease_ClampsOutOfRangeStartIndex() { var tracks = Tracks(3); await _queue.PlayRelease(tracks, startIndex: 99); Assert.Multiple(() => { Assert.That(_queue.CurrentIndex, Is.EqualTo(2)); Assert.That(_player.SelectedTracks.Single().EntryKey, Is.EqualTo("track-3")); }); } [Test] public async Task PlayRelease_WithEmptyTracks_IsNoOp() { await _queue.PlayRelease(Enumerable.Empty()); Assert.Multiple(() => { Assert.That(_queue.Items, Is.Empty); Assert.That(_queue.CurrentIndex, Is.EqualTo(-1)); Assert.That(_player.SelectedTracks, Is.Empty); }); } [Test] public async Task PlayRelease_PrependsToFront_RemovesPreviousCurrent_KeepsRemainderIntact() { // Deque PLAY (bug #5): PlayRelease into a non-empty queue prepends the release at the front, // removes the previously-current track, and leaves the up-next that sat after it intact behind // the prepend. Current was track-1 (index 0) → after prepend, the old current is dropped and // its tail (track-2, track-3) stays behind [x-1, x-2]. await _queue.PlayRelease(Tracks(3)); var second = new List { new() { EntryKey = "x-1", TrackName = "X1" }, new() { EntryKey = "x-2", TrackName = "X2" }, }; await _queue.PlayRelease(second); Assert.Multiple(() => { Assert.That(_queue.Items.Select(t => t.EntryKey), Is.EqualTo(new[] { "x-1", "x-2", "track-2", "track-3" })); Assert.That(_queue.CurrentIndex, Is.EqualTo(0)); Assert.That(_queue.Current!.EntryKey, Is.EqualTo("x-1")); Assert.That(_player.SelectedTracks.Last().EntryKey, Is.EqualTo("x-1")); }); } [Test] public async Task PlayRelease_FromMidQueueCurrent_DropsOnlyTheCurrentTrack_NotItsTail() { // Current advanced to track-2 (index 1) with track-3, track-4 after it. PLAY of a new release // drops only track-2 (the current) and keeps track-3, track-4 behind the prepend. The old // back-history (track-1, before the current) is discarded — a fresh PLAY defines a new front. await _queue.PlayRelease(Tracks(4)); await _queue.Next(); // current = track-2 at index 1 await _queue.PlayRelease(new List { new() { EntryKey = "p-1", TrackName = "P1" } }); Assert.Multiple(() => { Assert.That(_queue.Items.Select(t => t.EntryKey), Is.EqualTo(new[] { "p-1", "track-3", "track-4" })); Assert.That(_queue.CurrentIndex, Is.EqualTo(0)); Assert.That(_queue.Current!.EntryKey, Is.EqualTo("p-1")); }); } [Test] public async Task PlayRelease_WithStartIndex_PrependsWholeReleaseInOrder_CurrentAtStartIndex() { // A mid-album row play prepends the whole release in order; the chosen startIndex becomes // current. Tracks before it sit behind the pointer (Previous reaches them); tracks after are // up-next. The previous current is dropped. await _queue.PlayRelease(Tracks(2)); // existing queue: [track-1*, track-2] var release = new List { new() { EntryKey = "r-1", TrackName = "R1" }, new() { EntryKey = "r-2", TrackName = "R2" }, new() { EntryKey = "r-3", TrackName = "R3" }, }; await _queue.PlayRelease(release, startIndex: 1); Assert.Multiple(() => { Assert.That(_queue.Items.Select(t => t.EntryKey), Is.EqualTo(new[] { "r-1", "r-2", "r-3", "track-2" })); Assert.That(_queue.CurrentIndex, Is.EqualTo(1)); Assert.That(_queue.Current!.EntryKey, Is.EqualTo("r-2")); Assert.That(_queue.HasPrevious, Is.True); // r-1 is behind the pointer Assert.That(_player.SelectedTracks.Last().EntryKey, Is.EqualTo("r-2")); }); } [Test] public async Task PlayRelease_ViaLiveQueueItems_DoesNotCorruptListUnderPrepend() { // Aliasing guard retained under the deque model: a caller that passes the live Items reference // into PlayRelease must not corrupt the list. PlayRelease materializes tracks.ToList() before // the RemoveRange/InsertRange prepend, so the defensive copy survives the mutation. await _queue.PlayRelease(Tracks(4)); // Pass the live Items reference (current = track-1). Prepend drops the current and re-inserts // the copy at the front, with the tail (track-2..4) preserved behind it. await _queue.PlayRelease(_queue.Items, 2); Assert.Multiple(() => { // The defensive copy is intact: all four original tracks were re-prepended in order, and the // old current's tail follows. CurrentIndex is the chosen start. Assert.That(_queue.Items.Select(t => t.EntryKey), Is.EqualTo(new[] { "track-1", "track-2", "track-3", "track-4", "track-2", "track-3", "track-4" })); Assert.That(_queue.CurrentIndex, Is.EqualTo(2)); Assert.That(_queue.Current!.EntryKey, Is.EqualTo("track-3")); Assert.That(_player.SelectedTracks.Last().EntryKey, Is.EqualTo("track-3")); }); } // --- PlayTrack: deque PLAY of a single track (prepend to front) — bug #5 --- [Test] public async Task PlayTrack_IntoDormantQueue_BecomesSoleHeadAndStreams() { await _queue.PlayTrack(new TrackDto { EntryKey = "solo", TrackName = "Solo" }); Assert.Multiple(() => { Assert.That(_queue.Items.Select(t => t.EntryKey), Is.EqualTo(new[] { "solo" })); Assert.That(_queue.CurrentIndex, Is.EqualTo(0)); Assert.That(_player.SelectedTracks.Single().EntryKey, Is.EqualTo("solo")); }); } [Test] public async Task PlayTrack_FromNonEmptyQueue_PrependsDropsPreviousCurrent_KeepsRemainder() { // bug #5: PLAY of a single track from a non-empty queue prepends it as the new head, drops the // previously-current track, and leaves the remainder intact behind the new head. await _queue.PlayRelease(Tracks(3)); // [track-1*, track-2, track-3] await _queue.PlayTrack(new TrackDto { EntryKey = "jump-in", TrackName = "Jump In" }); Assert.Multiple(() => { Assert.That(_queue.Items.Select(t => t.EntryKey), Is.EqualTo(new[] { "jump-in", "track-2", "track-3" })); Assert.That(_queue.CurrentIndex, Is.EqualTo(0)); Assert.That(_queue.Current!.EntryKey, Is.EqualTo("jump-in")); Assert.That(_player.SelectedTracks.Last().EntryKey, Is.EqualTo("jump-in")); }); } [Test] public async Task PlayTrack_DisarmsAnArmedQueue() { _queue.Arm(Tracks(3)); await _queue.PlayTrack(new TrackDto { EntryKey = "override", TrackName = "Override" }); Assert.Multiple(() => { Assert.That(_queue.IsArmed, Is.False); Assert.That(_queue.Current!.EntryKey, Is.EqualTo("override")); }); } // --- Arm: prerender-safe load without streaming (release embed) --- [Test] public void Arm_LoadsTracksAtIndexZero_WithoutStreaming() { var tracks = Tracks(3); _queue.Arm(tracks); Assert.Multiple(() => { Assert.That(_queue.Items.Select(t => t.EntryKey), Is.EqualTo(new[] { "track-1", "track-2", "track-3" })); Assert.That(_queue.CurrentIndex, Is.EqualTo(0)); Assert.That(_queue.IsArmed, Is.True); // Arming is interop-free state only: nothing must have been streamed yet. Assert.That(_player.SelectedTracks, Is.Empty); }); } [Test] public void Arm_WithSingleTrackRelease_ArmsAOneItemQueueWithoutError() { _queue.Arm(Tracks(1)); Assert.Multiple(() => { Assert.That(_queue.Items, Has.Count.EqualTo(1)); Assert.That(_queue.CurrentIndex, Is.EqualTo(0)); Assert.That(_queue.IsArmed, Is.True); Assert.That(_queue.HasNext, Is.False); Assert.That(_player.SelectedTracks, Is.Empty); }); } [Test] public void Arm_WithEmptyTracks_IsNoOpAndLeavesQueueDisarmed() { _queue.Arm(Enumerable.Empty()); Assert.Multiple(() => { Assert.That(_queue.Items, Is.Empty); Assert.That(_queue.CurrentIndex, Is.EqualTo(-1)); Assert.That(_queue.IsArmed, Is.False); }); } [Test] public async Task Start_OnArmedQueue_StreamsTrackZeroAndDisarms() { // Models the embed's first-gesture path: FramePlayer arms the queue (no stream), then the // play click routes through Start(). _queue.Arm(Tracks(3)); await _queue.Start(); Assert.Multiple(() => { Assert.That(_queue.CurrentIndex, Is.EqualTo(0)); Assert.That(_queue.IsArmed, Is.False); Assert.That(_player.SelectedTracks.Single().EntryKey, Is.EqualTo("track-1")); }); } [Test] public async Task Start_OnUnarmedQueue_IsNoOp() { // A non-embed flow (PlayRelease already streaming) must not be disturbed by a stray Start. await _queue.PlayRelease(Tracks(3)); var streamedBefore = _player.SelectedTracks.Count; await _queue.Start(); Assert.Multiple(() => { Assert.That(_queue.IsArmed, Is.False); Assert.That(_player.SelectedTracks, Has.Count.EqualTo(streamedBefore), "Start on an unarmed queue must not re-stream"); }); } [Test] public async Task ArmedQueue_StartedThenAdvancesThroughWholeReleaseOnTrackEnded() { _queue.Arm(Tracks(3)); await _queue.Start(); _player.RaiseTrackEnded(); // → track-2 _player.RaiseTrackEnded(); // → track-3 Assert.Multiple(() => { Assert.That(_queue.CurrentIndex, Is.EqualTo(2)); Assert.That(_player.SelectedTracks.Select(t => t.EntryKey), Is.EqualTo(new[] { "track-1", "track-2", "track-3" })); }); } [Test] public void Arm_RaisesQueueChanged() { var raised = false; _queue.QueueChanged += () => raised = true; _queue.Arm(Tracks(2)); Assert.That(raised, Is.True); } [Test] public async Task Clear_DisarmsAnArmedQueue() { _queue.Arm(Tracks(2)); _queue.Clear(); Assert.That(_queue.IsArmed, Is.False); await Task.CompletedTask; } // --- Next / Previous mechanics and bounds --- [Test] public async Task Next_AdvancesThroughTracksInOrder() { await _queue.PlayRelease(Tracks(3)); await _queue.Next(); Assert.That(_queue.Current!.EntryKey, Is.EqualTo("track-2")); await _queue.Next(); Assert.That(_queue.Current!.EntryKey, Is.EqualTo("track-3")); Assert.That(_player.SelectedTracks.Select(t => t.EntryKey), Is.EqualTo(new[] { "track-1", "track-2", "track-3" })); } [Test] public async Task Next_AtLastTrack_IsNoOp() { await _queue.PlayRelease(Tracks(2), startIndex: 1); await _queue.Next(); Assert.Multiple(() => { Assert.That(_queue.CurrentIndex, Is.EqualTo(1)); Assert.That(_queue.HasNext, Is.False); // Only the initial PlayRelease selection — Next at the end drove no further playback. Assert.That(_player.SelectedTracks, Has.Count.EqualTo(1)); }); } [Test] public async Task Previous_StepsBackThroughTracks() { await _queue.PlayRelease(Tracks(3), startIndex: 2); await _queue.Previous(); Assert.Multiple(() => { Assert.That(_queue.CurrentIndex, Is.EqualTo(1)); Assert.That(_queue.Current!.EntryKey, Is.EqualTo("track-2")); Assert.That(_player.SelectedTracks.Last().EntryKey, Is.EqualTo("track-2")); }); } [Test] public async Task Previous_AtFirstTrack_IsNoOp() { await _queue.PlayRelease(Tracks(3)); await _queue.Previous(); Assert.Multiple(() => { Assert.That(_queue.CurrentIndex, Is.EqualTo(0)); Assert.That(_queue.HasPrevious, Is.False); Assert.That(_player.SelectedTracks, Has.Count.EqualTo(1)); }); } // --- Enqueue / EnqueueRange --- [Test] public void Enqueue_IntoDormantQueue_StagesCoherentIndexWithoutStartingPlayback() { // OQ8 (Phase 17 wave 17.1): the first add into a dormant queue stages a coherent // CurrentIndex (0) so the next play/skip is correct, but does NOT begin playback. (This // supersedes the pre-OQ8 expectation that a dormant Enqueue left CurrentIndex at -1.) _queue.Enqueue(new TrackDto { EntryKey = "a", TrackName = "A" }); Assert.Multiple(() => { Assert.That(_queue.Items, Has.Count.EqualTo(1)); Assert.That(_queue.CurrentIndex, Is.EqualTo(0)); Assert.That(_player.SelectedTracks, Is.Empty); }); } [Test] public async Task Enqueue_AfterPlayRelease_ExtendsTheQueueAndEnablesHasNext() { await _queue.PlayRelease(Tracks(1)); Assert.That(_queue.HasNext, Is.False); _queue.Enqueue(new TrackDto { EntryKey = "appended", TrackName = "Appended" }); Assert.Multiple(() => { Assert.That(_queue.HasNext, Is.True); Assert.That(_queue.CurrentIndex, Is.EqualTo(0)); }); } [Test] public void EnqueueRange_AppendsAllTracks() { _queue.EnqueueRange(Tracks(3)); Assert.That(_queue.Items, Has.Count.EqualTo(3)); } // --- Move (reorder) — Phase 17 wave 17.1 --- [Test] public async Task Move_ReordersItems_AndRaisesQueueChangedOnce() { // T1: Move(2, 0) on a 4-item queue reorders correctly; QueueChanged fired once. await _queue.PlayRelease(Tracks(4)); var changed = 0; _queue.QueueChanged += () => changed++; _queue.Move(2, 0); Assert.Multiple(() => { Assert.That(_queue.Items.Select(t => t.EntryKey), Is.EqualTo(new[] { "track-3", "track-1", "track-2", "track-4" })); Assert.That(changed, Is.EqualTo(1)); }); } [Test] public async Task Move_MovingOtherItemsAroundCurrent_KeepsSameTrackCurrent() { // T2 (moving others): with CurrentIndex on track-2, moving an item across it leaves track-2 current. await _queue.PlayRelease(Tracks(4), startIndex: 1); // current = track-2 at index 1 _queue.Move(3, 0); // move track-4 to the front → [4,1,2,3]; track-2 now at index 2 Assert.Multiple(() => { Assert.That(_queue.Current!.EntryKey, Is.EqualTo("track-2")); Assert.That(_queue.CurrentIndex, Is.EqualTo(2)); }); } [Test] public async Task Move_MovingCurrentItself_UpdatesCurrentIndexToNewSlot() { // T2 (moving current): moving the current track updates CurrentIndex to its new slot. await _queue.PlayRelease(Tracks(4), startIndex: 1); // current = track-2 at index 1 _queue.Move(1, 3); // move track-2 to the end → [1,3,4,2] Assert.Multiple(() => { Assert.That(_queue.Current!.EntryKey, Is.EqualTo("track-2")); Assert.That(_queue.CurrentIndex, Is.EqualTo(3)); }); } [Test] public async Task Move_DoesNotStreamAnything() { // T3: Move drives no playback — the fake player records no further SelectTrackStreaming call. await _queue.PlayRelease(Tracks(4)); var streamedBefore = _player.SelectedTracks.Count; _queue.Move(3, 0); _queue.Move(0, 2); Assert.That(_player.SelectedTracks, Has.Count.EqualTo(streamedBefore), "Move must not stream — it is a pure list/index mutation"); } [Test] public async Task Move_ReorderingCurrentTrack_AutoAdvancePointsAtCorrectNext() { // T10: reorder the currently-playing item; the next auto-advance follows the new order. await _queue.PlayRelease(Tracks(4)); // current = track-1 at index 0 _queue.Move(0, 2); // move current track-1 to index 2 → [2,3,1,4]; track-1 still current at index 2 Assert.That(_queue.CurrentIndex, Is.EqualTo(2)); _player.RaiseTrackEnded(); // organic end of track-1 → advance to index 3 (track-4) Assert.Multiple(() => { Assert.That(_queue.CurrentIndex, Is.EqualTo(3)); Assert.That(_queue.Current!.EntryKey, Is.EqualTo("track-4")); Assert.That(_player.SelectedTracks.Last().EntryKey, Is.EqualTo("track-4")); }); } [Test] public async Task Move_OutOfRangeOrEqualIndices_AreNoOpsAndDoNotRaiseQueueChanged() { // T8 (Move half): out-of-range and equal-index moves do not throw and do not fire QueueChanged. await _queue.PlayRelease(Tracks(3)); var raised = false; _queue.QueueChanged += () => raised = true; Assert.DoesNotThrow(() => { _queue.Move(-1, 0); _queue.Move(0, 5); _queue.Move(5, 0); _queue.Move(1, 1); }); Assert.Multiple(() => { Assert.That(raised, Is.False); Assert.That(_queue.Items.Select(t => t.EntryKey), Is.EqualTo(new[] { "track-1", "track-2", "track-3" })); Assert.That(_queue.CurrentIndex, Is.EqualTo(0)); }); } // --- RemoveAt — Phase 17 wave 17.1 --- [Test] public async Task RemoveAt_AfterCurrent_LeavesCurrentIndexUnchanged() { // T4: removing a track after the current leaves CurrentIndex unchanged, item gone. await _queue.PlayRelease(Tracks(4), startIndex: 1); // current = track-2 at index 1 _queue.RemoveAt(3); // remove track-4 (after current) Assert.Multiple(() => { Assert.That(_queue.CurrentIndex, Is.EqualTo(1)); Assert.That(_queue.Current!.EntryKey, Is.EqualTo("track-2")); Assert.That(_queue.Items.Select(t => t.EntryKey), Is.EqualTo(new[] { "track-1", "track-2", "track-3" })); }); } [Test] public async Task RemoveAt_BeforeCurrent_DecrementsCurrentIndex_SameTrackStaysCurrent() { // T5: removing before the current decrements CurrentIndex; the same track stays current. await _queue.PlayRelease(Tracks(4), startIndex: 2); // current = track-3 at index 2 _queue.RemoveAt(0); // remove track-1 (before current) Assert.Multiple(() => { Assert.That(_queue.CurrentIndex, Is.EqualTo(1)); Assert.That(_queue.Current!.EntryKey, Is.EqualTo("track-3")); }); } [Test] public async Task RemoveAt_OfCurrent_NotLast_DoesNotStop_AndResolvesToNextOccupant() { // T6 (not last): removing the current track does not stop the player; CurrentIndex resolves // to the new occupant of that slot (the next track). await _queue.PlayRelease(Tracks(4), startIndex: 1); // current = track-2 at index 1 var streamedBefore = _player.SelectedTracks.Count; _queue.RemoveAt(1); // remove current track-2 → [1,3,4]; index 1 now holds track-3 Assert.Multiple(() => { Assert.That(_queue.CurrentIndex, Is.EqualTo(1)); Assert.That(_queue.Current!.EntryKey, Is.EqualTo("track-3")); Assert.That(_player.StopCount, Is.EqualTo(0), "RemoveAt must not stop playback (C2)"); Assert.That(_player.SelectedTracks, Has.Count.EqualTo(streamedBefore), "RemoveAt must not re-stream"); }); } [Test] public async Task RemoveAt_OfCurrent_WhenLastSlot_ResolvesToDormantMinusOne() { // T6 (last slot, others remain): removing the current track when it is the last item has no // next occupant → CurrentIndex resolves to -1; playback is not stopped. await _queue.PlayRelease(Tracks(3), startIndex: 2); // current = track-3 at last index 2 _queue.RemoveAt(2); Assert.Multiple(() => { Assert.That(_queue.CurrentIndex, Is.EqualTo(-1)); Assert.That(_queue.Current, Is.Null); Assert.That(_queue.Items.Select(t => t.EntryKey), Is.EqualTo(new[] { "track-1", "track-2" })); Assert.That(_player.StopCount, Is.EqualTo(0)); }); } [Test] public async Task RemoveAt_OfLastRemainingTrack_EmptiesAndGoesDormant() { // T7: removing the last remaining item → empty + dormant; QueueChanged fired. await _queue.PlayRelease(Tracks(1)); var raised = false; _queue.QueueChanged += () => raised = true; _queue.RemoveAt(0); Assert.Multiple(() => { Assert.That(_queue.Items, Is.Empty); Assert.That(_queue.CurrentIndex, Is.EqualTo(-1)); Assert.That(_queue.Current, Is.Null); Assert.That(raised, Is.True); }); } [Test] public async Task RemoveAt_OutOfRange_IsNoOpAndDoesNotRaiseQueueChanged() { // T8 (RemoveAt half): out-of-range removal does not throw and does not fire QueueChanged. await _queue.PlayRelease(Tracks(3)); var raised = false; _queue.QueueChanged += () => raised = true; Assert.DoesNotThrow(() => { _queue.RemoveAt(-1); _queue.RemoveAt(3); }); Assert.Multiple(() => { Assert.That(raised, Is.False); Assert.That(_queue.Items, Has.Count.EqualTo(3)); Assert.That(_queue.CurrentIndex, Is.EqualTo(0)); }); } // --- Dormant Enqueue (OQ8: pure append, coherent index, no auto-play) — Phase 17 wave 17.1 --- [Test] public async Task Enqueue_IntoDormantQueue_LeavesCoherentCurrentIndexWithoutPlaying_ThenPlayStartsCorrectly() { // T9: Enqueue into a dormant queue stages a coherent CurrentIndex (0) but does not auto-play; // a subsequent play (Next from the staged position, or PlayRelease) behaves per OQ8. _queue.Enqueue(new TrackDto { EntryKey = "a", TrackName = "A" }); Assert.Multiple(() => { Assert.That(_queue.CurrentIndex, Is.EqualTo(0), "first add stages a coherent index"); Assert.That(_queue.Current!.EntryKey, Is.EqualTo("a")); Assert.That(_player.SelectedTracks, Is.Empty, "add is not play — nothing streamed"); Assert.That(_queue.IsArmed, Is.False, "dormant Enqueue does not arm"); }); _queue.Enqueue(new TrackDto { EntryKey = "b", TrackName = "B" }); Assert.Multiple(() => { // Second add appends without disturbing the staged current. Assert.That(_queue.CurrentIndex, Is.EqualTo(0)); Assert.That(_queue.HasNext, Is.True); Assert.That(_player.SelectedTracks, Is.Empty); }); // The coherent index means a skip-forward streams the right track without a prior play. await _queue.Next(); Assert.Multiple(() => { Assert.That(_queue.CurrentIndex, Is.EqualTo(1)); Assert.That(_player.SelectedTracks.Single().EntryKey, Is.EqualTo("b")); }); } [Test] public void EnqueueRange_IntoDormantQueue_StagesCoherentIndexWithoutPlaying() { // T9 (range variant): EnqueueRange into a dormant queue stages index 0, streams nothing. _queue.EnqueueRange(Tracks(3)); Assert.Multiple(() => { Assert.That(_queue.Items, Has.Count.EqualTo(3)); Assert.That(_queue.CurrentIndex, Is.EqualTo(0)); Assert.That(_queue.Current!.EntryKey, Is.EqualTo("track-1")); Assert.That(_player.SelectedTracks, Is.Empty); Assert.That(_queue.IsArmed, Is.False); }); } [Test] public void Enqueue_IntoDormantQueue_WhileTrackPlaysExternally_SeedsHeadThenAppends() { // Bug #3: a single track is playing NOT through the queue (the player's CurrentTrack is set, the // queue is dormant). The first Add-to-queue must seed the head with that now-playing track and // then append the added one → [now-playing, added], even if they are the same track. var nowPlaying = new TrackDto { Id = 7, EntryKey = "now-playing", TrackName = "Now Playing" }; _player.SimulateDirectPlay(nowPlaying); _queue.Enqueue(new TrackDto { EntryKey = "added", TrackName = "Added" }); Assert.Multiple(() => { Assert.That(_queue.Items.Select(t => t.EntryKey), Is.EqualTo(new[] { "now-playing", "added" })); Assert.That(_queue.CurrentIndex, Is.EqualTo(0), "the now-playing track is the head/current"); Assert.That(_queue.Current!.EntryKey, Is.EqualTo("now-playing")); Assert.That(_player.SelectedTracks, Is.Empty, "add is not play — nothing streamed"); }); // A second add appends a third item — no ghost/duplicate seeding. _queue.Enqueue(new TrackDto { EntryKey = "added-2", TrackName = "Added 2" }); Assert.That(_queue.Items.Select(t => t.EntryKey), Is.EqualTo(new[] { "now-playing", "added", "added-2" })); } [Test] public void Enqueue_OfTheSameExternallyPlayingTrack_SeedsHeadThenAppendsTheDuplicate() { // Bug #3 exact repro: add the very track that is playing externally. Result must be a 2-item // queue [now-playing(current), same-track-appended] — not a single ghost entry. var nowPlaying = new TrackDto { Id = 7, EntryKey = "the-track", TrackName = "The Track" }; _player.SimulateDirectPlay(nowPlaying); _queue.Enqueue(nowPlaying); Assert.Multiple(() => { Assert.That(_queue.Items, Has.Count.EqualTo(2)); Assert.That(_queue.Items.Select(t => t.EntryKey), Is.EqualTo(new[] { "the-track", "the-track" })); Assert.That(_queue.CurrentIndex, Is.EqualTo(0)); }); } [Test] public void EnqueueRange_IntoDormantQueue_WhileTrackPlaysExternally_SeedsHeadThenAppends() { var nowPlaying = new TrackDto { Id = 9, EntryKey = "live", TrackName = "Live" }; _player.SimulateDirectPlay(nowPlaying); _queue.EnqueueRange(Tracks(2)); Assert.Multiple(() => { Assert.That(_queue.Items.Select(t => t.EntryKey), Is.EqualTo(new[] { "live", "track-1", "track-2" })); Assert.That(_queue.CurrentIndex, Is.EqualTo(0)); Assert.That(_player.SelectedTracks, Is.Empty); }); } [Test] public void Enqueue_IntoDormantQueue_WithNothingPlaying_DoesNotSeedAPhantomHead() { // No external track playing → nothing to seed. The single added track is the head (OQ8 coherent // index), and there is no phantom duplicate. _queue.Enqueue(new TrackDto { EntryKey = "only", TrackName = "Only" }); Assert.Multiple(() => { Assert.That(_queue.Items.Select(t => t.EntryKey), Is.EqualTo(new[] { "only" })); Assert.That(_queue.CurrentIndex, Is.EqualTo(0)); }); } [Test] public async Task Enqueue_IntoActiveQueue_DoesNotMoveCurrentIndex() { // Guard the non-dormant path stays unchanged: appending while playing leaves current put. await _queue.PlayRelease(Tracks(2), startIndex: 1); // current = track-2 at index 1 _queue.Enqueue(new TrackDto { EntryKey = "tail", TrackName = "Tail" }); Assert.Multiple(() => { Assert.That(_queue.CurrentIndex, Is.EqualTo(1), "active-queue Enqueue must not disturb current"); Assert.That(_queue.Current!.EntryKey, Is.EqualTo("track-2")); }); } // --- JumpTo: row-jump within the deque (move pointer + stream once) --- [Test] public async Task JumpTo_MovesPointerForwardAndStreamsTheTargetOnce() { await _queue.PlayRelease(Tracks(4)); // current = track-1 var streamedBefore = _player.SelectedTracks.Count; await _queue.JumpTo(2); Assert.Multiple(() => { Assert.That(_queue.CurrentIndex, Is.EqualTo(2)); Assert.That(_queue.Current!.EntryKey, Is.EqualTo("track-3")); // Exactly one new stream — the intervening track-2 must NOT have been streamed. Assert.That(_player.SelectedTracks, Has.Count.EqualTo(streamedBefore + 1)); Assert.That(_player.SelectedTracks.Last().EntryKey, Is.EqualTo("track-3")); }); } [Test] public async Task JumpTo_MovesPointerBackwardAndStreamsTheTarget() { await _queue.PlayRelease(Tracks(4), startIndex: 3); // current = track-4 await _queue.JumpTo(1); Assert.Multiple(() => { Assert.That(_queue.CurrentIndex, Is.EqualTo(1)); Assert.That(_queue.Current!.EntryKey, Is.EqualTo("track-2")); Assert.That(_player.SelectedTracks.Last().EntryKey, Is.EqualTo("track-2")); }); } [Test] public async Task JumpTo_DoesNotDuplicateTheQueue() { // Regression guard: JumpTo must NOT prepend (it is not a PLAY) — the deque length is unchanged. await _queue.PlayRelease(Tracks(4)); await _queue.JumpTo(2); Assert.That(_queue.Items.Select(t => t.EntryKey), Is.EqualTo(new[] { "track-1", "track-2", "track-3", "track-4" })); } [Test] public async Task JumpTo_SameIndexOrOutOfRange_IsNoOp() { await _queue.PlayRelease(Tracks(3)); // current = track-1 var streamedBefore = _player.SelectedTracks.Count; await _queue.JumpTo(0); // already current await _queue.JumpTo(-1); await _queue.JumpTo(3); Assert.That(_player.SelectedTracks, Has.Count.EqualTo(streamedBefore), "no-op jumps must not re-stream"); } // --- Clear --- [Test] public async Task Clear_EmptiesQueueAndResetsIndexWithoutStoppingPlayer() { await _queue.PlayRelease(Tracks(3)); _queue.Clear(); Assert.Multiple(() => { Assert.That(_queue.Items, Is.Empty); Assert.That(_queue.CurrentIndex, Is.EqualTo(-1)); Assert.That(_queue.Current, Is.Null); // Clear is a queue-state reset; it must not tear the player down. Assert.That(_player.StopCount, Is.EqualTo(0)); }); } // --- ClearUpcoming (OQ5: keep the current track, drop the up-next) — Phase 17 wave 17.2 --- [Test] public async Task ClearUpcoming_KeepsCurrentTrack_DropsTheRest_WithoutStopping() { // Current = track-2; ClearUpcoming leaves only track-2 at index 0 and does not stop the player. await _queue.PlayRelease(Tracks(4), startIndex: 1); var streamedBefore = _player.SelectedTracks.Count; _queue.ClearUpcoming(); Assert.Multiple(() => { Assert.That(_queue.Items.Select(t => t.EntryKey), Is.EqualTo(new[] { "track-2" })); Assert.That(_queue.CurrentIndex, Is.EqualTo(0)); Assert.That(_queue.Current!.EntryKey, Is.EqualTo("track-2")); Assert.That(_queue.HasNext, Is.False); Assert.That(_queue.HasPrevious, Is.False); Assert.That(_player.StopCount, Is.EqualTo(0), "ClearUpcoming must not stop playback (C2)"); Assert.That(_player.SelectedTracks, Has.Count.EqualTo(streamedBefore), "ClearUpcoming must not re-stream"); }); } [Test] public async Task ClearUpcoming_RaisesQueueChangedOnce() { await _queue.PlayRelease(Tracks(3)); var changed = 0; _queue.QueueChanged += () => changed++; _queue.ClearUpcoming(); Assert.That(changed, Is.EqualTo(1)); } [Test] public async Task ClearUpcoming_WhenOnlyCurrentRemains_IsNoOpAndDoesNotRaiseQueueChanged() { await _queue.PlayRelease(Tracks(1)); var raised = false; _queue.QueueChanged += () => raised = true; _queue.ClearUpcoming(); Assert.Multiple(() => { Assert.That(_queue.Items, Has.Count.EqualTo(1)); Assert.That(_queue.CurrentIndex, Is.EqualTo(0)); Assert.That(raised, Is.False); }); } [Test] public void ClearUpcoming_OnEmptyQueue_IsNoOpAndDoesNotRaiseQueueChanged() { var raised = false; _queue.QueueChanged += () => raised = true; _queue.ClearUpcoming(); Assert.Multiple(() => { Assert.That(_queue.Items, Is.Empty); Assert.That(_queue.CurrentIndex, Is.EqualTo(-1)); Assert.That(raised, Is.False); }); } // --- QueueChanged notifications --- [Test] public async Task MutatingOperations_RaiseQueueChanged() { var count = 0; _queue.QueueChanged += () => count++; await _queue.PlayRelease(Tracks(3)); // 1 await _queue.Next(); // 2 await _queue.Previous(); // 3 _queue.Enqueue(new TrackDto { EntryKey = "z", TrackName = "Z" }); // 4 _queue.Clear(); // 5 Assert.That(count, Is.EqualTo(5)); } [Test] public void Clear_OnAlreadyEmptyQueue_DoesNotRaiseQueueChanged() { var raised = false; _queue.QueueChanged += () => raised = true; _queue.Clear(); Assert.That(raised, Is.False); } // --- Auto-advance on TrackEnded --- [Test] public async Task TrackEnded_AutoAdvancesToNextTrack() { await _queue.PlayRelease(Tracks(3)); _player.RaiseTrackEnded(); Assert.Multiple(() => { Assert.That(_queue.CurrentIndex, Is.EqualTo(1)); Assert.That(_queue.Current!.EntryKey, Is.EqualTo("track-2")); Assert.That(_player.SelectedTracks.Last().EntryKey, Is.EqualTo("track-2")); }); } [Test] public async Task TrackEnded_OnLastTrack_EmptiesTheQueueAndGoesDormant() { // Bug #2: when the current track ends naturally and there is nothing after it, the queue empties // (CurrentIndex == -1, dormant) rather than stranding the finished track as current. No replay. await _queue.PlayRelease(Tracks(2), startIndex: 1); var raised = false; _queue.QueueChanged += () => raised = true; _player.RaiseTrackEnded(); Assert.Multiple(() => { Assert.That(_queue.Items, Is.Empty); Assert.That(_queue.CurrentIndex, Is.EqualTo(-1)); Assert.That(_queue.Current, Is.Null); Assert.That(_player.SelectedTracks, Has.Count.EqualTo(1), "no replay on end"); Assert.That(raised, Is.True, "emptying the queue raises QueueChanged"); }); } [Test] public async Task TrackEnded_OnSingleTrackQueue_EmptiesTheQueue() { // Bug #2, single-track variant: a one-item queue playing to its end empties (dormant). await _queue.PlayRelease(Tracks(1)); _player.RaiseTrackEnded(); Assert.Multiple(() => { Assert.That(_queue.Items, Is.Empty); Assert.That(_queue.CurrentIndex, Is.EqualTo(-1)); }); } [Test] public void TrackEnded_OnEmptyQueue_IsIgnored() { _player.RaiseTrackEnded(); Assert.Multiple(() => { Assert.That(_queue.CurrentIndex, Is.EqualTo(-1)); Assert.That(_player.SelectedTracks, Is.Empty); }); } /// /// Regression guard for the cross-context spurious-advance bug: a direct single-track play /// (Session, StreamNowButton, resume) overwrites the player's CurrentTrack without touching the /// queue. When that external track reaches its natural end, TrackEnded fires — but the queue's /// Current no longer matches the player's CurrentTrack, so the queue must NOT advance. /// [Test] public async Task TrackEnded_WhenPlayerCurrentTrackIsNotQueueCurrent_DoesNotAdvance() { // Load a 3-track album into the queue (queue.Current → track-1, player.CurrentTrack → track-1). await _queue.PlayRelease(Tracks(3)); Assert.That(_queue.CurrentIndex, Is.EqualTo(0)); // Simulate a direct play (e.g. SessionDetail streams an unrelated track by Id = 99). // The player's CurrentTrack is now the session track, but the queue is still on track-1. var sessionTrack = new TrackDto { Id = 99, EntryKey = "session-track", TrackName = "Session Mix" }; _player.SimulateDirectPlay(sessionTrack); // The session track finishes naturally — player raises TrackEnded. _player.RaiseTrackEnded(); // The queue must not have advanced: index still 0, and no additional SelectTrackStreaming // calls beyond the initial PlayRelease selection. Assert.Multiple(() => { Assert.That(_queue.CurrentIndex, Is.EqualTo(0), "Queue must not advance when a direct-play track ends"); Assert.That(_player.SelectedTracks, Has.Count.EqualTo(1), "No further streaming must be triggered by the queue"); Assert.That(_player.SelectedTracks.Single().EntryKey, Is.EqualTo("track-1"), "Only the original PlayRelease selection must have been streamed"); }); } [Test] public async Task TrackEnded_PlaysWholeAlbumThroughToTheEnd_ThenEmptiesOnLastEnd() { await _queue.PlayRelease(Tracks(3)); _player.RaiseTrackEnded(); // → track-2 _player.RaiseTrackEnded(); // → track-3 _player.RaiseTrackEnded(); // last track ends → queue empties (bug #2) Assert.Multiple(() => { Assert.That(_queue.Items, Is.Empty); Assert.That(_queue.CurrentIndex, Is.EqualTo(-1)); Assert.That(_player.SelectedTracks.Select(t => t.EntryKey), Is.EqualTo(new[] { "track-1", "track-2", "track-3" })); }); } // --- Attach lifecycle --- [Test] public async Task Dispose_UnsubscribesFromTrackEnded_SoNoAutoAdvanceAfterDispose() { await _queue.PlayRelease(Tracks(3)); _queue.Dispose(); _player.RaiseTrackEnded(); Assert.That(_queue.CurrentIndex, Is.EqualTo(0)); } [Test] public async Task Attach_ToNewPlayer_RedirectsPlaybackAndAutoAdvance() { var second = new FakeStreamingPlayer(); _queue.Attach(second); await _queue.PlayRelease(Tracks(3)); Assert.That(second.SelectedTracks.Single().EntryKey, Is.EqualTo("track-1")); // The old player's TrackEnded must no longer drive this queue. _player.RaiseTrackEnded(); Assert.That(_queue.CurrentIndex, Is.EqualTo(0)); // The newly attached player does. second.RaiseTrackEnded(); Assert.That(_queue.CurrentIndex, Is.EqualTo(1)); } /// /// Records the tracks the queue asks the player to stream and lets a test raise the /// player's organic end-of-stream signal. Implements the full /// surface but only the members the queue actually drives carry behavior; the rest are inert /// — the queue touches nothing else, which is exactly the seam this fake pins down. /// private sealed class FakeStreamingPlayer : IStreamingPlayerService { public List SelectedTracks { get; } = new(); public int StopCount { get; private set; } public void RaiseTrackEnded() => TrackEnded?.Invoke(); /// /// Sets to without recording a /// entry. Models a direct single-track play (SessionDetail, /// StreamNowButton, resume) that overwrites the player state without going through the queue. /// public void SimulateDirectPlay(TrackDto track) => CurrentTrack = track; public Task SelectTrackStreaming(TrackDto track) { SelectedTracks.Add(track); CurrentTrack = track; return Task.CompletedTask; } public Task Stop() { StopCount++; return Task.CompletedTask; } public event Action? TrackEnded; // Part of the implemented contract but the queue never subscribes to it, so it is // intentionally never raised here. #pragma warning disable CS0067 public event Action? StateChanged; #pragma warning restore CS0067 // Inert remainder of the contract — the queue never invokes these. public bool IsInitialized => false; public bool IsLoaded => false; public bool IsLoading => false; public bool IsPlaying => false; public bool IsPaused => false; public double CurrentTime => 0; public double? Duration => null; public double Volume => 1.0; public double LoadProgress => 0; public string? ErrorMessage => null; public TrackDto? CurrentTrack { get; private set; } public double[]? WaveformProfile => null; public EventCallback? OnStateChanged { get; set; } public EventCallback? OnTrackSelected { get; set; } public bool IsStreamingMode => false; public bool CanStartStreaming => false; public bool HeaderParsed => false; public int BufferedChunks => 0; public Task InitializeAsync() => Task.CompletedTask; public Task SelectTrack(TrackDto track) => SelectTrackStreaming(track); public Task Unload() => Task.CompletedTask; public Task TogglePlayPause() => Task.CompletedTask; public Task Seek(double position) => Task.CompletedTask; public Task SetVolume(double volume) => Task.CompletedTask; public Task ClearError() => Task.CompletedTask; public Task WarmAudioContext() => Task.CompletedTask; public Task StageTrack(TrackDto track) => Task.CompletedTask; } }