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.
This commit is contained in:
@@ -0,0 +1,180 @@
|
||||
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");
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user