using MessagePack; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.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.Tests; /// /// End-to-end IPC round-trips over an in-memory loopback: IpcFocasClient talks /// to a test fake that plays the Host's role by reading frames, dispatching on kind, /// and responding with canned DTOs. Validates that every /// method translates to the right wire frame + decodes the response correctly. /// [Trait("Category", "Unit")] public sealed class IpcFocasClientTests { private const string Secret = "test-secret"; private static async Task ServerLoopAsync(Stream serverSide, Func dispatch, CancellationToken ct) { using var reader = new FrameReader(serverSide, leaveOpen: true); using var writer = new FrameWriter(serverSide, leaveOpen: true); // Hello handshake. var first = await reader.ReadFrameAsync(ct); if (first is null) return; var hello = MessagePackSerializer.Deserialize(first.Value.Body); var accepted = hello.SharedSecret == Secret; await writer.WriteAsync(FocasMessageKind.HelloAck, new HelloAck { Accepted = accepted, RejectReason = accepted ? null : "wrong-secret" }, ct); if (!accepted) return; while (!ct.IsCancellationRequested) { var frame = await reader.ReadFrameAsync(ct); if (frame is null) return; await dispatch(frame.Value.Kind, frame.Value.Body, writer); } } [Fact] public async Task Connect_sends_OpenSessionRequest_and_caches_session_id() { await using var loop = new IpcLoopback(); using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); OpenSessionRequest? received = null; var server = Task.Run(() => ServerLoopAsync(loop.ServerSide, async (kind, body, writer) => { if (kind == FocasMessageKind.OpenSessionRequest) { received = MessagePackSerializer.Deserialize(body); await writer.WriteAsync(FocasMessageKind.OpenSessionResponse, new OpenSessionResponse { Success = true, SessionId = 42 }, cts.Token); } }, cts.Token)); var ipc = await FocasIpcClient.ConnectAsync(loop.ClientSide, Secret, cts.Token); var client = new IpcFocasClient(ipc, FocasCncSeries.Thirty_i); await client.ConnectAsync(new FocasHostAddress("192.168.1.50", 8193), TimeSpan.FromSeconds(2), cts.Token); client.IsConnected.ShouldBeTrue(); received.ShouldNotBeNull(); received!.HostAddress.ShouldBe("192.168.1.50:8193"); received.CncSeries.ShouldBe((int)FocasCncSeries.Thirty_i); cts.Cancel(); try { await server; } catch { } } [Fact] public async Task Connect_throws_when_host_rejects() { await using var loop = new IpcLoopback(); using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); var server = Task.Run(() => ServerLoopAsync(loop.ServerSide, async (kind, body, writer) => { if (kind == FocasMessageKind.OpenSessionRequest) { await writer.WriteAsync(FocasMessageKind.OpenSessionResponse, new OpenSessionResponse { Success = false, Error = "unreachable", ErrorCode = "EW_SOCKET" }, cts.Token); } }, cts.Token)); var ipc = await FocasIpcClient.ConnectAsync(loop.ClientSide, Secret, cts.Token); var client = new IpcFocasClient(ipc); await Should.ThrowAsync(async () => await client.ConnectAsync(new FocasHostAddress("10.0.0.1", 8193), TimeSpan.FromSeconds(1), cts.Token)); cts.Cancel(); try { await server; } catch { } } [Fact] public async Task Read_sends_ReadRequest_and_decodes_response() { await using var loop = new IpcLoopback(); using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); ReadRequest? received = null; var server = Task.Run(() => ServerLoopAsync(loop.ServerSide, async (kind, body, writer) => { switch (kind) { case FocasMessageKind.OpenSessionRequest: await writer.WriteAsync(FocasMessageKind.OpenSessionResponse, new OpenSessionResponse { Success = true, SessionId = 1 }, cts.Token); break; case FocasMessageKind.ReadRequest: received = MessagePackSerializer.Deserialize(body); await writer.WriteAsync(FocasMessageKind.ReadResponse, new ReadResponse { Success = true, StatusCode = 0, ValueBytes = MessagePackSerializer.Serialize((int)12345), ValueTypeCode = FocasDataTypeCode.Int32, }, cts.Token); break; } }, cts.Token)); var ipc = await FocasIpcClient.ConnectAsync(loop.ClientSide, Secret, cts.Token); var client = new IpcFocasClient(ipc); await client.ConnectAsync(new FocasHostAddress("h", 8193), TimeSpan.FromSeconds(1), cts.Token); var addr = new FocasAddress(FocasAreaKind.Parameter, null, 1815, null); var (value, status) = await client.ReadAsync(addr, FocasDataType.Int32, cts.Token); status.ShouldBe(0u); value.ShouldBe(12345); received!.Address.Number.ShouldBe(1815); cts.Cancel(); try { await server; } catch { } } [Fact] public async Task Write_sends_WriteRequest_and_returns_status() { await using var loop = new IpcLoopback(); using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); var server = Task.Run(() => ServerLoopAsync(loop.ServerSide, async (kind, body, writer) => { switch (kind) { case FocasMessageKind.OpenSessionRequest: await writer.WriteAsync(FocasMessageKind.OpenSessionResponse, new OpenSessionResponse { Success = true, SessionId = 1 }, cts.Token); break; case FocasMessageKind.WriteRequest: var req = MessagePackSerializer.Deserialize(body); MessagePackSerializer.Deserialize(req.ValueBytes!).ShouldBe(3.14); await writer.WriteAsync(FocasMessageKind.WriteResponse, new WriteResponse { Success = true, StatusCode = 0 }, cts.Token); break; } }, cts.Token)); var ipc = await FocasIpcClient.ConnectAsync(loop.ClientSide, Secret, cts.Token); var client = new IpcFocasClient(ipc); await client.ConnectAsync(new FocasHostAddress("h", 8193), TimeSpan.FromSeconds(1), cts.Token); var status = await client.WriteAsync(new FocasAddress(FocasAreaKind.Macro, null, 500, null), FocasDataType.Float64, 3.14, cts.Token); status.ShouldBe(0u); cts.Cancel(); try { await server; } catch { } } [Fact] public async Task Write_pmc_bit_sends_first_class_RMW_frame() { await using var loop = new IpcLoopback(); using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); PmcBitWriteRequest? received = null; var server = Task.Run(() => ServerLoopAsync(loop.ServerSide, async (kind, body, writer) => { switch (kind) { case FocasMessageKind.OpenSessionRequest: await writer.WriteAsync(FocasMessageKind.OpenSessionResponse, new OpenSessionResponse { Success = true, SessionId = 1 }, cts.Token); break; case FocasMessageKind.PmcBitWriteRequest: received = MessagePackSerializer.Deserialize(body); await writer.WriteAsync(FocasMessageKind.PmcBitWriteResponse, new PmcBitWriteResponse { Success = true, StatusCode = 0 }, cts.Token); break; } }, cts.Token)); var ipc = await FocasIpcClient.ConnectAsync(loop.ClientSide, Secret, cts.Token); var client = new IpcFocasClient(ipc); await client.ConnectAsync(new FocasHostAddress("h", 8193), TimeSpan.FromSeconds(1), cts.Token); var addr = new FocasAddress(FocasAreaKind.Pmc, "R", 100, BitIndex: 5); var status = await client.WriteAsync(addr, FocasDataType.Bit, true, cts.Token); status.ShouldBe(0u); received.ShouldNotBeNull(); received!.BitIndex.ShouldBe(5); received.Value.ShouldBeTrue(); received.Address.PmcLetter.ShouldBe("R"); cts.Cancel(); try { await server; } catch { } } [Fact] public async Task Probe_round_trips_health_from_host() { await using var loop = new IpcLoopback(); using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); var server = Task.Run(() => ServerLoopAsync(loop.ServerSide, async (kind, body, writer) => { switch (kind) { case FocasMessageKind.OpenSessionRequest: await writer.WriteAsync(FocasMessageKind.OpenSessionResponse, new OpenSessionResponse { Success = true, SessionId = 1 }, cts.Token); break; case FocasMessageKind.ProbeRequest: await writer.WriteAsync(FocasMessageKind.ProbeResponse, new ProbeResponse { Healthy = true, ObservedAtUtcUnixMs = 1_700_000_000_000 }, cts.Token); break; } }, cts.Token)); var ipc = await FocasIpcClient.ConnectAsync(loop.ClientSide, Secret, cts.Token); var client = new IpcFocasClient(ipc); await client.ConnectAsync(new FocasHostAddress("h", 8193), TimeSpan.FromSeconds(1), cts.Token); (await client.ProbeAsync(cts.Token)).ShouldBeTrue(); cts.Cancel(); try { await server; } catch { } } [Fact] public async Task Error_response_from_host_surfaces_as_FocasIpcException() { await using var loop = new IpcLoopback(); using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); var server = Task.Run(() => ServerLoopAsync(loop.ServerSide, async (kind, body, writer) => { await writer.WriteAsync(FocasMessageKind.ErrorResponse, new ErrorResponse { Code = "backend-exception", Message = "simulated" }, cts.Token); }, cts.Token)); var ipc = await FocasIpcClient.ConnectAsync(loop.ClientSide, Secret, cts.Token); var client = new IpcFocasClient(ipc); var ex = await Should.ThrowAsync(async () => await client.ConnectAsync(new FocasHostAddress("h", 8193), TimeSpan.FromSeconds(1), cts.Token)); ex.Code.ShouldBe("backend-exception"); cts.Cancel(); try { await server; } catch { } } }