Files
deepdrft/DeepDrftTests/PreferenceAwareStreamingPlayerServiceTests.cs
daniel-c-harvey 77c6c42c94 remediate: replace eval cookie writes with safe JS helper + add tests (18.6 Track A)
Both SettingsCookieService and DarkModeCookieService now call window.DeepDrftSettings.setCookie (new Interop/settings/settings.ts) instead of eval. New tests cover SettingsServiceBase parse/format round-trip and the PreferenceAwareStreamingPlayerService invariant (Lossless skips probe; LowData inherits base).
2026-06-23 14:17:34 -04:00

210 lines
8.8 KiB
C#

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;
/// <summary>
/// Unit tests for the critical invariant on
/// <see cref="PreferenceAwareStreamingPlayerService.ResolveStreamFormatAsync"/> (Phase 18 wave 18.6):
/// <list type="bullet">
/// <item>A <see cref="StreamQuality.Lossless"/> preference returns <see cref="AudioFormat.Lossless"/>
/// immediately — without probing Opus capability or fetching the sidecar.</item>
/// <item>Any other preference (currently only <see cref="StreamQuality.LowData"/>) delegates to
/// <c>base.ResolveStreamFormatAsync</c>, so the AC7 capability gate and the C2 sidecar-absent →
/// lossless fallback from Phase 18.5 are inherited, not bypassed.</item>
/// </list>
/// The test infra (FakeJsRuntime, StubSidecarHandler, SingleClientFactory) mirrors
/// <see cref="OpusFormatSelectionTests"/> exactly so both test classes exercise the same seam with
/// the same fake boundary.
/// </summary>
[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<TValue> InvokeAsync<TValue>(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<TValue>(default!);
}
public ValueTask<TValue> InvokeAsync<TValue>(string identifier, CancellationToken cancellationToken, object?[]? args)
=> InvokeAsync<TValue>(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<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.
private sealed class TestablePreferencePlayer : PreferenceAwareStreamingPlayerService
{
public TestablePreferencePlayer(
AudioInteropService interop,
TrackMediaClient media,
PublicSiteSettings settings)
: base(interop, media, NullLogger<StreamingAudioPlayerService>.Instance, settings) { }
public Task<AudioFormat> 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");
}
}