using System.Buffers.Binary; using System.Diagnostics; using System.Net; using System.Net.Sockets; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Wire; namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests; /// /// Byte-level coverage for the FOCAS PDU-v3 fixes derived from a live FANUC 31i-B capture /// (2026-06-25). The fixtures under Fixtures/v3/ are the raw responses; the specific /// payload bytes are inlined here so the tests stay hermetic. See /// docs/plans/2026-06-25-focas-pdu-v3-30i-b-support.md + /// docs/plans/2026-06-25-focas-pdu-v3-implementation-plan.md. /// [Trait("Category", "Unit")] public sealed class FocasWireV3Tests { // ---- cnc_rdtimer (0x0120): little-endian {minute, msec} ---- // Captured cutting-time (type 2) payload from the live 31i-B. private static readonly byte[] CuttingTimerPayload = [0xac, 0xf2, 0x10, 0x00, 0x90, 0xa3, 0x00, 0x00]; [Fact] public void ParseTimer_decodes_the_live_payload_as_little_endian_minute_and_msec() { var timer = FocasWireClient.ParseTimer(2, CuttingTimerPayload); timer.Type.ShouldBe((short)2); timer.Minutes.ShouldBe(1_110_700); // 0x0010F2AC little-endian timer.Milliseconds.ShouldBe(41_872); // 0x0000A390 little-endian } [Fact] public void ParseTimer_little_endian_is_the_only_decode_with_an_in_range_msec() { // The whole point: under big-endian the msec field is nonsensical (~2.4e9), so it cannot be // the "fractional milliseconds 0..59999" field the model documents. Little-endian is in range. var beMsec = BinaryPrimitives.ReadUInt32BigEndian(CuttingTimerPayload.AsSpan(4, 4)); beMsec.ShouldBeGreaterThan(59_999u); var timer = FocasWireClient.ParseTimer(2, CuttingTimerPayload); timer.Milliseconds.ShouldBeInRange(0, 59_999); } [Fact] public void ParseTimer_handles_a_short_payload_without_throwing() { var timer = FocasWireClient.ParseTimer(0, []); timer.Minutes.ShouldBe(0); timer.Milliseconds.ShouldBe(0); } // ---- pmc_rdpmcrng (0x8001): byte width + range decode ---- [Theory] [InlineData((short)0, 1)] // Byte [InlineData((short)1, 2)] // Word [InlineData((short)2, 4)] // Long [InlineData((short)4, 4)] // Real [InlineData((short)5, 8)] // Double [InlineData((short)99, 1)] // unknown → 1 public void PmcByteWidth_maps_focas_datatype_codes(short dataType, int expectedWidth) { FocasWireClient.PmcByteWidth(dataType).ShouldBe(expectedWidth); } [Fact] public void ParsePmcRange_decodes_a_two_byte_word_into_one_value() { // A Word read of R100 must request bytes 100..101 (2 bytes); the CNC then returns 2 bytes. var range = FocasWireClient.ParsePmcRange(area: 5, dataType: 1, start: 100, end: 101, payload: [0x00, 0x64]); range.Values.Count.ShouldBe(1); range.Values[0].ShouldBe(100L); // 0x0064 big-endian } [Fact] public void ParsePmcRange_with_a_single_byte_for_a_word_yields_no_value() { // This is the pre-fix bug: requesting end==start for a Word returned 1 byte, the 2-byte // slot never completed, so the value list was empty → WireFocasClient mapped it BadOutOfRange. var range = FocasWireClient.ParsePmcRange(area: 5, dataType: 1, start: 100, end: 100, payload: [0x00]); range.Values.ShouldBeEmpty(); } [Fact] public void ParsePmcRange_decodes_a_single_byte_value() { var range = FocasWireClient.ParsePmcRange(area: 5, dataType: 0, start: 0, end: 0, payload: [0x07]); range.Values.Count.ShouldBe(1); range.Values[0].ShouldBe(7L); } // ---- cnc_rdsvmeter: 8-byte LOADELM stride + axis-name correlation ---- [Fact] public void ParseServoMeters_uses_an_eight_byte_stride_and_names_from_the_axis_block() { // Three 8-byte records {int32 data; int16 dec=10; int16 unit=0}: data = 0, 50, -3. byte[] sv = [ 0x00, 0x00, 0x00, 0x00, 0x00, 0x0a, 0x00, 0x00, // axis 1: data=0 0x00, 0x00, 0x00, 0x32, 0x00, 0x0a, 0x00, 0x00, // axis 2: data=50 0xff, 0xff, 0xff, 0xfd, 0x00, 0x0a, 0x00, 0x00, // axis 3: data=-3 ]; // 0x0089 axis-name block: 4-byte records X, Y, Z. byte[] names = [ 0x58, 0x00, 0x00, 0x00, // "X" 0x59, 0x00, 0x00, 0x00, // "Y" 0x5a, 0x00, 0x00, 0x00, // "Z" ]; var meters = FocasWireClient.ParseServoMeters(sv, names, maxCount: 32); meters.Count.ShouldBe(3); meters[0].Name.ShouldBe("X"); meters[0].Value.ShouldBe(0); meters[1].Name.ShouldBe("Y"); meters[1].Value.ShouldBe(50); // 8-byte stride: a 12-byte stride would misread this as 655360 meters[1].Decimal.ShouldBe((short)10); meters[2].Name.ShouldBe("Z"); meters[2].Value.ShouldBe(-3); } // ---- cnc_rddynamic2: 1-based axis guard ---- [Theory] [InlineData(0)] [InlineData(-1)] public async Task ReadDynamicAsync_rejects_a_non_positive_axis_index(int axisIndex) { using var client = new WireFocasClient(); await Should.ThrowAsync(async () => await client.ReadDynamicAsync(axisIndex, CancellationToken.None)); } // ---- read-stall hardening: a stalled peer must not wedge the poll loop ---- [Fact] public async Task ReadPduAsync_aborts_a_stalled_peer_within_the_cancellation_budget() { var (client, server) = await ConnectedPairAsync(); try { // Send only 5 of the 10 header bytes, then stall forever — the cnc_rdsvmeter hang shape. await server.GetStream().WriteAsync(new byte[] { 0xa0, 0xa0, 0xa0, 0xa0, 0x00 }); using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(300)); var sw = Stopwatch.StartNew(); await Should.ThrowAsync(async () => await FocasWireProtocol.ReadPduAsync(client.GetStream(), cts.Token)); sw.Stop(); // Must abort near the 300ms budget, not hang — generous ceiling for CI jitter. sw.Elapsed.ShouldBeLessThan(TimeSpan.FromSeconds(5)); } finally { client.Dispose(); server.Dispose(); } } private static async Task<(TcpClient client, TcpClient server)> ConnectedPairAsync() { var listener = new TcpListener(IPAddress.Loopback, 0); listener.Start(); try { var port = ((IPEndPoint)listener.LocalEndpoint).Port; var client = new TcpClient(); var connect = client.ConnectAsync(IPAddress.Loopback, port); var server = await listener.AcceptTcpClientAsync(); await connect; return (client, server); } finally { listener.Stop(); } } }