Files
lmxopcua/tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host.Tests/FwlibFrameHandlerTests.cs
Joseph Doherty 3892555631 FOCAS Tier-C PR C — IPC path end-to-end: Proxy IpcFocasClient + Host FwlibFrameHandler + IFocasBackend abstraction. Third of 5 PRs for #220. Ships the wire path from IFocasClient calls in the .NET 10 driver, over a named-pipe (or in-memory stream) to the .NET 4.8 Host's FwlibFrameHandler, dispatched to an IFocasBackend. Keeps the existing IFocasClient DI seam intact so existing unit tests are unaffected (172/172 still pass). Proxy side adds Ipc/FocasIpcClient (owns one pipe stream + call gate so concurrent callers don't interleave frames, supports both real NamedPipeClientStream and arbitrary Stream for in-memory test loopback) and Ipc/IpcFocasClient (implements IFocasClient by forwarding every call as an IPC frame — Connect sends OpenSessionRequest and caches the SessionId; Read sends ReadRequest and decodes the typed value via FocasDataTypeCode; Write sends WriteRequest for non-bit data or PmcBitWriteRequest when it's a PMC bit so the RMW critical section stays on the Host; Probe sends ProbeRequest; Dispose best-effort sends CloseSessionRequest); plus FocasIpcException surfacing Host-side ErrorResponse frames as typed exceptions. Host side adds Backend/IFocasBackend (the Host's view of one FOCAS session — Open/Close/Read/Write/PmcBitWrite/Probe) with two implementations: FakeFocasBackend (in-memory, per-address value store, honors bit-write RMW semantics against the containing byte — used by tests and as an OTOPCUA_FOCAS_BACKEND=fake operational stub) and UnconfiguredFocasBackend (structured failure pointing at docs/v2/focas-deployment.md — the safe default when OTOPCUA_FOCAS_BACKEND is unset or hardware isn't configured). Ipc/FwlibFrameHandler replaces StubFrameHandler: deserializes each request DTO, delegates to the IFocasBackend, re-serializes into the matching response kind. Catches backend exceptions and surfaces them as ErrorResponse{backend-exception} rather than tearing down the pipe. Program.cs now picks the backend from OTOPCUA_FOCAS_BACKEND env var (fake/unconfigured/fwlib32; fwlib32 still maps to Unconfigured because the real Fwlib32 P/Invoke integration is a hardware-dependent follow-up — #220 captures it). Tests: 7 new IPC round-trip tests on the Proxy side (IpcFocasClient vs. an IpcLoopback fake server: connect happy path, connect rejection, read decode, write round-trip, PMC bit write routes to first-class RMW frame, probe, ErrorResponse surfaces as typed exception) + 6 new Host-side tests on FwlibFrameHandler (OpenSession allocates id, read-without-session fails, full open/write/read round-trip preserves value, PmcBitWrite sets the specified bit, Probe reports healthy with open session, UnconfiguredBackend returns pointed-at-docs error with ErrorCode=NoFwlibBackend). Existing 165 FOCAS unit tests + 24 Shared tests + 3 Host handshake tests all unchanged. Total post-PR: 172+24+9 = 205 FOCAS-family tests green. What's NOT in this PR: the actual Fwlib32.dll P/Invoke integration inside the Host (FwlibHostedBackend) lands as a hardware-dependent follow-up since no CNC is available for validation; supervisor + respawn + crash-loop breaker comes in PR D; MMF + NSSM install scripts in PR E.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 14:10:52 -04:00

201 lines
9.2 KiB
C#

