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:
@@ -0,0 +1,174 @@
|
||||
using DeepDrftData.Data;
|
||||
using DeepDrftData.Repositories;
|
||||
using DeepDrftModels.Entities;
|
||||
using DeepDrftModels.Enums;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Diagnostics;
|
||||
|
||||
namespace DeepDrftTests;
|
||||
|
||||
/// <summary>
|
||||
/// Storage-layer tests for the Phase 16 telemetry writes (<see cref="EventRepository"/>): server-side
|
||||
/// release resolution (§2.3 / D4), the incremental play-counter bump in the same write (D6), the
|
||||
/// derived-release-total shape (a release's plays are the sum of its tracks'), and the share append.
|
||||
/// Runs on the EF in-memory provider like <see cref="HomeStatsQueryTests"/>. In-memory does not support
|
||||
/// real transactions, so the transaction-ignored warning is suppressed — the production Postgres path
|
||||
/// wraps the append + bump in one transaction, which the warning would otherwise turn into an error here.
|
||||
/// </summary>
|
||||
[TestFixture]
|
||||
public class PlayEventQueryTests
|
||||
{
|
||||
private DeepDrftContext _context = null!;
|
||||
|
||||
[SetUp]
|
||||
public void SetUp()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<DeepDrftContext>()
|
||||
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
|
||||
.ConfigureWarnings(w => w.Ignore(InMemoryEventId.TransactionIgnoredWarning))
|
||||
.Options;
|
||||
_context = new DeepDrftContext(options);
|
||||
}
|
||||
|
||||
[TearDown]
|
||||
public void TearDown() => _context.Dispose();
|
||||
|
||||
private EventRepository CreateRepository() => new(_context);
|
||||
|
||||
private async Task<(ReleaseEntity Release, TrackEntity Track)> SeedTrackAsync(string trackKey)
|
||||
{
|
||||
var release = new ReleaseEntity
|
||||
{
|
||||
EntryKey = Guid.NewGuid().ToString("N"),
|
||||
Title = "R",
|
||||
Artist = "A",
|
||||
Medium = ReleaseMedium.Cut,
|
||||
};
|
||||
var track = new TrackEntity { EntryKey = trackKey, TrackName = "T", Release = release };
|
||||
_context.Releases.Add(release);
|
||||
_context.Tracks.Add(track);
|
||||
await _context.SaveChangesAsync();
|
||||
return (release, track);
|
||||
}
|
||||
|
||||
// A play that reaches the repository (the floor is the tracker's job) writes exactly one play_event
|
||||
// row with the release id resolved server-side, and bumps the matching bucket on the track's counter.
|
||||
[Test]
|
||||
public async Task RecordPlayAsync_ResolvesReleaseAndBumpsCounter()
|
||||
{
|
||||
var (release, track) = await SeedTrackAsync("track-1");
|
||||
|
||||
await CreateRepository().RecordPlayAsync("track-1", PlayBucket.Complete, anonId: null);
|
||||
|
||||
var ev = await _context.PlayEvents.SingleAsync();
|
||||
Assert.That(ev.TrackEntryKey, Is.EqualTo("track-1"));
|
||||
Assert.That(ev.ReleaseId, Is.EqualTo(release.Id), "release resolved server-side from the track key");
|
||||
Assert.That(ev.Bucket, Is.EqualTo(PlayBucket.Complete));
|
||||
Assert.That(ev.AnonId, Is.Null, "no anonId is written in wave 16.1");
|
||||
|
||||
var counter = await _context.PlayCounters.SingleAsync(c => c.TrackId == track.Id);
|
||||
Assert.That(counter.CompleteCount, Is.EqualTo(1));
|
||||
Assert.That(counter.TotalPlays, Is.EqualTo(1));
|
||||
}
|
||||
|
||||
// Each bucket bumps its own column; total plays is the sum across buckets.
|
||||
[Test]
|
||||
public async Task RecordPlayAsync_BucketsAccumulateIndependently()
|
||||
{
|
||||
var (_, track) = await SeedTrackAsync("track-1");
|
||||
var repo = CreateRepository();
|
||||
|
||||
await repo.RecordPlayAsync("track-1", PlayBucket.Partial, null);
|
||||
await repo.RecordPlayAsync("track-1", PlayBucket.Sampled, null);
|
||||
await repo.RecordPlayAsync("track-1", PlayBucket.Sampled, null);
|
||||
await repo.RecordPlayAsync("track-1", PlayBucket.Complete, null);
|
||||
|
||||
var counter = await _context.PlayCounters.SingleAsync(c => c.TrackId == track.Id);
|
||||
Assert.That(counter.PartialCount, Is.EqualTo(1));
|
||||
Assert.That(counter.SampledCount, Is.EqualTo(2));
|
||||
Assert.That(counter.CompleteCount, Is.EqualTo(1));
|
||||
Assert.That(counter.TotalPlays, Is.EqualTo(4));
|
||||
Assert.That(await _context.PlayEvents.CountAsync(), Is.EqualTo(4));
|
||||
}
|
||||
|
||||
// Release totals are derived (D4): summing the counters of the release's tracks gives release plays;
|
||||
// there is no separate release-counter row.
|
||||
[Test]
|
||||
public async Task RecordPlayAsync_ReleaseTotalIsSumOfTrackCounters()
|
||||
{
|
||||
var release = new ReleaseEntity
|
||||
{
|
||||
EntryKey = Guid.NewGuid().ToString("N"), Title = "R", Artist = "A", Medium = ReleaseMedium.Cut,
|
||||
};
|
||||
var t1 = new TrackEntity { EntryKey = "t1", TrackName = "T1", Release = release };
|
||||
var t2 = new TrackEntity { EntryKey = "t2", TrackName = "T2", Release = release };
|
||||
_context.Releases.Add(release);
|
||||
_context.Tracks.AddRange(t1, t2);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
var repo = CreateRepository();
|
||||
await repo.RecordPlayAsync("t1", PlayBucket.Complete, null);
|
||||
await repo.RecordPlayAsync("t1", PlayBucket.Partial, null);
|
||||
await repo.RecordPlayAsync("t2", PlayBucket.Sampled, null);
|
||||
|
||||
var releaseTotal = await _context.PlayCounters
|
||||
.Where(c => _context.Tracks.Any(t => t.Id == c.TrackId && t.ReleaseId == release.Id))
|
||||
.SumAsync(c => c.PartialCount + c.SampledCount + c.CompleteCount);
|
||||
|
||||
Assert.That(releaseTotal, Is.EqualTo(3));
|
||||
}
|
||||
|
||||
// A loose track (no release) logs the event with a null release id and still bumps its own counter.
|
||||
[Test]
|
||||
public async Task RecordPlayAsync_LooseTrack_NullReleaseStillCounts()
|
||||
{
|
||||
var track = new TrackEntity { EntryKey = "loose", TrackName = "T" };
|
||||
_context.Tracks.Add(track);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
await CreateRepository().RecordPlayAsync("loose", PlayBucket.Sampled, null);
|
||||
|
||||
var ev = await _context.PlayEvents.SingleAsync();
|
||||
Assert.That(ev.ReleaseId, Is.Null);
|
||||
Assert.That((await _context.PlayCounters.SingleAsync(c => c.TrackId == track.Id)).SampledCount, Is.EqualTo(1));
|
||||
}
|
||||
|
||||
// A play of an unknown/removed track key still logs (null release, no counter bump) rather than failing.
|
||||
[Test]
|
||||
public async Task RecordPlayAsync_UnknownTrackKey_LogsEventWithoutCounter()
|
||||
{
|
||||
await CreateRepository().RecordPlayAsync("does-not-exist", PlayBucket.Partial, null);
|
||||
|
||||
var ev = await _context.PlayEvents.SingleAsync();
|
||||
Assert.That(ev.ReleaseId, Is.Null);
|
||||
Assert.That(await _context.PlayCounters.AnyAsync(), Is.False, "no track to roll up against");
|
||||
}
|
||||
|
||||
// A soft-deleted track resolves to null (the !IsDeleted guard) — the play still logs, no counter bump.
|
||||
[Test]
|
||||
public async Task RecordPlayAsync_SoftDeletedTrack_DoesNotResolveRelease()
|
||||
{
|
||||
var (_, track) = await SeedTrackAsync("gone");
|
||||
track.IsDeleted = true;
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
await CreateRepository().RecordPlayAsync("gone", PlayBucket.Complete, null);
|
||||
|
||||
var ev = await _context.PlayEvents.SingleAsync();
|
||||
Assert.That(ev.ReleaseId, Is.Null);
|
||||
Assert.That(await _context.PlayCounters.AnyAsync(c => c.TrackId == track.Id), Is.False);
|
||||
}
|
||||
|
||||
// A share append writes one row with the target, channel, and a null anonId.
|
||||
[Test]
|
||||
public async Task RecordShareAsync_AppendsRow()
|
||||
{
|
||||
await CreateRepository().RecordShareAsync(ShareTargetType.Release, "rel-key", ShareChannel.Embed, anonId: null);
|
||||
|
||||
var ev = await _context.ShareEvents.SingleAsync();
|
||||
Assert.That(ev.TargetType, Is.EqualTo(ShareTargetType.Release));
|
||||
Assert.That(ev.TargetKey, Is.EqualTo("rel-key"));
|
||||
Assert.That(ev.Channel, Is.EqualTo(ShareChannel.Embed));
|
||||
Assert.That(ev.AnonId, Is.Null);
|
||||
}
|
||||
}
|
||||
@@ -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 <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.
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
using DeepDrftModels.Enums;
|
||||
using DeepDrftPublic.Client.Services;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using Microsoft.JSInterop;
|
||||
using Microsoft.JSInterop.Infrastructure;
|
||||
|
||||
namespace DeepDrftTests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for the Phase 16 share tracker (<see cref="ShareTracker"/>): the per-(target,channel)
|
||||
/// debounce (§1b — at most one event per target+channel per 60s window per session). The tracker fires
|
||||
/// through a beacon that wraps <see cref="IJSRuntime"/>; the tests use a no-op JS runtime (the send is
|
||||
/// fire-and-forget and its outcome is irrelevant) and assert on the debounce decision via the bool the
|
||||
/// recorder returns — true when an event fired, false when debounced.
|
||||
/// </summary>
|
||||
[TestFixture]
|
||||
public class ShareTrackerTests
|
||||
{
|
||||
// sendBeacon interop is fire-and-forget; the tracker never reads the result, so a no-op runtime that
|
||||
// returns default for any invocation is sufficient to exercise the debounce logic.
|
||||
private sealed class NoopJsRuntime : IJSRuntime
|
||||
{
|
||||
public ValueTask<TValue> InvokeAsync<TValue>(string identifier, object?[]? args)
|
||||
=> ValueTask.FromResult<TValue>(default!);
|
||||
|
||||
public ValueTask<TValue> InvokeAsync<TValue>(string identifier, CancellationToken cancellationToken, object?[]? args)
|
||||
=> ValueTask.FromResult<TValue>(default!);
|
||||
}
|
||||
|
||||
// Minimal NavigationManager so the tracker can compose the (unused-in-test) beacon URL.
|
||||
private sealed class TestNavigationManager : NavigationManager
|
||||
{
|
||||
public TestNavigationManager() => Initialize("https://deepdrft.test/", "https://deepdrft.test/");
|
||||
protected override void NavigateToCore(string uri, bool forceLoad) { }
|
||||
}
|
||||
|
||||
private ShareTracker _tracker = null!;
|
||||
private readonly DateTimeOffset _t0 = new(2026, 6, 19, 12, 0, 0, TimeSpan.Zero);
|
||||
|
||||
[SetUp]
|
||||
public void SetUp()
|
||||
=> _tracker = new ShareTracker(new BeaconInterop(new NoopJsRuntime()), new TestNavigationManager());
|
||||
|
||||
// A copy-link records one share with channel = link.
|
||||
[Test]
|
||||
public void RecordShare_CopyLink_FiresOnce()
|
||||
=> Assert.That(_tracker.RecordShare(ShareTargetType.Track, "k", ShareChannel.Link, _t0), Is.True);
|
||||
|
||||
// A copy-embed records one share with channel = embed — distinct (target,channel) from the link copy.
|
||||
[Test]
|
||||
public void RecordShare_CopyEmbedAfterLink_FiresSeparately()
|
||||
{
|
||||
Assert.That(_tracker.RecordShare(ShareTargetType.Track, "k", ShareChannel.Link, _t0), Is.True);
|
||||
Assert.That(_tracker.RecordShare(ShareTargetType.Track, "k", ShareChannel.Embed, _t0), Is.True,
|
||||
"embed is a different channel from link — not debounced against it");
|
||||
}
|
||||
|
||||
// An immediate repeat copy of the same (target, channel) within the window is debounced.
|
||||
[Test]
|
||||
public void RecordShare_ImmediateRepeat_IsDebounced()
|
||||
{
|
||||
Assert.That(_tracker.RecordShare(ShareTargetType.Track, "k", ShareChannel.Link, _t0), Is.True);
|
||||
Assert.That(_tracker.RecordShare(ShareTargetType.Track, "k", ShareChannel.Link, _t0.AddSeconds(5)), Is.False);
|
||||
}
|
||||
|
||||
// After the 60s window elapses, the same (target, channel) fires again.
|
||||
[Test]
|
||||
public void RecordShare_AfterWindow_FiresAgain()
|
||||
{
|
||||
Assert.That(_tracker.RecordShare(ShareTargetType.Track, "k", ShareChannel.Link, _t0), Is.True);
|
||||
Assert.That(_tracker.RecordShare(ShareTargetType.Track, "k", ShareChannel.Link, _t0.AddSeconds(61)), Is.True);
|
||||
}
|
||||
|
||||
// Different targets debounce independently — sharing track A then track B both fire.
|
||||
[Test]
|
||||
public void RecordShare_DifferentTargets_FireIndependently()
|
||||
{
|
||||
Assert.That(_tracker.RecordShare(ShareTargetType.Track, "a", ShareChannel.Link, _t0), Is.True);
|
||||
Assert.That(_tracker.RecordShare(ShareTargetType.Track, "b", ShareChannel.Link, _t0), Is.True);
|
||||
}
|
||||
|
||||
// A track key and a release key are distinct targets even if the key string collides.
|
||||
[Test]
|
||||
public void RecordShare_TrackVsRelease_AreDistinctTargets()
|
||||
{
|
||||
Assert.That(_tracker.RecordShare(ShareTargetType.Track, "x", ShareChannel.Link, _t0), Is.True);
|
||||
Assert.That(_tracker.RecordShare(ShareTargetType.Release, "x", ShareChannel.Link, _t0), Is.True);
|
||||
}
|
||||
|
||||
// A blank target key never fires (defensive — the popover guards too).
|
||||
[Test]
|
||||
public void RecordShare_BlankKey_DoesNotFire()
|
||||
=> Assert.That(_tracker.RecordShare(ShareTargetType.Track, " ", ShareChannel.Link, _t0), Is.False);
|
||||
}
|
||||
Reference in New Issue
Block a user