using MessagePack; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.Contracts; namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.Tests; /// /// MessagePack round-trip coverage for every FOCAS IPC contract. Ensures /// [Key]-tagged fields survive serialize -> deserialize without loss so the /// wire format stays stable across Proxy (.NET 10) and Host (.NET 4.8) processes. /// [Trait("Category", "Unit")] public sealed class ContractRoundTripTests { private static T RoundTrip(T value) { var bytes = MessagePackSerializer.Serialize(value); return MessagePackSerializer.Deserialize(bytes); } [Fact] public void Hello_round_trips() { var original = new Hello { ProtocolMajor = 1, ProtocolMinor = 2, PeerName = "OtOpcUa.Server", SharedSecret = "abc-123", Features = ["bulk-read", "pmc-rmw"], }; var decoded = RoundTrip(original); decoded.ProtocolMajor.ShouldBe(1); decoded.ProtocolMinor.ShouldBe(2); decoded.PeerName.ShouldBe("OtOpcUa.Server"); decoded.SharedSecret.ShouldBe("abc-123"); decoded.Features.ShouldBe(["bulk-read", "pmc-rmw"]); } [Fact] public void HelloAck_rejected_carries_reason() { var original = new HelloAck { Accepted = false, RejectReason = "bad secret" }; var decoded = RoundTrip(original); decoded.Accepted.ShouldBeFalse(); decoded.RejectReason.ShouldBe("bad secret"); } [Fact] public void Heartbeat_and_ack_preserve_ticks() { var hb = RoundTrip(new Heartbeat { MonotonicTicks = 987654321 }); hb.MonotonicTicks.ShouldBe(987654321); var ack = RoundTrip(new HeartbeatAck { MonotonicTicks = 987654321, HostUtcUnixMs = 1_700_000_000_000 }); ack.MonotonicTicks.ShouldBe(987654321); ack.HostUtcUnixMs.ShouldBe(1_700_000_000_000); } [Fact] public void ErrorResponse_preserves_code_and_message() { var decoded = RoundTrip(new ErrorResponse { Code = "Fwlib32Crashed", Message = "EW_UNEXPECTED" }); decoded.Code.ShouldBe("Fwlib32Crashed"); decoded.Message.ShouldBe("EW_UNEXPECTED"); } [Fact] public void OpenSessionRequest_preserves_series_and_timeout() { var decoded = RoundTrip(new OpenSessionRequest { HostAddress = "192.168.1.50:8193", TimeoutMs = 3500, CncSeries = 5, }); decoded.HostAddress.ShouldBe("192.168.1.50:8193"); decoded.TimeoutMs.ShouldBe(3500); decoded.CncSeries.ShouldBe(5); } [Fact] public void OpenSessionResponse_failure_carries_error_code() { var decoded = RoundTrip(new OpenSessionResponse { Success = false, SessionId = 0, Error = "unreachable", ErrorCode = "EW_SOCKET", }); decoded.Success.ShouldBeFalse(); decoded.Error.ShouldBe("unreachable"); decoded.ErrorCode.ShouldBe("EW_SOCKET"); } [Fact] public void FocasAddressDto_carries_pmc_with_bit_index() { var decoded = RoundTrip(new FocasAddressDto { Kind = 0, PmcLetter = "R", Number = 100, BitIndex = 3, }); decoded.Kind.ShouldBe(0); decoded.PmcLetter.ShouldBe("R"); decoded.Number.ShouldBe(100); decoded.BitIndex.ShouldBe(3); } [Fact] public void FocasAddressDto_macro_omits_letter_and_bit() { var decoded = RoundTrip(new FocasAddressDto { Kind = 2, Number = 500 }); decoded.Kind.ShouldBe(2); decoded.PmcLetter.ShouldBeNull(); decoded.Number.ShouldBe(500); decoded.BitIndex.ShouldBeNull(); } [Fact] public void ReadRequest_and_response_round_trip() { var req = RoundTrip(new ReadRequest { SessionId = 42, Address = new FocasAddressDto { Kind = 1, Number = 1815 }, DataType = FocasDataTypeCode.Int32, TimeoutMs = 1500, }); req.SessionId.ShouldBe(42); req.Address.Number.ShouldBe(1815); req.DataType.ShouldBe(FocasDataTypeCode.Int32); var resp = RoundTrip(new ReadResponse { Success = true, StatusCode = 0, ValueBytes = MessagePackSerializer.Serialize((int)12345), ValueTypeCode = FocasDataTypeCode.Int32, SourceTimestampUtcUnixMs = 1_700_000_000_000, }); resp.Success.ShouldBeTrue(); resp.StatusCode.ShouldBe(0u); MessagePackSerializer.Deserialize(resp.ValueBytes!).ShouldBe(12345); resp.ValueTypeCode.ShouldBe(FocasDataTypeCode.Int32); } [Fact] public void WriteRequest_and_response_round_trip() { var req = RoundTrip(new WriteRequest { SessionId = 1, Address = new FocasAddressDto { Kind = 2, Number = 500 }, DataType = FocasDataTypeCode.Float64, ValueBytes = MessagePackSerializer.Serialize(3.14159), ValueTypeCode = FocasDataTypeCode.Float64, }); MessagePackSerializer.Deserialize(req.ValueBytes!).ShouldBe(3.14159); var resp = RoundTrip(new WriteResponse { Success = true, StatusCode = 0 }); resp.Success.ShouldBeTrue(); resp.StatusCode.ShouldBe(0u); } [Fact] public void PmcBitWriteRequest_preserves_bit_and_value() { var req = RoundTrip(new PmcBitWriteRequest { SessionId = 7, Address = new FocasAddressDto { Kind = 0, PmcLetter = "Y", Number = 12 }, BitIndex = 5, Value = true, }); req.BitIndex.ShouldBe(5); req.Value.ShouldBeTrue(); } [Fact] public void SubscribeRequest_round_trips_multiple_items() { var original = new SubscribeRequest { SessionId = 1, SubscriptionId = 100, IntervalMs = 250, Items = [ new() { MonitoredItemId = 1, Address = new() { Kind = 0, PmcLetter = "R", Number = 100 }, DataType = FocasDataTypeCode.Bit }, new() { MonitoredItemId = 2, Address = new() { Kind = 2, Number = 500 }, DataType = FocasDataTypeCode.Float64 }, ], }; var decoded = RoundTrip(original); decoded.Items.Length.ShouldBe(2); decoded.Items[0].MonitoredItemId.ShouldBe(1); decoded.Items[0].Address.PmcLetter.ShouldBe("R"); decoded.Items[1].DataType.ShouldBe(FocasDataTypeCode.Float64); } [Fact] public void SubscribeResponse_rejected_items_survive() { var decoded = RoundTrip(new SubscribeResponse { Success = true, RejectedMonitoredItemIds = [2, 7], }); decoded.RejectedMonitoredItemIds.ShouldBe([2, 7]); } [Fact] public void UnsubscribeRequest_round_trips() { var decoded = RoundTrip(new UnsubscribeRequest { SubscriptionId = 42 }); decoded.SubscriptionId.ShouldBe(42); } [Fact] public void OnDataChangeNotification_round_trips() { var original = new OnDataChangeNotification { SubscriptionId = 100, Changes = [ new() { MonitoredItemId = 1, StatusCode = 0, ValueBytes = MessagePackSerializer.Serialize(true), ValueTypeCode = FocasDataTypeCode.Bit, SourceTimestampUtcUnixMs = 1_700_000_000_000, }, ], }; var decoded = RoundTrip(original); decoded.Changes.Length.ShouldBe(1); MessagePackSerializer.Deserialize(decoded.Changes[0].ValueBytes!).ShouldBeTrue(); } [Fact] public void ProbeRequest_and_response_round_trip() { var req = RoundTrip(new ProbeRequest { SessionId = 1, TimeoutMs = 500 }); req.TimeoutMs.ShouldBe(500); var resp = RoundTrip(new ProbeResponse { Healthy = true, ObservedAtUtcUnixMs = 1_700_000_000_000 }); resp.Healthy.ShouldBeTrue(); resp.ObservedAtUtcUnixMs.ShouldBe(1_700_000_000_000); } [Fact] public void RuntimeStatusChangeNotification_round_trips() { var decoded = RoundTrip(new RuntimeStatusChangeNotification { SessionId = 5, RuntimeStatus = "Stopped", ObservedAtUtcUnixMs = 1_700_000_000_000, }); decoded.RuntimeStatus.ShouldBe("Stopped"); } [Fact] public void RecycleHostRequest_and_response_round_trip() { var req = RoundTrip(new RecycleHostRequest { Kind = "Hard", Reason = "wedge-detected" }); req.Kind.ShouldBe("Hard"); req.Reason.ShouldBe("wedge-detected"); var resp = RoundTrip(new RecycleStatusResponse { Accepted = true, GraceSeconds = 20 }); resp.Accepted.ShouldBeTrue(); resp.GraceSeconds.ShouldBe(20); } }