Add queue Move/RemoveAt + dormant-Enqueue coherence and shared QueueList (Phase 17.1)
This commit is contained in:
@@ -0,0 +1,124 @@
|
||||
@namespace DeepDrftPublic.Client.Controls
|
||||
@using DeepDrftModels.DTOs
|
||||
|
||||
@* Shared presentational queue list. Renders the ordered queue with the current track marked, and
|
||||
(when Editable) drag-reorder handles + per-row remove controls. This is the single "view" both
|
||||
the docked overlay (17.2) and the embedded panel (17.3) consume — one source, multiple views.
|
||||
|
||||
Purely presentational: owns no data fetch, no player wiring, and no IQueueService mutation of its
|
||||
own. Order changes, removals, and row jumps are surfaced to the parent as EventCallbacks; the
|
||||
parent calls the queue engine. It runs during prerender without JS interop (MudDropContainer's
|
||||
drag work is client-only and inert when no drag occurs). *@
|
||||
|
||||
@if (Items is { Count: > 0 })
|
||||
{
|
||||
@if (Editable)
|
||||
{
|
||||
<MudDropContainer T="QueueRow" @ref="_dropContainer" Items="Rows" ItemsSelector="@((row, zone) => true)"
|
||||
ItemDropped="OnItemDropped" Class="deepdrft-queue-list">
|
||||
<ChildContent>
|
||||
<MudDropZone T="QueueRow" Identifier="queue" Class="deepdrft-queue-zone" AllowReorder="true"/>
|
||||
</ChildContent>
|
||||
<ItemRenderer>
|
||||
@RenderRow(context)
|
||||
</ItemRenderer>
|
||||
</MudDropContainer>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="deepdrft-queue-list">
|
||||
@foreach (var row in Rows)
|
||||
{
|
||||
@RenderRow(row)
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
@code {
|
||||
/// <summary>The ordered tracks to render. Empty/null renders nothing.</summary>
|
||||
[Parameter] public IReadOnlyList<TrackDto>? Items { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Index of the current track within <see cref="Items"/>, or -1 when none. The matching row is
|
||||
/// rendered with a now-playing marker.
|
||||
/// </summary>
|
||||
[Parameter] public int CurrentIndex { get; set; } = -1;
|
||||
|
||||
/// <summary>
|
||||
/// When true, rows show drag handles and a remove control and reorder is enabled. When false the
|
||||
/// list is a read-only display (the embed's fixed-order shared queue).
|
||||
/// </summary>
|
||||
[Parameter] public bool Editable { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Raised when the user reorders a row: <c>(fromIndex, toIndex)</c>. The parent calls
|
||||
/// <c>IQueueService.Move</c>. Only fires when <see cref="Editable"/>.
|
||||
/// </summary>
|
||||
[Parameter] public EventCallback<(int FromIndex, int ToIndex)> OnReorder { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Raised when the user removes a row, carrying the row's index. The parent calls
|
||||
/// <c>IQueueService.RemoveAt</c>. Only fires when <see cref="Editable"/>.
|
||||
/// </summary>
|
||||
[Parameter] public EventCallback<int> OnRemove { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Raised when the user clicks a row body to jump playback to it, carrying the row's index. The
|
||||
/// parent decides whether/how to honour it (e.g. play from that index).
|
||||
/// </summary>
|
||||
[Parameter] public EventCallback<int> OnJump { get; set; }
|
||||
|
||||
private MudDropContainer<QueueRow>? _dropContainer;
|
||||
|
||||
// Index-tagged view rows. The index is the row's position in Items at render time and is the
|
||||
// value surfaced to the parent's callbacks — the component never mutates the underlying list.
|
||||
private List<QueueRow> Rows =>
|
||||
Items is null
|
||||
? []
|
||||
: Items.Select((track, index) => new QueueRow(index, track)).ToList();
|
||||
|
||||
private async Task OnItemDropped(MudItemDropInfo<QueueRow> dropInfo)
|
||||
{
|
||||
var from = dropInfo.Item!.Index;
|
||||
var to = dropInfo.IndexInZone;
|
||||
// MudDropContainer recomputes the list from the parent's next render; refresh its snapshot so
|
||||
// the dragged row snaps back until the parent's Move re-flows the cascaded Items.
|
||||
_dropContainer?.Refresh();
|
||||
if (from == to) return;
|
||||
await OnReorder.InvokeAsync((from, to));
|
||||
}
|
||||
|
||||
private sealed record QueueRow(int Index, TrackDto Track);
|
||||
|
||||
private RenderFragment RenderRow(QueueRow row) => __builder =>
|
||||
{
|
||||
var isCurrent = row.Index == CurrentIndex;
|
||||
<div class="@($"deepdrft-queue-row{(isCurrent ? " deepdrft-queue-row-current" : "")}")">
|
||||
@if (Editable)
|
||||
{
|
||||
<MudIcon Icon="@Icons.Material.Filled.DragIndicator" Size="Size.Small"
|
||||
Class="deepdrft-queue-drag-handle"/>
|
||||
}
|
||||
<span class="deepdrft-queue-position">@(row.Index + 1)</span>
|
||||
<div class="deepdrft-queue-body" @onclick="() => OnJump.InvokeAsync(row.Index)">
|
||||
<span class="deepdrft-queue-title">@row.Track.TrackName</span>
|
||||
@if (row.Track.Release is { Artist: var artist } && !string.IsNullOrWhiteSpace(artist))
|
||||
{
|
||||
<span class="deepdrft-queue-artist">@artist</span>
|
||||
}
|
||||
</div>
|
||||
@if (isCurrent)
|
||||
{
|
||||
<MudIcon Icon="@Icons.Material.Filled.GraphicEq" Size="Size.Small"
|
||||
Color="Color.Primary" Class="deepdrft-queue-nowplaying"/>
|
||||
}
|
||||
@if (Editable)
|
||||
{
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Close" Size="Size.Small"
|
||||
Class="deepdrft-queue-remove" aria-label="Remove from queue"
|
||||
OnClick="() => OnRemove.InvokeAsync(row.Index)"/>
|
||||
}
|
||||
</div>
|
||||
};
|
||||
}
|
||||
@@ -87,12 +87,45 @@ public interface IQueueService
|
||||
/// </summary>
|
||||
Task Start();
|
||||
|
||||
/// <summary>Appends a track to the end of the queue without changing what is currently playing.</summary>
|
||||
/// <summary>
|
||||
/// Appends a track to the end of the queue without changing what is currently playing.
|
||||
/// Into a dormant queue (<see cref="CurrentIndex"/> == -1) the append leaves a coherent
|
||||
/// <see cref="CurrentIndex"/> (the first appended track) so a subsequent play/skip is correct —
|
||||
/// but it does NOT begin playback (add is not play). Interop-free; safe during prerender.
|
||||
/// </summary>
|
||||
void Enqueue(TrackDto track);
|
||||
|
||||
/// <summary>Appends tracks to the end of the queue without changing what is currently playing.</summary>
|
||||
/// <summary>
|
||||
/// Appends tracks to the end of the queue without changing what is currently playing.
|
||||
/// Into a dormant queue (<see cref="CurrentIndex"/> == -1) the append leaves a coherent
|
||||
/// <see cref="CurrentIndex"/> (the first appended track) so a subsequent play/skip is correct —
|
||||
/// but it does NOT begin playback (add is not play). Interop-free; safe during prerender.
|
||||
/// </summary>
|
||||
void EnqueueRange(IEnumerable<TrackDto> tracks);
|
||||
|
||||
/// <summary>
|
||||
/// Reorders the queue, moving the track at <paramref name="fromIndex"/> to
|
||||
/// <paramref name="toIndex"/>, and re-emits <see cref="QueueChanged"/>. Adjusts
|
||||
/// <see cref="CurrentIndex"/> so the <em>same track</em> stays current across the move — it does
|
||||
/// not restart, re-stream, or interrupt the currently-playing track. Interop-free; safe during
|
||||
/// prerender. No-op (no throw, no <see cref="QueueChanged"/>) when either index is out of range
|
||||
/// or the indices are equal.
|
||||
/// </summary>
|
||||
void Move(int fromIndex, int toIndex);
|
||||
|
||||
/// <summary>
|
||||
/// Removes the track at <paramref name="index"/> and re-emits <see cref="QueueChanged"/>. Does
|
||||
/// not touch playback (the player stays a single-track device): removing the current track does
|
||||
/// not stop it — the playing track runs to its natural end while <see cref="CurrentIndex"/>
|
||||
/// resolves to the new occupant of that slot (the next track) so the next auto-advance/skip is
|
||||
/// coherent. Removing a track before the current decrements <see cref="CurrentIndex"/> (the same
|
||||
/// track stays current); removing after the current leaves it unchanged. Removing the last
|
||||
/// remaining track empties the queue (<see cref="CurrentIndex"/> == -1, dormant). Interop-free;
|
||||
/// safe during prerender. No-op (no throw, no <see cref="QueueChanged"/>) when
|
||||
/// <paramref name="index"/> is out of range.
|
||||
/// </summary>
|
||||
void RemoveAt(int index);
|
||||
|
||||
/// <summary>
|
||||
/// Advances to the next track and streams it. No-op when <see cref="HasNext"/> is false.
|
||||
/// </summary>
|
||||
|
||||
@@ -95,6 +95,11 @@ public sealed class QueueService : IQueueService, IDisposable
|
||||
public void Enqueue(TrackDto track)
|
||||
{
|
||||
_items.Add(track);
|
||||
// OQ8: appending into a dormant (empty) queue leaves a coherent CurrentIndex so the next
|
||||
// play/skip is correct — but does NOT auto-play (add is not play). PlayCurrent is never
|
||||
// called here, so this stays interop-free and prerender-safe.
|
||||
if (CurrentIndex == -1)
|
||||
CurrentIndex = 0;
|
||||
QueueChanged?.Invoke();
|
||||
}
|
||||
|
||||
@@ -102,7 +107,70 @@ public sealed class QueueService : IQueueService, IDisposable
|
||||
{
|
||||
var before = _items.Count;
|
||||
_items.AddRange(tracks);
|
||||
if (_items.Count != before)
|
||||
if (_items.Count == before) return;
|
||||
// OQ8: see Enqueue — first append into a dormant queue stages a coherent CurrentIndex
|
||||
// without playing. The first newly-appended track becomes current.
|
||||
if (CurrentIndex == -1)
|
||||
CurrentIndex = 0;
|
||||
QueueChanged?.Invoke();
|
||||
}
|
||||
|
||||
public void Move(int fromIndex, int toIndex)
|
||||
{
|
||||
if (fromIndex == toIndex) return;
|
||||
if (fromIndex < 0 || fromIndex >= _items.Count) return;
|
||||
if (toIndex < 0 || toIndex >= _items.Count) return;
|
||||
|
||||
var moved = _items[fromIndex];
|
||||
_items.RemoveAt(fromIndex);
|
||||
_items.Insert(toIndex, moved);
|
||||
|
||||
// Keep the same track current across the reorder. No playback is touched (C2): we only
|
||||
// recompute which index the current track now sits at.
|
||||
if (CurrentIndex == fromIndex)
|
||||
{
|
||||
CurrentIndex = toIndex;
|
||||
}
|
||||
else if (fromIndex < CurrentIndex && toIndex >= CurrentIndex)
|
||||
{
|
||||
// The current track shifted one slot toward the front to fill the vacated lower slot.
|
||||
CurrentIndex--;
|
||||
}
|
||||
else if (fromIndex > CurrentIndex && toIndex <= CurrentIndex)
|
||||
{
|
||||
// An item inserted at/above the current slot pushed the current track one slot back.
|
||||
CurrentIndex++;
|
||||
}
|
||||
|
||||
QueueChanged?.Invoke();
|
||||
}
|
||||
|
||||
public void RemoveAt(int index)
|
||||
{
|
||||
if (index < 0 || index >= _items.Count) return;
|
||||
|
||||
_items.RemoveAt(index);
|
||||
|
||||
if (_items.Count == 0)
|
||||
{
|
||||
// Last remaining track removed → empty + dormant. Does not stop the player (C2).
|
||||
CurrentIndex = -1;
|
||||
}
|
||||
else if (index < CurrentIndex)
|
||||
{
|
||||
// A track before the current was removed: the same track stays current at a lower index.
|
||||
CurrentIndex--;
|
||||
}
|
||||
else if (index == CurrentIndex && CurrentIndex >= _items.Count)
|
||||
{
|
||||
// The current track was removed and it was the last slot: there is no "next" occupant to
|
||||
// resolve to, so the queue goes dormant (CurrentIndex == -1). Playback is NOT stopped
|
||||
// (C2) — the just-removed track keeps playing to its natural end; auto-advance simply has
|
||||
// nothing further. Removing current when it is NOT the last leaves CurrentIndex pointing
|
||||
// at the new occupant of that slot (the next track), so no adjustment is needed there.
|
||||
CurrentIndex = -1;
|
||||
}
|
||||
|
||||
QueueChanged?.Invoke();
|
||||
}
|
||||
|
||||
|
||||
@@ -331,14 +331,17 @@ public class QueueServiceTests
|
||||
// --- Enqueue / EnqueueRange ---
|
||||
|
||||
[Test]
|
||||
public void Enqueue_AppendsWithoutChangingCurrentOrStartingPlayback()
|
||||
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(-1));
|
||||
Assert.That(_queue.CurrentIndex, Is.EqualTo(0));
|
||||
Assert.That(_player.SelectedTracks, Is.Empty);
|
||||
});
|
||||
}
|
||||
@@ -366,6 +369,296 @@ public class QueueServiceTests
|
||||
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]
|
||||
|
||||
Reference in New Issue
Block a user