Merge streaming-overhaul into dev (Opus low-data streaming, windowed streaming, HW-accel-off stabilization)

This commit is contained in:
daniel-c-harvey
2026-06-26 11:14:59 -04:00
97 changed files with 10086 additions and 591 deletions
+16
View File
@@ -0,0 +1,16 @@
using DeepDrftAPI.Services.Opus;
namespace DeepDrftTests;
/// <summary>
/// Test double for <see cref="IOpusTranscodeQueue"/>. The background Opus derive is out of scope for the
/// upload/dual-database tests — those assert SQL + source-vault behaviour, which is unchanged by Phase 18
/// (Opus is strictly additive). Enqueuing is a no-op so the upload path under test never spins up a real
/// transcode. Records the enqueued keys in case a test wants to assert the scheduling contract.
/// </summary>
public sealed class NoOpOpusTranscodeQueue : IOpusTranscodeQueue
{
public List<string> Enqueued { get; } = [];
public void Enqueue(string entryKey) => Enqueued.Add(entryKey);
}
+437
View File
@@ -0,0 +1,437 @@
using System.Buffers.Binary;
using System.Text;
using DeepDrftContent.Processors.Opus;
namespace DeepDrftTests;
/// <summary>
/// Coverage for the Phase 18.1 seek-index + setup-header extraction (§3.4a). These exercise the pure
/// Ogg-Opus walker and the sidecar codec over hand-built Ogg streams — no ffmpeg dependency — so the
/// granule→byte mapping, pre-skip correction (RFC 7845 §4.3), page-boundary snapping, 0.5 s bucketing,
/// t=0 anchor, clamp totals, and setup-header capture are asserted deterministically. The byte layout
/// mirrors a real Opus stream: an OpusHead page, an OpusTags page, then audio pages each carrying an
/// end granule position at 48 kHz.
/// </summary>
[TestFixture]
public class OggOpusParserTests
{
// libopus default pre-skip: 312 samples at 48 kHz (≈ 6.5 ms). FFmpeg may use 3840 (~80 ms).
// Using 312 here as a realistic non-zero value that is small enough not to affect the test
// granules (which start at 48000), while still exercising the pre-skip subtraction path.
private const ushort TestPreSkip = 312;
[Test]
public void Walk_CapturesSetupHeader_AsLeadingOpusHeadAndOpusTagsPagesVerbatim()
{
var head = OggPage(granule: 0, OpusHeadPacket());
var tags = OggPage(granule: 0, OpusTagsPacket());
var audio = OggPage(granule: 48000, AudioPacket(64));
var stream = Concat(head, tags, audio);
var walk = OggOpusParser.Walk(stream);
Assert.That(walk, Is.Not.Null, "A well-formed Ogg Opus stream must walk");
var expectedSetup = Concat(head, tags);
Assert.That(walk!.SetupHeaderBytes, Is.EqualTo(expectedSetup),
"Setup header must be the OpusHead + OpusTags pages, byte-for-byte, and stop before the first audio page");
}
[Test]
public void Walk_FirstSeekPoint_IsAnchoredAtTimeZero_PointingAtFirstAudioPage()
{
// Use a non-zero pre-skip to verify that the first seek point is explicitly anchored at
// corrected time = 0, not at the raw granule time.
var head = OggPage(granule: 0, OpusHeadPacket(preSkip: TestPreSkip));
var tags = OggPage(granule: 0, OpusTagsPacket());
var audio = OggPage(granule: 48000, AudioPacket(64));
var stream = Concat(head, tags, audio);
var firstAudioOffset = (ulong)(head.Length + tags.Length);
var walk = OggOpusParser.Walk(stream);
Assert.That(walk, Is.Not.Null);
var first = walk!.SeekIndex.Points[0];
// The first point's byte offset must be the first audio page start (exact page boundary).
Assert.That(first.ByteOffset, Is.EqualTo(firstAudioOffset),
"The first seek point must land on the first audio page's start offset (an exact page boundary)");
// The first point's stored granule is clamped to preSkip so corrected presentation time = 0.
// This guarantees a binary search for t=0 always resolves to the first audio page.
Assert.That(walk.SeekIndex.PreSkip, Is.EqualTo(TestPreSkip),
"PreSkip must be parsed from OpusHead and carried into the seek index");
Assert.That(walk.SeekIndex.PresentationTimeSeconds(first), Is.EqualTo(0.0).Within(1e-12),
"First seek point must have corrected presentation time = 0 so a seek to t=0 always resolves");
}
[Test]
public void Walk_PreSkip_IsSubtractedFromGranuleInTimeCalculations()
{
// Pre-skip of 3840 samples (≈ 80 ms, the libopus typical value used by ffmpeg).
// Without the fix, pageTime = 48000 / 48000 = 1.0 s; with fix, (48000 - 3840) / 48000 = 0.92 s.
const ushort preSkip = 3840;
var head = OggPage(granule: 0, OpusHeadPacket(preSkip: preSkip));
var tags = OggPage(granule: 0, OpusTagsPacket());
// First audio page at granule 48000 (1.0 s raw; 0.92 s corrected)
var a1 = OggPage(granule: 48000, AudioPacket(64));
// Second audio page at granule 96000 (2.0 s raw; 1.92 s corrected)
var a2 = OggPage(granule: 96000, AudioPacket(64));
// Third audio page at granule 144000 (3.0 s raw; 2.92 s corrected)
var a3 = OggPage(granule: 144000, AudioPacket(64));
var walk = OggOpusParser.Walk(Concat(head, tags, a1, a2, a3));
Assert.That(walk, Is.Not.Null);
var index = walk!.SeekIndex;
Assert.That(index.PreSkip, Is.EqualTo(preSkip), "PreSkip must be parsed from OpusHead");
// TotalDurationSeconds must be pre-skip-corrected: (144000 - 3840) / 48000 = 2.92 s
var expectedDuration = (144000.0 - preSkip) / 48000.0;
Assert.That(index.TotalDurationSeconds, Is.EqualTo(expectedDuration).Within(1e-9),
"TotalDurationSeconds must subtract preSkip (RFC 7845 §4.3), not use raw lastGranule / 48000");
// The second indexed point (first real bucket) must have corrected time, not raw time.
// With correctedTime(a2) = 1.92 s and bucket = 0.5 s, it should fall in the 1.5 s bucket.
if (index.Points.Count > 1)
{
var secondPoint = index.Points[1];
var corrected = index.PresentationTimeSeconds(secondPoint);
Assert.That(corrected, Is.GreaterThan(0.0),
"Non-first indexed points must have positive corrected presentation times");
Assert.That(secondPoint.RawTimeSeconds, Is.GreaterThan(corrected),
"Raw time must be greater than corrected time when pre-skip > 0");
}
}
[Test]
public void Walk_SeekToZero_ResolvesToFirstAudioPageOffset_WithNonZeroPreSkip()
{
// This is the AC9 / Critical-2 regression test: a seek to t=0 must resolve to the first audio
// page's byte offset, not produce "no entry found". With the old code (no t=0 anchor and no
// pre-skip correction), the first indexed point had correctedTime ≈ 0.92 s (for preSkip=3840),
// so a binary search for t=0 would find no entry with time ≤ 0 and fail.
const ushort preSkip = 3840;
var head = OggPage(granule: 0, OpusHeadPacket(preSkip: preSkip));
var tags = OggPage(granule: 0, OpusTagsPacket());
var a1 = OggPage(granule: 48000, AudioPacket(64));
var a2 = OggPage(granule: 96000, AudioPacket(64));
var stream = Concat(head, tags, a1, a2);
var firstAudioByteOffset = (ulong)(head.Length + tags.Length);
var walk = OggOpusParser.Walk(stream);
Assert.That(walk, Is.Not.Null);
var index = walk!.SeekIndex;
var firstPoint = index.Points[0];
// Simulate the binary search: find the largest entry with PresentationTimeSeconds ≤ 0.
// With the fix, the first point has corrected time = 0.0, so it IS found.
Assert.That(index.PresentationTimeSeconds(firstPoint), Is.EqualTo(0.0).Within(1e-12),
"First point corrected time must be exactly 0.0 so binary search for t=0 resolves it");
Assert.That(firstPoint.ByteOffset, Is.EqualTo(firstAudioByteOffset),
"The t=0 anchor must point at the first audio page's byte offset, not the stream start");
}
[Test]
public void Walk_EverySeekOffset_LandsOnARealPageBoundary()
{
// Ten audio pages, ~0.25 s each (12000 samples). Bucketing at 0.5 s means roughly every other
// page is indexed; every indexed offset must still be the start of some OggS page.
var head = OggPage(granule: 0, OpusHeadPacket());
var tags = OggPage(granule: 0, OpusTagsPacket());
var pages = new List<byte[]> { head, tags };
ulong granule = 0;
for (var i = 0; i < 10; i++)
{
granule += 12000; // 0.25 s at 48 kHz
pages.Add(OggPage(granule, AudioPacket(50 + i)));
}
var stream = Concat(pages.ToArray());
var pageOffsets = CollectPageStartOffsets(stream);
var walk = OggOpusParser.Walk(stream);
Assert.That(walk, Is.Not.Null);
foreach (var point in walk!.SeekIndex.Points)
{
Assert.That(pageOffsets, Does.Contain(point.ByteOffset),
$"Seek offset {point.ByteOffset} must be a real OggS page start");
}
}
[Test]
public void Walk_Bucketing_EmitsRoughlyOneEntryPerHalfSecond()
{
// Twenty audio pages of 0.25 s each = 5 s total (zero pre-skip). At 0.5 s buckets:
// first point (anchored at t=0) + one per 0.5 s boundary = 1 + 10 = 11 entries expected.
var head = OggPage(granule: 0, OpusHeadPacket());
var tags = OggPage(granule: 0, OpusTagsPacket());
var pages = new List<byte[]> { head, tags };
ulong granule = 0;
for (var i = 0; i < 20; i++)
{
granule += 12000; // 0.25 s
pages.Add(OggPage(granule, AudioPacket(40)));
}
var stream = Concat(pages.ToArray());
var walk = OggOpusParser.Walk(stream);
Assert.That(walk, Is.Not.Null);
// 5 s of audio with 0.5 s buckets: 1 anchor + 10 bucket crossings = 11 entries.
// Accept 1012 for floating-point boundary tolerance, but must be far below 20 (one per page).
Assert.That(walk!.SeekIndex.Points.Count, Is.InRange(10, 12),
"Bucketing must coalesce ~0.25 s pages into ~0.5 s index entries, not one per page");
}
[Test]
public void Walk_PointsAreStrictlyAscending_InBothGranuleAndOffset()
{
var head = OggPage(granule: 0, OpusHeadPacket());
var tags = OggPage(granule: 0, OpusTagsPacket());
var pages = new List<byte[]> { head, tags };
ulong granule = 0;
for (var i = 0; i < 12; i++)
{
granule += 24000; // 0.5 s — one index entry per page
pages.Add(OggPage(granule, AudioPacket(30)));
}
var walk = OggOpusParser.Walk(Concat(pages.ToArray()));
Assert.That(walk, Is.Not.Null);
var points = walk!.SeekIndex.Points;
for (var i = 1; i < points.Count; i++)
{
Assert.That(points[i].GranulePosition, Is.GreaterThan(points[i - 1].GranulePosition));
Assert.That(points[i].ByteOffset, Is.GreaterThan(points[i - 1].ByteOffset));
}
}
[Test]
public void Walk_ClampValues_ReflectPreSkipCorrectedDurationAndTotalByteLength()
{
const ushort preSkip = 312;
var head = OggPage(granule: 0, OpusHeadPacket(preSkip: preSkip));
var tags = OggPage(granule: 0, OpusTagsPacket());
var a1 = OggPage(granule: 48000, AudioPacket(64)); // 1.0 s raw; ~0.9935 s corrected
var a2 = OggPage(granule: 144000, AudioPacket(64)); // 3.0 s raw; ~2.9935 s corrected (final)
var stream = Concat(head, tags, a1, a2);
var walk = OggOpusParser.Walk(stream);
Assert.That(walk, Is.Not.Null);
Assert.That(walk!.SeekIndex.TotalByteLength, Is.EqualTo((ulong)stream.Length),
"Total byte length must equal the full stream length for end-of-stream clamping");
var expectedDuration = (144000.0 - preSkip) / 48000.0;
Assert.That(walk.SeekIndex.TotalDurationSeconds, Is.EqualTo(expectedDuration).Within(1e-9),
"TotalDurationSeconds must be pre-skip-corrected: (lastGranule - preSkip) / 48000");
Assert.That(walk.SeekIndex.PreSkip, Is.EqualTo(preSkip),
"PreSkip must round-trip through the seek index");
}
[Test]
public void Walk_MalformedStream_ReturnsNull_RatherThanThrowing()
{
var notOgg = Encoding.ASCII.GetBytes("this is not an ogg stream at all");
Assert.That(OggOpusParser.Walk(notOgg), Is.Null);
// OpusHead present but no audio pages → no seek points → null (nothing to index).
var headOnly = Concat(OggPage(0, OpusHeadPacket()), OggPage(0, OpusTagsPacket()));
Assert.That(OggOpusParser.Walk(headOnly), Is.Null);
}
[Test]
public void SeekIndex_RoundTrips_ThroughBinaryEncoding()
{
var points = new[]
{
new OpusSeekPoint(312, 200), // first point anchored at preSkip
new OpusSeekPoint(72000, 512),
new OpusSeekPoint(96000, 900),
};
var index = new OggOpusSeekIndex(points, TotalDurationSeconds: 2.0, TotalByteLength: 1024,
PreSkip: 312);
var restored = OggOpusSeekIndex.FromBytes(index.ToBytes());
Assert.That(restored, Is.Not.Null);
Assert.That(restored!.TotalByteLength, Is.EqualTo(1024UL));
Assert.That(restored.TotalDurationSeconds, Is.EqualTo(2.0));
Assert.That(restored.PreSkip, Is.EqualTo((ushort)312),
"PreSkip must survive the binary round-trip");
Assert.That(restored.Points, Is.EqualTo(points));
}
[Test]
public void SeekIndex_PresentationTimeSeconds_SubtractsPreSkip()
{
const ushort preSkip = 3840;
var point = new OpusSeekPoint(GranulePosition: 48000, ByteOffset: 200);
var index = new OggOpusSeekIndex(
new[] { point }, TotalDurationSeconds: 0.92, TotalByteLength: 500, PreSkip: preSkip);
var corrected = index.PresentationTimeSeconds(point);
var expected = (48000.0 - preSkip) / 48000.0; // ≈ 0.92 s
Assert.That(corrected, Is.EqualTo(expected).Within(1e-9),
"PresentationTimeSeconds must return (granule - preSkip) / 48000, not raw granule / 48000");
}
[Test]
public void SeekIndex_PresentationTimeSeconds_ClampsToZeroForFirstAnchorPoint()
{
const ushort preSkip = 3840;
// First anchor point: granule stored as preSkip, so corrected time = 0.
var firstPoint = new OpusSeekPoint(GranulePosition: preSkip, ByteOffset: 150);
var index = new OggOpusSeekIndex(
new[] { firstPoint }, TotalDurationSeconds: 2.0, TotalByteLength: 500, PreSkip: preSkip);
Assert.That(index.PresentationTimeSeconds(firstPoint), Is.EqualTo(0.0).Within(1e-12),
"The t=0 anchor point (granule == preSkip) must yield corrected time = 0.0 exactly");
}
[Test]
public void Sidecar_RoundTrips_PreservingSetupHeaderAndIndex()
{
var setup = Encoding.ASCII.GetBytes("OpusHead-and-OpusTags-bytes-go-here");
var index = new OggOpusSeekIndex(
new[] { new OpusSeekPoint(312, 200), new OpusSeekPoint(96000, 700) },
TotalDurationSeconds: 2.0, TotalByteLength: 800, PreSkip: 312);
var sidecar = new OpusSidecar(setup, index);
var restored = OpusSidecar.FromBytes(sidecar.ToBytes());
Assert.That(restored, Is.Not.Null);
Assert.That(restored!.SetupHeaderBytes, Is.EqualTo(setup),
"The sidecar must preserve the setup header so the client can prepend it to mid-stream slices");
Assert.That(restored.SeekIndex.Points, Is.EqualTo(index.Points));
Assert.That(restored.SeekIndex.TotalByteLength, Is.EqualTo(800UL));
Assert.That(restored.SeekIndex.PreSkip, Is.EqualTo((ushort)312),
"PreSkip must survive the sidecar binary round-trip");
}
[Test]
public void Sidecar_FromBytes_RejectsTruncatedBlob()
{
Assert.That(OpusSidecar.FromBytes(new byte[2]), Is.Null, "A blob shorter than the length prefix is corruption");
// A length prefix that overruns the buffer must be rejected, not over-read.
var bad = new byte[8];
BinaryPrimitives.WriteUInt32LittleEndian(bad, 9999);
Assert.That(OpusSidecar.FromBytes(bad), Is.Null);
}
// ---- Ogg stream construction helpers (minimal, single-packet pages) ----
// Builds one Ogg page wrapping a single packet payload with the given end granule position. The page
// header layout matches the spec the parser reads: capture "OggS", version, header-type, granulepos,
// serial, sequence, checksum (zeroed — the parser does not verify CRC), page-segments, segment table.
private static byte[] OggPage(ulong granule, byte[] packet)
{
// Lacing: a packet of length L is split into 255-byte segments plus a final < 255 segment.
var segments = new List<byte>();
var remaining = packet.Length;
while (remaining >= 255)
{
segments.Add(255);
remaining -= 255;
}
segments.Add((byte)remaining);
var header = new byte[OggOpusConstants.OggPageHeaderSize + segments.Count];
OggOpusConstants.CapturePattern.CopyTo(header);
header[4] = 0; // version
header[5] = 0; // header-type flags
BinaryPrimitives.WriteUInt64LittleEndian(header.AsSpan(OggOpusConstants.GranulePositionOffset, 8), granule);
BinaryPrimitives.WriteUInt32LittleEndian(header.AsSpan(14, 4), 0xDEAD); // serial
BinaryPrimitives.WriteUInt32LittleEndian(header.AsSpan(18, 4), 0); // sequence (unused by parser)
BinaryPrimitives.WriteUInt32LittleEndian(header.AsSpan(22, 4), 0); // checksum (unverified)
header[OggOpusConstants.PageSegmentCountOffset] = (byte)segments.Count;
for (var i = 0; i < segments.Count; i++)
header[OggOpusConstants.OggPageHeaderSize + i] = segments[i];
return Concat(header, packet);
}
private static byte[] OpusHeadPacket(ushort preSkip = 0)
{
// "OpusHead" + RFC 7845 §5.1 identification header:
// [0] version = 1, [1] channel count = 2,
// [2-3] pre_skip (little-endian uint16), [4-7] input sample rate = 0xBB80 = 48000,
// [8-9] output gain = 0, [10] channel mapping family = 0.
var tail = new byte[11];
tail[0] = 1; // version
tail[1] = 2; // channels
BinaryPrimitives.WriteUInt16LittleEndian(tail.AsSpan(2, 2), preSkip); // pre_skip
BinaryPrimitives.WriteUInt32LittleEndian(tail.AsSpan(4, 4), 48000); // input sample rate
tail[10] = 0; // channel mapping family
return Concat(OggOpusConstants.OpusHeadSignature.ToArray(), tail);
}
private static byte[] OpusTagsPacket()
{
// "OpusTags" + a tiny vendor string region (length-prefixed) + zero user comments.
var vendor = Encoding.ASCII.GetBytes("test");
var packet = new List<byte>();
packet.AddRange(OggOpusConstants.OpusTagsSignature.ToArray());
packet.AddRange(BitConverter.GetBytes((uint)vendor.Length));
packet.AddRange(vendor);
packet.AddRange(BitConverter.GetBytes(0u)); // user comment count
return packet.ToArray();
}
private static byte[] AudioPacket(int size)
{
var packet = new byte[size];
for (var i = 0; i < size; i++)
packet[i] = (byte)(i & 0xFF);
return packet;
}
private static List<ulong> CollectPageStartOffsets(byte[] stream)
{
var offsets = new List<ulong>();
var span = stream.AsSpan();
var offset = 0;
while (offset + OggOpusConstants.OggPageHeaderSize <= span.Length)
{
var page = span.Slice(offset);
if (!page[..4].SequenceEqual(OggOpusConstants.CapturePattern))
break;
var segmentCount = page[OggOpusConstants.PageSegmentCountOffset];
var payload = 0;
for (var i = 0; i < segmentCount; i++)
payload += page[OggOpusConstants.OggPageHeaderSize + i];
offsets.Add((ulong)offset);
offset += OggOpusConstants.OggPageHeaderSize + segmentCount + payload;
}
return offsets;
}
private static byte[] Concat(params byte[][] parts)
{
var total = parts.Sum(p => p.Length);
var result = new byte[total];
var cursor = 0;
foreach (var part in parts)
{
part.CopyTo(result, cursor);
cursor += part.Length;
}
return result;
}
}
+251
View File
@@ -0,0 +1,251 @@
using System.Text;
using Data.Data.Repositories;
using Data.Managers;
using DeepDrftAPI.Services;
using DeepDrftContent;
using DeepDrftContent.Constants;
using DeepDrftContent.FileDatabase.Models;
using DeepDrftContent.Processors;
using DeepDrftContent.Processors.Opus;
using DeepDrftData;
using DeepDrftData.Data;
using DeepDrftData.Repositories;
using DeepDrftModels.DTOs;
using DeepDrftModels.Entities;
using DeepDrftModels.Enums;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using FileDb = DeepDrftContent.FileDatabase.Services.FileDatabase;
namespace DeepDrftTests;
/// <summary>
/// Tests for the Phase 18.5 Backfill-Opus scheduling contract
/// (<see cref="UnifiedTrackService.BackfillOpusAsync"/> and <see cref="UnifiedTrackService.EnqueueOpusAsync"/>).
/// These assert the enqueue decision — which tracks get a background derive scheduled — over a real
/// <see cref="FileDb"/>, a real <see cref="TrackFormatResolver"/>, and an in-memory SQL store, with a
/// recording <see cref="NoOpOpusTranscodeQueue"/> standing in for the background worker (the actual transcode
/// is not exercised here — it needs ffmpeg and is out of scope for the scheduling contract).
///
/// The decision under test: a track is enqueued iff it lacks a COMPLETE Opus artifact (both the Opus audio
/// bytes and the seek/setup sidecar). A track with both is skipped; a half-derived track (audio without
/// sidecar) is treated as incomplete and re-enqueued so a backfill heals it.
/// </summary>
[TestFixture]
public class OpusBackfillTests
{
private string _testDir = string.Empty;
private DeepDrftContext _context = null!;
[SetUp]
public void SetUp()
{
_testDir = Path.Combine(Path.GetTempPath(), "OpusBackfillTests", Guid.NewGuid().ToString());
Directory.CreateDirectory(_testDir);
var options = new DbContextOptionsBuilder<DeepDrftContext>()
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
.Options;
_context = new DeepDrftContext(options);
}
[TearDown]
public void TearDown()
{
_context.Dispose();
try { Directory.Delete(_testDir, recursive: true); }
catch { /* Best-effort cleanup — ignore failures */ }
}
private TrackManager CreateManager()
{
var repository = new TrackRepository(
_context, NullLogger<Repository<DeepDrftContext, TrackEntity>>.Instance);
return new TrackManager(
repository, NullLogger<Manager<TrackEntity, TrackDto, TrackRepository, TrackConverter>>.Instance);
}
private sealed record Harness(
UnifiedTrackService Service,
NoOpOpusTranscodeQueue Queue,
TrackContentService Content,
FileDb FileDatabase,
ITrackService Sql);
private async Task<Harness> BuildAsync()
{
var fileDatabase = await FileDb.FromAsync(_testDir);
Assert.That(fileDatabase, Is.Not.Null);
var content = new TrackContentService(
fileDatabase!, new AudioProcessorRouter(
new AudioProcessor(), new Mp3AudioProcessor(), new FlacAudioProcessor()));
var waveforms = new WaveformProfileService(
fileDatabase!, new AudioProcessor(), new RmsLoudnessAlgorithm(),
Options.Create(new WaveformProfileOptions()), NullLogger<WaveformProfileService>.Instance);
var resolver = new TrackFormatResolver(
fileDatabase!, content, NullLogger<TrackFormatResolver>.Instance);
var queue = new NoOpOpusTranscodeQueue();
var sql = CreateManager();
var service = new UnifiedTrackService(
content, sql, fileDatabase!, waveforms, queue, resolver,
NullLogger<UnifiedTrackService>.Instance);
await fileDatabase!.CreateVaultAsync(VaultConstants.TrackOpus, MediaVaultType.Audio);
return new Harness(service, queue, content, fileDatabase!, sql);
}
// Seeds a track: stores a real source WAV in the tracks vault and a SQL row pointing at the same EntryKey.
// Returns the EntryKey so the test can selectively add Opus artifacts to a subset.
private async Task<string> SeedTrackAsync(Harness h, string title)
{
var wavPath = Path.Combine(_testDir, Guid.NewGuid().ToString("N") + ".wav");
await File.WriteAllBytesAsync(wavPath, BuildMinimalPcmWav(2.0));
var unpersisted = await h.Content.AddTrackAsync(wavPath, title, "Artist");
Assert.That(unpersisted, Is.Not.Null);
var dto = new TrackDto { EntryKey = unpersisted!.EntryKey, TrackName = title };
var created = await h.Sql.Create(dto);
Assert.That(created.Success, Is.True, created.Messages.FirstOrDefault()?.Message);
return unpersisted.EntryKey;
}
private async Task StoreOpusAudioAsync(Harness h, string entryKey)
{
var opus = new AudioBinary(new AudioBinaryParams("opus"u8.ToArray(), 4, ".opus", 2.0, 320));
Assert.That(
await h.FileDatabase.RegisterResourceAsync(
VaultConstants.TrackOpus, OpusTranscodeService.OpusAudioKey(entryKey), opus),
Is.True);
}
private async Task StoreSidecarAsync(Harness h, string entryKey)
{
var sidecar = new MediaBinary(new MediaBinaryParams("idx"u8.ToArray(), 3, ".opusidx"));
Assert.That(
await h.FileDatabase.RegisterResourceAsync(
VaultConstants.TrackOpus, OpusTranscodeService.OpusSidecarKey(entryKey), sidecar),
Is.True);
}
[Test]
public async Task BackfillOpus_EnqueuesOnlyTracksWithoutCompleteOpus()
{
var h = await BuildAsync();
// Three tracks: one fully derived (audio + sidecar), one bare (no Opus), one half-derived (audio only).
var complete = await SeedTrackAsync(h, "Complete");
await StoreOpusAudioAsync(h, complete);
await StoreSidecarAsync(h, complete);
var bare = await SeedTrackAsync(h, "Bare");
var halfDerived = await SeedTrackAsync(h, "HalfDerived");
await StoreOpusAudioAsync(h, halfDerived); // audio but no sidecar → unseekable → treated as incomplete
var result = await h.Service.BackfillOpusAsync(CancellationToken.None);
Assert.That(result.Success, Is.True, result.Messages.FirstOrDefault()?.Message);
Assert.Multiple(() =>
{
Assert.That(result.Value.Enqueued, Is.EqualTo(2), "the bare and half-derived tracks must be enqueued");
Assert.That(result.Value.Skipped, Is.EqualTo(1), "the fully-derived track must be skipped");
Assert.That(h.Queue.Enqueued, Does.Contain(bare));
Assert.That(h.Queue.Enqueued, Does.Contain(halfDerived));
Assert.That(h.Queue.Enqueued, Does.Not.Contain(complete), "a complete Opus artifact is not re-enqueued");
});
}
[Test]
public async Task BackfillOpus_WhenAllTracksHaveOpus_EnqueuesNothing()
{
var h = await BuildAsync();
var a = await SeedTrackAsync(h, "A");
await StoreOpusAudioAsync(h, a);
await StoreSidecarAsync(h, a);
var result = await h.Service.BackfillOpusAsync(CancellationToken.None);
Assert.That(result.Success, Is.True);
Assert.Multiple(() =>
{
Assert.That(result.Value.Enqueued, Is.Zero);
Assert.That(result.Value.Skipped, Is.EqualTo(1));
Assert.That(h.Queue.Enqueued, Is.Empty, "an all-derived catalogue schedules no transcodes");
});
}
[Test]
public async Task EnqueueOpus_KnownTrack_Enqueues()
{
var h = await BuildAsync();
var entryKey = await SeedTrackAsync(h, "Solo");
var result = await h.Service.EnqueueOpusAsync(entryKey, CancellationToken.None);
Assert.That(result.Success, Is.True);
Assert.That(h.Queue.Enqueued, Does.Contain(entryKey));
}
[Test]
public async Task EnqueueOpus_UnknownTrack_FailsWithNotFound_AndEnqueuesNothing()
{
var h = await BuildAsync();
var result = await h.Service.EnqueueOpusAsync("no-such-track", CancellationToken.None);
Assert.Multiple(() =>
{
Assert.That(result.Success, Is.False);
Assert.That(result.Messages.FirstOrDefault()?.Message, Is.EqualTo(UnifiedTrackService.TrackNotFoundMessage));
Assert.That(h.Queue.Enqueued, Is.Empty, "an unknown track must not schedule a transcode");
});
}
// Standard-PCM mono 16-bit 44.1 kHz WAV, full-scale square wave. Same layout as the other suites.
private static byte[] BuildMinimalPcmWav(double durationSeconds)
{
const int sampleRate = 44100;
const ushort channels = 1;
const ushort bitsPerSample = 16;
const ushort blockAlign = channels * (bitsPerSample / 8);
const uint byteRate = sampleRate * blockAlign;
var frames = (int)(sampleRate * durationSeconds);
var data = new byte[frames * blockAlign];
for (var i = 0; i < frames; i++)
{
var sample = (i % 2 == 0) ? short.MaxValue : short.MinValue;
data[i * 2] = (byte)(sample & 0xFF);
data[i * 2 + 1] = (byte)((sample >> 8) & 0xFF);
}
using var ms = new MemoryStream();
using var w = new BinaryWriter(ms, Encoding.ASCII, leaveOpen: true);
w.Write(Encoding.ASCII.GetBytes("RIFF"));
w.Write((uint)(36 + data.Length));
w.Write(Encoding.ASCII.GetBytes("WAVE"));
w.Write(Encoding.ASCII.GetBytes("fmt "));
w.Write(16u);
w.Write((ushort)1); // PCM
w.Write(channels);
w.Write((uint)sampleRate);
w.Write(byteRate);
w.Write(blockAlign);
w.Write(bitsPerSample);
w.Write(Encoding.ASCII.GetBytes("data"));
w.Write((uint)data.Length);
w.Write(data);
w.Flush();
return ms.ToArray();
}
}
+180
View File
@@ -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");
});
}
}
@@ -0,0 +1,209 @@
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");
}
}
+1
View File
@@ -1252,5 +1252,6 @@ public class QueueServiceTests
public Task ClearError() => Task.CompletedTask;
public Task WarmAudioContext() => Task.CompletedTask;
public Task StageTrack(TrackDto track) => Task.CompletedTask;
public Task ReloadPreservingPositionAsync() => Task.CompletedTask;
}
}
+171
View File
@@ -0,0 +1,171 @@
using System.Text;
using DeepDrftAPI.Services;
using DeepDrftContent;
using DeepDrftContent.Processors;
using DeepDrftContent.Processors.Opus;
using DeepDrftData;
using DeepDrftModels.DTOs;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Models.Common;
using NetBlocks.Models;
using FileDb = DeepDrftContent.FileDatabase.Services.FileDatabase;
namespace DeepDrftTests;
/// <summary>
/// Confirms the Phase 18.5 acceptance point that <see cref="UnifiedTrackService.ReplaceAudioAsync"/>
/// regenerates the Opus artifact: a replace must schedule a background Opus re-derive for the track, because
/// the stale Opus no longer matches the new source bytes (the same reason it regenerates the waveform datums
/// and re-derives duration). The enqueue was wired in 18.1; this test pins it so a future refactor cannot
/// silently drop it, leaving a track serving Opus that does not match its lossless source.
///
/// The vault + content + waveform collaborators are real (over a temp-dir <see cref="FileDb"/>); the SQL
/// service is a focused fake. The fake is used here rather than the in-memory EF store because the replace
/// path's duration write goes through <c>SetDuration</c> → <c>ExecuteUpdateAsync</c>, which the EF in-memory
/// provider does not support — the fake lets the orchestration run to the post-write enqueue under test.
/// </summary>
[TestFixture]
public class ReplaceAudioOpusRegenTests
{
private string _testDir = string.Empty;
[SetUp]
public void SetUp()
{
_testDir = Path.Combine(Path.GetTempPath(), "ReplaceAudioOpusRegenTests", Guid.NewGuid().ToString());
Directory.CreateDirectory(_testDir);
}
[TearDown]
public void TearDown()
{
try { Directory.Delete(_testDir, recursive: true); }
catch { /* Best-effort cleanup — ignore failures */ }
}
// A focused ITrackService fake: only GetById (returns the seeded track) and SetDuration (records the
// write and reports success) are meaningful — the two members the replace path calls. Everything else
// throws, documenting that the replace orchestration touches nothing else on the SQL boundary.
private sealed class FakeTrackService : ITrackService
{
private readonly TrackDto _track;
public double? LastDurationWritten { get; private set; }
public FakeTrackService(TrackDto track) => _track = track;
public Task<ResultContainer<TrackDto?>> GetById(long id) =>
Task.FromResult(ResultContainer<TrackDto?>.CreatePassResult(id == _track.Id ? _track : null));
public Task<ResultContainer<int>> SetDuration(long id, double durationSeconds, CancellationToken ct = default)
{
LastDurationWritten = durationSeconds;
return Task.FromResult(ResultContainer<int>.CreatePassResult(1));
}
// Unused by the replace path — fail loudly if the orchestration ever reaches them.
public Task<ResultContainer<TrackDto?>> GetByEntryKey(string entryKey) => throw new NotSupportedException();
public Task<ResultContainer<TrackDto?>> GetRandom(CancellationToken ct = default) => throw new NotSupportedException();
public Task<ResultContainer<List<TrackDto>>> GetAll() => throw new NotSupportedException();
public Task<ResultContainer<PagedResult<TrackDto>>> GetPaged(int p, int s, string? c, bool d, TrackFilter? f = null, CancellationToken ct = default) => throw new NotSupportedException();
public Task<ResultContainer<List<ReleaseDto>>> GetReleases(CancellationToken ct = default) => throw new NotSupportedException();
public Task<ResultContainer<List<GenreSummaryDto>>> GetDistinctGenres(CancellationToken ct = default) => throw new NotSupportedException();
public Task<ResultContainer<HomeStatsDto>> GetHomeStats(CancellationToken ct = default) => throw new NotSupportedException();
public Task<ResultContainer<List<TrackDto>>> GetTracksMissingDuration(CancellationToken ct = default) => throw new NotSupportedException();
public Task<ResultContainer<int>> UpdateDuration(long id, double d, CancellationToken ct = default) => throw new NotSupportedException();
public Task<ResultContainer<(ReleaseDto Release, bool WasCreated)>> FindOrCreateRelease(string t, string a, ReleaseDto r, CancellationToken ct = default) => throw new NotSupportedException();
public Task<ResultContainer<ReleaseDto?>> GetReleaseByTitleAndArtist(string t, string a, CancellationToken ct = default) => throw new NotSupportedException();
public Task<ResultContainer<TrackDto>> Create(TrackDto t) => throw new NotSupportedException();
public Task<ResultContainer<TrackDto>> Update(TrackDto t) => throw new NotSupportedException();
public Task<Result> Delete(long id) => throw new NotSupportedException();
public Task<Result> DeleteRelease(long id, CancellationToken ct = default) => throw new NotSupportedException();
public Task<ResultContainer<int>> CountLiveTracksByRelease(long releaseId, CancellationToken ct = default) => throw new NotSupportedException();
}
[Test]
public async Task ReplaceAudio_EnqueuesOpusRegen_ForTheReplacedTrack()
{
var fileDatabase = await FileDb.FromAsync(_testDir);
Assert.That(fileDatabase, Is.Not.Null);
var content = new TrackContentService(
fileDatabase!, new AudioProcessorRouter(
new AudioProcessor(), new Mp3AudioProcessor(), new FlacAudioProcessor()));
var waveforms = new WaveformProfileService(
fileDatabase!, new AudioProcessor(), new RmsLoudnessAlgorithm(),
Options.Create(new WaveformProfileOptions()), NullLogger<WaveformProfileService>.Instance);
var resolver = new TrackFormatResolver(
fileDatabase!, content, NullLogger<TrackFormatResolver>.Instance);
var queue = new NoOpOpusTranscodeQueue();
// Seed the source WAV in the vault and point a fake SQL row at the same EntryKey.
var originalPath = Path.Combine(_testDir, Guid.NewGuid().ToString("N") + ".wav");
await File.WriteAllBytesAsync(originalPath, BuildMinimalPcmWav(2.0));
var unpersisted = await content.AddTrackAsync(originalPath, "Original", "Artist");
Assert.That(unpersisted, Is.Not.Null);
const long trackId = 42;
var sql = new FakeTrackService(new TrackDto { Id = trackId, EntryKey = unpersisted!.EntryKey, TrackName = "Original" });
var service = new UnifiedTrackService(
content, sql, fileDatabase!, waveforms, queue, resolver,
NullLogger<UnifiedTrackService>.Instance);
// Replace the audio with a longer take.
var replacementPath = Path.Combine(_testDir, Guid.NewGuid().ToString("N") + ".wav");
await File.WriteAllBytesAsync(replacementPath, BuildMinimalPcmWav(6.0));
var result = await service.ReplaceAudioAsync(trackId, replacementPath, CancellationToken.None);
Assert.That(result.Success, Is.True, result.Messages.FirstOrDefault()?.Message);
Assert.Multiple(() =>
{
Assert.That(queue.Enqueued, Does.Contain(unpersisted.EntryKey),
"a replace must schedule an Opus re-derive so the artifact tracks the new source");
Assert.That(sql.LastDurationWritten, Is.GreaterThan(0),
"the replace must also write the new duration (the enqueue follows a successful duration write)");
});
}
// Standard-PCM mono 16-bit 44.1 kHz WAV, full-scale square wave. Same layout as the other suites.
private static byte[] BuildMinimalPcmWav(double durationSeconds)
{
const int sampleRate = 44100;
const ushort channels = 1;
const ushort bitsPerSample = 16;
const ushort blockAlign = channels * (bitsPerSample / 8);
const uint byteRate = sampleRate * blockAlign;
var frames = (int)(sampleRate * durationSeconds);
var data = new byte[frames * blockAlign];
for (var i = 0; i < frames; i++)
{
var sample = (i % 2 == 0) ? short.MaxValue : short.MinValue;
data[i * 2] = (byte)(sample & 0xFF);
data[i * 2 + 1] = (byte)((sample >> 8) & 0xFF);
}
using var ms = new MemoryStream();
using var w = new BinaryWriter(ms, Encoding.ASCII, leaveOpen: true);
w.Write(Encoding.ASCII.GetBytes("RIFF"));
w.Write((uint)(36 + data.Length));
w.Write(Encoding.ASCII.GetBytes("WAVE"));
w.Write(Encoding.ASCII.GetBytes("fmt "));
w.Write(16u);
w.Write((ushort)1); // PCM
w.Write(channels);
w.Write((uint)sampleRate);
w.Write(byteRate);
w.Write(blockAlign);
w.Write(bitsPerSample);
w.Write(Encoding.ASCII.GetBytes("data"));
w.Write((uint)data.Length);
w.Write(data);
w.Flush();
return ms.ToArray();
}
}
+427
View File
@@ -0,0 +1,427 @@
using System.Collections.Concurrent;
using System.Net;
using System.Net.Http.Headers;
using DeepDrftModels.DTOs;
using DeepDrftPublic.Client.Clients;
using DeepDrftPublic.Client.Services;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.JSInterop;
namespace DeepDrftTests;
/// <summary>
/// Phase 21 Direction B — the segmented forward read loop in <see cref="StreamingAudioPlayerService"/>.
/// Drives a real <c>SelectTrackStreaming</c> against a fake JS runtime and a scripted HTTP handler that
/// serves bounded 206 segments, then asserts the loop's contract:
/// <list type="bullet">
/// <item>forward playback fetches in bounded <c>bytes=start-end</c> segments (the network-memory bound);</item>
/// <item>the cursor advances contiguously across segment boundaries until the file total is reached (EOF);</item>
/// <item>the next segment is NOT fetched while the scheduler reports production paused (the fill gate);</item>
/// <item>a seek converges onto the SAME segment loop — reinit then continued segmentation, no forked path.</item>
/// </list>
/// True network/browser memory behaviour is Daniel's manual re-run; this pins the request sequencing and
/// gating the harness can observe.
/// </summary>
[TestFixture]
public class SegmentedStreamLoopTests
{
private const long SegmentSize = 4 * 1024 * 1024;
// Records every audio Range request and serves a bounded 206 slice. Audio bodies are zero-filled —
// the fake JS decoder does not inspect bytes; it scripts canStart/productionPaused directly.
private sealed class SegmentServer : HttpMessageHandler
{
private readonly long _total;
public List<(long From, long? To)> AudioRanges { get; } = new();
public SegmentServer(long total) => _total = total;
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
var path = request.RequestUri!.AbsolutePath;
// Waveform profile + sidecar fetches are best-effort side calls — 404 them so the load
// path falls back cleanly and the test stays focused on the audio segment loop.
if (path.EndsWith("/waveform") || path.Contains("/opus/"))
{
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound));
}
var rangeItem = request.Headers.Range!.Ranges.First();
var from = rangeItem.From ?? 0;
var to = rangeItem.To ?? (_total - 1);
if (to > _total - 1) to = _total - 1;
lock (AudioRanges) AudioRanges.Add((rangeItem.From ?? 0, rangeItem.To));
var body = new byte[Math.Max(0, to - from + 1)];
var response = new HttpResponseMessage(HttpStatusCode.PartialContent)
{
Content = new ByteArrayContent(body),
};
response.Content.Headers.ContentRange = new ContentRangeHeaderValue(from, to, _total);
response.Content.Headers.ContentType = new MediaTypeHeaderValue("audio/wav");
return Task.FromResult(response);
}
}
// Serves the first segment normally, then truncates subsequent segment bodies to a short slice
// (Content-Range reports the correct total, but the HTTP body ends early — simulating a
// connection drop mid-segment while cursor < totalLength).
private sealed class TruncatingAfterFirstSegmentServer : HttpMessageHandler
{
private readonly long _total;
private readonly long _truncatedBodyBytes; // bytes to actually deliver for non-first segments
private int _audioRequestCount;
public TruncatingAfterFirstSegmentServer(long total, long truncatedBodyBytes)
{
_total = total;
_truncatedBodyBytes = truncatedBodyBytes;
}
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
var path = request.RequestUri!.AbsolutePath;
if (path.EndsWith("/waveform") || path.Contains("/opus/"))
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound));
var rangeItem = request.Headers.Range!.Ranges.First();
var from = rangeItem.From ?? 0;
var to = rangeItem.To ?? (_total - 1);
if (to > _total - 1) to = _total - 1;
var requestIndex = Interlocked.Increment(ref _audioRequestCount);
// First segment (requestIndex == 1): serve fully. Subsequent segments: truncate.
var fullSliceLength = to - from + 1;
var bodyLength = requestIndex == 1 ? fullSliceLength : Math.Min(_truncatedBodyBytes, fullSliceLength);
var body = new byte[bodyLength];
var response = new HttpResponseMessage(HttpStatusCode.PartialContent)
{
Content = new ByteArrayContent(body),
};
// Content-Range always reports the true full total — the truncation is in the body, not the header.
response.Content.Headers.ContentRange = new ContentRangeHeaderValue(from, to, _total);
response.Content.Headers.ContentType = new MediaTypeHeaderValue("audio/wav");
return Task.FromResult(response);
}
}
// Serves the first segment normally, then returns HTTP 500 for all subsequent requests —
// simulating a mid-stream fetch failure after playback is underway.
private sealed class FailingAfterFirstSegmentServer : HttpMessageHandler
{
private readonly long _total;
private int _audioRequestCount;
public FailingAfterFirstSegmentServer(long total) => _total = total;
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
var path = request.RequestUri!.AbsolutePath;
if (path.EndsWith("/waveform") || path.Contains("/opus/"))
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound));
var requestIndex = Interlocked.Increment(ref _audioRequestCount);
if (requestIndex > 1)
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.InternalServerError));
var rangeItem = request.Headers.Range!.Ranges.First();
var from = rangeItem.From ?? 0;
var to = rangeItem.To ?? (_total - 1);
if (to > _total - 1) to = _total - 1;
var body = new byte[to - from + 1];
var response = new HttpResponseMessage(HttpStatusCode.PartialContent)
{
Content = new ByteArrayContent(body),
};
response.Content.Headers.ContentRange = new ContentRangeHeaderValue(from, to, _total);
response.Content.Headers.ContentType = new MediaTypeHeaderValue("audio/wav");
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/") };
}
/// <summary>
/// Scriptable JS runtime. processStreamingChunk reports canStart=true immediately (so playback
/// starts on the first chunk) and a productionPaused value pulled from a queue the test controls;
/// isProductionPaused (the inter-segment poll) reads a separate queue so a test can hold the gate
/// closed for N polls then release it. Records reinitializeFromOffset / markStreamComplete calls.
/// </summary>
private sealed class FakeJsRuntime : IJSRuntime
{
private readonly Func<bool> _chunkProductionPaused;
private readonly Func<bool> _isProductionPaused;
private readonly long? _seekByteOffset;
public FakeJsRuntime(
Func<bool>? chunkProductionPaused = null,
Func<bool>? isProductionPaused = null,
long? seekByteOffset = null)
{
_chunkProductionPaused = chunkProductionPaused ?? (() => false);
_isProductionPaused = isProductionPaused ?? (() => false);
_seekByteOffset = seekByteOffset;
}
public int ReinitCallCount { get; private set; }
public int MarkCompleteCallCount { get; private set; }
public int IsProductionPausedCallCount { get; private set; }
public int RecoverCallCount { get; private set; }
public ValueTask<TValue> InvokeAsync<TValue>(string identifier, object?[]? args)
{
switch (identifier)
{
case "DeepDrftAudio.isReady":
return Ok<TValue>(true);
case "DeepDrftAudio.canDecodeOggOpus":
return Ok<TValue>(false); // force the lossless path — no sidecar dance
case "DeepDrftAudio.isProductionPaused":
IsProductionPausedCallCount++;
return Ok<TValue>(_isProductionPaused());
case "DeepDrftAudio.processStreamingChunk":
return (ValueTask<TValue>)(object)ValueTask.FromResult(new StreamingResult
{
Success = true,
CanStartStreaming = true,
HeaderParsed = true,
BufferCount = 8,
Duration = 600,
ProductionPaused = _chunkProductionPaused(),
});
case "DeepDrftAudio.seek":
// When a seek offset is scripted, report seek-beyond-buffer so Seek() routes into
// SeekBeyondBuffer → the shared segment loop with a continuation reinit.
return (ValueTask<TValue>)(object)ValueTask.FromResult(_seekByteOffset is { } off
? new SeekResult { Success = true, SeekBeyondBuffer = true, ByteOffset = off }
: new SeekResult { Success = true });
case "DeepDrftAudio.reinitializeFromOffset":
ReinitCallCount++;
return Result<TValue>(true);
case "DeepDrftAudio.markStreamComplete":
MarkCompleteCallCount++;
return (ValueTask<TValue>)(object)ValueTask.FromResult(new StreamingResult { Success = true });
case "DeepDrftAudio.recoverFromFailedRefill":
RecoverCallCount++;
return Result<TValue>(true);
default:
// createPlayer / setOnProgressCallback / setOnEndCallback / setVolume /
// ensureAudioContextReady / initializeStreaming / startStreamingPlayback /
// stop / unload / disposePlayer → generic success.
if (typeof(TValue) == typeof(AudioOperationResult))
return Result<TValue>(true);
return Ok<TValue>(default!);
}
}
public ValueTask<TValue> InvokeAsync<TValue>(string identifier, CancellationToken cancellationToken, object?[]? args)
=> InvokeAsync<TValue>(identifier, args);
private static ValueTask<TValue> Ok<TValue>(object? value) => ValueTask.FromResult((TValue)value!);
private static ValueTask<TValue> Result<TValue>(bool success) =>
ValueTask.FromResult((TValue)(object)new AudioOperationResult { Success = success });
}
private static TrackDto Track() => new() { EntryKey = "mix-1", TrackName = "Long Mix" };
private static StreamingAudioPlayerService BuildPlayer(HttpMessageHandler server, FakeJsRuntime js)
{
var interop = new AudioInteropService(js);
var media = new TrackMediaClient(new SingleClientFactory(server));
return new StreamingAudioPlayerService(interop, media, NullLogger<StreamingAudioPlayerService>.Instance);
}
[Test]
public async Task ForwardPlayback_FetchesBoundedSegments_AdvancingCursorToEof()
{
// 10 MB file → 3 segments (4 MB, 4 MB, 2 MB tail). No back-pressure: drains straight through.
var total = 10L * 1024 * 1024;
var server = new SegmentServer(total);
var js = new FakeJsRuntime();
var player = BuildPlayer(server, js);
await player.SelectTrackStreaming(Track());
Assert.Multiple(() =>
{
Assert.That(server.AudioRanges, Has.Count.EqualTo(3),
"a 10 MB file at a 4 MB segment size is fetched as three bounded segments");
// Contiguous, bounded segments advancing the cursor to EOF.
Assert.That(server.AudioRanges[0], Is.EqualTo((0L, (long?)(SegmentSize - 1))));
Assert.That(server.AudioRanges[1], Is.EqualTo((SegmentSize, (long?)(2 * SegmentSize - 1))));
Assert.That(server.AudioRanges[2], Is.EqualTo((2 * SegmentSize, (long?)(3 * SegmentSize - 1))));
Assert.That(js.MarkCompleteCallCount, Is.EqualTo(1), "stream-complete fires once at true EOF, not per segment");
});
}
[Test]
public async Task ForwardPlayback_DoesNotFetchNextSegment_WhileProductionPaused()
{
var total = 10L * 1024 * 1024;
var server = new SegmentServer(total);
// Chunk results report paused=true (so the loop enters the inter-segment gate), and the poll
// stays paused for the first two checks, then releases — so the next segment is delayed, not
// skipped. The gate must hold the SECOND fetch until the poll clears.
var pollChecks = 0;
var js = new FakeJsRuntime(
chunkProductionPaused: () => true,
isProductionPaused: () => ++pollChecks < 3); // paused for polls 1,2; clear on poll 3
var player = BuildPlayer(server, js);
await player.SelectTrackStreaming(Track());
Assert.Multiple(() =>
{
Assert.That(js.IsProductionPausedCallCount, Is.GreaterThanOrEqualTo(3),
"the inter-segment gate must poll the fill signal while paused, not fetch immediately");
Assert.That(server.AudioRanges, Has.Count.EqualTo(3),
"once the gate clears, segmentation resumes and still reaches EOF — paused delays, never skips");
});
}
[Test]
public async Task SmallFile_FetchedInOneShortSegment_NoSecondFetch()
{
// 2 MB file < one segment: the first bounded request returns a short slice → EOF, no second fetch.
var total = 2L * 1024 * 1024;
var server = new SegmentServer(total);
var js = new FakeJsRuntime();
var player = BuildPlayer(server, js);
await player.SelectTrackStreaming(Track());
Assert.Multiple(() =>
{
Assert.That(server.AudioRanges, Has.Count.EqualTo(1), "a sub-segment file needs exactly one fetch");
Assert.That(server.AudioRanges[0], Is.EqualTo((0L, (long?)(SegmentSize - 1))),
"the request is still the bounded segment shape; the server returns the short available tail");
Assert.That(js.MarkCompleteCallCount, Is.EqualTo(1));
});
}
[Test]
public async Task ForwardLoad_NeverReinitializesDecoder()
{
var total = 20L * 1024 * 1024;
var server = new SegmentServer(total);
var js = new FakeJsRuntime();
var player = BuildPlayer(server, js);
await player.SelectTrackStreaming(Track());
Assert.Multiple(() =>
{
Assert.That(server.AudioRanges, Has.Count.EqualTo(5), "20 MB / 4 MB = five contiguous forward segments");
Assert.That(js.ReinitCallCount, Is.Zero,
"a forward load from byte 0 never reinitializes the decoder — reinit is the seek-only continuation step");
});
}
[Test]
public async Task MidStream_TruncatedSegment_RoutesToRecovery_NotSilentEof()
{
// 10 MB file → 3 segments. The second segment's body is short (1 MB instead of 4 MB) while
// cursor < totalLength — simulates a connection drop mid-segment. The loop must NOT treat this
// as EOF (must not call MarkStreamComplete) and must route to recovery (scheduler halted) so
// the buffered tail cannot drain into a silent false end.
var total = 10L * 1024 * 1024;
var truncatedBodyBytes = 1L * 1024 * 1024; // 1 MB short body for segment 2
var server = new TruncatingAfterFirstSegmentServer(total, truncatedBodyBytes);
var js = new FakeJsRuntime();
var player = BuildPlayer(server, js);
await player.SelectTrackStreaming(Track());
Assert.Multiple(() =>
{
Assert.That(js.MarkCompleteCallCount, Is.Zero,
"a truncated non-final segment must NOT be reported as a clean EOF — MarkStreamComplete must not fire");
Assert.That(js.RecoverCallCount, Is.EqualTo(1),
"a truncated segment while cursor < totalLength is a failure: scheduler must be halted via recovery");
Assert.That(player.IsLoaded, Is.True,
"recovery leaves the track loaded so the listener can retry — not torn down to unloaded");
Assert.That(player.IsStreamingMode, Is.True,
"recovery must restore IsStreamingMode=true so Seek() is not wedged (AC6 / Phase 21.3 retry contract)");
Assert.That(player.IsPaused, Is.True,
"recovery settles into a paused state, not playing");
Assert.That(player.ErrorMessage, Is.Not.Null.And.Not.Empty,
"recovery surfaces an error message to the UI");
});
}
[Test]
public async Task MidStream_SegmentFetchFailure_RoutesToRecovery_NotSilentFalseEnd()
{
// 10 MB file → 3 segments. The second segment fetch fails (HTTP 500), simulating a network
// error after playback is already underway. The loop must halt the JS scheduler via recovery
// rather than letting the buffered first-segment tail drain into a silent false end (AC6).
var total = 10L * 1024 * 1024;
var server = new FailingAfterFirstSegmentServer(total);
var js = new FakeJsRuntime();
var player = BuildPlayer(server, js);
await player.SelectTrackStreaming(Track());
Assert.Multiple(() =>
{
Assert.That(js.MarkCompleteCallCount, Is.Zero,
"a mid-stream fetch failure must not report clean EOF — MarkStreamComplete must not fire");
Assert.That(js.RecoverCallCount, Is.EqualTo(1),
"a mid-stream fetch failure must halt the scheduler via recovery, not leave it to drain");
Assert.That(player.IsLoaded, Is.True,
"recovery leaves the track loaded so the listener can retry — not torn down to unloaded");
Assert.That(player.IsStreamingMode, Is.True,
"recovery must restore IsStreamingMode=true so Seek() is not wedged (AC6 / Phase 21.3 retry contract)");
Assert.That(player.IsPaused, Is.True,
"recovery settles into a paused state, not playing");
Assert.That(player.ErrorMessage, Is.Not.Null.And.Not.Empty,
"recovery surfaces an error message to the UI");
});
}
[Test]
public async Task SeekBeyondBuffer_ReinitsOnceThenSegmentsForwardFromOffset()
{
// 20 MB file. After load, seek to a byte offset 12 MB in: the seek must reinit the decoder once,
// then continue the SAME bounded-segment loop from 12 MB to EOF (no forked fetch path — C1/C5).
var total = 20L * 1024 * 1024;
var seekOffset = 12L * 1024 * 1024;
var server = new SegmentServer(total);
var js = new FakeJsRuntime(seekByteOffset: seekOffset);
var player = BuildPlayer(server, js);
await player.SelectTrackStreaming(Track());
var afterLoad = server.AudioRanges.Count;
await player.Seek(300); // arbitrary time; the scripted seek returns the 12 MB byte offset
// Segments fetched by the seek/refill: everything after the initial-load segments.
var refillRanges = server.AudioRanges.Skip(afterLoad).ToList();
Assert.Multiple(() =>
{
Assert.That(js.ReinitCallCount, Is.EqualTo(1),
"a seek-beyond-buffer reinitializes the decoder exactly once for the header-less continuation");
Assert.That(refillRanges, Has.Count.EqualTo(2),
"from 12 MB to 20 MB at a 4 MB segment is two bounded segments (4 MB + 4 MB tail)");
Assert.That(refillRanges[0], Is.EqualTo((seekOffset, (long?)(seekOffset + SegmentSize - 1))),
"the first refill segment is bounded and starts at the resolved seek offset");
Assert.That(refillRanges[1], Is.EqualTo((seekOffset + SegmentSize, (long?)(seekOffset + 2 * SegmentSize - 1))),
"segmentation continues forward from the seek offset — the same loop, the same bounded shape");
});
}
}
+73
View File
@@ -0,0 +1,73 @@
using DeepDrftPublic.Client.Common;
using DeepDrftPublic.Client.Services;
namespace DeepDrftTests;
/// <summary>
/// Unit tests for the <see cref="SettingsServiceBase"/> parse/format contract (Phase 18 wave 18.6):
/// the <c>streamQuality</c> cookie round-trips through <c>FormatStreamQuality</c> → wire string →
/// <c>ParseStreamQuality</c>, and an absent, empty, or unrecognized cookie value defaults to
/// <see cref="StreamQuality.LowData"/> (the OQ2 default — a missing preference should never force
/// lossless unnecessarily on a low-bandwidth listener).
/// </summary>
[TestFixture]
public class SettingsServiceBaseTests
{
// Expose the protected static helpers via a concrete subclass — no other members needed.
private sealed class Exposed : SettingsServiceBase
{
public static StreamQuality Parse(string? v) => ParseStreamQuality(v);
public static string Format(StreamQuality q) => FormatStreamQuality(q);
}
// ── round-trip ──
// LowData serializes and deserializes intact.
[Test]
public void FormatThenParse_LowData_RoundTrips()
{
var wire = Exposed.Format(StreamQuality.LowData);
Assert.That(Exposed.Parse(wire), Is.EqualTo(StreamQuality.LowData));
}
// Lossless serializes and deserializes intact.
[Test]
public void FormatThenParse_Lossless_RoundTrips()
{
var wire = Exposed.Format(StreamQuality.Lossless);
Assert.That(Exposed.Parse(wire), Is.EqualTo(StreamQuality.Lossless));
}
// ── default for absent / garbled cookie ──
// A null cookie (no cookie present) defaults to LowData, not Lossless.
[Test]
public void Parse_Null_DefaultsToLowData()
=> Assert.That(Exposed.Parse(null), Is.EqualTo(StreamQuality.LowData));
// An empty string (cookie present but blank) defaults to LowData.
[Test]
public void Parse_Empty_DefaultsToLowData()
=> Assert.That(Exposed.Parse(string.Empty), Is.EqualTo(StreamQuality.LowData));
// A garbled/unknown name defaults to LowData rather than throwing.
// Note: Enum.TryParse accepts numeric strings as valid (e.g. "0" → LowData, "1" → Lossless),
// so only non-numeric unrecognized names are tested here.
[TestCase("garbage")]
[TestCase("lossless_extra")]
public void Parse_Unrecognized_DefaultsToLowData(string value)
=> Assert.That(Exposed.Parse(value), Is.EqualTo(StreamQuality.LowData));
// Case-insensitive parse: the enum parse is case-insensitive, so wire values survive case drift.
[TestCase("lossless")]
[TestCase("LOSSLESS")]
[TestCase("Lossless")]
public void Parse_LosslessCaseVariants_ParseCorrectly(string value)
=> Assert.That(Exposed.Parse(value), Is.EqualTo(StreamQuality.Lossless));
[TestCase("lowdata")]
[TestCase("LOWDATA")]
[TestCase("LowData")]
public void Parse_LowDataCaseVariants_ParseCorrectly(string value)
=> Assert.That(Exposed.Parse(value), Is.EqualTo(StreamQuality.LowData));
}
+289
View File
@@ -0,0 +1,289 @@
using System.Text;
using DeepDrftAPI.Controllers;
using DeepDrftContent.Constants;
using DeepDrftContent.FileDatabase.Models;
using DeepDrftContent.Processors;
using DeepDrftContent.Processors.Opus;
using DeepDrftModels.Enums;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using ContentTrackService = DeepDrftContent.TrackContentService;
using FileDb = DeepDrftContent.FileDatabase.Services.FileDatabase;
namespace DeepDrftTests;
/// <summary>
/// Delivery-layer tests for the Phase 18.3 <c>?format=</c> stream selector and the Opus seek/setup sidecar
/// endpoint on <see cref="TrackController"/>. These exercise the real <see cref="FileDb"/>, the real
/// <see cref="TrackContentService"/>, and the real <see cref="TrackFormatResolver"/> over temp-directory
/// vaults — the same integration posture as <see cref="TrackReplaceAudioTests"/>.
///
/// The SQL-only collaborators (<c>UnifiedTrackService</c>, <c>ITrackService</c>) are passed as null: the
/// actions under test (<see cref="TrackController.GetTrack"/>, <see cref="TrackController.GetOpusSeekData"/>)
/// only touch the FileDatabase + resolver path, never the SQL services, so standing up a database is not
/// required to assert the delivery contract.
///
/// The Range→206 contract is asserted at the load-bearing seam: ASP.NET performs the actual byte-slicing
/// for any <see cref="FileResult"/> whose <see cref="FileResult"/>.EnableRangeProcessing is true. The lossless
/// path proves this via the disk-stream <see cref="FileStreamResult"/>; the resolved Opus path via the
/// in-memory <see cref="FileContentResult"/> — both must report range processing enabled, the explicit fix
/// the 18.2 reviewer flagged for the byte[] path.
/// </summary>
[TestFixture]
public class TrackFormatDeliveryTests
{
private string _testDir = string.Empty;
[SetUp]
public void SetUp()
{
_testDir = Path.Combine(Path.GetTempPath(), "TrackFormatDeliveryTests", Guid.NewGuid().ToString());
Directory.CreateDirectory(_testDir);
}
[TearDown]
public void TearDown()
{
try { Directory.Delete(_testDir, recursive: true); }
catch { /* Best-effort cleanup — ignore failures */ }
}
// --- Format resolution at the endpoint ---
[Test]
public async Task GetTrack_FormatOpus_WhenOpusArtifactPresent_ServesOpusBytesAndOggContentType()
{
var (controller, entryKey) = await SeedAsync(withOpus: true, withSidecar: false);
var result = await controller.GetTrack(entryKey, format: "opus");
var file = result as FileContentResult;
Assert.That(file, Is.Not.Null, "Opus delivery serves an in-memory byte[] (FileContentResult)");
Assert.Multiple(() =>
{
Assert.That(file!.ContentType, Is.EqualTo("audio/ogg"), "Opus bytes must carry the audio/ogg content-type");
Assert.That(file.FileContents, Is.EqualTo(OpusBytes), "The served bytes must be the Opus artifact, not the source");
Assert.That(file.EnableRangeProcessing, Is.True, "Range processing must be enabled on the resolved Opus byte[] path");
});
}
// --- The C2 fallback ---
[Test]
public async Task GetTrack_FormatOpus_WhenNoOpusArtifact_FallsBackToLosslessBytesAndContentType()
{
// No Opus artifact stored — the resolver degrades to lossless (C2): the listener still gets audio,
// never a 404 or silence, and the content-type reports the lossless format actually served.
var (controller, entryKey) = await SeedAsync(withOpus: false, withSidecar: false);
var result = await controller.GetTrack(entryKey, format: "opus");
var file = result as FileContentResult;
Assert.That(file, Is.Not.Null, "The fallback still serves resolved bytes via the byte[] path");
Assert.Multiple(() =>
{
Assert.That(file!.ContentType, Is.EqualTo("audio/wav"), "Fallback content-type must be the lossless source's MIME");
Assert.That(file.FileContents, Is.EqualTo(_sourceWav), "Fallback must serve the lossless source bytes");
Assert.That(file.EnableRangeProcessing, Is.True, "Range processing stays enabled on the fallback path too");
});
}
[Test]
public async Task GetTrack_NoFormatParam_ServesLosslessDiskStream_ByteIdenticalToPrePhase18()
{
// The no-format path must be byte-identical to today: the zero-copy disk-stream FileStreamResult,
// NOT the resolver's in-memory byte[] path (which would force the whole source into memory).
var (controller, entryKey) = await SeedAsync(withOpus: true, withSidecar: false);
var result = await controller.GetTrack(entryKey, format: null);
var file = result as FileStreamResult;
Assert.That(file, Is.Not.Null, "Lossless delivery streams from disk (FileStreamResult), not a byte[]");
Assert.Multiple(() =>
{
Assert.That(file!.ContentType, Is.EqualTo("audio/wav"));
Assert.That(file.EnableRangeProcessing, Is.True, "Range→206 must work on the lossless disk-stream path");
});
}
[Test]
public async Task GetTrack_FormatLossless_TakesTheLosslessDiskStreamPath()
{
// An explicit format=lossless must behave exactly like no param — the disk-stream path, never Opus.
var (controller, entryKey) = await SeedAsync(withOpus: true, withSidecar: false);
var result = await controller.GetTrack(entryKey, format: "lossless");
Assert.That(result, Is.InstanceOf<FileStreamResult>(),
"format=lossless must take the disk-stream path even when an Opus artifact exists");
}
[Test]
public async Task GetTrack_FormatOpus_WhenTrackDoesNotExist_Returns404()
{
var controller = BuildController(await FreshDbAsync());
var result = await controller.GetTrack("no-such-track", format: "opus");
Assert.That(result, Is.InstanceOf<NotFoundResult>(),
"When even the lossless source is missing, the Opus request 404s (no audio at all)");
}
// --- Sidecar 200 / 404 ---
[Test]
public async Task GetOpusSeekData_WhenSidecarPresent_Returns200WithRawBytes()
{
var (controller, entryKey) = await SeedAsync(withOpus: true, withSidecar: true);
var result = await controller.GetOpusSeekData(entryKey);
var file = result as FileContentResult;
Assert.That(file, Is.Not.Null, "A stored sidecar is served as raw bytes");
Assert.Multiple(() =>
{
Assert.That(file!.ContentType, Is.EqualTo("application/octet-stream"));
Assert.That(file.FileContents, Is.EqualTo(SidecarBytes), "The served bytes must be the stored sidecar blob");
});
}
[Test]
public async Task GetOpusSeekData_WhenNoSidecar_Returns404()
{
var (controller, entryKey) = await SeedAsync(withOpus: true, withSidecar: false);
var result = await controller.GetOpusSeekData(entryKey);
Assert.That(result, Is.InstanceOf<NotFoundResult>(),
"No sidecar → 404, so the client degrades to lossless rather than treating it as an error");
}
// --- Fixtures + helpers ---
private static readonly byte[] OpusBytes = Encoding.ASCII.GetBytes("OggS-fake-opus-payload-for-delivery-test");
private static readonly byte[] SidecarBytes = Encoding.ASCII.GetBytes("setup-header+seek-index-sidecar-blob");
private byte[] _sourceWav = [];
private async Task<FileDb> FreshDbAsync()
{
var db = await FileDb.FromAsync(_testDir);
Assert.That(db, Is.Not.Null);
return db!;
}
// Seeds a track's lossless source in the tracks vault and, optionally, its Opus artifact and sidecar in
// the track-opus vault, then returns a controller wired over those real vaults plus the entry key.
private async Task<(TrackController Controller, string EntryKey)> SeedAsync(bool withOpus, bool withSidecar)
{
var db = await FreshDbAsync();
var content = new ContentTrackService(db, new AudioProcessorRouter(
new AudioProcessor(), new Mp3AudioProcessor(), new FlacAudioProcessor()));
var wavPath = Path.Combine(_testDir, Guid.NewGuid().ToString("N") + ".wav");
_sourceWav = BuildMinimalPcmWav(2.0);
await File.WriteAllBytesAsync(wavPath, _sourceWav);
var seeded = await content.AddTrackAsync(wavPath, "Track", "Artist");
Assert.That(seeded, Is.Not.Null);
var entryKey = seeded!.EntryKey;
// GetAudioBinaryAsync re-reads what AddTrackAsync stored, so the bytes we assert the fallback against
// are the exact stored source bytes (the processor may normalize the input WAV before storing).
var storedSource = await content.GetAudioBinaryAsync(entryKey);
Assert.That(storedSource, Is.Not.Null);
_sourceWav = storedSource!.Buffer;
await db.CreateVaultAsync(VaultConstants.TrackOpus, MediaVaultType.Audio);
if (withOpus)
{
var opus = new AudioBinary(new AudioBinaryParams(OpusBytes, OpusBytes.Length, ".opus", 2.0, 320));
Assert.That(
await db.RegisterResourceAsync(VaultConstants.TrackOpus, OpusTranscodeService.OpusAudioKey(entryKey), opus),
Is.True);
}
if (withSidecar)
{
var sidecar = new MediaBinary(new MediaBinaryParams(SidecarBytes, SidecarBytes.Length, ".opusidx"));
Assert.That(
await db.RegisterResourceAsync(VaultConstants.TrackOpus, OpusTranscodeService.OpusSidecarKey(entryKey), sidecar),
Is.True);
}
return (BuildController(db, content), entryKey);
}
private static TrackController BuildController(FileDb db, ContentTrackService? content = null)
{
content ??= new ContentTrackService(db, new AudioProcessorRouter(
new AudioProcessor(), new Mp3AudioProcessor(), new FlacAudioProcessor()));
var waveforms = new WaveformProfileService(
db, new AudioProcessor(), new RmsLoudnessAlgorithm(),
Options.Create(new WaveformProfileOptions()), NullLogger<WaveformProfileService>.Instance);
var resolver = new TrackFormatResolver(db, content, NullLogger<TrackFormatResolver>.Instance);
// SQL-only collaborators are null: the delivery actions under test never touch them.
var controller = new TrackController(
content,
db,
unifiedService: null!,
sqlTrackService: null!,
waveforms,
resolver,
stagingDirectory: null!,
NullLogger<TrackController>.Instance)
{
ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() }
};
return controller;
}
// Standard-PCM mono 16-bit 44.1 kHz WAV, full-scale square wave. Same layout as the other suites.
private static byte[] BuildMinimalPcmWav(double durationSeconds)
{
const int sampleRate = 44100;
const ushort channels = 1;
const ushort bitsPerSample = 16;
const ushort blockAlign = channels * (bitsPerSample / 8);
const uint byteRate = sampleRate * blockAlign;
var frames = (int)(sampleRate * durationSeconds);
var data = new byte[frames * blockAlign];
for (var i = 0; i < frames; i++)
{
var sample = (i % 2 == 0) ? short.MaxValue : short.MinValue;
data[i * 2] = (byte)(sample & 0xFF);
data[i * 2 + 1] = (byte)((sample >> 8) & 0xFF);
}
using var ms = new MemoryStream();
using var w = new BinaryWriter(ms, Encoding.ASCII, leaveOpen: true);
w.Write(Encoding.ASCII.GetBytes("RIFF"));
w.Write((uint)(36 + data.Length));
w.Write(Encoding.ASCII.GetBytes("WAVE"));
w.Write(Encoding.ASCII.GetBytes("fmt "));
w.Write(16u);
w.Write((ushort)1); // PCM
w.Write(channels);
w.Write((uint)sampleRate);
w.Write(byteRate);
w.Write(blockAlign);
w.Write(bitsPerSample);
w.Write(Encoding.ASCII.GetBytes("data"));
w.Write((uint)data.Length);
w.Write(data);
w.Flush();
return ms.ToArray();
}
}
+180
View File
@@ -0,0 +1,180 @@
using DeepDrftContent;
using DeepDrftContent.Constants;
using DeepDrftContent.FileDatabase.Models;
using DeepDrftContent.Processors;
using DeepDrftContent.Processors.Opus;
using DeepDrftModels.Enums;
using Microsoft.Extensions.Logging.Abstractions;
using FileDb = DeepDrftContent.FileDatabase.Services.FileDatabase;
namespace DeepDrftTests;
/// <summary>
/// Integration tests for the Phase 18.2 format resolution + sidecar lookup seam
/// (<see cref="TrackFormatResolver"/>) over a real <see cref="FileDb"/>. They exercise the four
/// resolution branches the brief specifies — lossless, Opus hit, the C2 Opus→lossless fallback, and
/// the unknown-track miss — plus sidecar hit/miss. Artifacts are seeded into the vaults exactly as
/// 18.1's <see cref="OpusTranscodeService"/> stores them (Opus audio under the bare EntryKey, sidecar
/// under the <c>-sidecar</c>-suffixed key, the source in the <c>tracks</c> vault), so the test is faithful
/// to the real storage convention rather than a stand-in.
/// </summary>
[TestFixture]
public class TrackFormatResolverTests
{
private string _testDir = string.Empty;
[SetUp]
public void SetUp()
{
_testDir = Path.Combine(Path.GetTempPath(), "TrackFormatResolverTests", Guid.NewGuid().ToString());
Directory.CreateDirectory(_testDir);
}
[TearDown]
public void TearDown()
{
try { Directory.Delete(_testDir, recursive: true); }
catch { /* Best-effort cleanup — ignore failures */ }
}
private static TrackFormatResolver CreateResolver(FileDb fileDatabase)
{
// The resolver only calls GetAudioBinaryAsync (a vault read), which never touches the router —
// but TrackContentService requires one, so supply a real router with real processors.
var router = new AudioProcessorRouter(
new AudioProcessor(), new Mp3AudioProcessor(), new FlacAudioProcessor());
var contentService = new TrackContentService(fileDatabase, router);
return new TrackFormatResolver(
fileDatabase, contentService, NullLogger<TrackFormatResolver>.Instance);
}
// Seeds a source artifact in the tracks vault with the given extension, mirroring how the upload path
// stores the original bytes (WAV/MP3/FLAC). Returns the bytes for downstream identity assertions.
private static async Task<byte[]> SeedSourceAsync(FileDb db, string entryKey, string extension)
{
await db.CreateVaultAsync(VaultConstants.Tracks, MediaVaultType.Audio);
var bytes = new byte[] { 1, 2, 3, 4, 5 };
var audio = new AudioBinary(new AudioBinaryParams(bytes, bytes.Length, extension, 12.0, 1411));
var ok = await db.RegisterResourceAsync(VaultConstants.Tracks, entryKey, audio);
Assert.That(ok, Is.True, "source seed must succeed");
return bytes;
}
// Seeds the Opus audio + sidecar in the track-opus vault exactly as OpusTranscodeService does:
// audio under OpusAudioKey (the bare EntryKey) with the .opus extension, sidecar under OpusSidecarKey.
private static async Task<(byte[] opus, byte[] sidecar)> SeedOpusAsync(FileDb db, string entryKey)
{
await db.CreateVaultAsync(VaultConstants.TrackOpus, MediaVaultType.Audio);
var opusBytes = new byte[] { 9, 9, 9 };
var opusAudio = new AudioBinary(new AudioBinaryParams(
opusBytes, opusBytes.Length, OggOpusConstants.OpusExtension, 12.0, 320));
var audioOk = await db.RegisterResourceAsync(
VaultConstants.TrackOpus, OpusTranscodeService.OpusAudioKey(entryKey), opusAudio);
Assert.That(audioOk, Is.True, "opus audio seed must succeed");
var sidecarBytes = new byte[] { 7, 7, 7, 7 };
var sidecar = new MediaBinary(new MediaBinaryParams(
sidecarBytes, sidecarBytes.Length, OggOpusConstants.SidecarExtension));
var sidecarOk = await db.RegisterResourceAsync(
VaultConstants.TrackOpus, OpusTranscodeService.OpusSidecarKey(entryKey), sidecar);
Assert.That(sidecarOk, Is.True, "sidecar seed must succeed");
return (opusBytes, sidecarBytes);
}
[Test]
public async Task ResolveAsync_Lossless_ReturnsSourceArtifactWithItsRealMime()
{
var db = (await FileDb.FromAsync(_testDir))!;
const string entryKey = "lossless-track";
// A FLAC source — proves the lossless branch does NOT assume WAV: the content-type tracks the
// stored extension's MIME, not a hard-coded audio/wav.
var bytes = await SeedSourceAsync(db, entryKey, ".flac");
var resolver = CreateResolver(db);
var resolved = await resolver.ResolveAsync(entryKey, AudioFormat.Lossless);
Assert.That(resolved, Is.Not.Null);
Assert.That(resolved!.ResolvedFormat, Is.EqualTo(AudioFormat.Lossless));
Assert.That(resolved.ContentType, Is.EqualTo("audio/flac"));
Assert.That(resolved.Audio.Buffer, Is.EqualTo(bytes));
Assert.That(resolved.DidFallBack(AudioFormat.Lossless), Is.False);
}
[Test]
public async Task ResolveAsync_OpusWhenArtifactExists_ReturnsOpusWithOggContentType()
{
var db = (await FileDb.FromAsync(_testDir))!;
const string entryKey = "opus-track";
await SeedSourceAsync(db, entryKey, ".wav");
var (opusBytes, _) = await SeedOpusAsync(db, entryKey);
var resolver = CreateResolver(db);
var resolved = await resolver.ResolveAsync(entryKey, AudioFormat.Opus);
Assert.That(resolved, Is.Not.Null);
Assert.That(resolved!.ResolvedFormat, Is.EqualTo(AudioFormat.Opus));
Assert.That(resolved.ContentType, Is.EqualTo("audio/ogg"));
Assert.That(resolved.Audio.Buffer, Is.EqualTo(opusBytes));
Assert.That(resolved.DidFallBack(AudioFormat.Opus), Is.False);
}
[Test]
public async Task ResolveAsync_OpusWhenNoArtifact_FallsBackToLosslessNeverNull()
{
var db = (await FileDb.FromAsync(_testDir))!;
const string entryKey = "no-opus-track";
// Source exists; no Opus artifact has been derived. The C2 rule: degrade to lossless, never 404.
var bytes = await SeedSourceAsync(db, entryKey, ".wav");
var resolver = CreateResolver(db);
var resolved = await resolver.ResolveAsync(entryKey, AudioFormat.Opus);
Assert.That(resolved, Is.Not.Null, "Opus absence must degrade to lossless, not null/404");
Assert.That(resolved!.ResolvedFormat, Is.EqualTo(AudioFormat.Lossless),
"the resolved format must reflect what was actually returned");
Assert.That(resolved.ContentType, Is.EqualTo("audio/wav"),
"a fallback returns the lossless content-type so the decoder picks the right decoder");
Assert.That(resolved.Audio.Buffer, Is.EqualTo(bytes));
Assert.That(resolved.DidFallBack(AudioFormat.Opus), Is.True);
}
[Test]
public async Task ResolveAsync_UnknownTrack_ReturnsNull()
{
var db = (await FileDb.FromAsync(_testDir))!;
var resolver = CreateResolver(db);
// No source at all — the one case the caller maps to 404. Holds for both requested formats:
// Opus falls back to lossless, finds nothing, and returns null too.
Assert.That(await resolver.ResolveAsync("ghost", AudioFormat.Lossless), Is.Null);
Assert.That(await resolver.ResolveAsync("ghost", AudioFormat.Opus), Is.Null);
}
[Test]
public async Task GetOpusSidecarAsync_WhenPresent_ReturnsBytes()
{
var db = (await FileDb.FromAsync(_testDir))!;
const string entryKey = "sidecar-track";
var (_, sidecarBytes) = await SeedOpusAsync(db, entryKey);
var resolver = CreateResolver(db);
var bytes = await resolver.GetOpusSidecarAsync(entryKey);
Assert.That(bytes, Is.Not.Null);
Assert.That(bytes, Is.EqualTo(sidecarBytes));
}
[Test]
public async Task GetOpusSidecarAsync_WhenAbsent_ReturnsNull()
{
var db = (await FileDb.FromAsync(_testDir))!;
var resolver = CreateResolver(db);
// No Opus artifacts derived for this track — the sidecar lookup misses to null, not an exception.
var bytes = await resolver.GetOpusSidecarAsync("no-sidecar-track");
Assert.That(bytes, Is.Null);
}
}
@@ -0,0 +1,96 @@
using System.Net;
using System.Net.Http.Headers;
using DeepDrftModels.Enums;
using DeepDrftPublic.Client.Clients;
namespace DeepDrftTests;
/// <summary>
/// Pins the Phase 21 Direction B bounded-Range request shape on <see cref="TrackMediaClient.GetTrackMedia"/>.
/// The network-memory bound rests on each forward fetch being a finite <c>bytes=start-end</c> slice (so the
/// browser buffers only one segment), and on the caller learning the file total from the 206
/// <c>Content-Range</c> header (the EOF boundary the segment cursor advances toward). Both are request/response
/// plumbing the harness can observe directly; the actual browser memory behaviour is Daniel's manual re-run.
/// </summary>
[TestFixture]
public class TrackMediaBoundedRangeTests
{
// Captures the outgoing Range header and returns a 206 with a Content-Range so TotalLength resolves.
private sealed class RangeCapturingHandler : HttpMessageHandler
{
private readonly long _total;
public RangeHeaderValue? CapturedRange { get; private set; }
public RangeCapturingHandler(long total) => _total = total;
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
CapturedRange = request.Headers.Range;
var from = request.Headers.Range?.Ranges.First().From ?? 0;
var to = request.Headers.Range?.Ranges.First().To ?? (_total - 1);
var body = new byte[to - from + 1];
var response = new HttpResponseMessage(HttpStatusCode.PartialContent)
{
Content = new ByteArrayContent(body),
};
response.Content.Headers.ContentRange = new ContentRangeHeaderValue(from, to, _total);
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/") };
}
[Test]
public async Task GetTrackMedia_WithByteEnd_SendsBoundedRange()
{
var handler = new RangeCapturingHandler(total: 100_000_000);
var client = new TrackMediaClient(new SingleClientFactory(handler));
await client.GetTrackMedia("track-1", byteOffset: 0, byteEnd: 4 * 1024 * 1024 - 1, format: AudioFormat.Lossless);
var range = handler.CapturedRange!.Ranges.First();
Assert.Multiple(() =>
{
Assert.That(range.From, Is.EqualTo(0), "bounded request starts at the cursor");
Assert.That(range.To, Is.EqualTo(4 * 1024 * 1024 - 1),
"bounded request must carry an inclusive end so the server returns a finite slice (one segment)");
});
}
[Test]
public async Task GetTrackMedia_WithoutByteEnd_SendsOpenEndedRange()
{
var handler = new RangeCapturingHandler(total: 100_000_000);
var client = new TrackMediaClient(new SingleClientFactory(handler));
await client.GetTrackMedia("track-1", byteOffset: 1024, byteEnd: null, format: AudioFormat.Lossless);
var range = handler.CapturedRange!.Ranges.First();
Assert.Multiple(() =>
{
Assert.That(range.From, Is.EqualTo(1024), "open-ended request starts at the cursor");
Assert.That(range.To, Is.Null, "no byteEnd → open-ended bytes=start- (pre-Direction-B shape, kept working)");
});
}
[Test]
public async Task GetTrackMedia_206Response_SurfacesTotalLengthFromContentRange()
{
var handler = new RangeCapturingHandler(total: 970_000_000);
var client = new TrackMediaClient(new SingleClientFactory(handler));
var result = await client.GetTrackMedia("track-1", byteOffset: 0, byteEnd: 4 * 1024 * 1024 - 1);
Assert.That(result.Success, Is.True);
Assert.That(result.Value!.TotalLength, Is.EqualTo(970_000_000),
"the file total (the segment cursor's EOF boundary) must come from the 206 Content-Range");
}
}
@@ -0,0 +1,80 @@
using System.Net;
using DeepDrftModels.Enums;
using DeepDrftPublic.Client.Clients;
namespace DeepDrftTests;
/// <summary>
/// Pins the Phase 21.4 transport fix: every audio media fetch must carry the browser response-streaming
/// option so the body streams incrementally in WASM. Without it the browser buffers the whole payload
/// before the response stream yields a byte, and the 21.2 read-loop pause backpressures nothing.
///
/// The flag is set by <c>SetBrowserResponseStreamingEnabled(true)</c>, which records it in
/// <see cref="HttpRequestMessage.Options"/> under the framework key <c>"WebAssemblyEnableStreamingResponse"</c>.
/// A stub handler reads that option back during <c>SendAsync</c> — the same network-boundary fake the Opus
/// format-selection tests use. True network backpressure is browser-only and cannot be unit-profiled; this
/// asserts the request is *configured* for streaming, which is the part the harness can observe. Daniel's
/// 21.4 manual re-run confirms the actual bounded-memory behaviour.
///
/// Both the initial full-stream request (byteOffset 0) and the seek/refill Range request (byteOffset &gt; 0,
/// Phase 21.3) flow through <see cref="TrackMediaClient.GetTrackMedia"/>, so both are asserted here.
/// </summary>
[TestFixture]
public class TrackMediaStreamingEnabledTests
{
// The framework key SetBrowserResponseStreamingEnabled writes into HttpRequestMessage.Options.
private static readonly HttpRequestOptionsKey<bool> StreamingOptionKey = new("WebAssemblyEnableStreamingResponse");
// Captures the streaming option off each outgoing request, then returns a minimal 200 with a body so
// GetTrackMedia reaches its pass path (ReadAsStreamAsync over ByteArrayContent).
private sealed class CapturingHandler : HttpMessageHandler
{
public bool? StreamingEnabled { get; private set; }
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
StreamingEnabled = request.Options.TryGetValue(StreamingOptionKey, out var enabled) ? enabled : null;
var response = new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new ByteArrayContent("audio-bytes"u8.ToArray()),
};
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/") };
}
[Test]
public async Task GetTrackMedia_InitialStream_EnablesBrowserResponseStreaming()
{
var handler = new CapturingHandler();
var client = new TrackMediaClient(new SingleClientFactory(handler));
var result = await client.GetTrackMedia("track-1", byteOffset: 0, format: AudioFormat.Lossless);
Assert.That(result.Success, Is.True, "the fetch should succeed against the stub");
Assert.That(handler.StreamingEnabled, Is.True,
"the initial media stream must enable browser response streaming or the read-loop pause backpressures nothing");
}
[Test]
public async Task GetTrackMedia_SeekOffsetStream_EnablesBrowserResponseStreaming()
{
var handler = new CapturingHandler();
var client = new TrackMediaClient(new SingleClientFactory(handler));
var result = await client.GetTrackMedia("track-1", byteOffset: 1_048_576, format: AudioFormat.Opus);
Assert.That(result.Success, Is.True, "the offset fetch should succeed against the stub");
Assert.That(handler.StreamingEnabled, Is.True,
"the seek/refill Range request must also enable streaming — 21.3 refill depends on the same backpressure");
}
}
@@ -4,6 +4,7 @@ using Data.Managers;
using DeepDrftAPI.Services;
using DeepDrftContent;
using DeepDrftContent.Processors;
using DeepDrftContent.Processors.Opus;
using DeepDrftData;
using DeepDrftData.Data;
using DeepDrftData.Repositories;
@@ -74,9 +75,13 @@ public class UploadDuplicateDetectionTests
var waveforms = new WaveformProfileService(
fileDatabase!, new AudioProcessor(), new RmsLoudnessAlgorithm(),
Options.Create(new WaveformProfileOptions()), NullLogger<WaveformProfileService>.Instance);
var resolver = new TrackFormatResolver(
fileDatabase!, content, NullLogger<TrackFormatResolver>.Instance);
return new UnifiedTrackService(
content, sqlTrackService, fileDatabase!, waveforms,
new NoOpOpusTranscodeQueue(),
resolver,
NullLogger<UnifiedTrackService>.Instance);
}
@@ -88,4 +88,93 @@ public class WaveformVisualizerControlStateTests
Assert.That(observedTheaterMode, Is.False);
}
// ── ApplyCapabilityDefault: the one-time HW-accel default-set ──
// No HW accel → lava defaults off, waveform stays on (the whole point of the feature).
[Test]
public void ApplyCapabilityDefault_NoHardwareAccel_LavaOffWaveformOn()
{
_state.ApplyCapabilityDefault(hardwareAccelerated: false);
Assert.Multiple(() =>
{
Assert.That(_state.LavaEnabled, Is.False);
Assert.That(_state.WaveformEnabled, Is.True);
});
}
// HW accel present → no change; lava keeps its shipped on-default (the common case is untouched).
[Test]
public void ApplyCapabilityDefault_HardwareAccelerated_LavaStaysOn()
{
_state.ApplyCapabilityDefault(hardwareAccelerated: true);
Assert.Multiple(() =>
{
Assert.That(_state.LavaEnabled, Is.True);
Assert.That(_state.WaveformEnabled, Is.True);
});
}
// No HW accel raises Changed once so the controls UI / bridge / theater observers all react.
[Test]
public void ApplyCapabilityDefault_NoHardwareAccel_RaisesChanged()
{
var raised = 0;
_state.Changed += () => raised++;
_state.ApplyCapabilityDefault(hardwareAccelerated: false);
Assert.That(raised, Is.EqualTo(1));
}
// HW accel present must not churn observers — no Changed when nothing changed.
[Test]
public void ApplyCapabilityDefault_HardwareAccelerated_DoesNotRaiseChanged()
{
var raised = 0;
_state.Changed += () => raised++;
_state.ApplyCapabilityDefault(hardwareAccelerated: true);
Assert.That(raised, Is.Zero);
}
// Guarded to once per session: a second call cannot re-clobber, even with a different verdict.
[Test]
public void ApplyCapabilityDefault_SecondCall_IsNoOp()
{
_state.ApplyCapabilityDefault(hardwareAccelerated: true); // first call wins (accelerated → on)
_state.ApplyCapabilityDefault(hardwareAccelerated: false); // must be ignored
Assert.That(_state.LavaEnabled, Is.True);
}
// Must not override an explicit in-session toggle: once applied, a user re-enabling lava on a
// software renderer sticks — a later (guarded) call never reverts it.
[Test]
public void ApplyCapabilityDefault_DoesNotOverrideExplicitUserToggle()
{
_state.ApplyCapabilityDefault(hardwareAccelerated: false); // default: lava off
_state.LavaEnabled = true; // user re-enables at their own risk
_state.ApplyCapabilityDefault(hardwareAccelerated: false); // guarded → no-op
Assert.That(_state.LavaEnabled, Is.True);
}
// No HW accel while Theater Mode is on with only lava active → coercion exits theater in the same
// cycle (lava off + waveform off would strand theater; here waveform stays on, but verify the
// coercion path is exercised when it WOULD strand).
[Test]
public void ApplyCapabilityDefault_NoHardwareAccel_CoercesStrandedTheater()
{
_state.WaveformEnabled = false; // only lava active
_state.TheaterMode = true;
_state.ApplyCapabilityDefault(hardwareAccelerated: false); // lava off → both off → strand
Assert.That(_state.TheaterMode, Is.False);
}
}