using System;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using MessagePack;
using Serilog;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host.Backend;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host.Ipc;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.Contracts;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host.Tests
{
/// <summary>
/// Validates that <see cref="FwlibFrameHandler"/> correctly dispatches each
/// <see cref="FocasMessageKind"/> to the corresponding <see cref="IFocasBackend"/>
/// method and serializes the response into the expected response kind. Uses
/// <see cref="FakeFocasBackend"/> so no hardware is needed.
/// </summary>
[Trait("Category", "Unit")]
public sealed class FwlibFrameHandlerTests
{
private static async Task RoundTripAsync<TReq, TResp>(
IFrameHandler handler, FocasMessageKind reqKind, TReq req, FocasMessageKind expectedRespKind,
Action<TResp> assertResponse)
{
using var buffer = new MemoryStream();
using var writer = new FrameWriter(buffer, leaveOpen: true);
await handler.HandleAsync(reqKind, MessagePackSerializer.Serialize(req), writer, CancellationToken.None);
buffer.Position = 0;
using var reader = new FrameReader(buffer, leaveOpen: true);
var frame = await reader.ReadFrameAsync(CancellationToken.None);
frame.HasValue.ShouldBeTrue();
frame!.Value.Kind.ShouldBe(expectedRespKind);
assertResponse(MessagePackSerializer.Deserialize<TResp>(frame.Value.Body));
}
private static FwlibFrameHandler BuildHandler() =>
new(new FakeFocasBackend(), new LoggerConfiguration().CreateLogger());
[Fact]
public async Task OpenSession_returns_a_new_session_id()
{
long sessionId = 0;
await RoundTripAsync<OpenSessionRequest, OpenSessionResponse>(
BuildHandler(),
FocasMessageKind.OpenSessionRequest,
new OpenSessionRequest { HostAddress = "h:8193" },
FocasMessageKind.OpenSessionResponse,
resp => { resp.Success.ShouldBeTrue(); resp.SessionId.ShouldBeGreaterThan(0L); sessionId = resp.SessionId; });
sessionId.ShouldBeGreaterThan(0L);
}
[Fact]
public async Task Read_without_open_session_returns_internal_error()
{
await RoundTripAsync<ReadRequest, ReadResponse>(
BuildHandler(),
FocasMessageKind.ReadRequest,
new ReadRequest
{
SessionId = 999,
Address = new FocasAddressDto { Kind = 0, PmcLetter = "R", Number = 100 },
DataType = FocasDataTypeCode.Int32,
},
FocasMessageKind.ReadResponse,
resp => { resp.Success.ShouldBeFalse(); resp.Error.ShouldContain("session-not-open"); });
}
[Fact]
public async Task Full_open_write_read_round_trip_preserves_value()
{
var handler = BuildHandler();
// Open.
using var buffer = new MemoryStream();
using var writer = new FrameWriter(buffer, leaveOpen: true);
await handler.HandleAsync(FocasMessageKind.OpenSessionRequest,
MessagePackSerializer.Serialize(new OpenSessionRequest { HostAddress = "h:8193" }), writer, CancellationToken.None);
buffer.Position = 0;
using var reader = new FrameReader(buffer, leaveOpen: true);
var openFrame = await reader.ReadFrameAsync(CancellationToken.None);
var openResp = MessagePackSerializer.Deserialize<OpenSessionResponse>(openFrame!.Value.Body);
var sessionId = openResp.SessionId;
// Write 42 at MACRO:500 as Int32.
buffer.Position = 0;
buffer.SetLength(0);
await handler.HandleAsync(FocasMessageKind.WriteRequest,
MessagePackSerializer.Serialize(new WriteRequest
{
SessionId = sessionId,
Address = new FocasAddressDto { Kind = 2, Number = 500 },
DataType = FocasDataTypeCode.Int32,
ValueTypeCode = FocasDataTypeCode.Int32,
ValueBytes = MessagePackSerializer.Serialize((int)42),
}), writer, CancellationToken.None);
// Read back.
buffer.Position = 0;
buffer.SetLength(0);
await handler.HandleAsync(FocasMessageKind.ReadRequest,
MessagePackSerializer.Serialize(new ReadRequest
{
SessionId = sessionId,
Address = new FocasAddressDto { Kind = 2, Number = 500 },
DataType = FocasDataTypeCode.Int32,
}), writer, CancellationToken.None);
buffer.Position = 0;
var readFrame = await reader.ReadFrameAsync(CancellationToken.None);
readFrame.HasValue.ShouldBeTrue();
readFrame!.Value.Kind.ShouldBe(FocasMessageKind.ReadResponse);
// With buffer reuse there may be multiple queued frames; we want the last one.
var lastResp = MessagePackSerializer.Deserialize<ReadResponse>(readFrame.Value.Body);
// If the Write frame is first, drain it.
if (lastResp.ValueBytes is null)
{
var next = await reader.ReadFrameAsync(CancellationToken.None);
lastResp = MessagePackSerializer.Deserialize<ReadResponse>(next!.Value.Body);
}
lastResp.Success.ShouldBeTrue();
MessagePackSerializer.Deserialize<int>(lastResp.ValueBytes!).ShouldBe(42);
}
[Fact]
public async Task PmcBitWrite_sets_specified_bit()
{
var handler = BuildHandler();
using var buffer = new MemoryStream();
using var writer = new FrameWriter(buffer, leaveOpen: true);
await handler.HandleAsync(FocasMessageKind.OpenSessionRequest,
MessagePackSerializer.Serialize(new OpenSessionRequest { HostAddress = "h:8193" }), writer, CancellationToken.None);
buffer.Position = 0;
using var reader = new FrameReader(buffer, leaveOpen: true);
var openFrame = await reader.ReadFrameAsync(CancellationToken.None);
var sessionId = MessagePackSerializer.Deserialize<OpenSessionResponse>(openFrame!.Value.Body).SessionId;
buffer.Position = 0; buffer.SetLength(0);
await handler.HandleAsync(FocasMessageKind.PmcBitWriteRequest,
MessagePackSerializer.Serialize(new PmcBitWriteRequest
{
SessionId = sessionId,
Address = new FocasAddressDto { Kind = 0, PmcLetter = "R", Number = 100 },
BitIndex = 3,
Value = true,
}), writer, CancellationToken.None);
buffer.Position = 0;
var resp = MessagePackSerializer.Deserialize<PmcBitWriteResponse>(
(await reader.ReadFrameAsync(CancellationToken.None))!.Value.Body);
resp.Success.ShouldBeTrue();
resp.StatusCode.ShouldBe(0u);
}
[Fact]
public async Task Probe_reports_healthy_when_session_open()
{
var handler = BuildHandler();
using var buffer = new MemoryStream();
using var writer = new FrameWriter(buffer, leaveOpen: true);
await handler.HandleAsync(FocasMessageKind.OpenSessionRequest,
MessagePackSerializer.Serialize(new OpenSessionRequest { HostAddress = "h:8193" }), writer, CancellationToken.None);
buffer.Position = 0;
using var reader = new FrameReader(buffer, leaveOpen: true);
var sessionId = MessagePackSerializer.Deserialize<OpenSessionResponse>(
(await reader.ReadFrameAsync(CancellationToken.None))!.Value.Body).SessionId;
buffer.Position = 0; buffer.SetLength(0);
await handler.HandleAsync(FocasMessageKind.ProbeRequest,
MessagePackSerializer.Serialize(new ProbeRequest { SessionId = sessionId }), writer, CancellationToken.None);
buffer.Position = 0;
var resp = MessagePackSerializer.Deserialize<ProbeResponse>(
(await reader.ReadFrameAsync(CancellationToken.None))!.Value.Body);
resp.Healthy.ShouldBeTrue();
}
[Fact]
public async Task Unconfigured_backend_returns_pointed_error_message()
{
var handler = new FwlibFrameHandler(new UnconfiguredFocasBackend(), new LoggerConfiguration().CreateLogger());
await RoundTripAsync<OpenSessionRequest, OpenSessionResponse>(
handler,
FocasMessageKind.OpenSessionRequest,
new OpenSessionRequest { HostAddress = "h:8193" },
FocasMessageKind.OpenSessionResponse,
resp =>
{
resp.Success.ShouldBeFalse();
resp.Error.ShouldContain("Fwlib32");
resp.ErrorCode.ShouldBe("NoFwlibBackend");
});
}
}
}