save progress
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
using System.Text;
|
||||
using System.Text.Encodings.Web;
|
||||
using System.Text.Json;
|
||||
using Xunit;
|
||||
|
||||
@@ -8,7 +9,7 @@ namespace StellaOps.Canonical.Json.Tests;
|
||||
public class CanonJsonTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Fact]
|
||||
public void Canonicalize_SameInput_ProducesSameHash()
|
||||
{
|
||||
var obj = new { foo = "bar", baz = 42, nested = new { x = 1, y = 2 } };
|
||||
@@ -21,7 +22,7 @@ public class CanonJsonTests
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Fact]
|
||||
public void Canonicalize_SortsKeysAlphabetically()
|
||||
{
|
||||
var obj = new { z = 3, a = 1, m = 2 };
|
||||
@@ -32,7 +33,7 @@ public class CanonJsonTests
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Fact]
|
||||
public void Canonicalize_HandlesNestedObjects()
|
||||
{
|
||||
var obj = new { outer = new { z = 9, a = 1 }, inner = new { b = 2 } };
|
||||
@@ -44,7 +45,7 @@ public class CanonJsonTests
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Fact]
|
||||
public void Canonicalize_HandlesArrays()
|
||||
{
|
||||
var obj = new { items = new[] { 3, 1, 2 } };
|
||||
@@ -55,7 +56,7 @@ public class CanonJsonTests
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Fact]
|
||||
public void Canonicalize_HandlesNullValues()
|
||||
{
|
||||
var obj = new { name = "test", value = (string?)null };
|
||||
@@ -65,7 +66,7 @@ public class CanonJsonTests
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Fact]
|
||||
public void Canonicalize_HandlesBooleans()
|
||||
{
|
||||
var obj = new { enabled = true, disabled = false };
|
||||
@@ -76,7 +77,7 @@ public class CanonJsonTests
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Fact]
|
||||
public void Canonicalize_HandlesDecimals()
|
||||
{
|
||||
var obj = new { value = 3.14159, integer = 42 };
|
||||
@@ -87,7 +88,7 @@ public class CanonJsonTests
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Fact]
|
||||
public void Canonicalize_HandlesEmptyObject()
|
||||
{
|
||||
var obj = new { };
|
||||
@@ -97,7 +98,7 @@ public class CanonJsonTests
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Fact]
|
||||
public void Canonicalize_HandlesEmptyArray()
|
||||
{
|
||||
var obj = new { items = Array.Empty<int>() };
|
||||
@@ -107,7 +108,7 @@ public class CanonJsonTests
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Fact]
|
||||
public void Canonicalize_WithCustomOptions_UsesOptions()
|
||||
{
|
||||
var obj = new { MyProperty = "test" };
|
||||
@@ -121,7 +122,7 @@ public class CanonJsonTests
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Fact]
|
||||
public void Canonicalize_RawJsonBytes_SortsKeys()
|
||||
{
|
||||
var rawJson = Encoding.UTF8.GetBytes(@"{""z"":3,""a"":1}");
|
||||
@@ -132,7 +133,40 @@ public class CanonJsonTests
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Fact]
|
||||
public void Canonicalize_RawJsonBytes_InvalidJson_Throws()
|
||||
{
|
||||
var rawJson = Encoding.UTF8.GetBytes(@"{""a"":");
|
||||
|
||||
Assert.ThrowsAny<JsonException>(() => CanonJson.CanonicalizeParsedJson(rawJson));
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Canonicalize_RawJsonBytes_CustomEncoder_EscapesUnsafeChars()
|
||||
{
|
||||
var rawJson = Encoding.UTF8.GetBytes(@"{""value"":""<tag>""}");
|
||||
var canonical = CanonJson.CanonicalizeParsedJson(rawJson, JavaScriptEncoder.Default);
|
||||
var json = Encoding.UTF8.GetString(canonical);
|
||||
|
||||
Assert.DoesNotContain("<", json);
|
||||
Assert.DoesNotContain(">", json);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Canonicalize_RawJsonBytes_HandlesScientificNotation()
|
||||
{
|
||||
var rawJson = Encoding.UTF8.GetBytes(@"{""value"":1e2,""small"":1e-6}");
|
||||
var canonical = CanonJson.CanonicalizeParsedJson(rawJson);
|
||||
|
||||
using var doc = JsonDocument.Parse(canonical);
|
||||
Assert.Equal(100d, doc.RootElement.GetProperty("value").GetDouble());
|
||||
Assert.Equal(0.000001d, doc.RootElement.GetProperty("small").GetDouble());
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Sha256Hex_ProducesLowercaseHex()
|
||||
{
|
||||
var bytes = Encoding.UTF8.GetBytes("test");
|
||||
@@ -142,7 +176,7 @@ public class CanonJsonTests
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Fact]
|
||||
public void Sha256Hex_ProducesConsistentHash()
|
||||
{
|
||||
var bytes = Encoding.UTF8.GetBytes("deterministic input");
|
||||
@@ -154,7 +188,7 @@ public class CanonJsonTests
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Fact]
|
||||
public void Sha256Prefixed_IncludesPrefix()
|
||||
{
|
||||
var bytes = Encoding.UTF8.GetBytes("test");
|
||||
@@ -165,7 +199,7 @@ public class CanonJsonTests
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Fact]
|
||||
public void Hash_CanonicalizesAndHashes()
|
||||
{
|
||||
var obj = new { z = 3, a = 1 };
|
||||
@@ -178,7 +212,7 @@ public class CanonJsonTests
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Fact]
|
||||
public void HashPrefixed_CanonicalizesAndHashesWithPrefix()
|
||||
{
|
||||
var obj = new { name = "test" };
|
||||
@@ -189,7 +223,7 @@ public class CanonJsonTests
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Fact]
|
||||
public void DifferentObjects_ProduceDifferentHashes()
|
||||
{
|
||||
var obj1 = new { value = 1 };
|
||||
@@ -202,7 +236,7 @@ public class CanonJsonTests
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Fact]
|
||||
public void KeyOrderDoesNotAffectHash()
|
||||
{
|
||||
// These should produce the same hash because keys are sorted
|
||||
@@ -218,7 +252,7 @@ public class CanonJsonTests
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Fact]
|
||||
public void Canonicalize_DeeplyNestedStructure()
|
||||
{
|
||||
var obj = new
|
||||
@@ -240,7 +274,7 @@ public class CanonJsonTests
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Fact]
|
||||
public void Canonicalize_ArrayOfObjects_SortsObjectKeys()
|
||||
{
|
||||
// Use raw JSON to test mixed object shapes in array
|
||||
@@ -254,10 +288,10 @@ public class CanonJsonTests
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Fact]
|
||||
public void Canonicalize_UnicodeStrings()
|
||||
{
|
||||
var obj = new { greeting = "Привет мир", emoji = "🚀" };
|
||||
var obj = new { greeting = "\u041f\u0440\u0438\u0432\u0435\u0442 \u043c\u0438\u0440", emoji = "\U0001F680" };
|
||||
var bytes = CanonJson.Canonicalize(obj);
|
||||
|
||||
// Verify deterministic hashing regardless of Unicode escaping
|
||||
@@ -272,7 +306,7 @@ public class CanonJsonTests
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Fact]
|
||||
public void Canonicalize_SpecialCharactersInStrings()
|
||||
{
|
||||
var obj = new { path = "C:\\Users\\test", quote = "He said \"hello\"" };
|
||||
|
||||
@@ -15,14 +15,14 @@ public class CanonVersionTests
|
||||
#region Version Constants
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Fact]
|
||||
public void V1_HasExpectedValue()
|
||||
{
|
||||
Assert.Equal("stella:canon:v1", CanonVersion.V1);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Fact]
|
||||
public void VersionFieldName_HasUnderscorePrefix()
|
||||
{
|
||||
Assert.Equal("_canonVersion", CanonVersion.VersionFieldName);
|
||||
@@ -30,7 +30,7 @@ public class CanonVersionTests
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Fact]
|
||||
public void Current_EqualsV1()
|
||||
{
|
||||
Assert.Equal(CanonVersion.V1, CanonVersion.Current);
|
||||
@@ -41,7 +41,7 @@ public class CanonVersionTests
|
||||
#region IsVersioned Detection
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Fact]
|
||||
public void IsVersioned_VersionedJson_ReturnsTrue()
|
||||
{
|
||||
var json = """{"_canonVersion":"stella:canon:v1","foo":"bar"}"""u8;
|
||||
@@ -49,7 +49,7 @@ public class CanonVersionTests
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Fact]
|
||||
public void IsVersioned_LegacyJson_ReturnsFalse()
|
||||
{
|
||||
var json = """{"foo":"bar"}"""u8;
|
||||
@@ -57,7 +57,7 @@ public class CanonVersionTests
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Fact]
|
||||
public void IsVersioned_EmptyJson_ReturnsFalse()
|
||||
{
|
||||
var json = "{}"u8;
|
||||
@@ -65,7 +65,7 @@ public class CanonVersionTests
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Fact]
|
||||
public void IsVersioned_TooShort_ReturnsFalse()
|
||||
{
|
||||
var json = """{"_ca":"v"}"""u8;
|
||||
@@ -73,7 +73,7 @@ public class CanonVersionTests
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Fact]
|
||||
public void IsVersioned_WrongFieldName_ReturnsFalse()
|
||||
{
|
||||
var json = """{"_version":"stella:canon:v1","foo":"bar"}"""u8;
|
||||
@@ -85,7 +85,7 @@ public class CanonVersionTests
|
||||
#region ExtractVersion
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Fact]
|
||||
public void ExtractVersion_VersionedJson_ReturnsVersion()
|
||||
{
|
||||
var json = """{"_canonVersion":"stella:canon:v1","foo":"bar"}"""u8;
|
||||
@@ -93,7 +93,7 @@ public class CanonVersionTests
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Fact]
|
||||
public void ExtractVersion_CustomVersion_ReturnsVersion()
|
||||
{
|
||||
var json = """{"_canonVersion":"custom:v2","foo":"bar"}"""u8;
|
||||
@@ -101,7 +101,7 @@ public class CanonVersionTests
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Fact]
|
||||
public void ExtractVersion_LegacyJson_ReturnsNull()
|
||||
{
|
||||
var json = """{"foo":"bar"}"""u8;
|
||||
@@ -109,7 +109,7 @@ public class CanonVersionTests
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Fact]
|
||||
public void ExtractVersion_EmptyVersion_ReturnsNull()
|
||||
{
|
||||
var json = """{"_canonVersion":"","foo":"bar"}"""u8;
|
||||
@@ -121,7 +121,7 @@ public class CanonVersionTests
|
||||
#region CanonicalizeVersioned
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Fact]
|
||||
public void CanonicalizeVersioned_IncludesVersionMarker()
|
||||
{
|
||||
var obj = new { foo = "bar" };
|
||||
@@ -133,7 +133,7 @@ public class CanonVersionTests
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Fact]
|
||||
public void CanonicalizeVersioned_VersionMarkerIsFirst()
|
||||
{
|
||||
var obj = new { aaa = 1, zzz = 2 };
|
||||
@@ -147,7 +147,7 @@ public class CanonVersionTests
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Fact]
|
||||
public void CanonicalizeVersioned_SortsOtherKeys()
|
||||
{
|
||||
var obj = new { z = 3, a = 1, m = 2 };
|
||||
@@ -159,7 +159,38 @@ public class CanonVersionTests
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Fact]
|
||||
public void CanonicalizeVersioned_WithCustomOptions_UsesOptions()
|
||||
{
|
||||
var obj = new { MyProperty = "test" };
|
||||
var options = new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower
|
||||
};
|
||||
|
||||
var canonical = CanonJson.CanonicalizeVersioned(obj, options);
|
||||
var json = Encoding.UTF8.GetString(canonical);
|
||||
|
||||
Assert.Contains(@"""my_property"":""test""", json);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void CanonicalizeVersioned_SkipsExistingVersionField()
|
||||
{
|
||||
var jsonInput = """{"_canonVersion":"legacy","foo":"bar"}""";
|
||||
var element = JsonSerializer.Deserialize<JsonElement>(jsonInput);
|
||||
|
||||
var canonical = CanonJson.CanonicalizeVersioned(element, "stella:canon:v1");
|
||||
var json = Encoding.UTF8.GetString(canonical);
|
||||
|
||||
var matches = Regex.Matches(json, "_canonVersion");
|
||||
Assert.Single(matches);
|
||||
Assert.Contains(@"""_canonVersion"":""stella:canon:v1""", json);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void CanonicalizeVersioned_CustomVersion_UsesProvidedVersion()
|
||||
{
|
||||
var obj = new { foo = "bar" };
|
||||
@@ -170,7 +201,7 @@ public class CanonVersionTests
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Fact]
|
||||
public void CanonicalizeVersioned_NullVersion_ThrowsArgumentException()
|
||||
{
|
||||
var obj = new { foo = "bar" };
|
||||
@@ -178,7 +209,7 @@ public class CanonVersionTests
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Fact]
|
||||
public void CanonicalizeVersioned_EmptyVersion_ThrowsArgumentException()
|
||||
{
|
||||
var obj = new { foo = "bar" };
|
||||
@@ -190,7 +221,7 @@ public class CanonVersionTests
|
||||
#region Hash Difference (Versioned vs Legacy)
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Fact]
|
||||
public void HashVersioned_DiffersFromLegacyHash()
|
||||
{
|
||||
var obj = new { foo = "bar", count = 42 };
|
||||
@@ -202,7 +233,7 @@ public class CanonVersionTests
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Fact]
|
||||
public void HashVersionedPrefixed_DiffersFromLegacyHashPrefixed()
|
||||
{
|
||||
var obj = new { foo = "bar", count = 42 };
|
||||
@@ -216,7 +247,7 @@ public class CanonVersionTests
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Fact]
|
||||
public void HashVersioned_SameInput_ProducesSameHash()
|
||||
{
|
||||
var obj = new { foo = "bar", count = 42 };
|
||||
@@ -228,7 +259,7 @@ public class CanonVersionTests
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Fact]
|
||||
public void HashVersioned_DifferentVersions_ProduceDifferentHashes()
|
||||
{
|
||||
var obj = new { foo = "bar" };
|
||||
@@ -244,7 +275,7 @@ public class CanonVersionTests
|
||||
#region Determinism
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Fact]
|
||||
public void CanonicalizeVersioned_SameInput_ProducesSameBytes()
|
||||
{
|
||||
var obj = new { name = "test", value = 123, nested = new { x = 1, y = 2 } };
|
||||
@@ -256,7 +287,7 @@ public class CanonVersionTests
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Fact]
|
||||
public void CanonicalizeVersioned_DifferentPropertyOrder_ProducesSameBytes()
|
||||
{
|
||||
// Create two objects with same properties but defined in different order
|
||||
@@ -273,7 +304,7 @@ public class CanonVersionTests
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Fact]
|
||||
public void CanonicalizeVersioned_StableAcrossMultipleCalls()
|
||||
{
|
||||
var obj = new { id = Guid.Parse("12345678-1234-1234-1234-123456789012"), name = "stable" };
|
||||
@@ -291,7 +322,7 @@ public class CanonVersionTests
|
||||
#region Golden File / Snapshot Tests
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Fact]
|
||||
public void CanonicalizeVersioned_KnownInput_ProducesKnownOutput()
|
||||
{
|
||||
// Golden test: exact output for known input to detect algorithm changes
|
||||
@@ -304,7 +335,7 @@ public class CanonVersionTests
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Fact]
|
||||
public void HashVersioned_KnownInput_ProducesKnownHash()
|
||||
{
|
||||
// Golden test: exact hash for known input to detect algorithm changes
|
||||
@@ -323,7 +354,7 @@ public class CanonVersionTests
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Fact]
|
||||
public void CanonicalizeVersioned_NestedObject_ProducesCorrectOutput()
|
||||
{
|
||||
var obj = new
|
||||
@@ -343,7 +374,7 @@ public class CanonVersionTests
|
||||
#region Backward Compatibility
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Fact]
|
||||
public void CanVersion_CanDistinguishLegacyFromVersioned()
|
||||
{
|
||||
var obj = new { foo = "bar" };
|
||||
@@ -356,7 +387,7 @@ public class CanonVersionTests
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Fact]
|
||||
public void LegacyCanonicalize_StillWorks()
|
||||
{
|
||||
// Ensure we haven't broken the legacy canonicalize method
|
||||
@@ -373,7 +404,7 @@ public class CanonVersionTests
|
||||
#region Edge Cases
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Fact]
|
||||
public void CanonicalizeVersioned_EmptyObject_IncludesOnlyVersion()
|
||||
{
|
||||
var obj = new { };
|
||||
@@ -384,7 +415,7 @@ public class CanonVersionTests
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Fact]
|
||||
public void CanonicalizeVersioned_WithSpecialCharacters_HandlesCorrectly()
|
||||
{
|
||||
var obj = new { message = "hello\nworld", special = "quote:\"test\"" };
|
||||
@@ -399,17 +430,16 @@ public class CanonVersionTests
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Fact]
|
||||
public void CanonicalizeVersioned_WithUnicodeCharacters_HandlesCorrectly()
|
||||
{
|
||||
var obj = new { greeting = "こんにちは", emoji = "🚀" };
|
||||
var obj = new { greeting = "\u3053\u3093\u306b\u3061\u306f", emoji = "\U0001F680" };
|
||||
var canonical = CanonJson.CanonicalizeVersioned(obj);
|
||||
var json = Encoding.UTF8.GetString(canonical);
|
||||
|
||||
var parsed = JsonSerializer.Deserialize<JsonElement>(json);
|
||||
Assert.Equal("こんにちは", parsed.GetProperty("greeting").GetString());
|
||||
Assert.Equal("🚀", parsed.GetProperty("emoji").GetString());
|
||||
Assert.Equal("\u3053\u3093\u306b\u3061\u306f", parsed.GetProperty("greeting").GetString());
|
||||
Assert.Equal("\U0001F680", parsed.GetProperty("emoji").GetString());
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
@@ -7,4 +7,4 @@ Source of truth: `docs/implplan/SPRINT_20251229_049_BE_csproj_audit_maint_tests.
|
||||
| --- | --- | --- |
|
||||
| AUDIT-0131-M | DONE | Maintainability audit for StellaOps.Canonical.Json.Tests. |
|
||||
| AUDIT-0131-T | DONE | Test coverage audit for StellaOps.Canonical.Json.Tests. |
|
||||
| AUDIT-0131-A | TODO | Pending approval for changes. |
|
||||
| AUDIT-0131-A | DONE | Tests updated to cover CanonJson fixes for AUDIT-0130-A. |
|
||||
|
||||
@@ -20,7 +20,14 @@ namespace StellaOps.Canonical.Json;
|
||||
/// </remarks>
|
||||
public static class CanonJson
|
||||
{
|
||||
private static readonly JsonWriterOptions CanonWriterOptions = new()
|
||||
private static readonly JsonSerializerOptions DefaultSerializerOptions = new()
|
||||
{
|
||||
WriteIndented = false,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
|
||||
};
|
||||
|
||||
private static readonly JsonWriterOptions DefaultWriterOptions = new()
|
||||
{
|
||||
Indented = false,
|
||||
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
|
||||
@@ -62,16 +69,11 @@ public static class CanonJson
|
||||
/// <returns>UTF-8 encoded canonical JSON bytes.</returns>
|
||||
public static byte[] Canonicalize<T>(T obj)
|
||||
{
|
||||
var json = JsonSerializer.SerializeToUtf8Bytes(obj, new JsonSerializerOptions
|
||||
{
|
||||
WriteIndented = false,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
|
||||
});
|
||||
var json = JsonSerializer.SerializeToUtf8Bytes(obj, DefaultSerializerOptions);
|
||||
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
using var ms = new MemoryStream();
|
||||
using var writer = new Utf8JsonWriter(ms, CanonWriterOptions);
|
||||
using var writer = new Utf8JsonWriter(ms, DefaultWriterOptions);
|
||||
|
||||
WriteElementSorted(doc.RootElement, writer);
|
||||
writer.Flush();
|
||||
@@ -92,7 +94,7 @@ public static class CanonJson
|
||||
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
using var ms = new MemoryStream();
|
||||
using var writer = new Utf8JsonWriter(ms, CanonWriterOptions);
|
||||
using var writer = new Utf8JsonWriter(ms, CreateWriterOptions(options));
|
||||
|
||||
WriteElementSorted(doc.RootElement, writer);
|
||||
writer.Flush();
|
||||
@@ -107,15 +109,50 @@ public static class CanonJson
|
||||
/// <returns>UTF-8 encoded canonical JSON bytes.</returns>
|
||||
public static byte[] CanonicalizeParsedJson(ReadOnlySpan<byte> jsonBytes)
|
||||
{
|
||||
using var doc = JsonDocument.Parse(jsonBytes.ToArray());
|
||||
var reader = new Utf8JsonReader(jsonBytes, isFinalBlock: true, state: default);
|
||||
using var doc = JsonDocument.ParseValue(ref reader);
|
||||
using var ms = new MemoryStream();
|
||||
using var writer = new Utf8JsonWriter(ms, CanonWriterOptions);
|
||||
using var writer = new Utf8JsonWriter(ms, DefaultWriterOptions);
|
||||
|
||||
WriteElementSorted(doc.RootElement, writer);
|
||||
writer.Flush();
|
||||
return ms.ToArray();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Canonicalizes raw JSON bytes using a custom encoder for output.
|
||||
/// </summary>
|
||||
/// <param name="jsonBytes">UTF-8 encoded JSON bytes.</param>
|
||||
/// <param name="encoder">Encoder to use for output escaping.</param>
|
||||
/// <returns>UTF-8 encoded canonical JSON bytes.</returns>
|
||||
public static byte[] CanonicalizeParsedJson(ReadOnlySpan<byte> jsonBytes, JavaScriptEncoder encoder)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(encoder);
|
||||
|
||||
var reader = new Utf8JsonReader(jsonBytes, isFinalBlock: true, state: default);
|
||||
using var doc = JsonDocument.ParseValue(ref reader);
|
||||
using var ms = new MemoryStream();
|
||||
using var writer = new Utf8JsonWriter(ms, new JsonWriterOptions
|
||||
{
|
||||
Indented = false,
|
||||
Encoder = encoder
|
||||
});
|
||||
|
||||
WriteElementSorted(doc.RootElement, writer);
|
||||
writer.Flush();
|
||||
return ms.ToArray();
|
||||
}
|
||||
|
||||
private static JsonWriterOptions CreateWriterOptions(JsonSerializerOptions? options)
|
||||
{
|
||||
var encoder = options?.Encoder ?? DefaultWriterOptions.Encoder;
|
||||
return new JsonWriterOptions
|
||||
{
|
||||
Indented = false,
|
||||
Encoder = encoder
|
||||
};
|
||||
}
|
||||
|
||||
private static void WriteElementSorted(JsonElement el, Utf8JsonWriter w)
|
||||
{
|
||||
switch (el.ValueKind)
|
||||
@@ -198,16 +235,11 @@ public static class CanonJson
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(version);
|
||||
|
||||
var json = JsonSerializer.SerializeToUtf8Bytes(obj, new JsonSerializerOptions
|
||||
{
|
||||
WriteIndented = false,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
|
||||
});
|
||||
var json = JsonSerializer.SerializeToUtf8Bytes(obj, DefaultSerializerOptions);
|
||||
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
using var ms = new MemoryStream();
|
||||
using var writer = new Utf8JsonWriter(ms, CanonWriterOptions);
|
||||
using var writer = new Utf8JsonWriter(ms, DefaultWriterOptions);
|
||||
|
||||
WriteElementVersioned(doc.RootElement, writer, version);
|
||||
writer.Flush();
|
||||
@@ -230,7 +262,7 @@ public static class CanonJson
|
||||
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
using var ms = new MemoryStream();
|
||||
using var writer = new Utf8JsonWriter(ms, CanonWriterOptions);
|
||||
using var writer = new Utf8JsonWriter(ms, CreateWriterOptions(options));
|
||||
|
||||
WriteElementVersioned(doc.RootElement, writer, version);
|
||||
writer.Flush();
|
||||
@@ -249,6 +281,11 @@ public static class CanonJson
|
||||
// Write remaining properties sorted
|
||||
foreach (var prop in el.EnumerateObject().OrderBy(p => p.Name, StringComparer.Ordinal))
|
||||
{
|
||||
if (string.Equals(prop.Name, CanonVersion.VersionFieldName, StringComparison.Ordinal))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
w.WritePropertyName(prop.Name);
|
||||
WriteElementSorted(prop.Value, w);
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ This library provides canonical JSON serialization that produces bit-identical o
|
||||
- **No Whitespace**: Compact output with no formatting variations
|
||||
- **Consistent Hashing**: SHA-256 hashes are always lowercase hex
|
||||
- **Cross-Platform**: Same output across Windows, Linux, containers
|
||||
- **Stable Defaults**: Default serialization uses camelCase naming and UnsafeRelaxed JSON escaping (override with custom options)
|
||||
|
||||
## Usage
|
||||
|
||||
@@ -51,6 +52,12 @@ byte[] canonical = CanonJson.CanonicalizeParsedJson(rawJson);
|
||||
// Result: {"a":2,"z":1}
|
||||
```
|
||||
|
||||
If you need stricter escaping rules for raw JSON, pass a custom encoder:
|
||||
|
||||
```csharp
|
||||
byte[] canonical = CanonJson.CanonicalizeParsedJson(rawJson, JavaScriptEncoder.Default);
|
||||
```
|
||||
|
||||
### Custom Serialization Options
|
||||
|
||||
```csharp
|
||||
@@ -62,6 +69,10 @@ var options = new JsonSerializerOptions
|
||||
byte[] canonical = CanonJson.Canonicalize(obj, options);
|
||||
```
|
||||
|
||||
Notes:
|
||||
- Default naming policy is `JsonNamingPolicy.CamelCase` for the object-to-JSON step.
|
||||
- Default encoder is `JavaScriptEncoder.UnsafeRelaxedJsonEscaping` for canonical output.
|
||||
|
||||
## API Reference
|
||||
|
||||
| Method | Description |
|
||||
@@ -69,6 +80,7 @@ byte[] canonical = CanonJson.Canonicalize(obj, options);
|
||||
| `Canonicalize<T>(obj)` | Serialize and canonicalize an object |
|
||||
| `Canonicalize<T>(obj, options)` | Serialize with custom options and canonicalize |
|
||||
| `CanonicalizeParsedJson(bytes)` | Canonicalize existing JSON bytes |
|
||||
| `CanonicalizeParsedJson(bytes, encoder)` | Canonicalize existing JSON with a custom encoder |
|
||||
| `Sha256Hex(bytes)` | Compute SHA-256, return lowercase hex |
|
||||
| `Sha256Prefixed(bytes)` | Compute SHA-256 with "sha256:" prefix |
|
||||
| `Hash<T>(obj)` | Canonicalize and hash in one step |
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -7,4 +7,4 @@ Source of truth: `docs/implplan/SPRINT_20251229_049_BE_csproj_audit_maint_tests.
|
||||
| --- | --- | --- |
|
||||
| AUDIT-0130-M | DONE | Maintainability audit for StellaOps.Canonical.Json. |
|
||||
| AUDIT-0130-T | DONE | Test coverage audit for StellaOps.Canonical.Json. |
|
||||
| AUDIT-0130-A | TODO | Pending approval for changes. |
|
||||
| AUDIT-0130-A | DONE | Applied canonicalization fixes and added tests. |
|
||||
|
||||
@@ -11,9 +11,10 @@ public static class InvariantCulture
|
||||
public static IDisposable Scope()
|
||||
{
|
||||
var original = CultureInfo.CurrentCulture;
|
||||
var originalUi = CultureInfo.CurrentUICulture;
|
||||
CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;
|
||||
CultureInfo.CurrentUICulture = CultureInfo.InvariantCulture;
|
||||
return new CultureScope(original);
|
||||
return new CultureScope(original, originalUi);
|
||||
}
|
||||
|
||||
public static int Compare(string? a, string? b) => string.Compare(a, b, StringComparison.Ordinal);
|
||||
@@ -25,11 +26,17 @@ public static class InvariantCulture
|
||||
private sealed class CultureScope : IDisposable
|
||||
{
|
||||
private readonly CultureInfo _original;
|
||||
public CultureScope(CultureInfo original) => _original = original;
|
||||
private readonly CultureInfo _originalUi;
|
||||
|
||||
public CultureScope(CultureInfo original, CultureInfo originalUi)
|
||||
{
|
||||
_original = original;
|
||||
_originalUi = originalUi;
|
||||
}
|
||||
public void Dispose()
|
||||
{
|
||||
CultureInfo.CurrentCulture = _original;
|
||||
CultureInfo.CurrentUICulture = _original;
|
||||
CultureInfo.CurrentUICulture = _originalUi;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -40,9 +47,14 @@ public static class InvariantCulture
|
||||
public static class Utf8Encoding
|
||||
{
|
||||
public static string Normalize(string input)
|
||||
{
|
||||
return input.Normalize(NormalizationForm.FormC);
|
||||
}
|
||||
=> Normalize(input, NormalizationForm.FormC);
|
||||
|
||||
public static byte[] GetBytes(string input) => Encoding.UTF8.GetBytes(Normalize(input));
|
||||
public static string Normalize(string input, NormalizationForm form)
|
||||
=> input.Normalize(form);
|
||||
|
||||
public static byte[] GetBytes(string input)
|
||||
=> Encoding.UTF8.GetBytes(Normalize(input));
|
||||
|
||||
public static byte[] GetBytes(string input, NormalizationForm form)
|
||||
=> Encoding.UTF8.GetBytes(Normalize(input, form));
|
||||
}
|
||||
|
||||
@@ -75,13 +75,55 @@ public sealed class StableDictionaryConverter<TKey, TValue> : JsonConverter<IDic
|
||||
public override void Write(Utf8JsonWriter writer, IDictionary<TKey, TValue> value, JsonSerializerOptions options)
|
||||
{
|
||||
writer.WriteStartObject();
|
||||
foreach (var kvp in value.OrderBy(x => x.Key?.ToString(), StringComparer.Ordinal))
|
||||
var ordered = value
|
||||
.Select(kvp => new
|
||||
{
|
||||
Key = kvp.Key,
|
||||
Value = kvp.Value,
|
||||
RawKeyString = ConvertKeyToString(kvp.Key)
|
||||
})
|
||||
.Select(kvp => new
|
||||
{
|
||||
kvp.Key,
|
||||
kvp.Value,
|
||||
kvp.RawKeyString,
|
||||
KeyString = ApplyKeyPolicy(kvp.RawKeyString, options)
|
||||
})
|
||||
.OrderBy(kvp => kvp.KeyString, StringComparer.Ordinal)
|
||||
.ThenBy(kvp => kvp.RawKeyString, StringComparer.Ordinal);
|
||||
|
||||
foreach (var kvp in ordered)
|
||||
{
|
||||
writer.WritePropertyName(kvp.Key?.ToString() ?? string.Empty);
|
||||
writer.WritePropertyName(kvp.KeyString);
|
||||
JsonSerializer.Serialize(writer, kvp.Value, options);
|
||||
}
|
||||
writer.WriteEndObject();
|
||||
}
|
||||
|
||||
private static string ConvertKeyToString(TKey key)
|
||||
{
|
||||
if (key is null)
|
||||
{
|
||||
throw new ArgumentException("Dictionary key cannot be null.", nameof(key));
|
||||
}
|
||||
|
||||
return key switch
|
||||
{
|
||||
string s => s,
|
||||
IFormattable formattable => formattable.ToString(null, CultureInfo.InvariantCulture),
|
||||
_ => Convert.ToString(key, CultureInfo.InvariantCulture)
|
||||
} ?? string.Empty;
|
||||
}
|
||||
|
||||
private static string ApplyKeyPolicy(string keyString, JsonSerializerOptions options)
|
||||
{
|
||||
if (options.DictionaryKeyPolicy is not null)
|
||||
{
|
||||
keyString = options.DictionaryKeyPolicy.ConvertName(keyString);
|
||||
}
|
||||
|
||||
return keyString;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -90,7 +132,10 @@ public sealed class StableDictionaryConverter<TKey, TValue> : JsonConverter<IDic
|
||||
public sealed class Iso8601DateTimeConverter : JsonConverter<DateTimeOffset>
|
||||
{
|
||||
public override DateTimeOffset Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
|
||||
=> DateTimeOffset.Parse(reader.GetString()!, CultureInfo.InvariantCulture);
|
||||
=> DateTimeOffset.Parse(
|
||||
reader.GetString() ?? throw new JsonException("DateTimeOffset value is null."),
|
||||
CultureInfo.InvariantCulture,
|
||||
DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal);
|
||||
|
||||
public override void Write(Utf8JsonWriter writer, DateTimeOffset value, JsonSerializerOptions options)
|
||||
=> writer.WriteStringValue(value.ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ss.fffZ", CultureInfo.InvariantCulture));
|
||||
|
||||
35
src/__Libraries/StellaOps.Canonicalization/README.md
Normal file
35
src/__Libraries/StellaOps.Canonicalization/README.md
Normal file
@@ -0,0 +1,35 @@
|
||||
# StellaOps.Canonicalization
|
||||
|
||||
Deterministic ordering and canonical JSON helpers used across StellaOps.
|
||||
|
||||
## Canonical JSON Defaults
|
||||
|
||||
`CanonicalJsonSerializer` uses these defaults unless you pass your own `JsonSerializerOptions`:
|
||||
|
||||
- Property naming: `JsonNamingPolicy.CamelCase`
|
||||
- Dictionary key naming: `JsonNamingPolicy.CamelCase`
|
||||
- Null handling: omit null values
|
||||
- Encoder: `JavaScriptEncoder.UnsafeRelaxedJsonEscaping`
|
||||
- Number handling: strict
|
||||
|
||||
These defaults are chosen for deterministic output. If you need stricter escaping or a different naming policy, use `JsonSerializerOptions` explicitly.
|
||||
|
||||
## Dictionary Key Handling
|
||||
|
||||
`StableDictionaryConverter` sorts keys using ordinal comparison of a stable string representation:
|
||||
|
||||
- String keys use the provided dictionary key policy (if any).
|
||||
- Non-string keys use invariant formatting when possible.
|
||||
- Null keys are rejected.
|
||||
- Duplicate keys after canonicalization are rejected to avoid ambiguous output.
|
||||
|
||||
## Date/Time Handling
|
||||
|
||||
`Iso8601DateTimeConverter` serializes `DateTimeOffset` values as UTC using the
|
||||
format `yyyy-MM-ddTHH:mm:ss.fffZ`. When parsing, offset-less values are treated
|
||||
as UTC to avoid local-time ambiguity.
|
||||
|
||||
## Determinism Verification
|
||||
|
||||
`DeterminismVerifier` can compare two JSON payloads and reports structural
|
||||
differences. Invalid JSON inputs are reported with context.
|
||||
@@ -4,6 +4,7 @@
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -7,4 +7,4 @@ Source of truth: `docs/implplan/SPRINT_20251229_049_BE_csproj_audit_maint_tests.
|
||||
| --- | --- | --- |
|
||||
| AUDIT-0132-M | DONE | Maintainability audit for StellaOps.Canonicalization. |
|
||||
| AUDIT-0132-T | DONE | Test coverage audit for StellaOps.Canonicalization. |
|
||||
| AUDIT-0132-A | TODO | Pending approval for changes. |
|
||||
| AUDIT-0132-A | DONE | Applied canonicalization fixes and added tests. |
|
||||
|
||||
@@ -42,12 +42,40 @@ public sealed class DeterminismVerifier
|
||||
private static IReadOnlyList<string> FindDifferences(string a, string b)
|
||||
{
|
||||
var differences = new List<string>();
|
||||
using var docA = JsonDocument.Parse(a);
|
||||
using var docB = JsonDocument.Parse(b);
|
||||
CompareElements(docA.RootElement, docB.RootElement, "$", differences);
|
||||
var parsedA = TryParseJson(a, "inputA", differences, out var docA);
|
||||
var parsedB = TryParseJson(b, "inputB", differences, out var docB);
|
||||
if (!parsedA || !parsedB)
|
||||
{
|
||||
return differences;
|
||||
}
|
||||
|
||||
using (docA)
|
||||
using (docB)
|
||||
{
|
||||
CompareElements(docA.RootElement, docB.RootElement, "$", differences);
|
||||
}
|
||||
return differences;
|
||||
}
|
||||
|
||||
private static bool TryParseJson(
|
||||
string json,
|
||||
string label,
|
||||
List<string> differences,
|
||||
out JsonDocument doc)
|
||||
{
|
||||
try
|
||||
{
|
||||
doc = JsonDocument.Parse(json);
|
||||
return true;
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
differences.Add($"{label}: invalid JSON ({ex.Message})");
|
||||
doc = null!;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static void CompareElements(JsonElement a, JsonElement b, string path, List<string> differences)
|
||||
{
|
||||
if (a.ValueKind != b.ValueKind)
|
||||
|
||||
13
src/__Libraries/StellaOps.Orchestrator.Schemas/AGENTS.md
Normal file
13
src/__Libraries/StellaOps.Orchestrator.Schemas/AGENTS.md
Normal file
@@ -0,0 +1,13 @@
|
||||
# StellaOps.Orchestrator.Schemas Agent Charter
|
||||
|
||||
## Mission
|
||||
Define Orchestrator message payload schemas used across services.
|
||||
|
||||
## Required Reading
|
||||
- docs/modules/orchestrator/architecture.md
|
||||
- docs/modules/platform/architecture-overview.md
|
||||
|
||||
## Working Agreement
|
||||
- Update sprint status in docs/implplan/SPRINT_*.md and local TASKS.md.
|
||||
- Preserve deterministic serialization and schema compatibility.
|
||||
- Add or update schema roundtrip tests for payload DTOs.
|
||||
10
src/__Libraries/StellaOps.Orchestrator.Schemas/TASKS.md
Normal file
10
src/__Libraries/StellaOps.Orchestrator.Schemas/TASKS.md
Normal file
@@ -0,0 +1,10 @@
|
||||
# StellaOps.Orchestrator.Schemas Task Board
|
||||
|
||||
This board mirrors active sprint tasks for this module.
|
||||
Source of truth: `docs/implplan/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`.
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
| AUDIT-0423-M | DONE | Maintainability audit for StellaOps.Orchestrator.Schemas. |
|
||||
| AUDIT-0423-T | DONE | Test coverage audit for StellaOps.Orchestrator.Schemas. |
|
||||
| AUDIT-0423-A | TODO | Pending approval for apply tasks. |
|
||||
10
src/__Libraries/StellaOps.Plugin/TASKS.md
Normal file
10
src/__Libraries/StellaOps.Plugin/TASKS.md
Normal file
@@ -0,0 +1,10 @@
|
||||
# StellaOps.Plugin Task Board
|
||||
|
||||
This board mirrors active sprint tasks for this module.
|
||||
Source of truth: `docs/implplan/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`.
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
| AUDIT-0436-M | DONE | Maintainability audit for StellaOps.Plugin. |
|
||||
| AUDIT-0436-T | DONE | Test coverage audit for StellaOps.Plugin. |
|
||||
| AUDIT-0436-A | TODO | APPLY pending approval for StellaOps.Plugin. |
|
||||
@@ -1,6 +1,8 @@
|
||||
using FluentAssertions;
|
||||
using System.Collections;
|
||||
using FluentAssertions;
|
||||
using StellaOps.Canonicalization.Json;
|
||||
using StellaOps.Canonicalization.Ordering;
|
||||
using StellaOps.Canonicalization.Verification;
|
||||
using Xunit;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
@@ -9,7 +11,7 @@ namespace StellaOps.Canonicalization.Tests;
|
||||
public class CanonicalJsonSerializerTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Fact]
|
||||
public void Serialize_Dictionary_OrdersKeysAlphabetically()
|
||||
{
|
||||
var dict = new Dictionary<string, int> { ["z"] = 1, ["a"] = 2, ["m"] = 3 };
|
||||
@@ -18,7 +20,25 @@ public class CanonicalJsonSerializerTests
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Fact]
|
||||
public void Serialize_Dictionary_NonStringKeys_UsesInvariantOrdering()
|
||||
{
|
||||
var dict = new Dictionary<int, string> { [2] = "a", [10] = "b" };
|
||||
var json = CanonicalJsonSerializer.Serialize(dict);
|
||||
json.Should().Be("{\"10\":\"b\",\"2\":\"a\"}");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Serialize_Dictionary_NullKey_Throws()
|
||||
{
|
||||
var dict = new NullKeyDictionary();
|
||||
Action act = () => CanonicalJsonSerializer.Serialize(dict);
|
||||
act.Should().Throw<Exception>().WithMessage("*null*");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Serialize_DateTimeOffset_UsesUtcIso8601()
|
||||
{
|
||||
var dt = new DateTimeOffset(2024, 1, 15, 10, 30, 0, TimeSpan.FromHours(5));
|
||||
@@ -28,7 +48,17 @@ public class CanonicalJsonSerializerTests
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Fact]
|
||||
public void Deserialize_DateTimeOffset_AssumesUtcWhenOffsetMissing()
|
||||
{
|
||||
var json = "{\"timestamp\":\"2024-01-15T10:30:00\"}";
|
||||
var result = CanonicalJsonSerializer.Deserialize<TimeWrapper>(json);
|
||||
result.Timestamp.Offset.Should().Be(TimeSpan.Zero);
|
||||
result.Timestamp.UtcDateTime.Should().Be(new DateTime(2024, 1, 15, 10, 30, 0, DateTimeKind.Utc));
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Serialize_NullValues_AreOmitted()
|
||||
{
|
||||
var obj = new { Name = "test", Value = (string?)null };
|
||||
@@ -37,7 +67,7 @@ public class CanonicalJsonSerializerTests
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Fact]
|
||||
public void SerializeWithDigest_ProducesConsistentDigest()
|
||||
{
|
||||
var obj = new { Name = "test", Value = 123 };
|
||||
@@ -45,12 +75,32 @@ public class CanonicalJsonSerializerTests
|
||||
var (_, digest2) = CanonicalJsonSerializer.SerializeWithDigest(obj);
|
||||
digest1.Should().Be(digest2);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void DeterminismVerifier_Compare_ReturnsDifferences()
|
||||
{
|
||||
var verifier = new DeterminismVerifier();
|
||||
var result = verifier.Compare("{\"a\":1}", "{\"a\":2}");
|
||||
result.IsIdentical.Should().BeFalse();
|
||||
result.Differences.Should().ContainSingle(d => d.Contains("$.a", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void DeterminismVerifier_Compare_InvalidJson_ReturnsError()
|
||||
{
|
||||
var verifier = new DeterminismVerifier();
|
||||
var result = verifier.Compare("{\"a\":", "{\"a\":1}");
|
||||
result.IsIdentical.Should().BeFalse();
|
||||
result.Differences.Should().ContainSingle(d => d.StartsWith("inputA: invalid JSON", StringComparison.Ordinal));
|
||||
}
|
||||
}
|
||||
|
||||
public class PackageOrdererTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Fact]
|
||||
public void StableOrder_OrdersByPurlFirst()
|
||||
{
|
||||
var packages = new[]
|
||||
@@ -62,3 +112,43 @@ public class PackageOrdererTests
|
||||
ordered[0].purl.Should().Be("pkg:npm/a@1.0.0");
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class TimeWrapper
|
||||
{
|
||||
public DateTimeOffset Timestamp { get; init; }
|
||||
}
|
||||
|
||||
internal sealed class NullKeyDictionary : IDictionary<string, int>
|
||||
{
|
||||
private readonly List<KeyValuePair<string, int>> _items =
|
||||
[
|
||||
new(null!, 1),
|
||||
new("b", 2)
|
||||
];
|
||||
|
||||
public IEnumerator<KeyValuePair<string, int>> GetEnumerator() => _items.GetEnumerator();
|
||||
IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
|
||||
public void Add(KeyValuePair<string, int> item) => throw new NotSupportedException();
|
||||
public void Clear() => throw new NotSupportedException();
|
||||
public bool Contains(KeyValuePair<string, int> item) => false;
|
||||
public void CopyTo(KeyValuePair<string, int>[] array, int arrayIndex) => throw new NotSupportedException();
|
||||
public bool Remove(KeyValuePair<string, int> item) => throw new NotSupportedException();
|
||||
public int Count => _items.Count;
|
||||
public bool IsReadOnly => true;
|
||||
public void Add(string key, int value) => throw new NotSupportedException();
|
||||
public bool ContainsKey(string key) => _items.Any(i => i.Key == key);
|
||||
public bool Remove(string key) => throw new NotSupportedException();
|
||||
public bool TryGetValue(string key, out int value)
|
||||
{
|
||||
var found = _items.FirstOrDefault(i => i.Key == key);
|
||||
value = found.Value;
|
||||
return found.Key is not null;
|
||||
}
|
||||
public int this[string key]
|
||||
{
|
||||
get => _items.First(i => i.Key == key).Value;
|
||||
set => throw new NotSupportedException();
|
||||
}
|
||||
public ICollection<string> Keys => _items.Select(i => i.Key!).ToList();
|
||||
public ICollection<int> Values => _items.Select(i => i.Value).ToList();
|
||||
}
|
||||
|
||||
@@ -7,4 +7,4 @@ Source of truth: `docs/implplan/SPRINT_20251229_049_BE_csproj_audit_maint_tests.
|
||||
| --- | --- | --- |
|
||||
| AUDIT-0133-M | DONE | Maintainability audit for StellaOps.Canonicalization.Tests. |
|
||||
| AUDIT-0133-T | DONE | Test coverage audit for StellaOps.Canonicalization.Tests. |
|
||||
| AUDIT-0133-A | TODO | Pending approval for changes. |
|
||||
| AUDIT-0133-A | DONE | Tests updated to cover canonicalization changes. |
|
||||
|
||||
12
src/__Libraries/__Tests/StellaOps.Plugin.Tests/AGENTS.md
Normal file
12
src/__Libraries/__Tests/StellaOps.Plugin.Tests/AGENTS.md
Normal file
@@ -0,0 +1,12 @@
|
||||
# StellaOps.Plugin.Tests Agent Charter
|
||||
|
||||
## Mission
|
||||
Validate plugin platform behavior (loading, DI, security, compatibility).
|
||||
|
||||
## Required Reading
|
||||
- docs/modules/platform/architecture-overview.md
|
||||
- docs/dev/plugins/README.md
|
||||
|
||||
## Working Agreement
|
||||
- Update sprint status in docs/implplan/SPRINT_*.md and local TASKS.md.
|
||||
- Keep tests deterministic and avoid flaky filesystem or time dependencies.
|
||||
10
src/__Libraries/__Tests/StellaOps.Plugin.Tests/TASKS.md
Normal file
10
src/__Libraries/__Tests/StellaOps.Plugin.Tests/TASKS.md
Normal file
@@ -0,0 +1,10 @@
|
||||
# StellaOps.Plugin.Tests Task Board
|
||||
|
||||
This board mirrors active sprint tasks for this module.
|
||||
Source of truth: `docs/implplan/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`.
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
| AUDIT-0437-M | DONE | Maintainability audit for StellaOps.Plugin.Tests. |
|
||||
| AUDIT-0437-T | DONE | Test coverage audit for StellaOps.Plugin.Tests. |
|
||||
| AUDIT-0437-A | DONE | APPLY waived (test project). |
|
||||
Reference in New Issue
Block a user