using System.Collections.Concurrent; using System.Net; using System.Net.Http.Headers; using DeepDrftModels.DTOs; using DeepDrftPublic.Client.Clients; using DeepDrftPublic.Client.Services; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.JSInterop; namespace DeepDrftTests; /// /// Phase 21 Direction B — the segmented forward read loop in . /// Drives a real SelectTrackStreaming against a fake JS runtime and a scripted HTTP handler that /// serves bounded 206 segments, then asserts the loop's contract: /// /// forward playback fetches in bounded bytes=start-end segments (the network-memory bound); /// the cursor advances contiguously across segment boundaries until the file total is reached (EOF); /// the next segment is NOT fetched while the scheduler reports production paused (the fill gate); /// a seek converges onto the SAME segment loop — reinit then continued segmentation, no forked path. /// /// True network/browser memory behaviour is Daniel's manual re-run; this pins the request sequencing and /// gating the harness can observe. /// [TestFixture] public class SegmentedStreamLoopTests { private const long SegmentSize = 4 * 1024 * 1024; // Records every audio Range request and serves a bounded 206 slice. Audio bodies are zero-filled — // the fake JS decoder does not inspect bytes; it scripts canStart/productionPaused directly. private sealed class SegmentServer : HttpMessageHandler { private readonly long _total; public List<(long From, long? To)> AudioRanges { get; } = new(); public SegmentServer(long total) => _total = total; protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) { var path = request.RequestUri!.AbsolutePath; // Waveform profile + sidecar fetches are best-effort side calls — 404 them so the load // path falls back cleanly and the test stays focused on the audio segment loop. if (path.EndsWith("/waveform") || path.Contains("/opus/")) { return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound)); } var rangeItem = request.Headers.Range!.Ranges.First(); var from = rangeItem.From ?? 0; var to = rangeItem.To ?? (_total - 1); if (to > _total - 1) to = _total - 1; lock (AudioRanges) AudioRanges.Add((rangeItem.From ?? 0, rangeItem.To)); var body = new byte[Math.Max(0, to - from + 1)]; var response = new HttpResponseMessage(HttpStatusCode.PartialContent) { Content = new ByteArrayContent(body), }; response.Content.Headers.ContentRange = new ContentRangeHeaderValue(from, to, _total); response.Content.Headers.ContentType = new MediaTypeHeaderValue("audio/wav"); return Task.FromResult(response); } } // Serves the first segment normally, then truncates subsequent segment bodies to a short slice // (Content-Range reports the correct total, but the HTTP body ends early — simulating a // connection drop mid-segment while cursor < totalLength). private sealed class TruncatingAfterFirstSegmentServer : HttpMessageHandler { private readonly long _total; private readonly long _truncatedBodyBytes; // bytes to actually deliver for non-first segments private int _audioRequestCount; public TruncatingAfterFirstSegmentServer(long total, long truncatedBodyBytes) { _total = total; _truncatedBodyBytes = truncatedBodyBytes; } protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) { var path = request.RequestUri!.AbsolutePath; if (path.EndsWith("/waveform") || path.Contains("/opus/")) return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound)); var rangeItem = request.Headers.Range!.Ranges.First(); var from = rangeItem.From ?? 0; var to = rangeItem.To ?? (_total - 1); if (to > _total - 1) to = _total - 1; var requestIndex = Interlocked.Increment(ref _audioRequestCount); // First segment (requestIndex == 1): serve fully. Subsequent segments: truncate. var fullSliceLength = to - from + 1; var bodyLength = requestIndex == 1 ? fullSliceLength : Math.Min(_truncatedBodyBytes, fullSliceLength); var body = new byte[bodyLength]; var response = new HttpResponseMessage(HttpStatusCode.PartialContent) { Content = new ByteArrayContent(body), }; // Content-Range always reports the true full total — the truncation is in the body, not the header. response.Content.Headers.ContentRange = new ContentRangeHeaderValue(from, to, _total); response.Content.Headers.ContentType = new MediaTypeHeaderValue("audio/wav"); return Task.FromResult(response); } } // Serves the first segment normally, then returns HTTP 500 for all subsequent requests — // simulating a mid-stream fetch failure after playback is underway. private sealed class FailingAfterFirstSegmentServer : HttpMessageHandler { private readonly long _total; private int _audioRequestCount; public FailingAfterFirstSegmentServer(long total) => _total = total; protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) { var path = request.RequestUri!.AbsolutePath; if (path.EndsWith("/waveform") || path.Contains("/opus/")) return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound)); var requestIndex = Interlocked.Increment(ref _audioRequestCount); if (requestIndex > 1) return Task.FromResult(new HttpResponseMessage(HttpStatusCode.InternalServerError)); var rangeItem = request.Headers.Range!.Ranges.First(); var from = rangeItem.From ?? 0; var to = rangeItem.To ?? (_total - 1); if (to > _total - 1) to = _total - 1; var body = new byte[to - from + 1]; var response = new HttpResponseMessage(HttpStatusCode.PartialContent) { Content = new ByteArrayContent(body), }; response.Content.Headers.ContentRange = new ContentRangeHeaderValue(from, to, _total); response.Content.Headers.ContentType = new MediaTypeHeaderValue("audio/wav"); return Task.FromResult(response); } } private sealed class SingleClientFactory : IHttpClientFactory { private readonly HttpMessageHandler _handler; public SingleClientFactory(HttpMessageHandler handler) => _handler = handler; public HttpClient CreateClient(string name) => new(_handler, disposeHandler: false) { BaseAddress = new Uri("https://content.test/") }; } /// /// Scriptable JS runtime. processStreamingChunk reports canStart=true immediately (so playback /// starts on the first chunk) and a productionPaused value pulled from a queue the test controls; /// isProductionPaused (the inter-segment poll) reads a separate queue so a test can hold the gate /// closed for N polls then release it. Records reinitializeFromOffset / markStreamComplete calls. /// private sealed class FakeJsRuntime : IJSRuntime { private readonly Func _chunkProductionPaused; private readonly Func _isProductionPaused; private readonly long? _seekByteOffset; public FakeJsRuntime( Func? chunkProductionPaused = null, Func? isProductionPaused = null, long? seekByteOffset = null) { _chunkProductionPaused = chunkProductionPaused ?? (() => false); _isProductionPaused = isProductionPaused ?? (() => false); _seekByteOffset = seekByteOffset; } public int ReinitCallCount { get; private set; } public int MarkCompleteCallCount { get; private set; } public int IsProductionPausedCallCount { get; private set; } public int RecoverCallCount { get; private set; } public ValueTask InvokeAsync(string identifier, object?[]? args) { switch (identifier) { case "DeepDrftAudio.isReady": return Ok(true); case "DeepDrftAudio.canDecodeOggOpus": return Ok(false); // force the lossless path — no sidecar dance case "DeepDrftAudio.isProductionPaused": IsProductionPausedCallCount++; return Ok(_isProductionPaused()); case "DeepDrftAudio.processStreamingChunk": return (ValueTask)(object)ValueTask.FromResult(new StreamingResult { Success = true, CanStartStreaming = true, HeaderParsed = true, BufferCount = 8, Duration = 600, ProductionPaused = _chunkProductionPaused(), }); case "DeepDrftAudio.seek": // When a seek offset is scripted, report seek-beyond-buffer so Seek() routes into // SeekBeyondBuffer → the shared segment loop with a continuation reinit. return (ValueTask)(object)ValueTask.FromResult(_seekByteOffset is { } off ? new SeekResult { Success = true, SeekBeyondBuffer = true, ByteOffset = off } : new SeekResult { Success = true }); case "DeepDrftAudio.reinitializeFromOffset": ReinitCallCount++; return Result(true); case "DeepDrftAudio.markStreamComplete": MarkCompleteCallCount++; return (ValueTask)(object)ValueTask.FromResult(new StreamingResult { Success = true }); case "DeepDrftAudio.recoverFromFailedRefill": RecoverCallCount++; return Result(true); default: // createPlayer / setOnProgressCallback / setOnEndCallback / setVolume / // ensureAudioContextReady / initializeStreaming / startStreamingPlayback / // stop / unload / disposePlayer → generic success. if (typeof(TValue) == typeof(AudioOperationResult)) return Result(true); return Ok(default!); } } public ValueTask InvokeAsync(string identifier, CancellationToken cancellationToken, object?[]? args) => InvokeAsync(identifier, args); private static ValueTask Ok(object? value) => ValueTask.FromResult((TValue)value!); private static ValueTask Result(bool success) => ValueTask.FromResult((TValue)(object)new AudioOperationResult { Success = success }); } private static TrackDto Track() => new() { EntryKey = "mix-1", TrackName = "Long Mix" }; private static StreamingAudioPlayerService BuildPlayer(HttpMessageHandler server, FakeJsRuntime js) { var interop = new AudioInteropService(js); var media = new TrackMediaClient(new SingleClientFactory(server)); return new StreamingAudioPlayerService(interop, media, NullLogger.Instance); } [Test] public async Task ForwardPlayback_FetchesBoundedSegments_AdvancingCursorToEof() { // 10 MB file → 3 segments (4 MB, 4 MB, 2 MB tail). No back-pressure: drains straight through. var total = 10L * 1024 * 1024; var server = new SegmentServer(total); var js = new FakeJsRuntime(); var player = BuildPlayer(server, js); await player.SelectTrackStreaming(Track()); Assert.Multiple(() => { Assert.That(server.AudioRanges, Has.Count.EqualTo(3), "a 10 MB file at a 4 MB segment size is fetched as three bounded segments"); // Contiguous, bounded segments advancing the cursor to EOF. Assert.That(server.AudioRanges[0], Is.EqualTo((0L, (long?)(SegmentSize - 1)))); Assert.That(server.AudioRanges[1], Is.EqualTo((SegmentSize, (long?)(2 * SegmentSize - 1)))); Assert.That(server.AudioRanges[2], Is.EqualTo((2 * SegmentSize, (long?)(3 * SegmentSize - 1)))); Assert.That(js.MarkCompleteCallCount, Is.EqualTo(1), "stream-complete fires once at true EOF, not per segment"); }); } [Test] public async Task ForwardPlayback_DoesNotFetchNextSegment_WhileProductionPaused() { var total = 10L * 1024 * 1024; var server = new SegmentServer(total); // Chunk results report paused=true (so the loop enters the inter-segment gate), and the poll // stays paused for the first two checks, then releases — so the next segment is delayed, not // skipped. The gate must hold the SECOND fetch until the poll clears. var pollChecks = 0; var js = new FakeJsRuntime( chunkProductionPaused: () => true, isProductionPaused: () => ++pollChecks < 3); // paused for polls 1,2; clear on poll 3 var player = BuildPlayer(server, js); await player.SelectTrackStreaming(Track()); Assert.Multiple(() => { Assert.That(js.IsProductionPausedCallCount, Is.GreaterThanOrEqualTo(3), "the inter-segment gate must poll the fill signal while paused, not fetch immediately"); Assert.That(server.AudioRanges, Has.Count.EqualTo(3), "once the gate clears, segmentation resumes and still reaches EOF — paused delays, never skips"); }); } [Test] public async Task SmallFile_FetchedInOneShortSegment_NoSecondFetch() { // 2 MB file < one segment: the first bounded request returns a short slice → EOF, no second fetch. var total = 2L * 1024 * 1024; var server = new SegmentServer(total); var js = new FakeJsRuntime(); var player = BuildPlayer(server, js); await player.SelectTrackStreaming(Track()); Assert.Multiple(() => { Assert.That(server.AudioRanges, Has.Count.EqualTo(1), "a sub-segment file needs exactly one fetch"); Assert.That(server.AudioRanges[0], Is.EqualTo((0L, (long?)(SegmentSize - 1))), "the request is still the bounded segment shape; the server returns the short available tail"); Assert.That(js.MarkCompleteCallCount, Is.EqualTo(1)); }); } [Test] public async Task ForwardLoad_NeverReinitializesDecoder() { var total = 20L * 1024 * 1024; var server = new SegmentServer(total); var js = new FakeJsRuntime(); var player = BuildPlayer(server, js); await player.SelectTrackStreaming(Track()); Assert.Multiple(() => { Assert.That(server.AudioRanges, Has.Count.EqualTo(5), "20 MB / 4 MB = five contiguous forward segments"); Assert.That(js.ReinitCallCount, Is.Zero, "a forward load from byte 0 never reinitializes the decoder — reinit is the seek-only continuation step"); }); } [Test] public async Task MidStream_TruncatedSegment_RoutesToRecovery_NotSilentEof() { // 10 MB file → 3 segments. The second segment's body is short (1 MB instead of 4 MB) while // cursor < totalLength — simulates a connection drop mid-segment. The loop must NOT treat this // as EOF (must not call MarkStreamComplete) and must route to recovery (scheduler halted) so // the buffered tail cannot drain into a silent false end. var total = 10L * 1024 * 1024; var truncatedBodyBytes = 1L * 1024 * 1024; // 1 MB short body for segment 2 var server = new TruncatingAfterFirstSegmentServer(total, truncatedBodyBytes); var js = new FakeJsRuntime(); var player = BuildPlayer(server, js); await player.SelectTrackStreaming(Track()); Assert.Multiple(() => { Assert.That(js.MarkCompleteCallCount, Is.Zero, "a truncated non-final segment must NOT be reported as a clean EOF — MarkStreamComplete must not fire"); Assert.That(js.RecoverCallCount, Is.EqualTo(1), "a truncated segment while cursor < totalLength is a failure: scheduler must be halted via recovery"); Assert.That(player.IsLoaded, Is.True, "recovery leaves the track loaded so the listener can retry — not torn down to unloaded"); Assert.That(player.IsPaused, Is.True, "recovery settles into a paused state, not playing"); Assert.That(player.ErrorMessage, Is.Not.Null.And.Not.Empty, "recovery surfaces an error message to the UI"); }); } [Test] public async Task MidStream_SegmentFetchFailure_RoutesToRecovery_NotSilentFalseEnd() { // 10 MB file → 3 segments. The second segment fetch fails (HTTP 500), simulating a network // error after playback is already underway. The loop must halt the JS scheduler via recovery // rather than letting the buffered first-segment tail drain into a silent false end (AC6). var total = 10L * 1024 * 1024; var server = new FailingAfterFirstSegmentServer(total); var js = new FakeJsRuntime(); var player = BuildPlayer(server, js); await player.SelectTrackStreaming(Track()); Assert.Multiple(() => { Assert.That(js.MarkCompleteCallCount, Is.Zero, "a mid-stream fetch failure must not report clean EOF — MarkStreamComplete must not fire"); Assert.That(js.RecoverCallCount, Is.EqualTo(1), "a mid-stream fetch failure must halt the scheduler via recovery, not leave it to drain"); Assert.That(player.IsLoaded, Is.True, "recovery leaves the track loaded so the listener can retry — not torn down to unloaded"); Assert.That(player.IsPaused, Is.True, "recovery settles into a paused state, not playing"); Assert.That(player.ErrorMessage, Is.Not.Null.And.Not.Empty, "recovery surfaces an error message to the UI"); }); } [Test] public async Task SeekBeyondBuffer_ReinitsOnceThenSegmentsForwardFromOffset() { // 20 MB file. After load, seek to a byte offset 12 MB in: the seek must reinit the decoder once, // then continue the SAME bounded-segment loop from 12 MB to EOF (no forked fetch path — C1/C5). var total = 20L * 1024 * 1024; var seekOffset = 12L * 1024 * 1024; var server = new SegmentServer(total); var js = new FakeJsRuntime(seekByteOffset: seekOffset); var player = BuildPlayer(server, js); await player.SelectTrackStreaming(Track()); var afterLoad = server.AudioRanges.Count; await player.Seek(300); // arbitrary time; the scripted seek returns the 12 MB byte offset // Segments fetched by the seek/refill: everything after the initial-load segments. var refillRanges = server.AudioRanges.Skip(afterLoad).ToList(); Assert.Multiple(() => { Assert.That(js.ReinitCallCount, Is.EqualTo(1), "a seek-beyond-buffer reinitializes the decoder exactly once for the header-less continuation"); Assert.That(refillRanges, Has.Count.EqualTo(2), "from 12 MB to 20 MB at a 4 MB segment is two bounded segments (4 MB + 4 MB tail)"); Assert.That(refillRanges[0], Is.EqualTo((seekOffset, (long?)(seekOffset + SegmentSize - 1))), "the first refill segment is bounded and starts at the resolved seek offset"); Assert.That(refillRanges[1], Is.EqualTo((seekOffset + SegmentSize, (long?)(seekOffset + 2 * SegmentSize - 1))), "segmentation continues forward from the seek offset — the same loop, the same bounded shape"); }); } }