Files
deepdrft/DeepDrftTests/OpusFormatSelectionTests.cs
daniel-c-harvey 2bde4908d7 Wire Opus end-to-end playback + Backfill-Opus action (Phase 18.5)
Player picks Opus when the browser can decode it and a sidecar exists (else lossless), injecting the sidecar before stream init; seek reuses the same format. Adds the Backfill-Opus bulk API endpoint + CMS action.
2026-06-23 12:39:13 -04:00

181 lines
8.0 KiB
C#

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;
/// <summary>
/// Unit tests for the Phase 18.5 player-side format-selection seam
/// (<see cref="StreamingAudioPlayerService.ResolveStreamFormatAsync"/>): 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: <see cref="AudioInteropService"/> (over a fake <see cref="IJSRuntime"/>
/// — <c>canDecodeOggOpus</c> + <c>setOpusSidecar</c>) and <see cref="TrackMediaClient"/> (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.
/// </summary>
[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<TValue> InvokeAsync<TValue>(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<TValue>(default!);
}
public ValueTask<TValue> InvokeAsync<TValue>(string identifier, CancellationToken cancellationToken, object?[]? args)
=> InvokeAsync<TValue>(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<HttpResponseMessage> 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<StreamingAudioPlayerService>.Instance) { }
public Task<AudioFormat> 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");
});
}
}