using System.Net; using DeepDrftModels.Enums; using DeepDrftPublic.Client.Clients; using DeepDrftPublic.Client.Common; using DeepDrftPublic.Client.Services; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.JSInterop; namespace DeepDrftTests; /// /// Unit tests for the critical invariant on /// (Phase 18 wave 18.6): /// /// A preference returns /// immediately — without probing Opus capability or fetching the sidecar. /// Any other preference (currently only ) delegates to /// base.ResolveStreamFormatAsync, so the AC7 capability gate and the C2 sidecar-absent → /// lossless fallback from Phase 18.5 are inherited, not bypassed. /// /// The test infra (FakeJsRuntime, StubSidecarHandler, SingleClientFactory) mirrors /// exactly so both test classes exercise the same seam with /// the same fake boundary. /// [TestFixture] public class PreferenceAwareStreamingPlayerServiceTests { // Scriptable JS runtime: tracks whether the Opus-capability probe was called so tests can assert // the Lossless branch never touches it. private sealed class FakeJsRuntime : IJSRuntime { private readonly bool _canDecode; private readonly bool _sidecarParseSucceeds; public FakeJsRuntime(bool canDecode = true, bool sidecarParseSucceeds = true) { _canDecode = canDecode; _sidecarParseSucceeds = sidecarParseSucceeds; } public int CanDecodeCallCount { get; private set; } public int SetSidecarCallCount { get; private set; } public ValueTask InvokeAsync(string identifier, object?[]? args) { if (identifier == "DeepDrftAudio.canDecodeOggOpus") { CanDecodeCallCount++; 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 an optional body) for sidecar requests; anything else 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. private sealed class TestablePreferencePlayer : PreferenceAwareStreamingPlayerService { public TestablePreferencePlayer( AudioInteropService interop, TrackMediaClient media, PublicSiteSettings settings) : base(interop, media, NullLogger.Instance, settings) { } public Task ResolveFormatForTest(string entryKey) => ResolveStreamFormatAsync(entryKey, CancellationToken.None); } private static readonly byte[] SidecarBytes = "setup-header+seek-index"u8.ToArray(); private static (TestablePreferencePlayer player, FakeJsRuntime js) Build( StreamQuality quality, bool canDecode = true, bool sidecarParseSucceeds = true, HttpStatusCode sidecarStatus = HttpStatusCode.OK, byte[]? sidecarBody = null) { sidecarBody ??= SidecarBytes; var js = new FakeJsRuntime(canDecode, sidecarParseSucceeds); var interop = new AudioInteropService(js); var media = new TrackMediaClient(new SingleClientFactory(new StubSidecarHandler(sidecarStatus, sidecarBody))); var settings = new PublicSiteSettings { StreamQuality = quality }; return (new TestablePreferencePlayer(interop, media, settings), js); } // ── Lossless branch: must NOT probe capability or fetch the sidecar ── // A Lossless preference short-circuits to AudioFormat.Lossless with zero JS calls. [Test] public async Task ResolveStreamFormat_LosslessPreference_ReturnsLosslessWithoutProbe() { var (player, js) = Build(StreamQuality.Lossless, canDecode: true); var format = await player.ResolveFormatForTest("track-1"); Assert.Multiple(() => { Assert.That(format, Is.EqualTo(AudioFormat.Lossless), "Lossless preference must yield lossless"); Assert.That(js.CanDecodeCallCount, Is.Zero, "Lossless branch must not probe Opus capability"); Assert.That(js.SetSidecarCallCount, Is.Zero, "Lossless branch must not inject a sidecar"); }); } // The Lossless branch is independent of browser Opus capability — even an incapable browser // must get lossless without a probe (the probe is unnecessary and should not fire). [Test] public async Task ResolveStreamFormat_LosslessPreference_IncapableBrowser_ReturnsLosslessWithoutProbe() { var (player, js) = Build(StreamQuality.Lossless, canDecode: false); var format = await player.ResolveFormatForTest("track-2"); Assert.Multiple(() => { Assert.That(format, Is.EqualTo(AudioFormat.Lossless)); Assert.That(js.CanDecodeCallCount, Is.Zero, "no probe on the Lossless path, even for incapable browsers"); }); } // ── LowData branch: must delegate to base (capability gate + C2 fallback are inherited) ── // Happy path: capable browser + present sidecar → Opus (base logic reached and succeeded). [Test] public async Task ResolveStreamFormat_LowDataPreference_CapableBrowser_SidecarPresent_ChoosesOpus() { var (player, js) = Build(StreamQuality.LowData, canDecode: true, sidecarParseSucceeds: true, HttpStatusCode.OK, SidecarBytes); var format = await player.ResolveFormatForTest("track-3"); Assert.Multiple(() => { Assert.That(format, Is.EqualTo(AudioFormat.Opus), "capable browser + present sidecar → Opus"); Assert.That(js.CanDecodeCallCount, Is.EqualTo(1), "LowData path must invoke the capability probe"); }); } // AC7 inherited: LowData + incapable browser → lossless (base capability gate fires). [Test] public async Task ResolveStreamFormat_LowDataPreference_IncapableBrowser_FallsBackToLossless() { var (player, js) = Build(StreamQuality.LowData, canDecode: false); var format = await player.ResolveFormatForTest("track-4"); Assert.Multiple(() => { Assert.That(format, Is.EqualTo(AudioFormat.Lossless), "AC7: incapable browser must get lossless"); Assert.That(js.CanDecodeCallCount, Is.EqualTo(1), "the capability probe was called (and returned false)"); Assert.That(js.SetSidecarCallCount, Is.Zero, "no sidecar injected when Opus is gated out"); }); } // C2 inherited: LowData + capable browser + no sidecar → lossless (base C2 fallback fires). [Test] public async Task ResolveStreamFormat_LowDataPreference_CapableBrowser_NoSidecar_FallsBackToLossless() { var (player, _) = Build(StreamQuality.LowData, canDecode: true, sidecarStatus: HttpStatusCode.NotFound, sidecarBody: null); var format = await player.ResolveFormatForTest("track-5"); Assert.That(format, Is.EqualTo(AudioFormat.Lossless), "C2: no sidecar → lossless even with a capable browser"); } }