Add queue Move/RemoveAt + dormant-Enqueue coherence and shared QueueList (Phase 17.1)
This commit is contained in:
@@ -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