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>
This commit is contained in:
@@ -0,0 +1,280 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user