using System.Net; using DeepDrftModels.Enums; using DeepDrftPublic.Client.Clients; using DeepDrftPublic.Client.Services; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.JSInterop; using Microsoft.JSInterop.Infrastructure; namespace DeepDrftTests; /// /// Unit tests for the Phase 18.5 player-side format-selection seam /// (): the default policy (OQ2) of "Opus /// when the browser can decode Ogg Opus AND a sidecar exists, else lossless", the capability gate (AC7), and /// the sidecar-absent → lossless fallback (C2). The seam is the single, overridable hook 18.6 will use to /// inject the listener's quality preference; these tests pin the capability-gated default it falls through to. /// /// The seam touches two collaborators: (over a fake /// — canDecodeOggOpus + setOpusSidecar) and (over a stub HTTP /// handler — the one-time sidecar fetch). Both are real instances wired over the fakes; only the network/JS /// boundary is faked, so the selection logic under test is exercised exactly as it runs in the browser. /// [TestFixture] public class OpusFormatSelectionTests { // A scriptable JS runtime: canDecodeOggOpus returns a configured bool; setOpusSidecar returns a // configured success/failure; every other invocation returns default. Records the calls so a test can // assert the set-before-init contract was honoured (the sidecar was actually handed to the player). private sealed class FakeJsRuntime : IJSRuntime { private readonly bool _canDecode; private readonly bool _sidecarParseSucceeds; public FakeJsRuntime(bool canDecode, bool sidecarParseSucceeds) { _canDecode = canDecode; _sidecarParseSucceeds = sidecarParseSucceeds; } public int SetSidecarCallCount { get; private set; } public ValueTask InvokeAsync(string identifier, object?[]? args) { if (identifier == "DeepDrftAudio.canDecodeOggOpus") return ValueTask.FromResult((TValue)(object)_canDecode); if (identifier == "DeepDrftAudio.setOpusSidecar") { SetSidecarCallCount++; var result = new AudioOperationResult { Success = _sidecarParseSucceeds, Error = _sidecarParseSucceeds ? null : "Invalid Opus sidecar blob", }; return ValueTask.FromResult((TValue)(object)result); } return ValueTask.FromResult(default!); } public ValueTask InvokeAsync(string identifier, CancellationToken cancellationToken, object?[]? args) => InvokeAsync(identifier, args); } // Returns a configured status (with a body) for GET api/track/{id}/opus/seekdata; any other request 404s. private sealed class StubSidecarHandler : HttpMessageHandler { private readonly HttpStatusCode _status; private readonly byte[] _body; public StubSidecarHandler(HttpStatusCode status, byte[]? body = null) { _status = status; _body = body ?? []; } protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) { var response = new HttpResponseMessage(_status); if (_status == HttpStatusCode.OK) response.Content = new ByteArrayContent(_body); 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/") }; } // Exposes the protected seam for direct assertion. The 18.6 override will replace this same method. private sealed class TestablePlayer : StreamingAudioPlayerService { public TestablePlayer(AudioInteropService interop, TrackMediaClient media) : base(interop, media, NullLogger.Instance) { } public Task ResolveFormatForTest(string entryKey) => ResolveStreamFormatAsync(entryKey, CancellationToken.None); } private static TestablePlayer BuildPlayer( bool canDecode, bool sidecarParseSucceeds, HttpStatusCode sidecarStatus, byte[]? sidecarBody) { var js = new FakeJsRuntime(canDecode, sidecarParseSucceeds); var interop = new AudioInteropService(js); var media = new TrackMediaClient(new SingleClientFactory(new StubSidecarHandler(sidecarStatus, sidecarBody))); return new TestablePlayer(interop, media); } private static readonly byte[] SidecarBytes = "setup-header+seek-index"u8.ToArray(); // Capable browser + present sidecar → Opus. The happy path: the default policy picks the low-data format. [Test] public async Task ResolveStreamFormat_CapableBrowser_SidecarPresent_ChoosesOpus() { var player = BuildPlayer(canDecode: true, sidecarParseSucceeds: true, HttpStatusCode.OK, SidecarBytes); var format = await player.ResolveFormatForTest("track-1"); Assert.That(format, Is.EqualTo(AudioFormat.Opus)); } // Capability gate (AC7): a browser that cannot decode Ogg Opus always gets lossless, and the sidecar is // never even fetched/injected — Opus is off the table before any sidecar work. [Test] public async Task ResolveStreamFormat_IncapableBrowser_ChoosesLossless_AndDoesNotInjectSidecar() { var js = new FakeJsRuntime(canDecode: false, sidecarParseSucceeds: true); var interop = new AudioInteropService(js); var media = new TrackMediaClient(new SingleClientFactory( new StubSidecarHandler(HttpStatusCode.OK, SidecarBytes))); var player = new TestablePlayer(interop, media); var format = await player.ResolveFormatForTest("track-1"); Assert.Multiple(() => { Assert.That(format, Is.EqualTo(AudioFormat.Lossless), "incapable browser must fall back to lossless"); Assert.That(js.SetSidecarCallCount, Is.Zero, "no sidecar should be injected when Opus is gated out"); }); } // C2 fallback: capable browser but no sidecar (legacy / not-yet-transcoded track, 404) → lossless. [Test] public async Task ResolveStreamFormat_CapableBrowser_NoSidecar_FallsBackToLossless() { var player = BuildPlayer(canDecode: true, sidecarParseSucceeds: true, HttpStatusCode.NotFound, sidecarBody: null); var format = await player.ResolveFormatForTest("track-1"); Assert.That(format, Is.EqualTo(AudioFormat.Lossless), "a capable browser with no Opus sidecar must request lossless, not Opus"); } // A present-but-unparseable sidecar (the JS decoder rejects the bytes) → lossless, so a malformed sidecar // never breaks playback. The injection was attempted (set-before-init), but its failure degrades safely. [Test] public async Task ResolveStreamFormat_SidecarPresentButUnparseable_FallsBackToLossless() { var js = new FakeJsRuntime(canDecode: true, sidecarParseSucceeds: false); var interop = new AudioInteropService(js); var media = new TrackMediaClient(new SingleClientFactory( new StubSidecarHandler(HttpStatusCode.OK, SidecarBytes))); var player = new TestablePlayer(interop, media); var format = await player.ResolveFormatForTest("track-1"); Assert.Multiple(() => { Assert.That(format, Is.EqualTo(AudioFormat.Lossless), "an unparseable sidecar must degrade to lossless"); Assert.That(js.SetSidecarCallCount, Is.EqualTo(1), "the player attempted the set-before-init injection"); }); } }