using DeepDrftModels.Enums; using DeepDrftPublic.Client.Services; namespace DeepDrftTests; /// /// Unit tests for the Phase 16 play-session tracker (): 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. /// [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)); } }