using System.Buffers.Binary; using System.Net.Sockets; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Wire; namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests; /// /// Offline byte-level coverage for the managed FOCAS/2 wire-protocol decode layer /// () — the big-endian framing + block-envelope decode that /// is this driver's analogue of P/Invoke struct marshalling. Before this suite the entire /// wire decode surface (including the cnc_getfigure figure stride) ran without a /// single byte-level test; every other test drives the IFocasClient seam through the /// Fake, which bypasses all framing. Recorded as Driver.FOCAS-013. /// [Trait("Category", "Unit")] public sealed class FocasWireProtocolTests { // ---- BuildPdu header layout ---- [Fact] public void BuildPdu_writes_magic_version_type_direction_and_length() { var body = new byte[] { 0xde, 0xad, 0xbe, 0xef }; var pdu = FocasWireProtocol.BuildPdu( FocasWireProtocol.TypeData, FocasWireProtocol.DirectionRequest, body); pdu.Length.ShouldBe(10 + body.Length); pdu[0].ShouldBe((byte)0xa0); pdu[1].ShouldBe((byte)0xa0); pdu[2].ShouldBe((byte)0xa0); pdu[3].ShouldBe((byte)0xa0); BinaryPrimitives.ReadUInt16BigEndian(pdu.AsSpan(4, 2)).ShouldBe(FocasWireProtocol.Version); pdu[6].ShouldBe(FocasWireProtocol.TypeData); pdu[7].ShouldBe(FocasWireProtocol.DirectionRequest); BinaryPrimitives.ReadUInt16BigEndian(pdu.AsSpan(8, 2)).ShouldBe((ushort)body.Length); pdu.AsSpan(10).ToArray().ShouldBe(body); } [Fact] public void BuildPdu_rejects_a_body_larger_than_ushort_max() { var tooBig = new byte[ushort.MaxValue + 1]; Should.Throw(() => FocasWireProtocol.BuildPdu(FocasWireProtocol.TypeData, FocasWireProtocol.DirectionRequest, tooBig)); } [Fact] public void BuildInitiateBody_writes_the_socket_index_big_endian() { var body = FocasWireProtocol.BuildInitiateBody(2); body.Length.ShouldBe(2); BinaryPrimitives.ReadUInt16BigEndian(body).ShouldBe((ushort)2); } // ---- BuildPdu -> ReadPduAsync round-trip over a real socket pair ---- [Fact] public async Task BuildPdu_round_trips_through_ReadPduAsync() { var body = new byte[] { 1, 2, 3, 4, 5 }; var pdu = FocasWireProtocol.BuildPdu( FocasWireProtocol.TypeData, FocasWireProtocol.DirectionResponse, body); var (client, server) = await ConnectedPairAsync(); try { await server.GetStream().WriteAsync(pdu); var read = await FocasWireProtocol.ReadPduAsync(client.GetStream(), CancellationToken.None); read.Type.ShouldBe(FocasWireProtocol.TypeData); read.Direction.ShouldBe(FocasWireProtocol.DirectionResponse); read.Body.ShouldBe(body); } finally { client.Dispose(); server.Dispose(); } } [Fact] public async Task ReadPduAsync_rejects_bad_magic() { var bad = new byte[10]; bad[0] = 0x00; // not 0xa0 var (client, server) = await ConnectedPairAsync(); try { await server.GetStream().WriteAsync(bad); await Should.ThrowAsync(async () => await FocasWireProtocol.ReadPduAsync(client.GetStream(), CancellationToken.None)); } finally { client.Dispose(); server.Dispose(); } } [Fact] public async Task ReadPduAsync_rejects_unsupported_version() { var header = new byte[10]; new byte[] { 0xa0, 0xa0, 0xa0, 0xa0 }.CopyTo(header, 0); BinaryPrimitives.WriteUInt16BigEndian(header.AsSpan(4, 2), 99); // unsupported version var (client, server) = await ConnectedPairAsync(); try { await server.GetStream().WriteAsync(header); await Should.ThrowAsync(async () => await FocasWireProtocol.ReadPduAsync(client.GetStream(), CancellationToken.None)); } finally { client.Dispose(); server.Dispose(); } } // ---- BuildRequestBody framing ---- [Fact] public void BuildRequestBody_prefixes_the_block_count_and_concatenates_blocks() { var blocks = new[] { new RequestBlock(0x0018, PathId: 1), new RequestBlock(0x0019, Arg1: 7, PathId: 1), }; var body = FocasWireProtocol.BuildRequestBody(blocks); BinaryPrimitives.ReadUInt16BigEndian(body.AsSpan(0, 2)).ShouldBe((ushort)2); // First block sits right after the 2-byte count; its declared block length is at +0. var firstLen = BinaryPrimitives.ReadUInt16BigEndian(body.AsSpan(2, 2)); firstLen.ShouldBe((ushort)0x1c); // no extra payload → header-only block BinaryPrimitives.ReadUInt16BigEndian(body.AsSpan(2 + 6, 2)).ShouldBe((ushort)0x0018); // command @ +6 } // ---- ParseResponseBlocks: command / RC / payload extraction ---- [Fact] public void ParseResponseBlocks_decodes_command_rc_and_payload() { var payload = new byte[] { 0xAA, 0xBB, 0xCC, 0xDD }; var body = BuildResponseBody( (command: (ushort)0x0018, rc: (short)0, payload: payload), (command: (ushort)0x0019, rc: (short)6 /* EW_NOOPT */, payload: Array.Empty())); var blocks = FocasWireProtocol.ParseResponseBlocks(body); blocks.Count.ShouldBe(2); blocks[0].Command.ShouldBe((ushort)0x0018); blocks[0].Rc.ShouldBe((short)0); blocks[0].Payload.ShouldBe(payload); blocks[1].Command.ShouldBe((ushort)0x0019); blocks[1].Rc.ShouldBe((short)6); blocks[1].Payload.ShouldBeEmpty(); } [Fact] public void ParseResponseBlocks_returns_empty_for_a_body_shorter_than_the_count_field() { FocasWireProtocol.ParseResponseBlocks(new byte[] { 0x00 }).ShouldBeEmpty(); } [Fact] public void ParseResponseBlocks_throws_on_a_truncated_block_length_field() { // count says 1 block, but only one stray byte follows the count. var body = new byte[] { 0x00, 0x01, 0x00 }; Should.Throw(() => FocasWireProtocol.ParseResponseBlocks(body)); } [Fact] public void ParseResponseBlocks_throws_when_the_declared_block_length_is_below_the_header() { var body = new byte[4]; BinaryPrimitives.WriteUInt16BigEndian(body.AsSpan(0, 2), 1); // 1 block BinaryPrimitives.WriteUInt16BigEndian(body.AsSpan(2, 2), 0x08); // block length < 0x10 Should.Throw(() => FocasWireProtocol.ParseResponseBlocks(body)); } // ---- The cnc_getfigure figure stride: decode a response block into shorts ---- [Fact] public void ParseResponseBlocks_yields_a_payload_that_decodes_as_a_sequence_of_figure_shorts() { // cnc_getfigure (command 0x00d3) returns one big-endian short per axis. Build the // response block and assert the payload decodes to the [3, 1, 0] figure sequence the // driver's auto-scale path reads. var figurePayload = new byte[6]; BinaryPrimitives.WriteInt16BigEndian(figurePayload.AsSpan(0, 2), 3); BinaryPrimitives.WriteInt16BigEndian(figurePayload.AsSpan(2, 2), 1); BinaryPrimitives.WriteInt16BigEndian(figurePayload.AsSpan(4, 2), 0); var body = BuildResponseBody((command: (ushort)0x00d3, rc: (short)0, payload: figurePayload)); var block = FocasWireProtocol.ParseResponseBlocks(body).Single(); var figures = new List(); for (var offset = 0; offset + 2 <= block.Payload.Length; offset += 2) figures.Add(BinaryPrimitives.ReadInt16BigEndian(block.Payload.AsSpan(offset, 2))); figures.ShouldBe(new[] { 3, 1, 0 }); } // ---- ReadAscii / ReadNameRecord ---- [Fact] public void ReadAscii_stops_at_the_first_NUL_and_trims_trailing_space() { var bytes = "30i \0junkafternul"u8.ToArray(); FocasWireProtocol.ReadAscii(bytes).ShouldBe("30i"); } [Fact] public void ReadAscii_trims_trailing_spaces_without_a_NUL() { FocasWireProtocol.ReadAscii("A1.0 "u8.ToArray()).ShouldBe("A1.0"); } [Fact] public void ReadNameRecord_reads_two_bytes_and_strips_padding() { FocasWireProtocol.ReadNameRecord("X \0\0"u8.ToArray()).ShouldBe("X"); FocasWireProtocol.ReadNameRecord("XY"u8.ToArray()).ShouldBe("XY"); FocasWireProtocol.ReadNameRecord(new byte[] { 0x58 }).ShouldBe(string.Empty); // < 2 bytes } // ---- helpers ---- /// Build a response body in the 0x21 response-block shape ParseResponseBlocks expects. private static byte[] BuildResponseBody(params (ushort command, short rc, byte[] payload)[] blocks) { var encoded = blocks.Select(b => { var blockLen = 0x10 + b.payload.Length; var block = new byte[blockLen]; BinaryPrimitives.WriteUInt16BigEndian(block.AsSpan(0, 2), (ushort)blockLen); BinaryPrimitives.WriteUInt16BigEndian(block.AsSpan(6, 2), b.command); BinaryPrimitives.WriteInt16BigEndian(block.AsSpan(8, 2), b.rc); BinaryPrimitives.WriteUInt16BigEndian(block.AsSpan(14, 2), (ushort)b.payload.Length); b.payload.CopyTo(block.AsSpan(16)); return block; }).ToArray(); var body = new byte[2 + encoded.Sum(b => b.Length)]; BinaryPrimitives.WriteUInt16BigEndian(body.AsSpan(0, 2), (ushort)encoded.Length); var offset = 2; foreach (var block in encoded) { block.CopyTo(body.AsSpan(offset)); offset += block.Length; } return body; } private static async Task<(TcpClient client, TcpClient server)> ConnectedPairAsync() { var listener = new TcpListener(System.Net.IPAddress.Loopback, 0); listener.Start(); try { var port = ((System.Net.IPEndPoint)listener.LocalEndpoint).Port; var client = new TcpClient(); var connect = client.ConnectAsync(System.Net.IPAddress.Loopback, port); var server = await listener.AcceptTcpClientAsync(); await connect; return (client, server); } finally { listener.Stop(); } } }