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());
}
}