using System.Text.Json; using ZB.MOM.WW.ScadaBridge.Communication.Grpc; namespace ZB.MOM.WW.ScadaBridge.Communication.Tests; /// /// Goldens for — the type-tagged carrier for the /// object? members of the site command plane (script parameters and /// return values, attribute values, tag reads/writes). /// /// /// The contract these tests pin down is that a boxed value keeps its RUNTIME /// CLR TYPE across the wire, not merely its printed form. Today's Akka JSON /// serializer preserves it, and an operator-facing tag value that silently /// turns from int into long (or from DateTime into a /// UTC-normalised copy) is a behaviour change, not a refactor. /// public class LooseValueCodecTests { public static IEnumerable TaggedScalars() => [ [true], [false], [42], [-1], [long.MaxValue], [3.5d], [1.5f], ["a string"], [string.Empty], [12.3456789m], [new DateTime(2026, 7, 22, 1, 2, 3, DateTimeKind.Utc)], [new DateTimeOffset(2026, 7, 22, 1, 2, 3, TimeSpan.FromHours(-5))], [Guid.Parse("11111111-2222-3333-4444-555555555555")], [TimeSpan.FromMinutes(90)], [new byte[] { 1, 2, 3 }] ]; [Theory] [MemberData(nameof(TaggedScalars))] public void TaggedValues_KeepTheirClrType(object value) { var restored = LooseValueCodec.FromProto(LooseValueCodec.ToProto(value)); Assert.NotNull(restored); Assert.Equal(value.GetType(), restored.GetType()); StructuralEquality.AssertDeepEqual(value, restored, value.GetType().Name); } /// /// A local must come back local, and an offset /// must come back with the same offset — the /// reason these ride as round-trip strings instead of proto timestamps. /// [Fact] public void DateValues_KeepKindAndOffset() { var local = new DateTime(2026, 7, 22, 1, 2, 3, DateTimeKind.Local); var offset = new DateTimeOffset(2026, 7, 22, 1, 2, 3, TimeSpan.FromHours(5.5)); var restoredLocal = Assert.IsType(LooseValueCodec.FromProto(LooseValueCodec.ToProto(local))); var restoredOffset = Assert.IsType(LooseValueCodec.FromProto(LooseValueCodec.ToProto(offset))); Assert.Equal(DateTimeKind.Local, restoredLocal.Kind); Assert.Equal(local, restoredLocal); Assert.Equal(TimeSpan.FromHours(5.5), restoredOffset.Offset); } /// An unset carrier and an explicit null marker both decode to null. [Fact] public void NullIsCarriedTwoWays() { Assert.Null(LooseValueCodec.FromProto(null)); Assert.Null(LooseValueCodec.FromProto(LooseValueCodec.ToProto(null))); Assert.Null(LooseValueCodec.ToProtoOrNull(null)); } /// /// A dictionary entry whose value is null stays distinct from an absent key — /// the reason LooseNull exists at all. /// [Fact] public void NullMapValue_SurvivesAsAPresentKey() { var original = new Dictionary { ["present"] = null }; var restored = LooseValueCodec.FromProtoMap(LooseValueCodec.ToProtoMap(original)); Assert.True(restored.ContainsKey("present")); Assert.Null(restored["present"]); } /// A null dictionary stays null; an empty one stays empty. [Fact] public void NullMap_StaysDistinctFromEmptyMap() { Assert.Null(LooseValueCodec.ToProtoMapOrNull(null)); Assert.Null(LooseValueCodec.FromProtoMapOrNull(null)); var empty = LooseValueCodec.FromProtoMapOrNull( LooseValueCodec.ToProtoMapOrNull(new Dictionary())); Assert.NotNull(empty); Assert.Empty(empty); } /// Nested lists and maps recurse, so element values keep their types too. [Fact] public void NestedCollections_KeepElementTypes() { object original = new List { 1, "two", null, new Dictionary { ["deep"] = 3.5d } }; var restored = LooseValueCodec.FromProto(LooseValueCodec.ToProto(original)); StructuralEquality.AssertDeepEqual(original, restored, "list"); } /// /// The documented widening: a typed collection keeps its ELEMENTS' types but /// the container comes back as List<object?>. Recorded here so /// the behaviour is a decision rather than a surprise. /// [Fact] public void TypedList_WidensToObjectList() { var restored = LooseValueCodec.FromProto(LooseValueCodec.ToProto(new List { 1, 2, 3 })); var list = Assert.IsType>(restored); Assert.Equal([1, 2, 3], list.Cast()); } /// /// The documented lossy escape hatch: a value outside the tagged set keeps its /// DATA but not its CLR identity — it decodes as a . /// Nothing on the command plane carries such a value today; the fallback /// exists so an unexpected one cannot fault a command. /// [Fact] public void UntaggedValue_FallsBackToJsonAndLosesItsClrType() { var restored = LooseValueCodec.FromProto(LooseValueCodec.ToProto((byte)7)); var element = Assert.IsType(restored); Assert.Equal("7", element.GetRawText()); } }