Merge p11-w1-queue-service into dev (P11 11.F: play-queue IQueueService + skip controls)
This commit is contained in:
@@ -30,6 +30,10 @@
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\DeepDrftContent\DeepDrftContent.csproj" />
|
||||
<ProjectReference Include="..\DeepDrftData\DeepDrftData.csproj" />
|
||||
<!-- Referenced for the client-side queue orchestrator (QueueService / IQueueService).
|
||||
The queue is pure domain logic, unit-testable against a fake IStreamingPlayerService
|
||||
with no browser/JS. -->
|
||||
<ProjectReference Include="..\DeepDrftPublic.Client\DeepDrftPublic.Client.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -0,0 +1,488 @@
|
||||
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));
|
||||
});
|
||||
}
|
||||
|
||||
// --- 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_AppendsWithoutChangingCurrentOrStartingPlayback()
|
||||
{
|
||||
_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(_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));
|
||||
}
|
||||
|
||||
// --- 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);
|
||||
});
|
||||
}
|
||||
|
||||
/// <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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user