using DeepDrftModels.DTOs; using DeepDrftPublic.Client.Services; using Microsoft.AspNetCore.Components; namespace DeepDrftTests; /// /// Unit tests for the 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: /// enqueue, ordered advance, next/previous bounds, clear, current-index integrity, and /// auto-advance 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_ReplacesAnExistingQueue() { 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, Has.Count.EqualTo(2)); Assert.That(_queue.Items.Select(t => t.EntryKey), Is.EqualTo(new[] { "x-1", "x-2" })); Assert.That(_queue.CurrentIndex, Is.EqualTo(0)); }); } // --- 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 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")); }); } // --- 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)); }); } // --- 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_DoesNotAdvanceOrReplay() { await _queue.PlayRelease(Tracks(2), startIndex: 1); _player.RaiseTrackEnded(); Assert.Multiple(() => { Assert.That(_queue.CurrentIndex, Is.EqualTo(1)); Assert.That(_player.SelectedTracks, Has.Count.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() { await _queue.PlayRelease(Tracks(3)); _player.RaiseTrackEnded(); // → track-2 _player.RaiseTrackEnded(); // → track-3 _player.RaiseTrackEnded(); // last track: no advance 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" })); }); } // --- 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; } }