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); } } 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 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 }); 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(SegmentServer 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 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"); }); } }