diff --git a/src/Packages/Audience/Runtime/ImmutableAudience.cs b/src/Packages/Audience/Runtime/ImmutableAudience.cs index 08770f274..bd04abc49 100644 --- a/src/Packages/Audience/Runtime/ImmutableAudience.cs +++ b/src/Packages/Audience/Runtime/ImmutableAudience.cs @@ -52,6 +52,59 @@ public static class ImmutableAudience // assignments from SetConsent without taking _initLock. private static volatile Session? _session; + // Diagnostic getters. Safe from any thread, any time — return a + // zero-value default (false / null / 0 / ConsentLevel.None) outside + // Init..Shutdown. Values are tick-level snapshots, not invariants. + + // Flipped true at the end of a successful Init, false by Shutdown. + public static bool Initialized => _initialized; + + // Persisted value, which may differ from AudienceConfig.Consent if + // a prior session changed it. + public static ConsentLevel CurrentConsent => _state.Level; + + // Last value passed to Identify(). Cleared by Reset and any consent + // downgrade out of Full. + public static string? UserId => _state.UserId; + + // Display-only — Reset and SetConsent(None) wipe it, so it is not + // a stable identifier across sessions. + public static string? AnonymousId + { + get + { + if (!_initialized) return null; + var config = _config; + if (config == null || !_state.Level.CanTrack()) return null; + // PersistentDataPath is validated non-null in Init; compiler can't propagate that. + return Identity.Get(config.PersistentDataPath!); + } + } + + // Changes on extended-pause rollover. Also null while consent is None. + public static string? SessionId => _session?.SessionId; + + // Memory + disk counts are read without holding the drain lock, so + // the sum can drift by a few events. + public static int QueueSize + { + get + { + // Fence off the volatile _initialized load first, matching + // the protocol documented on the reference fields. Without + // this, a weak-memory-order reader could observe + // _initialized=true but _queue/_store still null — the ?. + // short-circuits to 0 in that case, but the inconsistency + // would break the protocol the file claims to follow. + if (!_initialized) return 0; + var queue = _queue; + var store = _store; + var memory = queue?.InMemoryCount ?? 0; + var disk = store?.Count() ?? 0; + return memory + disk; + } + } + // Starts the SDK. Call once at launch. public static void Init(AudienceConfig config) { @@ -720,8 +773,6 @@ internal static void ResetState() } } - internal static ConsentLevel CurrentConsent => _state.Level; - internal static void FlushQueueToDiskForTesting() => _queue?.FlushSync(); // Drives SendBatch without a real timer so the overlapping-tick guard is testable. diff --git a/src/Packages/Audience/Runtime/Transport/EventQueue.cs b/src/Packages/Audience/Runtime/Transport/EventQueue.cs index 66922e3f0..7c457cbb1 100644 --- a/src/Packages/Audience/Runtime/Transport/EventQueue.cs +++ b/src/Packages/Audience/Runtime/Transport/EventQueue.cs @@ -49,6 +49,12 @@ internal EventQueue(DiskStore store, int flushIntervalSeconds, int flushSize) _drainThread.Start(); } + // Approximate count of events currently in the in-memory queue + // awaiting drain to disk. Lock-free read on ConcurrentQueue.Count + // — a snapshot that can race with concurrent enqueue / dequeue. + // Good enough for status-panel display; not an invariant. + internal int InMemoryCount => _memory.Count; + // Enqueues a message dictionary. Lock-free; safe from any thread. // The dictionary is not copied -- callers must not mutate it after // enqueue. Serialisation happens on the drain thread so Track() stays diff --git a/src/Packages/Audience/Tests/Runtime/ImmutableAudienceTests.cs b/src/Packages/Audience/Tests/Runtime/ImmutableAudienceTests.cs index 4e3b05fc0..e2a445a05 100644 --- a/src/Packages/Audience/Tests/Runtime/ImmutableAudienceTests.cs +++ b/src/Packages/Audience/Tests/Runtime/ImmutableAudienceTests.cs @@ -60,6 +60,117 @@ protected override Task SendAsync(HttpRequestMessage reques } } + // ----------------------------------------------------------------- + // Diagnostic getters (Initialized / CurrentConsent / UserId / + // AnonymousId / SessionId / QueueSize) + // ----------------------------------------------------------------- + + [Test] + public void Initialized_FlipsAroundInitAndShutdown() + { + Assert.IsFalse(ImmutableAudience.Initialized, + "Initialized should be false before Init"); + + ImmutableAudience.Init(MakeConfig()); + Assert.IsTrue(ImmutableAudience.Initialized, + "Initialized should flip true after Init"); + + ImmutableAudience.Shutdown(); + Assert.IsFalse(ImmutableAudience.Initialized, + "Initialized should flip back to false after Shutdown"); + } + + [Test] + public void CurrentConsent_ReflectsLatestSetConsent() + { + ImmutableAudience.Init(MakeConfig(ConsentLevel.Anonymous)); + Assert.AreEqual(ConsentLevel.Anonymous, ImmutableAudience.CurrentConsent); + + ImmutableAudience.SetConsent(ConsentLevel.Full); + Assert.AreEqual(ConsentLevel.Full, ImmutableAudience.CurrentConsent); + + ImmutableAudience.SetConsent(ConsentLevel.None); + Assert.AreEqual(ConsentLevel.None, ImmutableAudience.CurrentConsent); + } + + [Test] + public void UserId_Uninitialised_ReturnsNull() + { + Assert.IsNull(ImmutableAudience.UserId); + } + + [Test] + public void UserId_AfterIdentifyAndReset_TracksState() + { + ImmutableAudience.Init(MakeConfig(ConsentLevel.Full)); + Assert.IsNull(ImmutableAudience.UserId, + "UserId should be null until Identify is called"); + + ImmutableAudience.Identify("player-42", IdentityType.Custom); + Assert.AreEqual("player-42", ImmutableAudience.UserId, + "UserId must reflect the most recent Identify call"); + + ImmutableAudience.Reset(); + Assert.IsNull(ImmutableAudience.UserId, + "Reset must clear UserId so the next player is not attributed to the previous one"); + } + + [Test] + public void AnonymousId_ConsentNone_ReturnsNull() + { + // Anonymous identifier is consent-gated: below tracking consent, + // no stable id should leak through the getter. + ImmutableAudience.Init(MakeConfig(ConsentLevel.None)); + + Assert.IsNull(ImmutableAudience.AnonymousId); + } + + [Test] + public void AnonymousId_ConsentAnonymous_ReturnsPersistedId() + { + ImmutableAudience.Init(MakeConfig(ConsentLevel.Anonymous)); + // Track once so Identity.GetOrCreate runs and writes the id file. + ImmutableAudience.Track("warmup_event"); + + var id = ImmutableAudience.AnonymousId; + Assert.IsFalse(string.IsNullOrEmpty(id), + "AnonymousId should return the persisted id once tracking has created one"); + } + + [Test] + public void SessionId_MirrorsSessionLifecycle() + { + Assert.IsNull(ImmutableAudience.SessionId, + "SessionId should be null before Init"); + + ImmutableAudience.Init(MakeConfig(ConsentLevel.Anonymous)); + Assert.IsFalse(string.IsNullOrEmpty(ImmutableAudience.SessionId), + "SessionId should be non-null once Init creates a session"); + + ImmutableAudience.Shutdown(); + Assert.IsNull(ImmutableAudience.SessionId, + "SessionId should be null after Shutdown disposes the session"); + } + + [Test] + public void QueueSize_ZeroBeforeInit_GrowsWithEnqueue() + { + Assert.AreEqual(0, ImmutableAudience.QueueSize, + "QueueSize should be 0 before Init"); + + ImmutableAudience.Init(MakeConfig(ConsentLevel.Anonymous)); + // Init enqueues session_start + game_launch; those stay + // in-memory until a flush. QueueSize sums memory + disk so the + // pre-flush snapshot must be > 0. + var afterInit = ImmutableAudience.QueueSize; + Assert.Greater(afterInit, 0, + "QueueSize should include session_start and game_launch after Init"); + + ImmutableAudience.Track("explicit_track_event"); + Assert.Greater(ImmutableAudience.QueueSize, afterInit, + "QueueSize should grow when a new event is enqueued"); + } + // ----------------------------------------------------------------- // Unity context provider // -----------------------------------------------------------------