Files
deepdrft/DeepDrftTests/PlayTrackerTests.cs
T
daniel-c-harvey 2af0d8650b fix(telemetry): first-party fetch for play/share, beacon only on unload
Route normal play closes (end/switch/stop) and all shares through a same-origin
HttpClient POST so privacy-hardened browsers stop blocking them; keep sendBeacon
for the tab-unload edge. Rename the JS module off telemetry/beacon to session/
lifecycle so the retained fallback isn't name-matched. No new data or identifiers.
2026-06-26 21:11:43 -04:00

252 lines
9.4 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using DeepDrftModels.Enums;
using DeepDrftPublic.Client.Services;
namespace DeepDrftTests;
/// <summary>
/// Unit tests for the Phase 16 play-session tracker (<see cref="PlayTracker"/>): the engagement-floor
/// gate (§1d / D2 — ≥3s OR ≥5% of duration, whichever smaller) and the three-bucket completion
/// classification (§1a / D1 — partial &lt;30%, sampled 3080%, complete &gt;80%), exercised behind a
/// fake sink so the logic is tested with no player or JS interop — the seam the spec calls out as
/// testable. Also covers the high-water (seek-backward) and idempotent-close invariants.
/// </summary>
[TestFixture]
public class PlayTrackerTests
{
// Captures emitted plays so assertions read the (key, bucket) the tracker classified. The two arms are
// captured separately so a test can assert which transport a given close selected (fetch vs unload).
// Emitted folds both arms for the floor/bucket assertions that don't care about transport.
private sealed class FakeSink : IPlayEventSink
{
public List<(string Key, PlayBucket Bucket)> Emitted { get; } = new();
public List<(string Key, PlayBucket Bucket)> FetchEmitted { get; } = new();
public List<(string Key, PlayBucket Bucket)> UnloadEmitted { get; } = new();
public Task EmitPlayAsync(string trackEntryKey, PlayBucket bucket)
{
FetchEmitted.Add((trackEntryKey, bucket));
Emitted.Add((trackEntryKey, bucket));
return Task.CompletedTask;
}
public void EmitPlayOnUnload(string trackEntryKey, PlayBucket bucket)
{
UnloadEmitted.Add((trackEntryKey, bucket));
Emitted.Add((trackEntryKey, bucket));
}
}
private FakeSink _sink = null!;
private PlayTracker _tracker = null!;
[SetUp]
public void SetUp()
{
_sink = new FakeSink();
_tracker = new PlayTracker(_sink);
}
// Drive a full session: open, set duration, advance to a high-water position, close.
private void PlaySession(string key, double duration, double highWater)
{
_tracker.OnPlaybackStarted(key);
_tracker.SetDuration(duration);
_tracker.OnProgress(highWater);
_tracker.Close();
}
// --- Engagement floor (§1d / D2) ---
// A long track floors on the 3-second wall (3s < 5% of 200s = 10s): under 3s sends nothing.
[Test]
public void Close_LongTrackUnderThreeSeconds_SendsNothing()
{
PlaySession("t", duration: 200, highWater: 2.5);
Assert.That(_sink.Emitted, Is.Empty);
}
// The same long track at exactly the 3-second floor crosses it and records a (partial) play.
[Test]
public void Close_LongTrackAtThreeSecondFloor_RecordsPlay()
{
PlaySession("t", duration: 200, highWater: 3.0);
Assert.That(_sink.Emitted, Has.Count.EqualTo(1));
Assert.That(_sink.Emitted[0].Bucket, Is.EqualTo(PlayBucket.Partial));
}
// A short clip floors on the percentage (5% of 40s = 2s < 3s): 1.5s is below 2s → nothing.
[Test]
public void Close_ShortClipUnderFivePercent_SendsNothing()
{
PlaySession("t", duration: 40, highWater: 1.5);
Assert.That(_sink.Emitted, Is.Empty);
}
// The same short clip at the 5%-of-duration floor (2s) crosses it and records.
[Test]
public void Close_ShortClipAtFivePercentFloor_RecordsPlay()
{
PlaySession("t", duration: 40, highWater: 2.0);
Assert.That(_sink.Emitted, Has.Count.EqualTo(1));
}
// --- Bucket classification (§1a / D1) ---
// [floor, 30%) → partial. 60/200 = 30% is the boundary, so 59.9/200 (just under) is partial.
[Test]
public void Close_UnderThirtyPercent_ClassifiesPartial()
{
PlaySession("t", duration: 200, highWater: 50); // 25%
Assert.That(_sink.Emitted[0].Bucket, Is.EqualTo(PlayBucket.Partial));
}
// Exactly 30% is the start of the sampled band [30%, 80%].
[Test]
public void Close_AtThirtyPercent_ClassifiesSampled()
{
PlaySession("t", duration: 200, highWater: 60); // 30%
Assert.That(_sink.Emitted[0].Bucket, Is.EqualTo(PlayBucket.Sampled));
}
// Mid-band is sampled.
[Test]
public void Close_MidBand_ClassifiesSampled()
{
PlaySession("t", duration: 200, highWater: 120); // 60%
Assert.That(_sink.Emitted[0].Bucket, Is.EqualTo(PlayBucket.Sampled));
}
// Exactly 80% is the inclusive top of the sampled band — still sampled, not complete.
[Test]
public void Close_AtEightyPercent_ClassifiesSampled()
{
PlaySession("t", duration: 200, highWater: 160); // 80%
Assert.That(_sink.Emitted[0].Bucket, Is.EqualTo(PlayBucket.Sampled));
}
// Past 80% is complete.
[Test]
public void Close_OverEightyPercent_ClassifiesComplete()
{
PlaySession("t", duration: 200, highWater: 190); // 95%
Assert.That(_sink.Emitted[0].Bucket, Is.EqualTo(PlayBucket.Complete));
}
// A full listen classifies complete, and the high-water is clamped (never over 100%).
[Test]
public void Close_FullListen_ClassifiesComplete()
{
PlaySession("t", duration: 200, highWater: 200);
Assert.That(_sink.Emitted[0].Bucket, Is.EqualTo(PlayBucket.Complete));
}
// --- High-water invariant (§1d: seeks never lower the mark) ---
// Seeking backward after reaching the end still classifies complete — the max position wins.
[Test]
public void Close_SeekBackwardAfterEnd_StaysComplete()
{
_tracker.OnPlaybackStarted("t");
_tracker.SetDuration(200);
_tracker.OnProgress(190); // reached 95%
_tracker.OnProgress(20); // seek back to 10% — must NOT lower the high-water
_tracker.Close();
Assert.That(_sink.Emitted[0].Bucket, Is.EqualTo(PlayBucket.Complete));
}
// --- Session lifecycle ---
// Carries the track key the session opened with through to the emitted event.
[Test]
public void Close_RecordsTheOpenedTrackKey()
{
PlaySession("the-entry-key", duration: 100, highWater: 90);
Assert.That(_sink.Emitted[0].Key, Is.EqualTo("the-entry-key"));
}
// Close is idempotent — a second close (e.g. organic end after the unload beacon already fired) emits nothing further.
[Test]
public void Close_CalledTwice_EmitsOnce()
{
PlaySession("t", duration: 100, highWater: 90);
_tracker.Close();
Assert.That(_sink.Emitted, Has.Count.EqualTo(1));
}
// A session with no duration ever set (header never parsed) has no fraction to classify → nothing.
[Test]
public void Close_WithoutDuration_SendsNothing()
{
_tracker.OnPlaybackStarted("t");
_tracker.OnProgress(30);
_tracker.Close();
Assert.That(_sink.Emitted, Is.Empty);
}
// Progress before any session opens is ignored (no open session to advance), so a later open+close
// starts the high-water from zero.
[Test]
public void OnProgress_BeforeOpen_IsIgnored()
{
_tracker.OnProgress(150);
PlaySession("t", duration: 200, highWater: 10); // 5% — partial
Assert.That(_sink.Emitted[0].Bucket, Is.EqualTo(PlayBucket.Partial));
}
// Opening a new session while one is still open closes (and records) the prior one — a track-switch
// that did not route through Close still records the superseded listen. Two complete plays result.
[Test]
public void OnPlaybackStarted_WhileOpen_ClosesPriorSession()
{
_tracker.OnPlaybackStarted("first");
_tracker.SetDuration(100);
_tracker.OnProgress(95); // first: complete
_tracker.OnPlaybackStarted("second"); // supersedes — records first
_tracker.SetDuration(100);
_tracker.OnProgress(95); // second: complete
_tracker.Close();
Assert.That(_sink.Emitted.Select(e => e.Key), Is.EqualTo(new[] { "first", "second" }));
Assert.That(_sink.Emitted.All(e => e.Bucket == PlayBucket.Complete), Is.True);
}
// A replay (open the same key again after closing) is a second, independent play (§1d).
[Test]
public void Replay_RecordsTwoPlays()
{
PlaySession("t", duration: 100, highWater: 95);
PlaySession("t", duration: 100, highWater: 95);
Assert.That(_sink.Emitted, Has.Count.EqualTo(2));
}
// --- Transport-arm selection (telemetry transport-resilience) ---
// A normal close (organic end / track-switch / stop) emits over the first-party fetch arm — the page
// is alive, so the awaitable HttpClient POST is the heuristic-safe transport.
[Test]
public void Close_NormalClose_EmitsOverFetchArm()
{
_tracker.OnPlaybackStarted("t");
_tracker.SetDuration(100);
_tracker.OnProgress(95);
_tracker.Close(); // viaUnload defaults to false
Assert.That(_sink.FetchEmitted, Has.Count.EqualTo(1));
Assert.That(_sink.UnloadEmitted, Is.Empty);
}
// The page-unload close emits over the sendBeacon arm — an awaited fetch would be cancelled as the
// page freezes, so this rare edge keeps the beacon.
[Test]
public void Close_ViaUnload_EmitsOverBeaconArm()
{
_tracker.OnPlaybackStarted("t");
_tracker.SetDuration(100);
_tracker.OnProgress(95);
_tracker.Close(viaUnload: true);
Assert.That(_sink.UnloadEmitted, Has.Count.EqualTo(1));
Assert.That(_sink.FetchEmitted, Is.Empty);
}
}