Merge streaming-overhaul into dev (Opus low-data streaming, windowed streaming, HW-accel-off stabilization)
This commit is contained in:
@@ -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);
|
||||
}
|
||||
@@ -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 10–12 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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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 > 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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user