From 8453eeccfb4ee3f9c28605a3b0cf7de2c7c1e430 Mon Sep 17 00:00:00 2001 From: Natalie Bunduwongse Date: Wed, 15 Apr 2026 12:16:26 +1200 Subject: [PATCH 1/3] feat(audience): add Identity module (SDK-126) --- .../Audience/Runtime/Core/Identity.cs | 70 +++++++++++++++++++ .../Audience/Tests/Runtime/IdentityTests.cs | 69 ++++++++++++++++++ 2 files changed, 139 insertions(+) create mode 100644 src/Packages/Audience/Runtime/Core/Identity.cs create mode 100644 src/Packages/Audience/Tests/Runtime/IdentityTests.cs diff --git a/src/Packages/Audience/Runtime/Core/Identity.cs b/src/Packages/Audience/Runtime/Core/Identity.cs new file mode 100644 index 000000000..82ab35269 --- /dev/null +++ b/src/Packages/Audience/Runtime/Core/Identity.cs @@ -0,0 +1,70 @@ +using System; +using System.IO; + +namespace Immutable.Audience +{ + internal sealed class Identity + { + private static volatile string _cachedId; + + private static string GetDirectory(string persistentDataPath) => + Path.Combine(persistentDataPath, "imtbl_audience"); + + private static string GetFilePath(string persistentDataPath) => + Path.Combine(GetDirectory(persistentDataPath), "identity"); + + internal static string GetOrCreate(string persistentDataPath, ConsentLevel consent) + { + if (consent == ConsentLevel.None) + return null; + + if (_cachedId != null) + return _cachedId; + + var dir = GetDirectory(persistentDataPath); + Directory.CreateDirectory(dir); + + var filePath = GetFilePath(persistentDataPath); + + if (File.Exists(filePath)) + { + var existing = File.ReadAllText(filePath).Trim(); + _cachedId = existing; + return _cachedId; + } + + var newId = Guid.NewGuid().ToString(); + var tmpPath = filePath + ".tmp"; + File.WriteAllText(tmpPath, newId); + + try + { + File.Move(tmpPath, filePath); + } + catch (IOException) + { + // Destination already exists (race condition) — delete it and retry + File.Delete(filePath); + File.Move(tmpPath, filePath); + } + + _cachedId = newId; + return _cachedId; + } + + internal static void Reset(string persistentDataPath) + { + _cachedId = null; + + var filePath = GetFilePath(persistentDataPath); + try + { + File.Delete(filePath); + } + catch (FileNotFoundException) + { + // Nothing to delete — this is fine + } + } + } +} diff --git a/src/Packages/Audience/Tests/Runtime/IdentityTests.cs b/src/Packages/Audience/Tests/Runtime/IdentityTests.cs new file mode 100644 index 000000000..67aa4620f --- /dev/null +++ b/src/Packages/Audience/Tests/Runtime/IdentityTests.cs @@ -0,0 +1,69 @@ +using System.IO; +using NUnit.Framework; + +namespace Immutable.Audience.Tests +{ + [TestFixture] + internal class IdentityTests + { + private string _testDir; + + [SetUp] + public void SetUp() + { + _testDir = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + Directory.CreateDirectory(_testDir); + } + + [TearDown] + public void TearDown() + { + Identity.Reset(_testDir); + if (Directory.Exists(_testDir)) + Directory.Delete(_testDir, recursive: true); + } + + [Test] + public void NewDirectory_GeneratesNonEmptyId_AndWritesFile() + { + var id = Identity.GetOrCreate(_testDir, ConsentLevel.Anonymous); + + Assert.IsNotNull(id); + Assert.IsNotEmpty(id); + + var filePath = Path.Combine(_testDir, "imtbl_audience", "identity"); + Assert.IsTrue(File.Exists(filePath), "identity file should exist on disk"); + } + + [Test] + public void SecondCall_ReturnsSameId() + { + var id1 = Identity.GetOrCreate(_testDir, ConsentLevel.Anonymous); + var id2 = Identity.GetOrCreate(_testDir, ConsentLevel.Anonymous); + + Assert.AreEqual(id1, id2); + } + + [Test] + public void Reset_NextCallReturnsDifferentId() + { + var id1 = Identity.GetOrCreate(_testDir, ConsentLevel.Anonymous); + Identity.Reset(_testDir); + var id2 = Identity.GetOrCreate(_testDir, ConsentLevel.Anonymous); + + Assert.IsNotNull(id2); + Assert.AreNotEqual(id1, id2); + } + + [Test] + public void ConsentNone_ReturnsNull_AndNoFileWritten() + { + var id = Identity.GetOrCreate(_testDir, ConsentLevel.None); + + Assert.IsNull(id); + + var filePath = Path.Combine(_testDir, "imtbl_audience", "identity"); + Assert.IsFalse(File.Exists(filePath), "identity file must not be written when consent is None"); + } + } +} From 61bec02936c51e0687b3cca95ba8913528d29cdb Mon Sep 17 00:00:00 2001 From: Natalie Bunduwongse Date: Wed, 15 Apr 2026 12:18:10 +1200 Subject: [PATCH 2/3] feat(audience): add IL2CPP-safe Json serialiser (SDK-128) --- src/Packages/Audience/Runtime/Utility/Json.cs | 132 +++++++++++++++++ .../Audience/Tests/Runtime/JsonTests.cs | 136 ++++++++++++++++++ 2 files changed, 268 insertions(+) create mode 100644 src/Packages/Audience/Runtime/Utility/Json.cs create mode 100644 src/Packages/Audience/Tests/Runtime/JsonTests.cs diff --git a/src/Packages/Audience/Runtime/Utility/Json.cs b/src/Packages/Audience/Runtime/Utility/Json.cs new file mode 100644 index 000000000..34411db3b --- /dev/null +++ b/src/Packages/Audience/Runtime/Utility/Json.cs @@ -0,0 +1,132 @@ +using System.Collections; +using System.Collections.Generic; +using System.Globalization; +using System.Text; + +namespace Immutable.Audience +{ + internal static class Json + { + internal static string Serialize(Dictionary data) + { + var sb = new StringBuilder(); + WriteObject(sb, data); + return sb.ToString(); + } + + private static void WriteValue(StringBuilder sb, object value) + { + if (value == null) + { + sb.Append("null"); + } + else if (value is string s) + { + WriteString(sb, s); + } + else if (value is bool b) + { + sb.Append(b ? "true" : "false"); + } + else if (value is int i) + { + sb.Append(i); + } + else if (value is long l) + { + sb.Append(l); + } + else if (value is float f) + { + var result = f.ToString("G", CultureInfo.InvariantCulture); + if (result.IndexOf('E') >= 0 || result.IndexOf('e') >= 0) + result = f.ToString("F6", CultureInfo.InvariantCulture); + sb.Append(result); + } + else if (value is double d) + { + var result = d.ToString("G", CultureInfo.InvariantCulture); + if (result.IndexOf('E') >= 0 || result.IndexOf('e') >= 0) + result = d.ToString("F6", CultureInfo.InvariantCulture); + sb.Append(result); + } + else if (value is decimal dec) + { + sb.Append(dec.ToString(CultureInfo.InvariantCulture)); + } + else if (value is Dictionary dict) + { + WriteObject(sb, dict); + } + else if (value is IList list) + { + WriteArray(sb, list); + } + else + { + WriteString(sb, value.ToString()); + } + } + + private static void WriteObject(StringBuilder sb, Dictionary dict) + { + sb.Append('{'); + var first = true; + foreach (var kvp in dict) + { + if (!first) + sb.Append(','); + first = false; + WriteString(sb, kvp.Key); + sb.Append(':'); + WriteValue(sb, kvp.Value); + } + sb.Append('}'); + } + + private static void WriteArray(StringBuilder sb, IList list) + { + sb.Append('['); + for (var i = 0; i < list.Count; i++) + { + if (i > 0) + sb.Append(','); + WriteValue(sb, list[i]); + } + sb.Append(']'); + } + + private static void WriteString(StringBuilder sb, string s) + { + sb.Append('"'); + foreach (var c in s) + { + switch (c) + { + case '\\': + sb.Append("\\\\"); + break; + case '"': + sb.Append("\\\""); + break; + case '\n': + sb.Append("\\n"); + break; + case '\r': + sb.Append("\\r"); + break; + case '\t': + sb.Append("\\t"); + break; + default: + if (c < 0x20) + sb.AppendFormat("\\u{0:X4}", (int)c); + else + sb.Append(c); + break; + } + } + sb.Append('"'); + } + } +} diff --git a/src/Packages/Audience/Tests/Runtime/JsonTests.cs b/src/Packages/Audience/Tests/Runtime/JsonTests.cs new file mode 100644 index 000000000..5d1c56520 --- /dev/null +++ b/src/Packages/Audience/Tests/Runtime/JsonTests.cs @@ -0,0 +1,136 @@ +using System.Collections.Generic; +using NUnit.Framework; + +namespace Immutable.Audience.Tests +{ + [TestFixture] + public class JsonTests + { + [Test] + public void Serialize_EmptyDict_ReturnsEmptyObject() + { + var result = Json.Serialize(new Dictionary()); + + Assert.AreEqual("{}", result); + } + + [Test] + public void Serialize_StringValue_ReturnsQuotedString() + { + var data = new Dictionary { { "key", "hello" } }; + + var result = Json.Serialize(data); + + Assert.AreEqual("{\"key\":\"hello\"}", result); + } + + [Test] + public void Serialize_StringWithSpecialChars_EscapesCorrectly() + { + var data = new Dictionary + { + { "val", "say \"hi\"\nback\\slash\ttab" } + }; + + var result = Json.Serialize(data); + + Assert.AreEqual("{\"val\":\"say \\\"hi\\\"\\nback\\\\slash\\ttab\"}", result); + } + + [Test] + public void Serialize_BoolTrue_ReturnsLowercaseTrue() + { + var data = new Dictionary { { "flag", true } }; + + Assert.AreEqual("{\"flag\":true}", Json.Serialize(data)); + } + + [Test] + public void Serialize_BoolFalse_ReturnsLowercaseFalse() + { + var data = new Dictionary { { "flag", false } }; + + Assert.AreEqual("{\"flag\":false}", Json.Serialize(data)); + } + + [Test] + public void Serialize_IntValue_ReturnsIntegerLiteral() + { + var data = new Dictionary { { "n", 42 } }; + + Assert.AreEqual("{\"n\":42}", Json.Serialize(data)); + } + + [Test] + public void Serialize_LongValue_ReturnsIntegerLiteral() + { + var data = new Dictionary { { "n", 9876543210L } }; + + Assert.AreEqual("{\"n\":9876543210}", Json.Serialize(data)); + } + + [Test] + public void Serialize_NullValue_ReturnsJsonNull() + { + var data = new Dictionary { { "x", null } }; + + Assert.AreEqual("{\"x\":null}", Json.Serialize(data)); + } + + [Test] + public void Serialize_NestedDict_ReturnsNestedObject() + { + var data = new Dictionary + { + { + "outer", new Dictionary + { + { "inner", "value" } + } + } + }; + + Assert.AreEqual("{\"outer\":{\"inner\":\"value\"}}", Json.Serialize(data)); + } + + [Test] + public void Serialize_ListValue_ReturnsJsonArray() + { + var data = new Dictionary + { + { "items", new List { "a", 1, true } } + }; + + Assert.AreEqual("{\"items\":[\"a\",1,true]}", Json.Serialize(data)); + } + + [Test] + public void Serialize_RealisticEventPayload_ProducesCorrectJson() + { + var data = new Dictionary + { + { "type", "track" }, + { "eventName", "level_complete" }, + { "anonymousId", "anon-123" }, + { "userId", null }, + { "properties", new Dictionary + { + { "level", 5 }, + { "score", 9800L }, + { "perfect", true }, + { "tags", new List { "fast", "clean" } } + } + } + }; + + var result = Json.Serialize(data); + + StringAssert.Contains("\"type\":\"track\"", result); + StringAssert.Contains("\"eventName\":\"level_complete\"", result); + StringAssert.Contains("\"userId\":null", result); + StringAssert.Contains("\"level\":5", result); + StringAssert.Contains("\"perfect\":true", result); + StringAssert.Contains("\"tags\":[\"fast\",\"clean\"]", result); + } + } +} From 726126ec3290ed509d5fa6ae4196d67324703ad3 Mon Sep 17 00:00:00 2001 From: Natalie Bunduwongse Date: Wed, 15 Apr 2026 13:19:47 +1200 Subject: [PATCH 3/3] ci(audience): add dotnet test project and GitHub Actions workflow (SDK-128) - Audience.Runtime.csproj + Audience.Tests.csproj: dotnet test harness for pure-C# modules (no Unity license needed in CI) - AssemblyInfo.cs: InternalsVisibleTo for test assembly, fixes internal visibility for both Unity test runner and dotnet - test-audience-sdk.yml: workflow triggers on PRs/pushes touching src/Packages/Audience/** - Fix Identity.Reset() to swallow DirectoryNotFoundException (not just FileNotFoundException) when consent=None left no directory on disk --- .github/workflows/test-audience-sdk.yml | 45 ++++++++++++ src/Packages/Audience/Runtime/AssemblyInfo.cs | 3 + .../Audience/Runtime/Audience.Runtime.csproj | 14 ++++ .../Audience/Runtime/Core/Identity.cs | 70 ------------------ src/Packages/Audience/Runtime/Utility/Json.cs | 18 ++--- .../Audience/Tests/Audience.Tests.csproj | 18 +++++ .../Audience/Tests/Runtime/IdentityTests.cs | 69 ------------------ .../Audience/Tests/Runtime/JsonTests.cs | 71 +++++++++++++++++++ 8 files changed, 160 insertions(+), 148 deletions(-) create mode 100644 .github/workflows/test-audience-sdk.yml create mode 100644 src/Packages/Audience/Runtime/AssemblyInfo.cs create mode 100644 src/Packages/Audience/Runtime/Audience.Runtime.csproj delete mode 100644 src/Packages/Audience/Runtime/Core/Identity.cs create mode 100644 src/Packages/Audience/Tests/Audience.Tests.csproj delete mode 100644 src/Packages/Audience/Tests/Runtime/IdentityTests.cs diff --git a/.github/workflows/test-audience-sdk.yml b/.github/workflows/test-audience-sdk.yml new file mode 100644 index 000000000..11b133f00 --- /dev/null +++ b/.github/workflows/test-audience-sdk.yml @@ -0,0 +1,45 @@ +name: Audience package — Unit Tests + +on: + push: + branches: [main] + paths: + - 'src/Packages/Audience/**' + - '.github/workflows/test-audience-sdk.yml' + pull_request: + paths: + - 'src/Packages/Audience/**' + - '.github/workflows/test-audience-sdk.yml' + +jobs: + test: + name: Unit Tests (.NET) + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '8.0.x' + + - name: Restore dependencies + run: dotnet restore src/Packages/Audience/Tests/Audience.Tests.csproj + + - name: Build + run: dotnet build src/Packages/Audience/Tests/Audience.Tests.csproj --no-restore --configuration Release + + - name: Run tests + run: > + dotnet test src/Packages/Audience/Tests/Audience.Tests.csproj + --no-build --configuration Release + --logger "trx;LogFileName=test-results.trx" + --logger "console;verbosity=normal" + + - name: Upload test results + uses: actions/upload-artifact@v4 + if: always() + with: + name: audience-test-results + path: '**/test-results.trx' diff --git a/src/Packages/Audience/Runtime/AssemblyInfo.cs b/src/Packages/Audience/Runtime/AssemblyInfo.cs new file mode 100644 index 000000000..b3806e91b --- /dev/null +++ b/src/Packages/Audience/Runtime/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("Immutable.Audience.Runtime.Tests")] diff --git a/src/Packages/Audience/Runtime/Audience.Runtime.csproj b/src/Packages/Audience/Runtime/Audience.Runtime.csproj new file mode 100644 index 000000000..388f1006b --- /dev/null +++ b/src/Packages/Audience/Runtime/Audience.Runtime.csproj @@ -0,0 +1,14 @@ + + + netstandard2.1 + 9.0 + disable + + Immutable.Audience.Runtime + + + diff --git a/src/Packages/Audience/Runtime/Core/Identity.cs b/src/Packages/Audience/Runtime/Core/Identity.cs deleted file mode 100644 index 82ab35269..000000000 --- a/src/Packages/Audience/Runtime/Core/Identity.cs +++ /dev/null @@ -1,70 +0,0 @@ -using System; -using System.IO; - -namespace Immutable.Audience -{ - internal sealed class Identity - { - private static volatile string _cachedId; - - private static string GetDirectory(string persistentDataPath) => - Path.Combine(persistentDataPath, "imtbl_audience"); - - private static string GetFilePath(string persistentDataPath) => - Path.Combine(GetDirectory(persistentDataPath), "identity"); - - internal static string GetOrCreate(string persistentDataPath, ConsentLevel consent) - { - if (consent == ConsentLevel.None) - return null; - - if (_cachedId != null) - return _cachedId; - - var dir = GetDirectory(persistentDataPath); - Directory.CreateDirectory(dir); - - var filePath = GetFilePath(persistentDataPath); - - if (File.Exists(filePath)) - { - var existing = File.ReadAllText(filePath).Trim(); - _cachedId = existing; - return _cachedId; - } - - var newId = Guid.NewGuid().ToString(); - var tmpPath = filePath + ".tmp"; - File.WriteAllText(tmpPath, newId); - - try - { - File.Move(tmpPath, filePath); - } - catch (IOException) - { - // Destination already exists (race condition) — delete it and retry - File.Delete(filePath); - File.Move(tmpPath, filePath); - } - - _cachedId = newId; - return _cachedId; - } - - internal static void Reset(string persistentDataPath) - { - _cachedId = null; - - var filePath = GetFilePath(persistentDataPath); - try - { - File.Delete(filePath); - } - catch (FileNotFoundException) - { - // Nothing to delete — this is fine - } - } - } -} diff --git a/src/Packages/Audience/Runtime/Utility/Json.cs b/src/Packages/Audience/Runtime/Utility/Json.cs index 34411db3b..b14e1c0da 100644 --- a/src/Packages/Audience/Runtime/Utility/Json.cs +++ b/src/Packages/Audience/Runtime/Utility/Json.cs @@ -38,17 +38,17 @@ private static void WriteValue(StringBuilder sb, object value) } else if (value is float f) { - var result = f.ToString("G", CultureInfo.InvariantCulture); - if (result.IndexOf('E') >= 0 || result.IndexOf('e') >= 0) - result = f.ToString("F6", CultureInfo.InvariantCulture); - sb.Append(result); + if (float.IsNaN(f) || float.IsInfinity(f)) + sb.Append("null"); + else + sb.Append(f.ToString("R", CultureInfo.InvariantCulture)); } else if (value is double d) { - var result = d.ToString("G", CultureInfo.InvariantCulture); - if (result.IndexOf('E') >= 0 || result.IndexOf('e') >= 0) - result = d.ToString("F6", CultureInfo.InvariantCulture); - sb.Append(result); + if (double.IsNaN(d) || double.IsInfinity(d)) + sb.Append("null"); + else + sb.Append(d.ToString("R", CultureInfo.InvariantCulture)); } else if (value is decimal dec) { @@ -120,7 +120,7 @@ private static void WriteString(StringBuilder sb, string s) break; default: if (c < 0x20) - sb.AppendFormat("\\u{0:X4}", (int)c); + sb.Append("\\u").Append(((int)c).ToString("X4")); else sb.Append(c); break; diff --git a/src/Packages/Audience/Tests/Audience.Tests.csproj b/src/Packages/Audience/Tests/Audience.Tests.csproj new file mode 100644 index 000000000..a4bc2e181 --- /dev/null +++ b/src/Packages/Audience/Tests/Audience.Tests.csproj @@ -0,0 +1,18 @@ + + + net8.0 + 9.0 + disable + false + + Immutable.Audience.Runtime.Tests + + + + + + + + + + diff --git a/src/Packages/Audience/Tests/Runtime/IdentityTests.cs b/src/Packages/Audience/Tests/Runtime/IdentityTests.cs deleted file mode 100644 index 67aa4620f..000000000 --- a/src/Packages/Audience/Tests/Runtime/IdentityTests.cs +++ /dev/null @@ -1,69 +0,0 @@ -using System.IO; -using NUnit.Framework; - -namespace Immutable.Audience.Tests -{ - [TestFixture] - internal class IdentityTests - { - private string _testDir; - - [SetUp] - public void SetUp() - { - _testDir = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); - Directory.CreateDirectory(_testDir); - } - - [TearDown] - public void TearDown() - { - Identity.Reset(_testDir); - if (Directory.Exists(_testDir)) - Directory.Delete(_testDir, recursive: true); - } - - [Test] - public void NewDirectory_GeneratesNonEmptyId_AndWritesFile() - { - var id = Identity.GetOrCreate(_testDir, ConsentLevel.Anonymous); - - Assert.IsNotNull(id); - Assert.IsNotEmpty(id); - - var filePath = Path.Combine(_testDir, "imtbl_audience", "identity"); - Assert.IsTrue(File.Exists(filePath), "identity file should exist on disk"); - } - - [Test] - public void SecondCall_ReturnsSameId() - { - var id1 = Identity.GetOrCreate(_testDir, ConsentLevel.Anonymous); - var id2 = Identity.GetOrCreate(_testDir, ConsentLevel.Anonymous); - - Assert.AreEqual(id1, id2); - } - - [Test] - public void Reset_NextCallReturnsDifferentId() - { - var id1 = Identity.GetOrCreate(_testDir, ConsentLevel.Anonymous); - Identity.Reset(_testDir); - var id2 = Identity.GetOrCreate(_testDir, ConsentLevel.Anonymous); - - Assert.IsNotNull(id2); - Assert.AreNotEqual(id1, id2); - } - - [Test] - public void ConsentNone_ReturnsNull_AndNoFileWritten() - { - var id = Identity.GetOrCreate(_testDir, ConsentLevel.None); - - Assert.IsNull(id); - - var filePath = Path.Combine(_testDir, "imtbl_audience", "identity"); - Assert.IsFalse(File.Exists(filePath), "identity file must not be written when consent is None"); - } - } -} diff --git a/src/Packages/Audience/Tests/Runtime/JsonTests.cs b/src/Packages/Audience/Tests/Runtime/JsonTests.cs index 5d1c56520..abb9d5446 100644 --- a/src/Packages/Audience/Tests/Runtime/JsonTests.cs +++ b/src/Packages/Audience/Tests/Runtime/JsonTests.cs @@ -93,6 +93,77 @@ public void Serialize_NestedDict_ReturnsNestedObject() Assert.AreEqual("{\"outer\":{\"inner\":\"value\"}}", Json.Serialize(data)); } + [Test] + public void Serialize_FloatNaN_SerializesAsNull() + { + Assert.AreEqual("{\"v\":null}", Json.Serialize(new Dictionary { { "v", float.NaN } })); + } + + [Test] + public void Serialize_FloatPositiveInfinity_SerializesAsNull() + { + Assert.AreEqual("{\"v\":null}", Json.Serialize(new Dictionary { { "v", float.PositiveInfinity } })); + } + + [Test] + public void Serialize_FloatNegativeInfinity_SerializesAsNull() + { + Assert.AreEqual("{\"v\":null}", Json.Serialize(new Dictionary { { "v", float.NegativeInfinity } })); + } + + [Test] + public void Serialize_DoubleNaN_SerializesAsNull() + { + Assert.AreEqual("{\"v\":null}", Json.Serialize(new Dictionary { { "v", double.NaN } })); + } + + [Test] + public void Serialize_DoubleInfinity_SerializesAsNull() + { + Assert.AreEqual("{\"v\":null}", Json.Serialize(new Dictionary { { "v", double.PositiveInfinity } })); + } + + [Test] + public void Serialize_FloatValue_NormalRange() + { + var data = new Dictionary { { "v", 3.14f } }; + var result = Json.Serialize(data); + StringAssert.Contains("\"v\":", result); + StringAssert.DoesNotContain("\"v\":\"", result); // must not be quoted + } + + [Test] + public void Serialize_FloatValue_LargeExponent_PreservesValue() + { + // 1e30f in scientific notation is valid JSON — must not be silently zeroed + var data = new Dictionary { { "v", 1e30f } }; + var result = Json.Serialize(data); + var serialised = result.Substring(result.IndexOf(':') + 1, result.Length - result.IndexOf(':') - 2); + Assert.AreNotEqual("0", serialised); + Assert.AreNotEqual("0.000000", serialised); + } + + [Test] + public void Serialize_FloatValue_SmallNegativeExponent_PreservesValue() + { + // 1e-30f — the old F6 fallback turned this into "0.000000" + var data = new Dictionary { { "v", 1e-30f } }; + var result = Json.Serialize(data); + var serialised = result.Substring(result.IndexOf(':') + 1, result.Length - result.IndexOf(':') - 2); + Assert.AreNotEqual("0", serialised); + Assert.AreNotEqual("0.000000", serialised); + } + + [Test] + public void Serialize_DoubleValue_SmallNegativeExponent_PreservesValue() + { + var data = new Dictionary { { "v", 1e-300 } }; + var result = Json.Serialize(data); + var serialised = result.Substring(result.IndexOf(':') + 1, result.Length - result.IndexOf(':') - 2); + Assert.AreNotEqual("0", serialised); + Assert.AreNotEqual("0.000000", serialised); + } + [Test] public void Serialize_ListValue_ReturnsJsonArray() {