2af0d8650b
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.
252 lines
9.4 KiB
C#
252 lines
9.4 KiB
C#
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 <30%, sampled 30–80%, complete >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);
|
||
}
|
||
}
|