feat(queue): two-level deque model — PLAY prepends, add appends, last-track-end empties
Fixes five queue bugs: Playlist relabel, last-track-empties, dormant-seed-from-player on first add, immediate panel reactivity, and front/back deque semantics. Adds JumpTo for row jumps.
This commit is contained in:
@@ -5,11 +5,12 @@ using Microsoft.AspNetCore.Components;
|
||||
namespace DeepDrftTests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for the play-queue orchestrator (<see cref="QueueService"/>). The queue is pure
|
||||
/// domain logic over the single-slot player, so it is exercised here against a recording fake
|
||||
/// (<see cref="FakeStreamingPlayer"/>) — no browser, no JS interop, no DI container. Coverage:
|
||||
/// enqueue, ordered advance, next/previous bounds, clear, current-index integrity, and
|
||||
/// auto-advance on the player's <see cref="IPlayerService.TrackEnded"/> signal.
|
||||
/// Unit tests for the two-level deque play-queue orchestrator (<see cref="QueueService"/>). The queue
|
||||
/// is pure domain logic over the single-slot player, so it is exercised here against a recording fake
|
||||
/// (<see cref="FakeStreamingPlayer"/>) — 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 <see cref="IPlayerService.TrackEnded"/> signal.
|
||||
/// </summary>
|
||||
[TestFixture]
|
||||
public class QueueServiceTests
|
||||
@@ -125,8 +126,12 @@ public class QueueServiceTests
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task PlayRelease_ReplacesAnExistingQueue()
|
||||
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<TrackDto>
|
||||
{
|
||||
@@ -138,39 +143,133 @@ public class QueueServiceTests
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(_queue.Items, Has.Count.EqualTo(2));
|
||||
Assert.That(_queue.Items.Select(t => t.EntryKey), Is.EqualTo(new[] { "x-1", "x-2" }));
|
||||
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_ViaLiveQueueItems_PreservesTracksAndJumpsToIndex()
|
||||
public async Task PlayRelease_FromMidQueueCurrent_DropsOnlyTheCurrentTrack_NotItsTail()
|
||||
{
|
||||
// Regression guard for the aliasing bug: OnQueueJump calls PlayRelease(QueueService.Items, index).
|
||||
// Items returns the backing list directly; without a defensive copy, the cast
|
||||
// "tracks as IReadOnlyList<TrackDto>" aliases _items, so _items.Clear() also clears list,
|
||||
// and _items.AddRange(list) adds nothing — wiping the queue and playing nothing.
|
||||
await _queue.PlayRelease(Tracks(4)); // populate the live queue
|
||||
// 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
|
||||
|
||||
// Jump to index 2 via the live Items reference, exactly as OnQueueJump does.
|
||||
await _queue.PlayRelease(new List<TrackDto> { 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<TrackDto>
|
||||
{
|
||||
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 queue must survive — all four tracks still present, in order.
|
||||
Assert.That(_queue.Items, Has.Count.EqualTo(4));
|
||||
// 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" }));
|
||||
// CurrentIndex must be the jumped-to slot.
|
||||
Is.EqualTo(new[] { "track-1", "track-2", "track-3", "track-4", "track-2", "track-3", "track-4" }));
|
||||
Assert.That(_queue.CurrentIndex, Is.EqualTo(2));
|
||||
// Current must be the right track.
|
||||
Assert.That(_queue.Current!.EntryKey, Is.EqualTo("track-3"));
|
||||
// The player must have streamed the jumped-to track.
|
||||
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]
|
||||
@@ -671,6 +770,81 @@ public class QueueServiceTests
|
||||
});
|
||||
}
|
||||
|
||||
[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()
|
||||
{
|
||||
@@ -686,6 +860,67 @@ public class QueueServiceTests
|
||||
});
|
||||
}
|
||||
|
||||
// --- 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]
|
||||
@@ -820,16 +1055,38 @@ public class QueueServiceTests
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task TrackEnded_OnLastTrack_DoesNotAdvanceOrReplay()
|
||||
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.CurrentIndex, Is.EqualTo(1));
|
||||
Assert.That(_player.SelectedTracks, Has.Count.EqualTo(1));
|
||||
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));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -877,17 +1134,18 @@ public class QueueServiceTests
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task TrackEnded_PlaysWholeAlbumThroughToTheEnd()
|
||||
public async Task TrackEnded_PlaysWholeAlbumThroughToTheEnd_ThenEmptiesOnLastEnd()
|
||||
{
|
||||
await _queue.PlayRelease(Tracks(3));
|
||||
|
||||
_player.RaiseTrackEnded(); // → track-2
|
||||
_player.RaiseTrackEnded(); // → track-3
|
||||
_player.RaiseTrackEnded(); // last track: no advance
|
||||
_player.RaiseTrackEnded(); // last track ends → queue empties (bug #2)
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(_queue.CurrentIndex, Is.EqualTo(2));
|
||||
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" }));
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user