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 { "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); + } + } +}