From 16f03891a4c028cd081311c2552b2ac4bc12115f Mon Sep 17 00:00:00 2001 From: ImmutableJeffrey Date: Fri, 1 May 2026 21:30:00 +1000 Subject: [PATCH 1/9] refactor(audience-sdk): introduce AudienceErrorMessages catalogue Names the message strings routed through AudienceConfig.OnError so a wording tweak touches one file and runtime emit / test assert read from the same constants. - Log.cs: adds AudienceErrorMessages with the LocalStorageReadFailed / BatchPartiallyRejected formatters and the BatchRejectedPrefix / ServerErrorWillRetryPrefix / ConsentSyncFailedWithStatus / ConsentSyncThrew entries. - HttpTransport.cs: flush onError messages route through AudienceErrorMessages. - ImmutableAudience.cs: consent-sync onError messages route through AudienceErrorMessages. --- .../Audience/Runtime/ImmutableAudience.cs | 4 ++-- .../Runtime/Transport/HttpTransport.cs | 8 ++++---- src/Packages/Audience/Runtime/Utility/Log.cs | 19 +++++++++++++++++++ 3 files changed, 25 insertions(+), 6 deletions(-) diff --git a/src/Packages/Audience/Runtime/ImmutableAudience.cs b/src/Packages/Audience/Runtime/ImmutableAudience.cs index c9fc1a542..8e4235e40 100644 --- a/src/Packages/Audience/Runtime/ImmutableAudience.cs +++ b/src/Packages/Audience/Runtime/ImmutableAudience.cs @@ -656,7 +656,7 @@ private static void SyncConsentToBackend(AudienceConfig config, ConsentLevel lev } NotifyErrorCallback(onError, AudienceErrorCode.ConsentSyncFailed, - $"Consent sync failed with status {(int)response.StatusCode}"); + AudienceErrorMessages.ConsentSyncFailedWithStatus((int)response.StatusCode)); return; } } @@ -667,7 +667,7 @@ private static void SyncConsentToBackend(AudienceConfig config, ConsentLevel lev catch (Exception ex) { NotifyErrorCallback(onError, AudienceErrorCode.ConsentSyncFailed, - $"Consent sync threw: {ex.Message}"); + AudienceErrorMessages.ConsentSyncThrew(ex)); } }); } diff --git a/src/Packages/Audience/Runtime/Transport/HttpTransport.cs b/src/Packages/Audience/Runtime/Transport/HttpTransport.cs index 2e14da13d..e2915f70d 100644 --- a/src/Packages/Audience/Runtime/Transport/HttpTransport.cs +++ b/src/Packages/Audience/Runtime/Transport/HttpTransport.cs @@ -81,7 +81,7 @@ internal async Task SendBatchAsync(CancellationToken ct = default) // Non-IOException = unrecoverable storage failure (e.g. permissions); // retry won't help. Drop the batch, report via onError. _store.Delete(batch); - NotifyError(AudienceErrorCode.FlushFailed, $"Local storage read failed: {ex.Message}"); + NotifyError(AudienceErrorCode.FlushFailed, AudienceErrorMessages.LocalStorageReadFailed(ex)); return true; } @@ -122,7 +122,7 @@ internal async Task SendBatchAsync(CancellationToken ct = default) if (rejected > 0) { NotifyError(AudienceErrorCode.ValidationRejected, - $"Batch partially rejected: {rejected} of {batch.Count} events dropped"); + AudienceErrorMessages.BatchPartiallyRejected(rejected, batch.Count)); } } else if (statusCode == (int)HttpStatusCode.TooManyRequests) @@ -149,7 +149,7 @@ internal async Task SendBatchAsync(CancellationToken ct = default) _store.Delete(batch); ResetBackoff(); NotifyError(AudienceErrorCode.ValidationRejected, - FormatHttpError("Batch rejected", statusCode, rejectionBody)); + FormatHttpError(AudienceErrorMessages.BatchRejectedPrefix, statusCode, rejectionBody)); } else { @@ -158,7 +158,7 @@ internal async Task SendBatchAsync(CancellationToken ct = default) var serverBody = await ReadBodyForErrorAsync(response).ConfigureAwait(false); RecordFailure(); NotifyError(AudienceErrorCode.FlushFailed, - FormatHttpError("Server error, will retry", statusCode, serverBody)); + FormatHttpError(AudienceErrorMessages.ServerErrorWillRetryPrefix, statusCode, serverBody)); } } catch (OperationCanceledException) when (ct.IsCancellationRequested) diff --git a/src/Packages/Audience/Runtime/Utility/Log.cs b/src/Packages/Audience/Runtime/Utility/Log.cs index 7a2c9fbf3..92eb4fdc7 100644 --- a/src/Packages/Audience/Runtime/Utility/Log.cs +++ b/src/Packages/Audience/Runtime/Utility/Log.cs @@ -43,6 +43,25 @@ private static void Emit(string line) } } + // Error messages we pass to AudienceConfig.OnError. + internal static class AudienceErrorMessages + { + internal static string LocalStorageReadFailed(Exception ex) => + $"Local storage read failed: {ex.Message}"; + + internal static string BatchPartiallyRejected(int rejected, int total) => + $"Batch partially rejected: {rejected} of {total} events dropped"; + + internal const string BatchRejectedPrefix = "Batch rejected"; + internal const string ServerErrorWillRetryPrefix = "Server error, will retry"; + + internal static string ConsentSyncFailedWithStatus(int statusCode) => + $"Consent sync failed with status {statusCode}"; + + internal static string ConsentSyncThrew(Exception ex) => + $"Consent sync threw: {ex.Message}"; + } + internal static class AudienceLogs { // ---- Init / config validation ---- From 10ce826fa63c4436f07e3d0cddcee4bb224afc89 Mon Sep 17 00:00:00 2001 From: ImmutableJeffrey Date: Fri, 1 May 2026 21:45:00 +1000 Subject: [PATCH 2/9] refactor(audience-sdk): introduce AudienceArgumentMessages catalogue Names the ArgumentException strings thrown from public SDK surfaces so user-code that reads .Message stays in sync if a wording is tweaked in one site but not another. - Log.cs: adds AudienceArgumentMessages with the Init / config validation entries (PublishableKeyRequired, PersistentDataPathRequired) and the typed-event ToProperties entries (ProgressionStatusRequired, ResourceFlow / Currency / AmountRequired, PurchaseValue / CurrencyInvalid, MilestoneReachedNameRequired). - TypedEvents.cs: Progression / Resource / Purchase / MilestoneReached ArgumentException messages route through AudienceArgumentMessages. - ImmutableAudience.cs: Init validation ArgumentException messages route through AudienceArgumentMessages. --- .../Audience/Runtime/Events/TypedEvents.cs | 15 +++++++-------- .../Audience/Runtime/ImmutableAudience.cs | 4 ++-- src/Packages/Audience/Runtime/Utility/Log.cs | 19 +++++++++++++++++++ 3 files changed, 28 insertions(+), 10 deletions(-) diff --git a/src/Packages/Audience/Runtime/Events/TypedEvents.cs b/src/Packages/Audience/Runtime/Events/TypedEvents.cs index a946d7400..6c388eaa2 100644 --- a/src/Packages/Audience/Runtime/Events/TypedEvents.cs +++ b/src/Packages/Audience/Runtime/Events/TypedEvents.cs @@ -84,7 +84,7 @@ public class Progression : IEvent public Dictionary ToProperties() { if (Status is null) - throw new ArgumentException("Progression.Status is required. Set it before calling Track(IEvent)."); + throw new ArgumentException(AudienceArgumentMessages.ProgressionStatusRequired); var props = new Dictionary { @@ -169,11 +169,11 @@ public class Resource : IEvent public Dictionary ToProperties() { if (Flow is null) - throw new ArgumentException("Resource.Flow is required. Set it before calling Track(IEvent)."); + throw new ArgumentException(AudienceArgumentMessages.ResourceFlowRequired); if (string.IsNullOrEmpty(Currency)) - throw new ArgumentException("Resource.Currency is required. Set a non-empty string before calling Track(IEvent)."); + throw new ArgumentException(AudienceArgumentMessages.ResourceCurrencyRequired); if (Amount is null) - throw new ArgumentException("Resource.Amount is required. Set it before calling Track(IEvent)."); + throw new ArgumentException(AudienceArgumentMessages.ResourceAmountRequired); var props = new Dictionary { @@ -245,10 +245,9 @@ private static bool IsIso4217(string s) public Dictionary ToProperties() { if (Currency == null || !IsIso4217(Currency)) - throw new ArgumentException( - $"Purchase.Currency '{Currency}' must be a three-letter uppercase ISO 4217 code"); + throw new ArgumentException(AudienceArgumentMessages.PurchaseCurrencyInvalid(Currency)); if (Value is null) - throw new ArgumentException("Purchase.Value is required. Set it before calling Track(IEvent)."); + throw new ArgumentException(AudienceArgumentMessages.PurchaseValueRequired); var props = new Dictionary { @@ -284,7 +283,7 @@ public class MilestoneReached : IEvent public Dictionary ToProperties() { if (string.IsNullOrEmpty(Name)) - throw new ArgumentException("MilestoneReached.Name must not be null or empty"); + throw new ArgumentException(AudienceArgumentMessages.MilestoneReachedNameRequired); return new Dictionary { diff --git a/src/Packages/Audience/Runtime/ImmutableAudience.cs b/src/Packages/Audience/Runtime/ImmutableAudience.cs index 8e4235e40..3068386e5 100644 --- a/src/Packages/Audience/Runtime/ImmutableAudience.cs +++ b/src/Packages/Audience/Runtime/ImmutableAudience.cs @@ -146,12 +146,12 @@ public static void Init(AudienceConfig config) { if (config == null) throw new ArgumentNullException(nameof(config)); if (string.IsNullOrEmpty(config.PublishableKey)) - throw new ArgumentException("PublishableKey is required", nameof(config)); + throw new ArgumentException(AudienceArgumentMessages.PublishableKeyRequired, nameof(config)); if (string.IsNullOrEmpty(config.PersistentDataPath)) config.PersistentDataPath = DefaultPersistentDataPathProvider?.Invoke(); if (string.IsNullOrEmpty(config.PersistentDataPath)) - throw new ArgumentException("PersistentDataPath is required", nameof(config)); + throw new ArgumentException(AudienceArgumentMessages.PersistentDataPathRequired, nameof(config)); // Normalize casing so dashboards aggregate consistently. The // DistributionPlatforms constants ship lowercase; a studio that diff --git a/src/Packages/Audience/Runtime/Utility/Log.cs b/src/Packages/Audience/Runtime/Utility/Log.cs index 92eb4fdc7..edf56ac5e 100644 --- a/src/Packages/Audience/Runtime/Utility/Log.cs +++ b/src/Packages/Audience/Runtime/Utility/Log.cs @@ -43,6 +43,25 @@ private static void Emit(string line) } } + // ArgumentException messages thrown from public SDK methods. + internal static class AudienceArgumentMessages + { + // Init / config validation + internal const string PublishableKeyRequired = "PublishableKey is required"; + internal const string PersistentDataPathRequired = "PersistentDataPath is required"; + + // Typed-event ToProperties validation + internal const string ProgressionStatusRequired = "Progression.Status is required. Set it before calling Track(IEvent)."; + internal const string ResourceFlowRequired = "Resource.Flow is required. Set it before calling Track(IEvent)."; + internal const string ResourceCurrencyRequired = "Resource.Currency is required. Set a non-empty string before calling Track(IEvent)."; + internal const string ResourceAmountRequired = "Resource.Amount is required. Set it before calling Track(IEvent)."; + internal const string PurchaseValueRequired = "Purchase.Value is required. Set it before calling Track(IEvent)."; + internal const string MilestoneReachedNameRequired = "MilestoneReached.Name must not be null or empty"; + + internal static string PurchaseCurrencyInvalid(string? currency) => + $"Purchase.Currency '{currency}' must be a three-letter uppercase ISO 4217 code"; + } + // Error messages we pass to AudienceConfig.OnError. internal static class AudienceErrorMessages { From 426ff4b9a68831b2281ea252c1a10cd8606598ea Mon Sep 17 00:00:00 2001 From: ImmutableJeffrey Date: Fri, 1 May 2026 22:00:00 +1000 Subject: [PATCH 3/9] refactor(audience-sdk): promote Log.Prefix to internal and add Log.WarnPrefix Hoists the SDK stamp into named constants so the sample-app log adapter can strip it back off without hardcoding the same string. - Log.cs: promotes Prefix from private to internal and adds WarnPrefix following the "{Prefix} WARN:" shape Log.Warn emits. - Log.cs: Warn now reads its prefix from WarnPrefix instead of inlining the format. --- src/Packages/Audience/Runtime/Utility/Log.cs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/Packages/Audience/Runtime/Utility/Log.cs b/src/Packages/Audience/Runtime/Utility/Log.cs index edf56ac5e..788f09323 100644 --- a/src/Packages/Audience/Runtime/Utility/Log.cs +++ b/src/Packages/Audience/Runtime/Utility/Log.cs @@ -6,7 +6,9 @@ namespace Immutable.Audience { internal static class Log { - private const string Prefix = "[ImmutableAudience]"; + // Prepended to every SDK log line. Internal so the sample-app log adapter can strip it. + internal const string Prefix = "[ImmutableAudience]"; + internal const string WarnPrefix = Prefix + " WARN:"; internal static bool Enabled { get; set; } @@ -20,7 +22,7 @@ internal static void Debug(string message) } internal static void Warn(string message) => - Emit($"{Prefix} WARN: {message}"); + Emit($"{WarnPrefix} {message}"); private static void Emit(string line) { From 7c8d867b7eb7f96a937049a7a7aa35e089f7f256 Mon Sep 17 00:00:00 2001 From: ImmutableJeffrey Date: Fri, 1 May 2026 22:15:00 +1000 Subject: [PATCH 4/9] refactor(audience-sdk): introduce IdentityTypeExtensions.ParseLowercaseString Adds the inverse of ToLowercaseString so callers reading user-supplied or wire-format identity strings can round-trip back into the enum without hand-rolling another switch. - IdentityType.cs: adds IdentityTypeExtensions.ParseLowercaseString, the case-insensitive inverse of ToLowercaseString. Falls back to Custom for unknown / empty values; never throws. - AudienceSample.cs: ParseIdentityType reverse mapper delegates to IdentityTypeExtensions.ParseLowercaseString instead of hand-rolling its own switch. --- .../Assets/SampleApp/Scripts/AudienceSample.cs | 13 ++----------- src/Packages/Audience/Runtime/IdentityType.cs | 13 +++++++++++++ 2 files changed, 15 insertions(+), 11 deletions(-) diff --git a/examples/audience/Assets/SampleApp/Scripts/AudienceSample.cs b/examples/audience/Assets/SampleApp/Scripts/AudienceSample.cs index 225994742..8d49941d8 100644 --- a/examples/audience/Assets/SampleApp/Scripts/AudienceSample.cs +++ b/examples/audience/Assets/SampleApp/Scripts/AudienceSample.cs @@ -375,16 +375,7 @@ private void ResetIdentityMirror() // Parses a wire-format identity string (e.g. "steam") back into the // IdentityType enum the SDK now requires. Falls back to Custom for // unknown or empty values. - private static IdentityType ParseIdentityType(string? value) => (value ?? "").ToLowerInvariant() switch - { - "passport" => IdentityType.Passport, - "steam" => IdentityType.Steam, - "epic" => IdentityType.Epic, - "google" => IdentityType.Google, - "apple" => IdentityType.Apple, - "discord" => IdentityType.Discord, - "email" => IdentityType.Email, - _ => IdentityType.Custom, - }; + private static IdentityType ParseIdentityType(string? value) => + IdentityTypeExtensions.ParseLowercaseString(value); } } diff --git a/src/Packages/Audience/Runtime/IdentityType.cs b/src/Packages/Audience/Runtime/IdentityType.cs index 327924a5c..6d5fe1d3b 100644 --- a/src/Packages/Audience/Runtime/IdentityType.cs +++ b/src/Packages/Audience/Runtime/IdentityType.cs @@ -56,5 +56,18 @@ internal static class IdentityTypeExtensions _ => throw new System.ArgumentOutOfRangeException(nameof(type), type, "Unknown IdentityType value; cast an out-of-range value."), }; + + internal static IdentityType ParseLowercaseString(string? value) => + (value ?? string.Empty).ToLowerInvariant() switch + { + "passport" => IdentityType.Passport, + "steam" => IdentityType.Steam, + "epic" => IdentityType.Epic, + "google" => IdentityType.Google, + "apple" => IdentityType.Apple, + "discord" => IdentityType.Discord, + "email" => IdentityType.Email, + _ => IdentityType.Custom, + }; } } From 70243a764464dcfef772d08def3f5b01da32269f Mon Sep 17 00:00:00 2001 From: ImmutableJeffrey Date: Fri, 1 May 2026 22:30:00 +1000 Subject: [PATCH 5/9] refactor(audience-sample): centralise UXML / CSS / message strings into SampleAppUi Moves the SampleAppUi catalogue from Tests/Runtime to Scripts so non-test code can reference it, and expands it to cover every UXML element name, CSS class, button caption, resource path, and side string the sample manipulates. - Moves SampleAppUi.cs from Tests/Runtime to Scripts so AudienceSample.cs / .UI.cs / .Events.cs can reference it. - SampleAppUi gains AliasFromType / AliasToType identity-field names. - SampleAppUi.Layout names the layout-internal elements (sdk-version, tab-bar, typed-events-host, log-resize-handle, page-scroll, controls-column, log-column, sample-app-grid, log-count, accordion-{item, title, content}). - SampleAppUi.Css names every CSS class the sample manipulates (state-*, accordion-*, log-*, badge-*, status-value, field-*, etc.). - SampleAppUi.ButtonText (Send / Copy / Copied) names dynamically-built button captions. - SampleAppUi.Resources names the four Resources.Load asset paths. - SampleAppUi.Messages holds six human-readable side strings used in consent / identity flows. - SampleAppUi.LogLabels gains Ready / Sdk / OnError / Init / Shutdown / Reset / Flush / DeleteData / Page / Track / SetConsent. - SampleAppUi.LogBadgeText names the two-letter SDK / APP pill text. - SampleAppUi.StatusBar.EmptyText replaces six hardcoded em-dash placeholder glyphs. - AudienceSample.cs / .UI.cs / .Events.cs read UXML element names, CSS classes, dynamic button captions, resource paths, and log labels from SampleAppUi. - SampleAppLiveFireTests.cs reads UXML element names, log labels, and the env-var key from SampleAppUi. --- .../Scripts/AudienceSample.Events.cs | 72 +++-- .../SampleApp/Scripts/AudienceSample.UI.cs | 273 ++++++++-------- .../SampleApp/Scripts/AudienceSample.cs | 63 ++-- .../Assets/SampleApp/Scripts/SampleAppUi.cs | 305 ++++++++++++++++++ .../Runtime => Scripts}/SampleAppUi.cs.meta | 0 .../Tests/Runtime/SampleAppLiveFireTests.cs | 18 +- .../SampleApp/Tests/Runtime/SampleAppUi.cs | 177 ---------- 7 files changed, 528 insertions(+), 380 deletions(-) create mode 100644 examples/audience/Assets/SampleApp/Scripts/SampleAppUi.cs rename examples/audience/Assets/SampleApp/{Tests/Runtime => Scripts}/SampleAppUi.cs.meta (100%) delete mode 100644 examples/audience/Assets/SampleApp/Tests/Runtime/SampleAppUi.cs diff --git a/examples/audience/Assets/SampleApp/Scripts/AudienceSample.Events.cs b/examples/audience/Assets/SampleApp/Scripts/AudienceSample.Events.cs index 301661e8d..455b3fa2a 100644 --- a/examples/audience/Assets/SampleApp/Scripts/AudienceSample.Events.cs +++ b/examples/audience/Assets/SampleApp/Scripts/AudienceSample.Events.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; using System.Globalization; +using System.Linq; using UnityEngine.UIElements; namespace Immutable.Audience.Samples.SampleApp @@ -38,6 +39,18 @@ internal readonly struct EventSpec private const string OptionalEnumSentinel = "(not set)"; + private static readonly string[] ProgressionStatusValues = + Enum.GetValues(typeof(ProgressionStatus)) + .Cast() + .Select(s => s.ToLowercaseString()) + .ToArray(); + + private static readonly string[] ResourceFlowValues = + Enum.GetValues(typeof(ResourceFlow)) + .Cast() + .Select(f => f.ToLowercaseString()) + .ToArray(); + // ---- Event catalogue ---- internal static readonly EventSpec[] Catalogue = @@ -63,7 +76,7 @@ internal readonly struct EventSpec // it as auto-tracked on Init with no public typed class; firing it // from the Send button would double-emit. new EventSpec(EventNames.Progression, new[] { - EventField.Enum(EventPropertyKeys.Status, new[] { "start", "complete", "fail" }), + EventField.Enum(EventPropertyKeys.Status, ProgressionStatusValues), EventField.Text(EventPropertyKeys.World, optional: true), EventField.Text(EventPropertyKeys.Level, optional: true), EventField.Text(EventPropertyKeys.Stage, optional: true), @@ -71,7 +84,7 @@ internal readonly struct EventSpec EventField.Number(EventPropertyKeys.DurationSec, optional: true), }), new EventSpec(EventNames.Resource, new[] { - EventField.Enum(EventPropertyKeys.Flow, new[] { "sink", "source" }), + EventField.Enum(EventPropertyKeys.Flow, ResourceFlowValues), EventField.Text(EventPropertyKeys.Currency), EventField.Number(EventPropertyKeys.Amount), EventField.Text(EventPropertyKeys.ItemType, optional: true), @@ -103,33 +116,33 @@ internal readonly struct EventSpec return new Progression { Status = ParseProgressionStatus(props), - World = OptionalString(props, "world"), - Level = OptionalString(props, "level"), - Stage = OptionalString(props, "stage"), - Score = OptionalInt(props, "score"), - DurationSec = OptionalFloat(props, "durationSec"), + World = OptionalString(props, EventPropertyKeys.World), + Level = OptionalString(props, EventPropertyKeys.Level), + Stage = OptionalString(props, EventPropertyKeys.Stage), + Score = OptionalInt(props, EventPropertyKeys.Score), + DurationSec = OptionalFloat(props, EventPropertyKeys.DurationSec), }; case EventNames.Resource: return new Resource { Flow = ParseResourceFlow(props), - Currency = OptionalString(props, "currency") ?? "", - Amount = OptionalFloat(props, "amount") ?? 0f, - ItemType = OptionalString(props, "itemType"), - ItemId = OptionalString(props, "itemId"), + Currency = OptionalString(props, EventPropertyKeys.Currency) ?? "", + Amount = OptionalFloat(props, EventPropertyKeys.Amount) ?? 0f, + ItemType = OptionalString(props, EventPropertyKeys.ItemType), + ItemId = OptionalString(props, EventPropertyKeys.ItemId), }; case EventNames.Purchase: return new Purchase { - Currency = OptionalString(props, "currency") ?? "", - Value = OptionalDecimal(props, "value") ?? 0m, - ItemId = OptionalString(props, "itemId"), - ItemName = OptionalString(props, "itemName"), - Quantity = OptionalInt(props, "quantity"), - TransactionId = OptionalString(props, "transactionId"), + Currency = OptionalString(props, EventPropertyKeys.Currency) ?? "", + Value = OptionalDecimal(props, EventPropertyKeys.Value) ?? 0m, + ItemId = OptionalString(props, EventPropertyKeys.ItemId), + ItemName = OptionalString(props, EventPropertyKeys.ItemName), + Quantity = OptionalInt(props, EventPropertyKeys.Quantity), + TransactionId = OptionalString(props, EventPropertyKeys.TransactionId), }; case EventNames.MilestoneReached: - return new MilestoneReached { Name = OptionalString(props, "name") ?? "" }; + return new MilestoneReached { Name = OptionalString(props, EventPropertyKeys.Name) ?? "" }; default: return null; } @@ -137,27 +150,20 @@ internal readonly struct EventSpec private static ProgressionStatus? ParseProgressionStatus(Dictionary props) { - var s = OptionalString(props, "status"); + var s = OptionalString(props, EventPropertyKeys.Status); if (string.IsNullOrEmpty(s)) return null; - return s switch - { - "start" => ProgressionStatus.Start, - "complete" => ProgressionStatus.Complete, - "fail" => ProgressionStatus.Fail, - _ => (ProgressionStatus?)null, - }; + foreach (ProgressionStatus value in Enum.GetValues(typeof(ProgressionStatus))) + if (value.ToLowercaseString() == s) return value; + return null; } private static ResourceFlow? ParseResourceFlow(Dictionary props) { - var s = OptionalString(props, "flow"); + var s = OptionalString(props, EventPropertyKeys.Flow); if (string.IsNullOrEmpty(s)) return null; - return s switch - { - "source" => ResourceFlow.Source, - "sink" => ResourceFlow.Sink, - _ => (ResourceFlow?)null, - }; + foreach (ResourceFlow value in Enum.GetValues(typeof(ResourceFlow))) + if (value.ToLowercaseString() == s) return value; + return null; } private static string? OptionalString(Dictionary props, string key) => diff --git a/examples/audience/Assets/SampleApp/Scripts/AudienceSample.UI.cs b/examples/audience/Assets/SampleApp/Scripts/AudienceSample.UI.cs index 1c70fbd78..5b0a81c48 100644 --- a/examples/audience/Assets/SampleApp/Scripts/AudienceSample.UI.cs +++ b/examples/audience/Assets/SampleApp/Scripts/AudienceSample.UI.cs @@ -16,22 +16,42 @@ public sealed partial class AudienceSample // ---- Constants & tables ---- private static readonly ConsentLevel[] ConsentOrder = { ConsentLevel.None, ConsentLevel.Anonymous, ConsentLevel.Full }; - private static readonly string[] ConsentStateClass = { "state-err", "state-warn", "state-ok" }; + private static readonly string[] ConsentStateClass = { SampleAppUi.Css.StateErr, SampleAppUi.Css.StateWarn, SampleAppUi.Css.StateOk }; private static readonly (string TabId, string PanelId)[] Tabs = { - ("tab-setup", "panel-setup"), - ("tab-consent", "panel-consent"), - ("tab-typed-events", "panel-typed-events"), - ("tab-identity", "panel-identity"), + (SampleAppUi.Tabs.Setup, SampleAppUi.Panels.Setup), + (SampleAppUi.Tabs.Consent, SampleAppUi.Panels.Consent), + (SampleAppUi.Tabs.TypedEvents, SampleAppUi.Panels.TypedEvents), + (SampleAppUi.Tabs.Identity, SampleAppUi.Panels.Identity), }; - private static readonly string[] StateClasses = { "state-ok", "state-warn", "state-err", "dim" }; + private static readonly string[] StateClasses = { SampleAppUi.Css.StateOk, SampleAppUi.Css.StateWarn, SampleAppUi.Css.StateErr, SampleAppUi.Css.Dim }; private const int CollapseThreshold = 240; private const int StatusPollIntervalMs = 500; private const float NarrowBreakpointPx = 1024f; + // Log pane drag-resize bounds and main-column padding tracker. + private const float LogResizeMinHeight = 120f; + private const float LogResizeMaxHeight = 1400f; + private const float MainPaddingBottomPx = 64f; + // Pixel slack used when deciding whether the log was at the bottom + // before a content mutation; treated as "at bottom" if within this. + private const float LogScrollBottomEpsilonPx = 4f; + + // Toast / flash timings. + private const int CopyButtonRevertMs = 800; + private const int CopiedFlashDurationMs = 1500; + + // Local-time format for the per-row timestamp (header) and the + // round-trip ISO format used in the copy-to-clipboard payload. + private const string LogRowTimestampFormat = "HH:mm:ss.fff"; + + // ✓ glyph injected into the runtime Toggle so the checked state + // shows a tick rather than a bare coloured square. + private const string DebugToggleTickGlyph = "✓"; + // ---- UI document state ---- // All fields below are populated by BindElements before any other access. #pragma warning disable 8618 @@ -105,7 +125,7 @@ private void InitializeUi() _sdkVersionLabel.text = $"v{Immutable.Audience.Constants.LibraryVersion}"; OnSdkStateChanged(); - AppendLog("READY", "Sample app loaded. Paste a publishable key and click Init.", LogLevel.Info, LogSource.App); + AppendLog(SampleAppUi.LogLabels.Ready, SampleAppUi.Messages.Ready, LogLevel.Info, LogSource.App); // Status bar mirrors live SDK state (UserId, SessionId, QueueSize) — // poll instead of subscribing because the SDK doesn't expose changes. @@ -120,9 +140,9 @@ private bool LoadUiDocument() { var doc = GetComponent() ?? gameObject.AddComponent(); if (doc.panelSettings == null) - doc.panelSettings = Resources.Load("AudienceSampleAppPanelSettings"); + doc.panelSettings = Resources.Load(SampleAppUi.Resources.PanelSettings); - var tree = Resources.Load("AudienceSample"); + var tree = Resources.Load(SampleAppUi.Resources.SampleAppTree); if (tree == null) { Debug.LogError("[Audience Sample] missing Resources/AudienceSample.uxml"); @@ -137,7 +157,7 @@ private bool LoadUiDocument() _root.Clear(); tree.CloneTree(_root); - var uss = Resources.Load("AudienceSample"); + var uss = Resources.Load(SampleAppUi.Resources.SampleAppStyleSheet); if (uss != null) _root.styleSheets.Add(uss); return true; } @@ -150,71 +170,71 @@ private T Require(string name) where T : VisualElement => private void BindElements() { - _prodWarning = Require