Files
lmxopcua/tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.Tests/FramingTests.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

108 lines
4.4 KiB
C#

using System.IO;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.Contracts;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.Tests;
[Trait("Category", "Unit")]
public sealed class FramingTests
{
[Fact]
public async Task FrameWriter_round_trips_single_frame_through_FrameReader()
{
var buffer = new MemoryStream();
using (var writer = new FrameWriter(buffer, leaveOpen: true))
{
await writer.WriteAsync(FocasMessageKind.Hello,
new Hello { PeerName = "proxy", SharedSecret = "s3cr3t" }, TestContext.Current.CancellationToken);
}
buffer.Position = 0;
using var reader = new FrameReader(buffer, leaveOpen: true);
var frame = await reader.ReadFrameAsync(TestContext.Current.CancellationToken);
frame.ShouldNotBeNull();
frame!.Value.Kind.ShouldBe(FocasMessageKind.Hello);
var hello = FrameReader.Deserialize<Hello>(frame.Value.Body);
hello.PeerName.ShouldBe("proxy");
hello.SharedSecret.ShouldBe("s3cr3t");
}
[Fact]
public async Task FrameReader_returns_null_on_clean_EOF_at_frame_boundary()
{
using var empty = new MemoryStream();
using var reader = new FrameReader(empty, leaveOpen: true);
var frame = await reader.ReadFrameAsync(TestContext.Current.CancellationToken);
frame.ShouldBeNull();
}
[Fact]
public async Task FrameReader_throws_on_oversized_length_prefix()
{
var hostile = new byte[] { 0x7F, 0xFF, 0xFF, 0xFF, 0x01 }; // length > 16 MiB
using var stream = new MemoryStream(hostile);
using var reader = new FrameReader(stream, leaveOpen: true);
await Should.ThrowAsync<InvalidDataException>(async () =>
await reader.ReadFrameAsync(TestContext.Current.CancellationToken));
}
[Fact]
public async Task FrameReader_throws_on_mid_frame_eof()
{
var buffer = new MemoryStream();
using (var writer = new FrameWriter(buffer, leaveOpen: true))
{
await writer.WriteAsync(FocasMessageKind.Hello, new Hello { PeerName = "x" },
TestContext.Current.CancellationToken);
}
// Truncate so body is incomplete.
var truncated = buffer.ToArray()[..(buffer.ToArray().Length - 2)];
using var partial = new MemoryStream(truncated);
using var reader = new FrameReader(partial, leaveOpen: true);
await Should.ThrowAsync<EndOfStreamException>(async () =>
await reader.ReadFrameAsync(TestContext.Current.CancellationToken));
}
[Fact]
public async Task FrameWriter_serializes_concurrent_writes()
{
var buffer = new MemoryStream();
using var writer = new FrameWriter(buffer, leaveOpen: true);
var tasks = Enumerable.Range(0, 20).Select(i => writer.WriteAsync(
FocasMessageKind.Heartbeat,
new Heartbeat { MonotonicTicks = i },
TestContext.Current.CancellationToken)).ToArray();
await Task.WhenAll(tasks);
buffer.Position = 0;
using var reader = new FrameReader(buffer, leaveOpen: true);
var seen = new List<long>();
while (await reader.ReadFrameAsync(TestContext.Current.CancellationToken) is { } frame)
{
frame.Kind.ShouldBe(FocasMessageKind.Heartbeat);
seen.Add(FrameReader.Deserialize<Heartbeat>(frame.Body).MonotonicTicks);
}
seen.Count.ShouldBe(20);
seen.OrderBy(x => x).ShouldBe(Enumerable.Range(0, 20).Select(x => (long)x));
}
[Fact]
public void MessageKind_values_are_stable()
{
// Guardrail — if someone reorders/renumbers, the wire format breaks for deployed peers.
((byte)FocasMessageKind.Hello).ShouldBe((byte)0x01);
((byte)FocasMessageKind.Heartbeat).ShouldBe((byte)0x03);
((byte)FocasMessageKind.OpenSessionRequest).ShouldBe((byte)0x10);
((byte)FocasMessageKind.ReadRequest).ShouldBe((byte)0x30);
((byte)FocasMessageKind.WriteRequest).ShouldBe((byte)0x32);
((byte)FocasMessageKind.PmcBitWriteRequest).ShouldBe((byte)0x34);
((byte)FocasMessageKind.SubscribeRequest).ShouldBe((byte)0x40);
((byte)FocasMessageKind.OnDataChangeNotification).ShouldBe((byte)0x43);
((byte)FocasMessageKind.ProbeRequest).ShouldBe((byte)0x70);
((byte)FocasMessageKind.ErrorResponse).ShouldBe((byte)0xFE);
}
}