Files
deepdrft/DeepDrftTests/PlayTrackerTests.cs
T
daniel-c-harvey dbd90ee52a feat(phase-16): anonymous play & share telemetry substrate (wave 16.1)
Player-service play-session tracker (floor + 3-bucket classify), SharePopover share tracker with debounce, sendBeacon interop, proxied rate-limited POST api/event/{play,share}, append-only event logs + incremental play_counter with server-side release resolution. Migration authored, not applied. No anonId, no read surface.
2026-06-19 12:59:00 -04:00

206 lines
7.6 KiB
C#
Raw Permalink 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.
private sealed class FakeSink : IPlayEventSink
{
public List<(string Key, PlayBucket Bucket)> Emitted { get; } = new();
public void EmitPlay(string trackEntryKey, PlayBucket 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));
}
}