2bde4908d7
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.
181 lines
8.0 KiB
C#
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");
|
|
});
|
|
}
|
|
}
|