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.
This commit is contained in:
daniel-c-harvey
2026-06-19 12:59:00 -04:00
parent 1931574ad4
commit dbd90ee52a
35 changed files with 2460 additions and 2 deletions
+205
View File
@@ -0,0 +1,205 @@
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));
}
}