Files
deepdrft/DeepDrftTests/QueueServiceTests.cs
T

999 lines
34 KiB
C#

using DeepDrftModels.DTOs;
using DeepDrftPublic.Client.Services;
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.
/// </summary>
[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<TrackDto> 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<TrackDto>());
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<TrackDto>
{
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));
});
}
[Test]
public async Task PlayRelease_ViaLiveQueueItems_PreservesTracksAndJumpsToIndex()
{
// 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
// Jump to index 2 via the live Items reference, exactly as OnQueueJump does.
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));
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.
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"));
});
}
// --- 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<TrackDto>());
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));
});
}
// --- 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_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);
});
}
/// <summary>
/// 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.
/// </summary>
[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));
}
/// <summary>
/// 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 <see cref="IStreamingPlayerService"/>
/// 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.
/// </summary>
private sealed class FakeStreamingPlayer : IStreamingPlayerService
{
public List<TrackDto> SelectedTracks { get; } = new();
public int StopCount { get; private set; }
public void RaiseTrackEnded() => TrackEnded?.Invoke();
/// <summary>
/// Sets <see cref="CurrentTrack"/> to <paramref name="track"/> without recording a
/// <see cref="SelectedTracks"/> entry. Models a direct single-track play (SessionDetail,
/// StreamNowButton, resume) that overwrites the player state without going through the queue.
/// </summary>
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;
}
}