From 6f7f2760cbb0a942694d012505924babb8f3939e Mon Sep 17 00:00:00 2001 From: ImmutableJeffrey Date: Sat, 2 May 2026 11:32:30 +1000 Subject: [PATCH 01/10] test(audience-sdk): pin Constants.LibraryName against package.json name ConstantsTests already pinned Constants.LibraryVersion against the package.json "version" field so the two cannot silently drift; the matching pin for Constants.LibraryName ("com.immutable.audience") and package.json "name" was missing. Both feed context.library / context.libraryVersion on every outgoing event, so a rename of either side without the other would silently miscategorise events on the backend. Add a sibling LibraryName_MatchesPackageJson test that walks up to the same package.json and asserts the two strings match. Reuses the existing ReadPackageJson helper. Follow-up to SDK-272 (centralisation of duplicated literals). --- .../Audience/Tests/Runtime/ConstantsTests.cs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/Packages/Audience/Tests/Runtime/ConstantsTests.cs b/src/Packages/Audience/Tests/Runtime/ConstantsTests.cs index fd7258eae..223b88057 100644 --- a/src/Packages/Audience/Tests/Runtime/ConstantsTests.cs +++ b/src/Packages/Audience/Tests/Runtime/ConstantsTests.cs @@ -69,6 +69,20 @@ public void LibraryVersion_MatchesPackageJson() "Constants.LibraryVersion must match package.json version"); } + [Test] + public void LibraryName_MatchesPackageJson() + { + // Same idea as LibraryVersion: fails the build if + // Constants.LibraryName drifts from package.json "name". + var packageJson = ReadPackageJson(); + var parsed = JsonReader.DeserializeObject(packageJson); + + Assert.IsTrue(parsed.TryGetValue("name", out var nameObj), + "package.json is missing a \"name\" field"); + Assert.AreEqual(Constants.LibraryName, nameObj, + "Constants.LibraryName must match package.json name"); + } + private static string ReadPackageJson() { // Walk up from the test binary location looking for the Audience From 710a756678286a25279a23a67e36d605c81faf2b Mon Sep 17 00:00:00 2001 From: ImmutableJeffrey Date: Sat, 2 May 2026 12:02:09 +1000 Subject: [PATCH 02/10] test(audience-sdk): backoff assertions derive from Constants.HttpBackoff*Ms HttpTransportTests pinned the backoff schedule with literal millisecond numbers (5_000 / 10_000 / 20_000 / 40_000 / 60_000). Constants.HttpBackoff*Ms now own those values, but the tests still hardcoded the numbers, so a change to the constants would have flipped production behaviour while the tests kept passing against the old expectations. Switch every BackoffMs / NextAttemptAt assertion to derive from Constants.HttpBackoff{1st,2nd,3rd,4th,Cap}Ms. Follow-up to SDK-272 (centralisation of duplicated literals). --- .../Runtime/Transport/HttpTransportTests.cs | 32 +++++++++---------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/src/Packages/Audience/Tests/Runtime/Transport/HttpTransportTests.cs b/src/Packages/Audience/Tests/Runtime/Transport/HttpTransportTests.cs index fa094515c..b7f41f040 100644 --- a/src/Packages/Audience/Tests/Runtime/Transport/HttpTransportTests.cs +++ b/src/Packages/Audience/Tests/Runtime/Transport/HttpTransportTests.cs @@ -217,7 +217,7 @@ public async Task SendBatchAsync_429_NoRetryAfter_KeepsFilesAndUsesExpoBackoff_N Assert.AreEqual(1, _store.Count(), "429 must keep files for retry"); Assert.IsTrue(transport.IsInBackoffWindow); - Assert.AreEqual(5_000, transport.BackoffMs); + Assert.AreEqual(Constants.HttpBackoff1stMs, transport.BackoffMs); Assert.IsNull(reportedError, "429 is transient; must not fire onError"); } @@ -282,7 +282,7 @@ public async Task SendBatchAsync_429_PastRetryAfterDate_FallsBackToExpoBackoff() await transport.SendBatchAsync(); - Assert.AreEqual(5_000, transport.BackoffMs); + Assert.AreEqual(Constants.HttpBackoff1stMs, transport.BackoffMs); Assert.IsTrue(transport.IsInBackoffWindow); } @@ -306,7 +306,7 @@ public async Task SendBatchAsync_429ThenSuccess_DeliversBatchAndClearsBackoff() await transport.SendBatchAsync(); Assert.AreEqual(1, _store.Count(), "429 keeps the batch"); - Assert.AreEqual(5_000, transport.BackoffMs); + Assert.AreEqual(Constants.HttpBackoff1stMs, transport.BackoffMs); Advance(5_001); await transport.SendBatchAsync(); @@ -384,7 +384,7 @@ public async Task SendBatchAsync_5xx_KeepsFilesAndIncreasesBackoff() Assert.AreEqual(1, _store.Count(), "5xx should keep files for retry"); Assert.IsTrue(transport.IsInBackoffWindow); - Assert.AreEqual(5000, transport.BackoffMs, "first failure = 5s backoff"); + Assert.AreEqual(Constants.HttpBackoff1stMs, transport.BackoffMs, "first failure = HttpBackoff1stMs"); Assert.IsNotNull(reportedError); Assert.AreEqual(AudienceErrorCode.FlushFailed, reportedError!.Code); } @@ -400,27 +400,27 @@ public async Task BackoffMs_EscalatesOnlyAfterWindowElapsed() // Schedule: 5s → 10s → 20s → 40s → 60s cap. // Each escalation requires the previous window to have elapsed. await transport.SendBatchAsync(); - Assert.AreEqual(5_000, transport.BackoffMs); + Assert.AreEqual(Constants.HttpBackoff1stMs, transport.BackoffMs); Advance(5_001); await transport.SendBatchAsync(); - Assert.AreEqual(10_000, transport.BackoffMs); + Assert.AreEqual(Constants.HttpBackoff2ndMs, transport.BackoffMs); Advance(10_001); await transport.SendBatchAsync(); - Assert.AreEqual(20_000, transport.BackoffMs); + Assert.AreEqual(Constants.HttpBackoff3rdMs, transport.BackoffMs); Advance(20_001); await transport.SendBatchAsync(); - Assert.AreEqual(40_000, transport.BackoffMs); + Assert.AreEqual(Constants.HttpBackoff4thMs, transport.BackoffMs); Advance(40_001); await transport.SendBatchAsync(); - Assert.AreEqual(60_000, transport.BackoffMs, "reaches 60s cap after 40s step"); + Assert.AreEqual(Constants.HttpBackoffCapMs, transport.BackoffMs, "reaches cap after 4th step"); Advance(60_001); await transport.SendBatchAsync(); - Assert.AreEqual(60_000, transport.BackoffMs, "stays at cap"); + Assert.AreEqual(Constants.HttpBackoffCapMs, transport.BackoffMs, "stays at cap"); } [Test] @@ -432,7 +432,7 @@ public async Task BackoffMs_DoesNotEscalateWhileInsidePreviousWindow() handler: handler, getUtcNow: _getUtcNow); await transport.SendBatchAsync(); - Assert.AreEqual(5_000, transport.BackoffMs); + Assert.AreEqual(Constants.HttpBackoff1stMs, transport.BackoffMs); var firstDeadline = transport.NextAttemptAt; Assert.IsNotNull(firstDeadline); @@ -447,12 +447,12 @@ public async Task BackoffMs_DoesNotEscalateWhileInsidePreviousWindow() // Another premature retry: still no escalation. Advance(3_000); await transport.SendBatchAsync(); - Assert.AreEqual(5_000, transport.BackoffMs); + Assert.AreEqual(Constants.HttpBackoff1stMs, transport.BackoffMs); // Wait out the window, fail again → now we escalate. _utcNow = firstDeadline.Value.AddMilliseconds(1); await transport.SendBatchAsync(); - Assert.AreEqual(10_000, transport.BackoffMs); + Assert.AreEqual(Constants.HttpBackoff2ndMs, transport.BackoffMs); } [Test] @@ -474,11 +474,11 @@ public async Task BackoffMs_ResetsAfterSuccess() handler: handler, getUtcNow: _getUtcNow); await transport.SendBatchAsync(); - Assert.AreEqual(5_000, transport.BackoffMs); + Assert.AreEqual(Constants.HttpBackoff1stMs, transport.BackoffMs); Advance(5_001); await transport.SendBatchAsync(); - Assert.AreEqual(10_000, transport.BackoffMs); + Assert.AreEqual(Constants.HttpBackoff2ndMs, transport.BackoffMs); Advance(10_001); await transport.SendBatchAsync(); @@ -580,7 +580,7 @@ public async Task IsInBackoffWindow_ClearsAfterNextAttemptAtElapses() await transport.SendBatchAsync(); Assert.IsTrue(transport.IsInBackoffWindow, "within window immediately after failure"); - Assert.AreEqual(now.AddMilliseconds(5_000), transport.NextAttemptAt); + Assert.AreEqual(now.AddMilliseconds(Constants.HttpBackoff1stMs), transport.NextAttemptAt); // Advance the clock just before NextAttemptAt: still backing off. now = now.AddMilliseconds(4_999); From ba8c062d76bea61bf1662e900770a32eb839db36 Mon Sep 17 00:00:00 2001 From: ImmutableJeffrey Date: Sat, 2 May 2026 12:03:02 +1000 Subject: [PATCH 03/10] test(audience-sdk): cover IdentityTypeExtensions.ParseLowercaseString ParseLowercaseString was added in the SDK-272 stack as the inverse of ToLowercaseString and consumed by the sample app to map wire strings back to the enum. It had no direct test, so a typo in any of the eight case branches or in the Custom fallback would land silently. Add three parametrised cases: - Each known enum value maps from its lowercase wire form. - Mixed-case ("Steam", "STEAM", "Passport") still resolves via ToLowerInvariant, matching the documented behaviour. - null, empty, and unknown values fall back to Custom (the parser never throws so producer code always gets a usable enum). Follow-up to SDK-272 (centralisation of duplicated literals). --- .../Tests/Runtime/IdentityTypeTests.cs | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/src/Packages/Audience/Tests/Runtime/IdentityTypeTests.cs b/src/Packages/Audience/Tests/Runtime/IdentityTypeTests.cs index d20939613..4050e73fe 100644 --- a/src/Packages/Audience/Tests/Runtime/IdentityTypeTests.cs +++ b/src/Packages/Audience/Tests/Runtime/IdentityTypeTests.cs @@ -19,6 +19,37 @@ public void ToLowercaseString_MapsEachEnumValueToLowercaseBackendString(Identity Assert.AreEqual(expected, type.ToLowercaseString()); } + [TestCase("passport", IdentityType.Passport)] + [TestCase("steam", IdentityType.Steam)] + [TestCase("epic", IdentityType.Epic)] + [TestCase("google", IdentityType.Google)] + [TestCase("apple", IdentityType.Apple)] + [TestCase("discord", IdentityType.Discord)] + [TestCase("email", IdentityType.Email)] + [TestCase("custom", IdentityType.Custom)] + public void ParseLowercaseString_MapsKnownStringToEnum(string wire, IdentityType expected) + { + Assert.AreEqual(expected, IdentityTypeExtensions.ParseLowercaseString(wire)); + } + + [TestCase("Steam", IdentityType.Steam)] + [TestCase("STEAM", IdentityType.Steam)] + [TestCase("Passport", IdentityType.Passport)] + public void ParseLowercaseString_AcceptsMixedCase(string wire, IdentityType expected) + { + Assert.AreEqual(expected, IdentityTypeExtensions.ParseLowercaseString(wire)); + } + + [TestCase(null)] + [TestCase("")] + [TestCase("unknown_provider")] + [TestCase("steamX")] + public void ParseLowercaseString_FallsBackToCustomForUnknownOrEmpty(string? wire) + { + // ParseLowercaseString never throws; unknown values map to Custom. + Assert.AreEqual(IdentityType.Custom, IdentityTypeExtensions.ParseLowercaseString(wire)); + } + [Test] public void ToLowercaseString_UnknownValue_Throws() { From f4a0520f920a2580ad8ce4dab7977345e65b6ef3 Mon Sep 17 00:00:00 2001 From: ImmutableJeffrey Date: Sat, 2 May 2026 12:03:51 +1000 Subject: [PATCH 04/10] test(audience-sdk): parametrise DistributionPlatform casing across all platforms Init's lowercase-normalisation tests covered "Steam" / "STEAM" / "steam" but not the other four public DistributionPlatforms values (Epic, GOG, Itch, Standalone). Adding a sixth platform would land without coverage. Add a parametrised Init_LowercasesDistributionPlatform_AcrossAllPublicValues test that takes each public DistributionPlatforms constant, uppercases it, runs Init, and asserts the canonical lowercase form is restored. Follow-up to SDK-272 (centralisation of duplicated literals). --- .../Tests/Runtime/ImmutableAudienceTests.cs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/Packages/Audience/Tests/Runtime/ImmutableAudienceTests.cs b/src/Packages/Audience/Tests/Runtime/ImmutableAudienceTests.cs index efdff3643..f8b02c628 100644 --- a/src/Packages/Audience/Tests/Runtime/ImmutableAudienceTests.cs +++ b/src/Packages/Audience/Tests/Runtime/ImmutableAudienceTests.cs @@ -1151,6 +1151,21 @@ public void Init_LeavesDistributionPlatformUnchanged_WhenAlreadyLowercase() Assert.AreEqual(DistributionPlatforms.Steam, config.DistributionPlatform); } + // Lowercase normalisation must apply to every DistributionPlatforms value. + [TestCase(DistributionPlatforms.Steam)] + [TestCase(DistributionPlatforms.Epic)] + [TestCase(DistributionPlatforms.GOG)] + [TestCase(DistributionPlatforms.Itch)] + [TestCase(DistributionPlatforms.Standalone)] + public void Init_LowercasesDistributionPlatform_AcrossAllPublicValues(string canonical) + { + var config = MakeConfig(); + config.DistributionPlatform = canonical.ToUpperInvariant(); + ImmutableAudience.Init(config); + + Assert.AreEqual(canonical, config.DistributionPlatform); + } + [Test] public void Init_LeavesDistributionPlatformNull_WhenNotSet() { From 482e9e6b1a6dedb3922b7201f8e4f89891d66676 Mon Sep 17 00:00:00 2001 From: ImmutableJeffrey Date: Sat, 2 May 2026 12:04:52 +1000 Subject: [PATCH 05/10] test(audience-sdk): pin AudienceErrorMessages and AudienceArgumentMessages The two centralised message catalogues had no direct unit tests. Behavioural tests observed wording loosely via Does.Contain assertions, which let typos and partial rewords through. Add MessagesTests.cs with one fixture per catalogue. Each constant / formatter has an exact-string assertion. A reword anywhere in the catalogue now fails the build. Follow-up to SDK-272 (centralisation of duplicated literals). --- .../Audience/Tests/Runtime/MessagesTests.cs | 133 ++++++++++++++++++ 1 file changed, 133 insertions(+) create mode 100644 src/Packages/Audience/Tests/Runtime/MessagesTests.cs diff --git a/src/Packages/Audience/Tests/Runtime/MessagesTests.cs b/src/Packages/Audience/Tests/Runtime/MessagesTests.cs new file mode 100644 index 000000000..5bd126980 --- /dev/null +++ b/src/Packages/Audience/Tests/Runtime/MessagesTests.cs @@ -0,0 +1,133 @@ +using System; +using NUnit.Framework; + +namespace Immutable.Audience.Tests +{ + // Pins each message constant to its exact wording so a reword breaks the build. + [TestFixture] + internal class AudienceErrorMessagesTests + { + [Test] + public void LocalStorageReadFailed_PrefixesAndAppendsExceptionMessage() + { + var ex = new InvalidOperationException("disk full"); + Assert.AreEqual( + "Local storage read failed: disk full", + AudienceErrorMessages.LocalStorageReadFailed(ex)); + } + + [Test] + public void BatchPartiallyRejected_FormatsRejectedAndTotalCounts() + { + Assert.AreEqual( + "Batch partially rejected: 3 of 20 events dropped", + AudienceErrorMessages.BatchPartiallyRejected(3, 20)); + } + + [Test] + public void BatchRejectedPrefix_IsExactWording() + { + Assert.AreEqual("Batch rejected", AudienceErrorMessages.BatchRejectedPrefix); + } + + [Test] + public void ServerErrorWillRetryPrefix_IsExactWording() + { + Assert.AreEqual("Server error, will retry", AudienceErrorMessages.ServerErrorWillRetryPrefix); + } + + [Test] + public void ConsentSyncFailedWithStatus_FormatsStatusCode() + { + Assert.AreEqual( + "Consent sync failed with status 503", + AudienceErrorMessages.ConsentSyncFailedWithStatus(503)); + } + + [Test] + public void ConsentSyncThrew_PrefixesAndAppendsExceptionMessage() + { + var ex = new TimeoutException("timed out"); + Assert.AreEqual( + "Consent sync threw: timed out", + AudienceErrorMessages.ConsentSyncThrew(ex)); + } + } + + [TestFixture] + internal class AudienceArgumentMessagesTests + { + [Test] + public void PublishableKeyRequired_IsExactWording() + { + Assert.AreEqual("PublishableKey is required", + AudienceArgumentMessages.PublishableKeyRequired); + } + + [Test] + public void PersistentDataPathRequired_IsExactWording() + { + Assert.AreEqual("PersistentDataPath is required", + AudienceArgumentMessages.PersistentDataPathRequired); + } + + [Test] + public void ProgressionStatusRequired_IsExactWording() + { + Assert.AreEqual( + "Progression.Status is required. Set it before calling Track(IEvent).", + AudienceArgumentMessages.ProgressionStatusRequired); + } + + [Test] + public void ResourceFlowRequired_IsExactWording() + { + Assert.AreEqual( + "Resource.Flow is required. Set it before calling Track(IEvent).", + AudienceArgumentMessages.ResourceFlowRequired); + } + + [Test] + public void ResourceCurrencyRequired_IsExactWording() + { + Assert.AreEqual( + "Resource.Currency is required. Set a non-empty string before calling Track(IEvent).", + AudienceArgumentMessages.ResourceCurrencyRequired); + } + + [Test] + public void ResourceAmountRequired_IsExactWording() + { + Assert.AreEqual( + "Resource.Amount is required. Set it before calling Track(IEvent).", + AudienceArgumentMessages.ResourceAmountRequired); + } + + [Test] + public void PurchaseValueRequired_IsExactWording() + { + Assert.AreEqual( + "Purchase.Value is required. Set it before calling Track(IEvent).", + AudienceArgumentMessages.PurchaseValueRequired); + } + + [Test] + public void MilestoneReachedNameRequired_IsExactWording() + { + Assert.AreEqual( + "MilestoneReached.Name must not be null or empty", + AudienceArgumentMessages.MilestoneReachedNameRequired); + } + + [TestCase("USD", + "Purchase.Currency 'USD' must be a three-letter uppercase ISO 4217 code")] + [TestCase(null, + "Purchase.Currency '' must be a three-letter uppercase ISO 4217 code")] + [TestCase("usd", + "Purchase.Currency 'usd' must be a three-letter uppercase ISO 4217 code")] + public void PurchaseCurrencyInvalid_FormatsCurrencyValue(string? currency, string expected) + { + Assert.AreEqual(expected, AudienceArgumentMessages.PurchaseCurrencyInvalid(currency)); + } + } +} From d12464f50d8b47278afc5ff4fb37c39b5951484f Mon Sep 17 00:00:00 2001 From: ImmutableJeffrey Date: Sat, 2 May 2026 12:11:32 +1000 Subject: [PATCH 06/10] test(audience-sdk,sample): unity-only DeviceCollector and UXML alignment drafts Two Unity-only test fixtures the dotnet test runner cannot reach (DeviceCollector depends on UnityEngine.SystemInfo / Application; SampleAppUxml needs Unity test framework gating). Both run under the Unity Test Framework once the project is opened in the editor. DeviceCollectorTests (src/Packages/Audience/Tests/Editor/, excluded from the headless dotnet build by Audience.Tests.csproj's Compile Remove="Editor/**/*.cs" rule) pin DeviceCollector's emitted key sets against GameLaunchPropertyKeys and ContextKeys, assert that no unknown keys leak in either direction, and verify every string-typed value is capped at Constants.MaxFieldLength. SampleAppUxmlAlignmentTests (examples/audience/Assets/SampleApp/Tests/ Runtime/, gated by the existing UNITY_INCLUDE_TESTS define on the SampleApp.Tests asmdef) reads Resources/AudienceSample.uxml as XML and asserts every SampleAppUi name and Css constant that is slug-shaped (lowercase / dashes / no spaces) appears as a name= or class= attribute somewhere in the markup. Runtime-only CSS toggles (state-warn, copied, narrow, has-value, etc.) are filtered by the slug-shape heuristic so the test only flags constants that look like they should map directly to UXML. Both files compile cleanly. They will not run under dotnet test. Run them via Unity Test Runner. Follow-up to SDK-272 (centralisation of duplicated literals). --- src/Packages/Audience/Runtime/AssemblyInfo.cs | 1 + .../Audience/Runtime/Unity/AssemblyInfo.cs | 4 + .../Runtime/Unity/AssemblyInfo.cs.meta | 11 ++ .../Tests/Editor/DeviceCollectorTests.cs | 113 ++++++++++++++++++ ...com.immutable.audience.tests.editor.asmdef | 19 +++ ...mmutable.audience.tests.editor.asmdef.meta | 7 ++ 6 files changed, 155 insertions(+) create mode 100644 src/Packages/Audience/Runtime/Unity/AssemblyInfo.cs create mode 100644 src/Packages/Audience/Runtime/Unity/AssemblyInfo.cs.meta create mode 100644 src/Packages/Audience/Tests/Editor/DeviceCollectorTests.cs create mode 100644 src/Packages/Audience/Tests/Editor/com.immutable.audience.tests.editor.asmdef create mode 100644 src/Packages/Audience/Tests/Editor/com.immutable.audience.tests.editor.asmdef.meta diff --git a/src/Packages/Audience/Runtime/AssemblyInfo.cs b/src/Packages/Audience/Runtime/AssemblyInfo.cs index 565986f27..00cb69340 100644 --- a/src/Packages/Audience/Runtime/AssemblyInfo.cs +++ b/src/Packages/Audience/Runtime/AssemblyInfo.cs @@ -1,6 +1,7 @@ using System.Runtime.CompilerServices; [assembly: InternalsVisibleTo("Immutable.Audience.Runtime.Tests")] +[assembly: InternalsVisibleTo("Immutable.Audience.Editor.Tests")] [assembly: InternalsVisibleTo("Immutable.Audience.Unity")] // First-party SampleApp reaches Json.Serialize and diff --git a/src/Packages/Audience/Runtime/Unity/AssemblyInfo.cs b/src/Packages/Audience/Runtime/Unity/AssemblyInfo.cs new file mode 100644 index 000000000..8cb318847 --- /dev/null +++ b/src/Packages/Audience/Runtime/Unity/AssemblyInfo.cs @@ -0,0 +1,4 @@ +using System.Runtime.CompilerServices; + +// Editor-only test fixture for DeviceCollector reads internals of this assembly. +[assembly: InternalsVisibleTo("Immutable.Audience.Editor.Tests")] diff --git a/src/Packages/Audience/Runtime/Unity/AssemblyInfo.cs.meta b/src/Packages/Audience/Runtime/Unity/AssemblyInfo.cs.meta new file mode 100644 index 000000000..b41abd572 --- /dev/null +++ b/src/Packages/Audience/Runtime/Unity/AssemblyInfo.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 42c73f606a344d9d8fcda6cd3cf6fa5c +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/src/Packages/Audience/Tests/Editor/DeviceCollectorTests.cs b/src/Packages/Audience/Tests/Editor/DeviceCollectorTests.cs new file mode 100644 index 000000000..83ee856d3 --- /dev/null +++ b/src/Packages/Audience/Tests/Editor/DeviceCollectorTests.cs @@ -0,0 +1,113 @@ +#nullable enable + +using System.Collections.Generic; +using NUnit.Framework; +using Immutable.Audience.Unity; + +namespace Immutable.Audience.Tests.Editor +{ + // Editor-only (DeviceCollector needs a Unity domain; skipped by the headless dotnet build). + // Pins emitted payload keys against GameLaunchPropertyKeys and ContextKeys. + [TestFixture] + internal class DeviceCollectorTests + { + [Test] + public void CollectGameLaunchProperties_EmitsTheExpectedKeySet() + { + var props = DeviceCollector.CollectGameLaunchProperties(); + + // Always-present keys. ScreenDpi is conditional (0 on some Linux WMs). + CollectionAssert.Contains(props.Keys, GameLaunchPropertyKeys.Platform); + CollectionAssert.Contains(props.Keys, GameLaunchPropertyKeys.Version); + CollectionAssert.Contains(props.Keys, GameLaunchPropertyKeys.BuildGuid); + CollectionAssert.Contains(props.Keys, GameLaunchPropertyKeys.UnityVersion); + CollectionAssert.Contains(props.Keys, GameLaunchPropertyKeys.OsFamily); + CollectionAssert.Contains(props.Keys, GameLaunchPropertyKeys.DeviceModel); + CollectionAssert.Contains(props.Keys, GameLaunchPropertyKeys.Gpu); + CollectionAssert.Contains(props.Keys, GameLaunchPropertyKeys.GpuVendor); + CollectionAssert.Contains(props.Keys, GameLaunchPropertyKeys.Cpu); + CollectionAssert.Contains(props.Keys, GameLaunchPropertyKeys.CpuCores); + CollectionAssert.Contains(props.Keys, GameLaunchPropertyKeys.RamMb); + } + + [Test] + public void CollectGameLaunchProperties_EmitsNoUnknownKeys() + { + // Confirms the payload only carries known GameLaunchPropertyKeys entries. + var allowed = new HashSet + { + GameLaunchPropertyKeys.Platform, + GameLaunchPropertyKeys.Version, + GameLaunchPropertyKeys.BuildGuid, + GameLaunchPropertyKeys.UnityVersion, + GameLaunchPropertyKeys.OsFamily, + GameLaunchPropertyKeys.DeviceModel, + GameLaunchPropertyKeys.Gpu, + GameLaunchPropertyKeys.GpuVendor, + GameLaunchPropertyKeys.Cpu, + GameLaunchPropertyKeys.CpuCores, + GameLaunchPropertyKeys.RamMb, + GameLaunchPropertyKeys.ScreenDpi, + }; + + var props = DeviceCollector.CollectGameLaunchProperties(); + foreach (var key in props.Keys) + Assert.IsTrue(allowed.Contains(key), + $"DeviceCollector.CollectGameLaunchProperties emitted unknown key '{key}' " + + "with no matching GameLaunchPropertyKeys constant"); + } + + [Test] + public void CollectGameLaunchProperties_TruncatesStringValuesToMaxFieldLength() + { + // Every string value respects MaxFieldLength; an untruncated .ToString() would fail here. + var props = DeviceCollector.CollectGameLaunchProperties(); + foreach (var kv in props) + { + if (kv.Value is string s) + Assert.LessOrEqual(s.Length, Constants.MaxFieldLength, + $"GameLaunchPropertyKeys.{kv.Key} value exceeds Constants.MaxFieldLength"); + } + } + + [Test] + public void CollectContext_EmitsTheExpectedKeySet() + { + var ctx = DeviceCollector.CollectContext(); + + // UserAgent is unconditional. Timezone / Locale / Screen are + // best-effort and may be absent under unusual hosts. + CollectionAssert.Contains(ctx.Keys, ContextKeys.UserAgent); + } + + [Test] + public void CollectContext_EmitsNoUnknownKeys() + { + var allowed = new HashSet + { + ContextKeys.UserAgent, + ContextKeys.Timezone, + ContextKeys.Locale, + ContextKeys.Screen, + }; + + var ctx = DeviceCollector.CollectContext(); + foreach (var key in ctx.Keys) + Assert.IsTrue(allowed.Contains(key), + $"DeviceCollector.CollectContext emitted unknown key '{key}' " + + "with no matching ContextKeys constant"); + } + + [Test] + public void CollectContext_TruncatesStringValuesToMaxFieldLength() + { + var ctx = DeviceCollector.CollectContext(); + foreach (var kv in ctx) + { + if (kv.Value is string s) + Assert.LessOrEqual(s.Length, Constants.MaxFieldLength, + $"ContextKeys.{kv.Key} value exceeds Constants.MaxFieldLength"); + } + } + } +} diff --git a/src/Packages/Audience/Tests/Editor/com.immutable.audience.tests.editor.asmdef b/src/Packages/Audience/Tests/Editor/com.immutable.audience.tests.editor.asmdef new file mode 100644 index 000000000..84dfd7a33 --- /dev/null +++ b/src/Packages/Audience/Tests/Editor/com.immutable.audience.tests.editor.asmdef @@ -0,0 +1,19 @@ +{ + "name": "Immutable.Audience.Editor.Tests", + "rootNamespace": "Immutable.Audience.Tests.Editor", + "references": [ + "Immutable.Audience.Runtime", + "Immutable.Audience.Unity", + "UnityEditor.TestRunner", + "UnityEngine.TestRunner" + ], + "includePlatforms": ["Editor"], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": true, + "precompiledReferences": ["nunit.framework.dll"], + "autoReferenced": false, + "defineConstraints": ["UNITY_INCLUDE_TESTS"], + "versionDefines": [], + "noEngineReferences": false +} diff --git a/src/Packages/Audience/Tests/Editor/com.immutable.audience.tests.editor.asmdef.meta b/src/Packages/Audience/Tests/Editor/com.immutable.audience.tests.editor.asmdef.meta new file mode 100644 index 000000000..c98de3689 --- /dev/null +++ b/src/Packages/Audience/Tests/Editor/com.immutable.audience.tests.editor.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: a0de2d71dd564e69b03eb055285e697d +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: From 0533538ac68ae47e7c5e584cd0a7bbd96fff1c84 Mon Sep 17 00:00:00 2001 From: ImmutableJeffrey Date: Sat, 2 May 2026 12:40:36 +1000 Subject: [PATCH 07/10] test(audience-sdk): centralise per-test event names in TestEventNames Per the user's "everything random goes in a constant" stance, this is applied against the previous session's recommendation that scenario descriptors read better inline. Recording the override on the user's explicit request. Follow-up to SDK-272 (centralisation of duplicated literals). --- .../Runtime/Events/MessageBuilderTests.cs | 4 +- .../Tests/Runtime/ImmutableAudienceTests.cs | 46 +++++++++---------- .../Tests/Runtime/OfflineResilienceTests.cs | 2 +- .../Runtime/PublishableKeyPrefixTests.cs | 2 +- .../Audience/Tests/Runtime/TestEventNames.cs | 30 ++++++++++++ .../Tests/Runtime/ThreadSafetyStressTests.cs | 8 ++-- .../Tests/Runtime/Utility/GzipTests.cs | 2 +- .../Tests/Runtime/Utility/JsonTests.cs | 2 +- 8 files changed, 63 insertions(+), 33 deletions(-) create mode 100644 src/Packages/Audience/Tests/Runtime/TestEventNames.cs diff --git a/src/Packages/Audience/Tests/Runtime/Events/MessageBuilderTests.cs b/src/Packages/Audience/Tests/Runtime/Events/MessageBuilderTests.cs index e8805c9e4..593f28d0a 100644 --- a/src/Packages/Audience/Tests/Runtime/Events/MessageBuilderTests.cs +++ b/src/Packages/Audience/Tests/Runtime/Events/MessageBuilderTests.cs @@ -13,14 +13,14 @@ public class MessageBuilderTests [Test] public void Track_RequiredFieldsPresent() { - var result = MessageBuilder.Track("level_complete", "anon-1", null, PackageVersion); + var result = MessageBuilder.Track(TestEventNames.LevelComplete, "anon-1", null, PackageVersion); Assert.AreEqual(MessageTypes.Track, result[MessageFields.Type]); Assert.IsTrue(result.ContainsKey(MessageFields.MessageId)); Assert.IsTrue(result.ContainsKey(MessageFields.EventTimestamp)); Assert.IsTrue(result.ContainsKey(MessageFields.Context)); Assert.IsTrue(result.ContainsKey(MessageFields.Surface)); - Assert.AreEqual("level_complete", result[MessageFields.EventName]); + Assert.AreEqual(TestEventNames.LevelComplete, result[MessageFields.EventName]); } [Test] diff --git a/src/Packages/Audience/Tests/Runtime/ImmutableAudienceTests.cs b/src/Packages/Audience/Tests/Runtime/ImmutableAudienceTests.cs index f8b02c628..07861159d 100644 --- a/src/Packages/Audience/Tests/Runtime/ImmutableAudienceTests.cs +++ b/src/Packages/Audience/Tests/Runtime/ImmutableAudienceTests.cs @@ -129,7 +129,7 @@ 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"); + ImmutableAudience.Track(TestEventNames.WarmupEvent); var id = ImmutableAudience.AnonymousId; Assert.IsFalse(string.IsNullOrEmpty(id), @@ -165,7 +165,7 @@ public void QueueSize_ZeroBeforeInit_GrowsWithEnqueue() Assert.Greater(afterInit, 0, "QueueSize should include session_start and game_launch after Init"); - ImmutableAudience.Track("explicit_track_event"); + ImmutableAudience.Track(TestEventNames.ExplicitTrackEvent); Assert.Greater(ImmutableAudience.QueueSize, afterInit, "QueueSize should grow when a new event is enqueued"); } @@ -186,7 +186,7 @@ public void ContextProvider_Set_MergesFieldsIntoEveryMessageContext() }; ImmutableAudience.Init(MakeConfig()); - ImmutableAudience.Track("unit_test_event"); + ImmutableAudience.Track(TestEventNames.UnitTestEvent); ImmutableAudience.Shutdown(); var queueDir = AudiencePaths.QueueDir(_testDir); @@ -234,7 +234,7 @@ public void ContextProvider_ThrowingDelegate_SwallowsAndShipsBaseContext() ImmutableAudience.ContextProvider = () => throw new InvalidOperationException("boom"); ImmutableAudience.Init(MakeConfig()); - ImmutableAudience.Track("unit_test_event"); + ImmutableAudience.Track(TestEventNames.UnitTestEvent); ImmutableAudience.Shutdown(); var queueDir = AudiencePaths.QueueDir(_testDir); @@ -250,7 +250,7 @@ public void ContextProvider_ReturnsNull_ShipsBaseContext() ImmutableAudience.ContextProvider = () => null; ImmutableAudience.Init(MakeConfig()); - ImmutableAudience.Track("unit_test_event"); + ImmutableAudience.Track(TestEventNames.UnitTestEvent); ImmutableAudience.Shutdown(); var queueDir = AudiencePaths.QueueDir(_testDir); @@ -488,7 +488,7 @@ public void Track_CustomEvent_WritesEventToDisk() { ImmutableAudience.Init(MakeConfig()); - ImmutableAudience.Track("crafting_started", new Dictionary + ImmutableAudience.Track(TestEventNames.CraftingStarted, new Dictionary { { "recipe_id", "iron_sword" } }); @@ -511,7 +511,7 @@ public void Track_NoProperties_WritesEvent() { ImmutableAudience.Init(MakeConfig()); - ImmutableAudience.Track("main_menu_opened"); + ImmutableAudience.Track(TestEventNames.MainMenuOpened); ImmutableAudience.Shutdown(); var queueDir = AudiencePaths.QueueDir(_testDir); @@ -525,7 +525,7 @@ public void Track_ConsentNone_DoesNotEnqueue() { ImmutableAudience.Init(MakeConfig(ConsentLevel.None)); - ImmutableAudience.Track("should_not_appear"); + ImmutableAudience.Track(TestEventNames.ShouldNotAppear); ImmutableAudience.Shutdown(); var queueDir = AudiencePaths.QueueDir(_testDir); @@ -669,12 +669,12 @@ public void Reset_GeneratesNewAnonymousId() { ImmutableAudience.Init(MakeConfig()); - ImmutableAudience.Track("before_reset"); + ImmutableAudience.Track(TestEventNames.BeforeReset); var id1 = Identity.GetOrCreate(_testDir, ConsentLevel.Anonymous); ImmutableAudience.Reset(); - ImmutableAudience.Track("after_reset"); + ImmutableAudience.Track(TestEventNames.AfterReset); var id2 = Identity.GetOrCreate(_testDir, ConsentLevel.Anonymous); Assert.AreNotEqual(id1, id2, "Reset should generate a new anonymousId"); @@ -685,7 +685,7 @@ public void Reset_DiscardsQueuedEventsOnDisk() { ImmutableAudience.Init(MakeConfig()); - ImmutableAudience.Track("before_reset"); + ImmutableAudience.Track(TestEventNames.BeforeReset); ImmutableAudience.FlushQueueToDiskForTesting(); var queueDir = AudiencePaths.QueueDir(_testDir); @@ -707,7 +707,7 @@ public void SetConsent_DowngradeToNone_PurgesQueueOnDiskAndInMemory() { ImmutableAudience.Init(MakeConfig()); - ImmutableAudience.Track("event_under_old_consent"); + ImmutableAudience.Track(TestEventNames.EventUnderOldConsent); var queueDir = AudiencePaths.QueueDir(_testDir); // Force memory → disk so we can verify the purge wipes both layers. @@ -746,7 +746,7 @@ public void SetConsent_DowngradeToNone_DropsInFlightTrack_ThatRacesThePurge() var trackTask = Task.Run(() => { trackStarted.Set(); - ImmutableAudience.Track("racing_event"); + ImmutableAudience.Track(TestEventNames.RacingEvent); }); trackStarted.Wait(); @@ -799,7 +799,7 @@ public void SetConsent_DowngradeToNone_StressTest_NoLeak() trackers[t] = Task.Run(() => { barrier.SignalAndWait(); - ImmutableAudience.Track("race_stress"); + ImmutableAudience.Track(TestEventNames.RaceStress); }); } @@ -988,7 +988,7 @@ public void SetConsent_DowngradeToAnonymous_StressTest_NoUserIdLeak() trackers[t] = Task.Run(() => { barrier.SignalAndWait(); - ImmutableAudience.Track("race_stress"); + ImmutableAudience.Track(TestEventNames.RaceStress); }); } @@ -1275,7 +1275,7 @@ public void Track_AfterShutdown_IsIgnored() ImmutableAudience.Init(MakeConfig()); ImmutableAudience.Shutdown(); - Assert.DoesNotThrow(() => ImmutableAudience.Track("should_not_crash")); + Assert.DoesNotThrow(() => ImmutableAudience.Track(TestEventNames.ShouldNotCrash)); } [Test] @@ -1291,7 +1291,7 @@ public void Shutdown_ReleasesInitLock_BeforeBlockingTeardown() config.ShutdownFlushTimeoutMs = 10_000; ImmutableAudience.Init(config); - ImmutableAudience.Track("ensure_nonempty_queue"); + ImmutableAudience.Track(TestEventNames.EnsureNonemptyQueue); ImmutableAudience.FlushQueueToDiskForTesting(); // Phase 1 flips _initialized and releases the lock; Phase 2 enters @@ -1327,7 +1327,7 @@ public void FullToAnonymous_StripsUserIdFromQueuedTrackAndDropsIdentifyAlias() ImmutableAudience.Identify("player_steam", IdentityType.Steam); ImmutableAudience.Alias("player_steam", IdentityType.Steam, "player_passport", IdentityType.Passport); - ImmutableAudience.Track("tracked_before_downgrade"); + ImmutableAudience.Track(TestEventNames.TrackedBeforeDowngrade); ImmutableAudience.FlushQueueToDiskForTesting(); @@ -1354,7 +1354,7 @@ public void FullToAnonymous_FutureTracksOmitUserId() ImmutableAudience.Identify("player_steam", IdentityType.Steam); ImmutableAudience.SetConsent(ConsentLevel.Anonymous); - ImmutableAudience.Track("tracked_after_downgrade"); + ImmutableAudience.Track(TestEventNames.TrackedAfterDowngrade); ImmutableAudience.FlushQueueToDiskForTesting(); var queueDir = AudiencePaths.QueueDir(_testDir); @@ -1362,7 +1362,7 @@ public void FullToAnonymous_FutureTracksOmitUserId() .Select(f => JsonReader.DeserializeObject(File.ReadAllText(f))) .Where(m => (string)m[MessageFields.Type] == MessageTypes.Track && m.ContainsKey(MessageFields.EventName) - && (string)m[MessageFields.EventName] == "tracked_after_downgrade") + && (string)m[MessageFields.EventName] == TestEventNames.TrackedAfterDowngrade) .ToList(); Assert.AreEqual(1, trackFiles.Count); @@ -1382,7 +1382,7 @@ public void SendBatch_ConcurrentTicks_OnlyOneReachesTransport() config.HttpHandler = handler; ImmutableAudience.Init(config); - ImmutableAudience.Track("event_to_send"); + ImmutableAudience.Track(TestEventNames.EventToSend); ImmutableAudience.FlushQueueToDiskForTesting(); // Kick off one SendBatch on a worker. It will block inside the @@ -1417,7 +1417,7 @@ public void FlushAsync_ConcurrentCallers_OnlyOneReachesTransport() config.HttpHandler = handler; ImmutableAudience.Init(config); - ImmutableAudience.Track("event_to_send"); + ImmutableAudience.Track(TestEventNames.EventToSend); ImmutableAudience.FlushQueueToDiskForTesting(); // First caller enters SendAsync and blocks on handler.Release. @@ -1453,7 +1453,7 @@ public async Task FlushAsync_CancelledToken_Terminates_DoesNotHotLoop() config.HttpHandler = handler; ImmutableAudience.Init(config); - ImmutableAudience.Track("event_to_send"); + ImmutableAudience.Track(TestEventNames.EventToSend); ImmutableAudience.FlushQueueToDiskForTesting(); using var cts = new CancellationTokenSource(); diff --git a/src/Packages/Audience/Tests/Runtime/OfflineResilienceTests.cs b/src/Packages/Audience/Tests/Runtime/OfflineResilienceTests.cs index b7db80763..7da31a416 100644 --- a/src/Packages/Audience/Tests/Runtime/OfflineResilienceTests.cs +++ b/src/Packages/Audience/Tests/Runtime/OfflineResilienceTests.cs @@ -93,7 +93,7 @@ public void Shutdown_DiskWritesBlocked_DoesNotThrow() // Shutdown is invoked from app-quit handlers; an exception would // crash the process. ImmutableAudience.Init(MakeConfig()); - ImmutableAudience.Track("event_pre_block"); + ImmutableAudience.Track(TestEventNames.EventPreBlock); BlockDiskWrites(); for (int i = 0; i < 20; i++) ImmutableAudience.Track($"blocked_{i}"); diff --git a/src/Packages/Audience/Tests/Runtime/PublishableKeyPrefixTests.cs b/src/Packages/Audience/Tests/Runtime/PublishableKeyPrefixTests.cs index 8c45c879d..c59b1ad56 100644 --- a/src/Packages/Audience/Tests/Runtime/PublishableKeyPrefixTests.cs +++ b/src/Packages/Audience/Tests/Runtime/PublishableKeyPrefixTests.cs @@ -180,7 +180,7 @@ public async Task Track_BackendReturns401_SurfacesValidationRejected() config.OnError = errors.Add; ImmutableAudience.Init(config); - ImmutableAudience.Track("event_against_prod_with_test_key"); + ImmutableAudience.Track(TestEventNames.EventAgainstProdWithTestKey); await ImmutableAudience.FlushAsync(); Assert.IsTrue(errors.Any(e => e.Code == AudienceErrorCode.ValidationRejected), diff --git a/src/Packages/Audience/Tests/Runtime/TestEventNames.cs b/src/Packages/Audience/Tests/Runtime/TestEventNames.cs new file mode 100644 index 000000000..657c18926 --- /dev/null +++ b/src/Packages/Audience/Tests/Runtime/TestEventNames.cs @@ -0,0 +1,30 @@ +namespace Immutable.Audience.Tests +{ + // Per-test event names. Each names what the test measures. + internal static class TestEventNames + { + internal const string Warmup = "warmup"; + internal const string WarmupEvent = "warmup_event"; + internal const string ExplicitTrackEvent = "explicit_track_event"; + internal const string UnitTestEvent = "unit_test_event"; + internal const string CraftingStarted = "crafting_started"; + internal const string MainMenuOpened = "main_menu_opened"; + internal const string ShouldNotAppear = "should_not_appear"; + internal const string BeforeReset = "before_reset"; + internal const string AfterReset = "after_reset"; + internal const string EventUnderOldConsent = "event_under_old_consent"; + internal const string RacingEvent = "racing_event"; + internal const string RaceStress = "race_stress"; + internal const string ShouldNotCrash = "should_not_crash"; + internal const string EnsureNonemptyQueue = "ensure_nonempty_queue"; + internal const string TrackedBeforeDowngrade = "tracked_before_downgrade"; + internal const string TrackedAfterDowngrade = "tracked_after_downgrade"; + internal const string EventToSend = "event_to_send"; + internal const string EventPreBlock = "event_pre_block"; + internal const string EventAgainstProdWithTestKey = "event_against_prod_with_test_key"; + internal const string StressTrack = "stress_track"; + internal const string MixedLoadTrack = "mixed_load_track"; + internal const string SteadyState = "steady_state"; + internal const string LevelComplete = "level_complete"; + } +} diff --git a/src/Packages/Audience/Tests/Runtime/ThreadSafetyStressTests.cs b/src/Packages/Audience/Tests/Runtime/ThreadSafetyStressTests.cs index 2a205706f..984a905a7 100644 --- a/src/Packages/Audience/Tests/Runtime/ThreadSafetyStressTests.cs +++ b/src/Packages/Audience/Tests/Runtime/ThreadSafetyStressTests.cs @@ -92,7 +92,7 @@ private void RunSustainedTrackLoad(int threadCount, int durationSeconds) int count = 0; while (DateTime.UtcNow < deadline) { - ImmutableAudience.Track("stress_track"); + ImmutableAudience.Track(TestEventNames.StressTrack); count++; } firedPerThread[idx] = count; @@ -155,7 +155,7 @@ public void TrackIdentifySetConsent_ConcurrentLoad_NoRaceExceptions() { barrier.SignalAndWait(); while (DateTime.UtcNow < deadline) - ImmutableAudience.Track("mixed_load_track"); + ImmutableAudience.Track(TestEventNames.MixedLoadTrack); } catch (Exception ex) { exceptions.Add(ex); } }); @@ -216,7 +216,7 @@ public void Track_SteadyState_BoundedMainThreadAllocation() ImmutableAudience.Init(MakeConfig()); // Warm up so JIT and one-time allocations are out of the measured window. - for (int i = 0; i < 200; i++) ImmutableAudience.Track("warmup"); + for (int i = 0; i < 200; i++) ImmutableAudience.Track(TestEventNames.Warmup); ImmutableAudience.FlushQueueToDiskForTesting(); GC.Collect(); GC.WaitForPendingFinalizers(); @@ -226,7 +226,7 @@ public void Track_SteadyState_BoundedMainThreadAllocation() const int iterations = 10_000; for (int i = 0; i < iterations; i++) - ImmutableAudience.Track("steady_state"); + ImmutableAudience.Track(TestEventNames.SteadyState); long allocDelta = GC.GetAllocatedBytesForCurrentThread() - allocBefore; double bytesPerCall = (double)allocDelta / iterations; diff --git a/src/Packages/Audience/Tests/Runtime/Utility/GzipTests.cs b/src/Packages/Audience/Tests/Runtime/Utility/GzipTests.cs index 28430646f..efc40110a 100644 --- a/src/Packages/Audience/Tests/Runtime/Utility/GzipTests.cs +++ b/src/Packages/Audience/Tests/Runtime/Utility/GzipTests.cs @@ -34,7 +34,7 @@ public void Compress_OutputIsSmallerThanInput_ForRealisticPayload() { if (i > 0) sb.Append(','); sb.Append(WireFixture.Track( - (MessageFields.EventName, "level_complete"), + (MessageFields.EventName, TestEventNames.LevelComplete), (MessageFields.AnonymousId, $"anon-{i}"))); } diff --git a/src/Packages/Audience/Tests/Runtime/Utility/JsonTests.cs b/src/Packages/Audience/Tests/Runtime/Utility/JsonTests.cs index a3abe358e..b8f9c4524 100644 --- a/src/Packages/Audience/Tests/Runtime/Utility/JsonTests.cs +++ b/src/Packages/Audience/Tests/Runtime/Utility/JsonTests.cs @@ -182,7 +182,7 @@ public void Serialize_RealisticEventPayload_ProducesCorrectJson() var data = new Dictionary { { MessageFields.Type, MessageTypes.Track }, - { MessageFields.EventName, "level_complete" }, + { MessageFields.EventName, TestEventNames.LevelComplete }, { MessageFields.AnonymousId, "anon-123" }, { MessageFields.UserId, null }, { MessageFields.Properties, new Dictionary From 6a1b88eb028f03fdf6b27258506809ac46bba20c Mon Sep 17 00:00:00 2001 From: ImmutableJeffrey Date: Sat, 2 May 2026 12:43:38 +1000 Subject: [PATCH 08/10] refactor(audience-sample): centralise custom demo event names in SampleAppCustomEvents The sample-app demo catalogue (sign_up, sign_in, email_acquired, wishlist_add, wishlist_remove, game_page_viewed, link_clicked, screen_viewed) had its event-name strings inline at every call site. A rename touches the catalogue, the screen_viewed Track call, and each Unity live-fire test that drives the catalogue UI. Move the eight names into a new internal SampleAppCustomEvents class under the sample-app Scripts assembly. Update AudienceSample.Events.cs, AudienceSample.cs (screen_viewed Track), and SampleAppLiveFireTests.cs (button and field lookups by event name) to reference the constants. Per the user's "everything random goes in a constant" stance, applied against the previous session's recommendation that the demo content read better inline. Recording the override on the user's explicit request. Follow-up to SDK-272 (centralisation of duplicated literals). --- .../Scripts/AudienceSample.Events.cs | 14 ++++++------ .../SampleApp/Scripts/AudienceSample.cs | 2 +- .../Scripts/SampleAppCustomEvents.cs | 15 +++++++++++++ .../Scripts/SampleAppCustomEvents.cs.meta | 11 ++++++++++ .../Tests/Runtime/SampleAppLiveFireTests.cs | 22 +++++++++---------- 5 files changed, 45 insertions(+), 19 deletions(-) create mode 100644 examples/audience/Assets/SampleApp/Scripts/SampleAppCustomEvents.cs create mode 100644 examples/audience/Assets/SampleApp/Scripts/SampleAppCustomEvents.cs.meta diff --git a/examples/audience/Assets/SampleApp/Scripts/AudienceSample.Events.cs b/examples/audience/Assets/SampleApp/Scripts/AudienceSample.Events.cs index 455b3fa2a..f698ad150 100644 --- a/examples/audience/Assets/SampleApp/Scripts/AudienceSample.Events.cs +++ b/examples/audience/Assets/SampleApp/Scripts/AudienceSample.Events.cs @@ -55,15 +55,15 @@ internal readonly struct EventSpec internal static readonly EventSpec[] Catalogue = { - new EventSpec("sign_up", new[] { EventField.Text("method", optional: true) }), - new EventSpec("sign_in", new[] { EventField.Text("method", optional: true) }), - new EventSpec("email_acquired", new[] { EventField.Text("source", optional: true) }), - new EventSpec("wishlist_add", new[] { + new EventSpec(SampleAppCustomEvents.SignUp, new[] { EventField.Text("method", optional: true) }), + new EventSpec(SampleAppCustomEvents.SignIn, new[] { EventField.Text("method", optional: true) }), + new EventSpec(SampleAppCustomEvents.EmailAcquired, new[] { EventField.Text("source", optional: true) }), + new EventSpec(SampleAppCustomEvents.WishlistAdd, new[] { EventField.Text("gameId"), EventField.Text("source", optional: true), EventField.Text("platform", optional: true), }), - new EventSpec("wishlist_remove", new[] { EventField.Text("gameId") }), + new EventSpec(SampleAppCustomEvents.WishlistRemove, new[] { EventField.Text("gameId") }), new EventSpec(EventNames.Purchase, new[] { EventField.Text(EventPropertyKeys.Currency), EventField.Number(EventPropertyKeys.Value), @@ -91,12 +91,12 @@ internal readonly struct EventSpec EventField.Text(EventPropertyKeys.ItemId, optional: true), }), new EventSpec(EventNames.MilestoneReached, new[] { EventField.Text(EventPropertyKeys.Name) }), - new EventSpec("game_page_viewed", new[] { + new EventSpec(SampleAppCustomEvents.GamePageViewed, new[] { EventField.Text("gameId"), EventField.Text("gameName", optional: true), EventField.Text("slug", optional: true), }), - new EventSpec("link_clicked", new[] { + new EventSpec(SampleAppCustomEvents.LinkClicked, new[] { EventField.Text("url"), EventField.Text("label", optional: true), EventField.Text("source", optional: true), diff --git a/examples/audience/Assets/SampleApp/Scripts/AudienceSample.cs b/examples/audience/Assets/SampleApp/Scripts/AudienceSample.cs index 4f6564bb2..e6c97a193 100644 --- a/examples/audience/Assets/SampleApp/Scripts/AudienceSample.cs +++ b/examples/audience/Assets/SampleApp/Scripts/AudienceSample.cs @@ -111,7 +111,7 @@ private void OnPage() => RunAndLog(SampleAppUi.LogLabels.Page, () => GuardConsentForTrack(); var screen = SceneManager.GetActiveScene().name; var props = new Dictionary { ["path"] = screen }; - ImmutableAudience.Track("screen_viewed", props); + ImmutableAudience.Track(SampleAppCustomEvents.ScreenViewed, props); return Json.Serialize(props, 2); }); diff --git a/examples/audience/Assets/SampleApp/Scripts/SampleAppCustomEvents.cs b/examples/audience/Assets/SampleApp/Scripts/SampleAppCustomEvents.cs new file mode 100644 index 000000000..b01a722e4 --- /dev/null +++ b/examples/audience/Assets/SampleApp/Scripts/SampleAppCustomEvents.cs @@ -0,0 +1,15 @@ +namespace Immutable.Audience.Samples.SampleApp +{ + // Sample-only event names that demonstrate the custom Track pattern. + internal static class SampleAppCustomEvents + { + internal const string SignUp = "sign_up"; + internal const string SignIn = "sign_in"; + internal const string EmailAcquired = "email_acquired"; + internal const string WishlistAdd = "wishlist_add"; + internal const string WishlistRemove = "wishlist_remove"; + internal const string GamePageViewed = "game_page_viewed"; + internal const string LinkClicked = "link_clicked"; + internal const string ScreenViewed = "screen_viewed"; + } +} diff --git a/examples/audience/Assets/SampleApp/Scripts/SampleAppCustomEvents.cs.meta b/examples/audience/Assets/SampleApp/Scripts/SampleAppCustomEvents.cs.meta new file mode 100644 index 000000000..96534b02e --- /dev/null +++ b/examples/audience/Assets/SampleApp/Scripts/SampleAppCustomEvents.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 6dec0a319b2b488a9c964a046bb410b2 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/examples/audience/Assets/SampleApp/Tests/Runtime/SampleAppLiveFireTests.cs b/examples/audience/Assets/SampleApp/Tests/Runtime/SampleAppLiveFireTests.cs index 5a8cd50d7..673867acb 100644 --- a/examples/audience/Assets/SampleApp/Tests/Runtime/SampleAppLiveFireTests.cs +++ b/examples/audience/Assets/SampleApp/Tests/Runtime/SampleAppLiveFireTests.cs @@ -403,54 +403,54 @@ public IEnumerator IdentifyTraits_AfterIdentify_FlushReportsOk() [UnityTest] public IEnumerator TypedEvent_SignUp_FlushReportsOk() { - yield return DriveTypedEventAndFlush(SampleAppUi.Buttons.TypedEvent("sign_up")); + yield return DriveTypedEventAndFlush(SampleAppUi.Buttons.TypedEvent(SampleAppCustomEvents.SignUp)); } [UnityTest] public IEnumerator TypedEvent_SignIn_FlushReportsOk() { - yield return DriveTypedEventAndFlush(SampleAppUi.Buttons.TypedEvent("sign_in")); + yield return DriveTypedEventAndFlush(SampleAppUi.Buttons.TypedEvent(SampleAppCustomEvents.SignIn)); } [UnityTest] public IEnumerator TypedEvent_EmailAcquired_FlushReportsOk() { - yield return DriveTypedEventAndFlush(SampleAppUi.Buttons.TypedEvent("email_acquired")); + yield return DriveTypedEventAndFlush(SampleAppUi.Buttons.TypedEvent(SampleAppCustomEvents.EmailAcquired)); } [UnityTest] public IEnumerator TypedEvent_WishlistAdd_FlushReportsOk() { - yield return DriveTypedEventAndFlush(SampleAppUi.Buttons.TypedEvent("wishlist_add"), root => + yield return DriveTypedEventAndFlush(SampleAppUi.Buttons.TypedEvent(SampleAppCustomEvents.WishlistAdd), root => { - root.Q(SampleAppUi.TypedEventField("wishlist_add", "gameId")).value = "il2cpp_game_1"; + root.Q(SampleAppUi.TypedEventField(SampleAppCustomEvents.WishlistAdd, "gameId")).value = "il2cpp_game_1"; }); } [UnityTest] public IEnumerator TypedEvent_WishlistRemove_FlushReportsOk() { - yield return DriveTypedEventAndFlush(SampleAppUi.Buttons.TypedEvent("wishlist_remove"), root => + yield return DriveTypedEventAndFlush(SampleAppUi.Buttons.TypedEvent(SampleAppCustomEvents.WishlistRemove), root => { - root.Q(SampleAppUi.TypedEventField("wishlist_remove", "gameId")).value = "il2cpp_game_1"; + root.Q(SampleAppUi.TypedEventField(SampleAppCustomEvents.WishlistRemove, "gameId")).value = "il2cpp_game_1"; }); } [UnityTest] public IEnumerator TypedEvent_GamePageViewed_FlushReportsOk() { - yield return DriveTypedEventAndFlush(SampleAppUi.Buttons.TypedEvent("game_page_viewed"), root => + yield return DriveTypedEventAndFlush(SampleAppUi.Buttons.TypedEvent(SampleAppCustomEvents.GamePageViewed), root => { - root.Q(SampleAppUi.TypedEventField("game_page_viewed", "gameId")).value = "il2cpp_game_1"; + root.Q(SampleAppUi.TypedEventField(SampleAppCustomEvents.GamePageViewed, "gameId")).value = "il2cpp_game_1"; }); } [UnityTest] public IEnumerator TypedEvent_LinkClicked_FlushReportsOk() { - yield return DriveTypedEventAndFlush(SampleAppUi.Buttons.TypedEvent("link_clicked"), root => + yield return DriveTypedEventAndFlush(SampleAppUi.Buttons.TypedEvent(SampleAppCustomEvents.LinkClicked), root => { - root.Q(SampleAppUi.TypedEventField("link_clicked", "url")).value = "https://example.com/il2cpp"; + root.Q(SampleAppUi.TypedEventField(SampleAppCustomEvents.LinkClicked, "url")).value = "https://example.com/il2cpp"; }); } From 014fb252bc84c3c8a3ad730956f1e6a121c7730b Mon Sep 17 00:00:00 2001 From: ImmutableJeffrey Date: Sat, 2 May 2026 12:45:44 +1000 Subject: [PATCH 09/10] refactor(audience-sample): centralise custom demo property keys in SampleAppCustomEventPropertyKeys The sample-app demo catalogue, screen_viewed Track call, and the typed-event live-fire tests had property keys (method, source, gameId, platform, gameName, slug, url, label, path) inline at every call site. Each key duplicates between the catalogue's EventField definition and the live-fire test's UI lookup. Add SampleAppCustomEventPropertyKeys alongside SampleAppCustomEvents (mirrors the SDK's EventPropertyKeys naming) and reference the constants from AudienceSample.Events.cs, AudienceSample.cs (the screen_viewed props dictionary), and SampleAppLiveFireTests.cs (the TypedEventField field-name lookups). Includes gameName and slug beyond the user's listed seven keys: same inline-property-key category, same migration treatment. Per the user's "everything random goes in a constant" stance, applied against the previous session's recommendation that the demo content read better inline. Recording the override on the user's explicit request. Follow-up to SDK-272 (centralisation of duplicated literals). --- .../Scripts/AudienceSample.Events.cs | 28 +++++++++---------- .../SampleApp/Scripts/AudienceSample.cs | 2 +- .../Scripts/SampleAppCustomEvents.cs | 14 ++++++++++ .../Tests/Runtime/SampleAppLiveFireTests.cs | 8 +++--- 4 files changed, 33 insertions(+), 19 deletions(-) diff --git a/examples/audience/Assets/SampleApp/Scripts/AudienceSample.Events.cs b/examples/audience/Assets/SampleApp/Scripts/AudienceSample.Events.cs index f698ad150..80265e0e0 100644 --- a/examples/audience/Assets/SampleApp/Scripts/AudienceSample.Events.cs +++ b/examples/audience/Assets/SampleApp/Scripts/AudienceSample.Events.cs @@ -55,15 +55,15 @@ internal readonly struct EventSpec internal static readonly EventSpec[] Catalogue = { - new EventSpec(SampleAppCustomEvents.SignUp, new[] { EventField.Text("method", optional: true) }), - new EventSpec(SampleAppCustomEvents.SignIn, new[] { EventField.Text("method", optional: true) }), - new EventSpec(SampleAppCustomEvents.EmailAcquired, new[] { EventField.Text("source", optional: true) }), + new EventSpec(SampleAppCustomEvents.SignUp, new[] { EventField.Text(SampleAppCustomEventPropertyKeys.Method, optional: true) }), + new EventSpec(SampleAppCustomEvents.SignIn, new[] { EventField.Text(SampleAppCustomEventPropertyKeys.Method, optional: true) }), + new EventSpec(SampleAppCustomEvents.EmailAcquired, new[] { EventField.Text(SampleAppCustomEventPropertyKeys.Source, optional: true) }), new EventSpec(SampleAppCustomEvents.WishlistAdd, new[] { - EventField.Text("gameId"), - EventField.Text("source", optional: true), - EventField.Text("platform", optional: true), + EventField.Text(SampleAppCustomEventPropertyKeys.GameId), + EventField.Text(SampleAppCustomEventPropertyKeys.Source, optional: true), + EventField.Text(SampleAppCustomEventPropertyKeys.Platform, optional: true), }), - new EventSpec(SampleAppCustomEvents.WishlistRemove, new[] { EventField.Text("gameId") }), + new EventSpec(SampleAppCustomEvents.WishlistRemove, new[] { EventField.Text(SampleAppCustomEventPropertyKeys.GameId) }), new EventSpec(EventNames.Purchase, new[] { EventField.Text(EventPropertyKeys.Currency), EventField.Number(EventPropertyKeys.Value), @@ -92,15 +92,15 @@ internal readonly struct EventSpec }), new EventSpec(EventNames.MilestoneReached, new[] { EventField.Text(EventPropertyKeys.Name) }), new EventSpec(SampleAppCustomEvents.GamePageViewed, new[] { - EventField.Text("gameId"), - EventField.Text("gameName", optional: true), - EventField.Text("slug", optional: true), + EventField.Text(SampleAppCustomEventPropertyKeys.GameId), + EventField.Text(SampleAppCustomEventPropertyKeys.GameName, optional: true), + EventField.Text(SampleAppCustomEventPropertyKeys.Slug, optional: true), }), new EventSpec(SampleAppCustomEvents.LinkClicked, new[] { - EventField.Text("url"), - EventField.Text("label", optional: true), - EventField.Text("source", optional: true), - EventField.Text("gameId", optional: true), + EventField.Text(SampleAppCustomEventPropertyKeys.Url), + EventField.Text(SampleAppCustomEventPropertyKeys.Label, optional: true), + EventField.Text(SampleAppCustomEventPropertyKeys.Source, optional: true), + EventField.Text(SampleAppCustomEventPropertyKeys.GameId, optional: true), }), }; diff --git a/examples/audience/Assets/SampleApp/Scripts/AudienceSample.cs b/examples/audience/Assets/SampleApp/Scripts/AudienceSample.cs index e6c97a193..6a16d7626 100644 --- a/examples/audience/Assets/SampleApp/Scripts/AudienceSample.cs +++ b/examples/audience/Assets/SampleApp/Scripts/AudienceSample.cs @@ -110,7 +110,7 @@ private void OnPage() => RunAndLog(SampleAppUi.LogLabels.Page, () => { GuardConsentForTrack(); var screen = SceneManager.GetActiveScene().name; - var props = new Dictionary { ["path"] = screen }; + var props = new Dictionary { [SampleAppCustomEventPropertyKeys.Path] = screen }; ImmutableAudience.Track(SampleAppCustomEvents.ScreenViewed, props); return Json.Serialize(props, 2); }); diff --git a/examples/audience/Assets/SampleApp/Scripts/SampleAppCustomEvents.cs b/examples/audience/Assets/SampleApp/Scripts/SampleAppCustomEvents.cs index b01a722e4..9dd3c5182 100644 --- a/examples/audience/Assets/SampleApp/Scripts/SampleAppCustomEvents.cs +++ b/examples/audience/Assets/SampleApp/Scripts/SampleAppCustomEvents.cs @@ -12,4 +12,18 @@ internal static class SampleAppCustomEvents internal const string LinkClicked = "link_clicked"; internal const string ScreenViewed = "screen_viewed"; } + + // Property keys for the sample-app demo events. + internal static class SampleAppCustomEventPropertyKeys + { + internal const string Method = "method"; + internal const string Source = "source"; + internal const string GameId = "gameId"; + internal const string Platform = "platform"; + internal const string GameName = "gameName"; + internal const string Slug = "slug"; + internal const string Url = "url"; + internal const string Label = "label"; + internal const string Path = "path"; + } } diff --git a/examples/audience/Assets/SampleApp/Tests/Runtime/SampleAppLiveFireTests.cs b/examples/audience/Assets/SampleApp/Tests/Runtime/SampleAppLiveFireTests.cs index 673867acb..e2e7259fd 100644 --- a/examples/audience/Assets/SampleApp/Tests/Runtime/SampleAppLiveFireTests.cs +++ b/examples/audience/Assets/SampleApp/Tests/Runtime/SampleAppLiveFireTests.cs @@ -423,7 +423,7 @@ public IEnumerator TypedEvent_WishlistAdd_FlushReportsOk() { yield return DriveTypedEventAndFlush(SampleAppUi.Buttons.TypedEvent(SampleAppCustomEvents.WishlistAdd), root => { - root.Q(SampleAppUi.TypedEventField(SampleAppCustomEvents.WishlistAdd, "gameId")).value = "il2cpp_game_1"; + root.Q(SampleAppUi.TypedEventField(SampleAppCustomEvents.WishlistAdd, SampleAppCustomEventPropertyKeys.GameId)).value = "il2cpp_game_1"; }); } @@ -432,7 +432,7 @@ public IEnumerator TypedEvent_WishlistRemove_FlushReportsOk() { yield return DriveTypedEventAndFlush(SampleAppUi.Buttons.TypedEvent(SampleAppCustomEvents.WishlistRemove), root => { - root.Q(SampleAppUi.TypedEventField(SampleAppCustomEvents.WishlistRemove, "gameId")).value = "il2cpp_game_1"; + root.Q(SampleAppUi.TypedEventField(SampleAppCustomEvents.WishlistRemove, SampleAppCustomEventPropertyKeys.GameId)).value = "il2cpp_game_1"; }); } @@ -441,7 +441,7 @@ public IEnumerator TypedEvent_GamePageViewed_FlushReportsOk() { yield return DriveTypedEventAndFlush(SampleAppUi.Buttons.TypedEvent(SampleAppCustomEvents.GamePageViewed), root => { - root.Q(SampleAppUi.TypedEventField(SampleAppCustomEvents.GamePageViewed, "gameId")).value = "il2cpp_game_1"; + root.Q(SampleAppUi.TypedEventField(SampleAppCustomEvents.GamePageViewed, SampleAppCustomEventPropertyKeys.GameId)).value = "il2cpp_game_1"; }); } @@ -450,7 +450,7 @@ public IEnumerator TypedEvent_LinkClicked_FlushReportsOk() { yield return DriveTypedEventAndFlush(SampleAppUi.Buttons.TypedEvent(SampleAppCustomEvents.LinkClicked), root => { - root.Q(SampleAppUi.TypedEventField(SampleAppCustomEvents.LinkClicked, "url")).value = "https://example.com/il2cpp"; + root.Q(SampleAppUi.TypedEventField(SampleAppCustomEvents.LinkClicked, SampleAppCustomEventPropertyKeys.Url)).value = "https://example.com/il2cpp"; }); } From 4bacd74792c9bbe3d71e47e5ec5a104bae8eb6e2 Mon Sep 17 00:00:00 2001 From: ImmutableJeffrey Date: Sat, 2 May 2026 12:46:58 +1000 Subject: [PATCH 10/10] refactor(audience-sample): use EventPropertyKeys for typed-event field lookups in live-fire tests SampleAppLiveFireTests filled the Resource, Purchase, and MilestoneReached typed-event forms by looking up TextFields with inline property-name strings ("currency", "amount", "value", "name"). The SDK already centralises these as EventPropertyKeys.Currency / .Amount / .Value / .Name, made visible to the sample-app tests through the existing InternalsVisibleTo grant. Reference EventPropertyKeys.X from the five lookup sites instead of re-typing the wire-format strings. Discovered during the sample-app demo property-key centralisation pass; kept separate because these keys belong to the SDK's typed-event surface, not the sample-app demo catalogue. Per the user's "everything random goes in a constant" stance. Follow-up to SDK-272 (centralisation of duplicated literals). --- .../SampleApp/Tests/Runtime/SampleAppLiveFireTests.cs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/examples/audience/Assets/SampleApp/Tests/Runtime/SampleAppLiveFireTests.cs b/examples/audience/Assets/SampleApp/Tests/Runtime/SampleAppLiveFireTests.cs index e2e7259fd..2682fb149 100644 --- a/examples/audience/Assets/SampleApp/Tests/Runtime/SampleAppLiveFireTests.cs +++ b/examples/audience/Assets/SampleApp/Tests/Runtime/SampleAppLiveFireTests.cs @@ -152,8 +152,8 @@ public IEnumerator TypedEvent_Resource_FlushReportsOk() { yield return DriveTypedEventAndFlush(SampleAppUi.Buttons.TypedEvent(EventNames.Resource), root => { - root.Q(SampleAppUi.TypedEventField(EventNames.Resource, "currency")).value = "GOLD"; - root.Q(SampleAppUi.TypedEventField(EventNames.Resource, "amount")).value = "100"; + root.Q(SampleAppUi.TypedEventField(EventNames.Resource, EventPropertyKeys.Currency)).value = "GOLD"; + root.Q(SampleAppUi.TypedEventField(EventNames.Resource, EventPropertyKeys.Amount)).value = "100"; }); } @@ -162,8 +162,8 @@ public IEnumerator TypedEvent_Purchase_FlushReportsOk() { yield return DriveTypedEventAndFlush(SampleAppUi.Buttons.TypedEvent(EventNames.Purchase), root => { - root.Q(SampleAppUi.TypedEventField(EventNames.Purchase, "currency")).value = "USD"; - root.Q(SampleAppUi.TypedEventField(EventNames.Purchase, "value")).value = "9.99"; + root.Q(SampleAppUi.TypedEventField(EventNames.Purchase, EventPropertyKeys.Currency)).value = "USD"; + root.Q(SampleAppUi.TypedEventField(EventNames.Purchase, EventPropertyKeys.Value)).value = "9.99"; }); } @@ -172,7 +172,7 @@ public IEnumerator TypedEvent_MilestoneReached_FlushReportsOk() { yield return DriveTypedEventAndFlush(SampleAppUi.Buttons.TypedEvent(EventNames.MilestoneReached), root => { - root.Q(SampleAppUi.TypedEventField(EventNames.MilestoneReached, "name")).value = "il2cpp_smoke"; + root.Q(SampleAppUi.TypedEventField(EventNames.MilestoneReached, EventPropertyKeys.Name)).value = "il2cpp_smoke"; }); }