Files
lmxopcua/tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.Tests/ContractRoundTripTests.cs
Joseph Doherty e6ff39148b FOCAS Tier-C PR A — Driver.FOCAS.Shared MessagePack IPC contracts. First PR of the 5-PR #220 split (isolation plan at docs/v2/implementation/focas-isolation-plan.md). Adds a new netstandard2.0 project consumable by both the .NET 10 Proxy and the future .NET 4.8 x86 Host, carrying every wire DTO the Proxy <-> Host pair will exchange: Hello/HelloAck + Heartbeat/HeartbeatAck + ErrorResponse for session negotiation (shared-secret + protocol major/minor mirroring Galaxy.Shared); OpenSessionRequest/Response + CloseSessionRequest carrying the declared FocasCncSeries so the Host picks up the pre-flight matrix; FocasAddressDto + FocasDataTypeCode for wire-compatible serialization of parsed addresses (0=Pmc/1=Param/2=Macro matches FocasAreaKind enum order so both sides cast (int)); ReadRequest/Response + WriteRequest/Response with MessagePack-serialized boxed values tagged by FocasDataTypeCode; PmcBitWriteRequest/Response as a first-class RMW operation so the critical section stays Host-side; Subscribe/Unsubscribe/OnDataChangeNotification for poll-loop-pushes-deltas model (FOCAS has no CNC-initiated callbacks); Probe + RuntimeStatusChange + Recycle surface for Tier-C supervision. Framing is [4-byte BE length][1-byte kind][body] with 16 MiB body cap matching Galaxy; FocasMessageKind byte values align with Galaxy ranges so an operator reading a hex dump doesn't have to context-switch. FrameReader/FrameWriter ported from Galaxy.Shared with thread-safe concurrent-write serialization. 24 new unit tests: 18 per-DTO round-trip tests covering every field + 6 framing tests (single-frame round-trip, clean-EOF returns null, oversized-length rejection, mid-frame EOF throws, 20-way concurrent-write ordering preserved, MessageKind byte values locked as wire-stable). No driver changes; existing 165 FOCAS unit tests still pass unchanged. PR B (Host skeleton) goes next.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 13:55:35 -04:00

281 lines
9.0 KiB
C#

using MessagePack;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.Contracts;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.Tests;
/// <summary>
/// MessagePack round-trip coverage for every FOCAS IPC contract. Ensures
/// <c>[Key]</c>-tagged fields survive serialize -> deserialize without loss so the
/// wire format stays stable across Proxy (.NET 10) and Host (.NET 4.8) processes.
/// </summary>
[Trait("Category", "Unit")]
public sealed class ContractRoundTripTests
{
private static T RoundTrip<T>(T value)
{
var bytes = MessagePackSerializer.Serialize(value);
return MessagePackSerializer.Deserialize<T>(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<int>(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<double>(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<bool>(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);
}
}