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/Utility/Json.cs b/src/Packages/Audience/Runtime/Utility/Json.cs
new file mode 100644
index 000000000..b14e1c0da
--- /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)
+ {
+ if (float.IsNaN(f) || float.IsInfinity(f))
+ sb.Append("null");
+ else
+ sb.Append(f.ToString("R", CultureInfo.InvariantCulture));
+ }
+ else if (value is double d)
+ {
+ if (double.IsNaN(d) || double.IsInfinity(d))
+ sb.Append("null");
+ else
+ sb.Append(d.ToString("R", CultureInfo.InvariantCulture));
+ }
+ 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.Append("\\u").Append(((int)c).ToString("X4"));
+ else
+ sb.Append(c);
+ break;
+ }
+ }
+ sb.Append('"');
+ }
+ }
+}
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/JsonTests.cs b/src/Packages/Audience/Tests/Runtime/JsonTests.cs
new file mode 100644
index 000000000..abb9d5446
--- /dev/null
+++ b/src/Packages/Audience/Tests/Runtime/JsonTests.cs
@@ -0,0 +1,207 @@
+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_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()
+ {
+ var data = new Dictionary
+ {
+ { "items", new List