using System.Collections.Frozen; using Mbproxy.Bcd; using Mbproxy.Proxy; using Mbproxy.Proxy.Multiplexing; using Microsoft.Extensions.Logging.Abstractions; using Shouldly; using Xunit; namespace Mbproxy.Tests.Proxy; /// /// Unit tests for using synthetic PDU byte arrays. /// No network, no simulator. Each test builds a hand-rolled , /// calls , and asserts resulting bytes + counter deltas. /// [Trait("Category", "Unit")] public sealed class BcdPduPipelineTests { private static readonly BcdPduPipeline Pipeline = new(); // ── Factories ──────────────────────────────────────────────────────────── /// /// Builds a from a set of BcdTag entries. /// The context has a fresh instance. /// private static PerPlcContext MakeContext(params BcdTag[] tags) { var frozen = tags .ToDictionary(t => t.Address) .ToFrozenDictionary(); var map = frozen.Count > 0 ? new BcdTagMap(frozen) : BcdTagMap.Empty; return new PerPlcContext { PlcName = "TestPLC", TagMap = map, Counters = new ProxyCounters(), Logger = NullLogger.Instance, }; } /// /// Phase 9: the rewriter consumes rather /// than a per-pair last-request slot. Tests build a synthetic /// to drive response decoding. /// private static InFlightRequest MakeInFlight(byte fc, ushort startAddress, ushort qty) => new( UnitId: 1, Fc: fc, StartAddress: startAddress, Qty: qty, // Phase 9: always exactly one party. We don't have a real UpstreamPipe in // pipeline unit tests; the rewriter never dereferences the party list, so a // null-forgiving placeholder is safe. InterestedParties: Array.Empty(), SentAtUtc: DateTimeOffset.UtcNow); /// FC03 response PDU: [fc=03][byteCount][reg0Hi][reg0Lo]... private static byte[] Fc03Response(params ushort[] registers) { var pdu = new byte[2 + registers.Length * 2]; pdu[0] = 0x03; pdu[1] = (byte)(registers.Length * 2); for (int i = 0; i < registers.Length; i++) { pdu[2 + i * 2] = (byte)(registers[i] >> 8); pdu[2 + i * 2 + 1] = (byte)(registers[i] & 0xFF); } return pdu; } /// FC04 response PDU: same shape as FC03 but fc=04. private static byte[] Fc04Response(params ushort[] registers) { var pdu = Fc03Response(registers); pdu[0] = 0x04; return pdu; } /// FC03 request PDU: [fc=03][addrHi][addrLo][qtyHi][qtyLo] private static byte[] Fc03Request(ushort address, ushort qty) => [0x03, (byte)(address >> 8), (byte)(address & 0xFF), (byte)(qty >> 8), (byte)(qty & 0xFF)]; /// FC06 request PDU: [fc=06][addrHi][addrLo][valHi][valLo] private static byte[] Fc06Request(ushort address, ushort value) => [0x06, (byte)(address >> 8), (byte)(address & 0xFF), (byte)(value >> 8), (byte)(value & 0xFF)]; /// FC16 request PDU: [fc=10][startHi][startLo][qtyHi][qtyLo][byteCount][reg data...] private static byte[] Fc16Request(ushort start, params ushort[] registers) { ushort qty = (ushort)registers.Length; var pdu = new byte[6 + registers.Length * 2]; pdu[0] = 0x10; pdu[1] = (byte)(start >> 8); pdu[2] = (byte)(start & 0xFF); pdu[3] = (byte)(qty >> 8); pdu[4] = (byte)(qty & 0xFF); pdu[5] = (byte)(registers.Length * 2); for (int i = 0; i < registers.Length; i++) { pdu[6 + i * 2] = (byte)(registers[i] >> 8); pdu[6 + i * 2 + 1] = (byte)(registers[i] & 0xFF); } return pdu; } /// /// Simulate sending an FC03/04 request then reading the response. /// Phase 9: builds an matching the request and attaches /// it to the response-call context (replacing the per-pair last-request slot). /// private void SendRequestThenProcessResponse( PerPlcContext ctx, byte[] requestPdu, byte[] responsePdu) { Pipeline.Process(MbapDirection.RequestToBackend, ReadOnlySpan.Empty, requestPdu.AsSpan(), ctx); // Extract the request start/qty so we can build the InFlightRequest the multiplexer // would attach to the response call. byte fc = requestPdu[0]; ushort start = 0, qty = 0; if (fc is 0x03 or 0x04 && requestPdu.Length >= 5) { start = (ushort)((requestPdu[1] << 8) | requestPdu[2]); qty = (ushort)((requestPdu[3] << 8) | requestPdu[4]); } var responseCtx = ctx.WithCurrentRequest(MakeInFlight(fc, start, qty)); Pipeline.Process(MbapDirection.ResponseToClient, ReadOnlySpan.Empty, responsePdu.AsSpan(), responseCtx); } // ── Helper to read a register pair from a response PDU ────────────────── private static ushort ReadReg(byte[] pdu, int offsetWords) => (ushort)((pdu[2 + offsetWords * 2] << 8) | pdu[2 + offsetWords * 2 + 1]); // ── FC03 response tests ────────────────────────────────────────────────── [Fact] public void FC03_Single16BitBcd_AtReadAddress_DecodesNibbles() { // Raw wire value 0x1234 → decoded binary 1234 var ctx = MakeContext(BcdTag.Create(100, 16)); var req = Fc03Request(100, 1); var rsp = Fc03Response(0x1234); SendRequestThenProcessResponse(ctx, req, rsp); ReadReg(rsp, 0).ShouldBe((ushort)1234); ctx.Counters.Snapshot().RewrittenSlots.ShouldBe(1); } [Fact] public void FC03_Full32BitBcdPair_WithinReadRange_DecodesNibbles() { // 32-bit BCD pair at 100/101: low=0x5678 (5678), high=0x1234 (1234) // Decoded = 1234 * 10000 + 5678 = 12345678 // Binary: low 4 digits = 5678, high 4 digits = 1234 var ctx = MakeContext(BcdTag.Create(100, 32)); var req = Fc03Request(100, 2); var rsp = Fc03Response(0x5678, 0x1234); // [0]=low, [1]=high SendRequestThenProcessResponse(ctx, req, rsp); ReadReg(rsp, 0).ShouldBe((ushort)5678); // decoded low 4 digits ReadReg(rsp, 1).ShouldBe((ushort)1234); // decoded high 4 digits ctx.Counters.Snapshot().RewrittenSlots.ShouldBe(2); } [Fact] public void FC03_Partial32Bit_LowOnly_qty1_AtLowAddr_PassesThroughRaw() { // Read qty=1 at the low address of a 32-bit pair — only half the pair is in range. var ctx = MakeContext(BcdTag.Create(100, 32)); var req = Fc03Request(100, 1); var rsp = Fc03Response(0x5678); SendRequestThenProcessResponse(ctx, req, rsp); ReadReg(rsp, 0).ShouldBe((ushort)0x5678); // unchanged ctx.Counters.Snapshot().PartialBcdWarnings.ShouldBe(1); ctx.Counters.Snapshot().RewrittenSlots.ShouldBe(0); } [Fact] public void FC03_Partial32Bit_HighOnly_qty1_AtHighAddr_PassesThroughRaw() { // Read qty=1 starting at the HIGH register of a 32-bit pair (address 101 when tag is at 100). // TryGetForRange returns OffsetWords = -1 for the hit (low register is before the range). var ctx = MakeContext(BcdTag.Create(100, 32)); var req = Fc03Request(101, 1); // only reading high register var rsp = Fc03Response(0x1234); SendRequestThenProcessResponse(ctx, req, rsp); ReadReg(rsp, 0).ShouldBe((ushort)0x1234); // unchanged (partial overlap) ctx.Counters.Snapshot().PartialBcdWarnings.ShouldBe(1); ctx.Counters.Snapshot().RewrittenSlots.ShouldBe(0); } [Fact] public void FC03_Mixed_16BitBcd_And_NonBcd_InSameRead_OnlyBcdSlotRewritten() { // Registers: [0]=non-BCD at addr 99, [1]=BCD 16-bit at addr 100, [2]=non-BCD at addr 101 var ctx = MakeContext(BcdTag.Create(100, 16)); var req = Fc03Request(99, 3); var rsp = Fc03Response(0xABCD, 0x1234, 0xDEAD); SendRequestThenProcessResponse(ctx, req, rsp); ReadReg(rsp, 0).ShouldBe((ushort)0xABCD); // non-BCD, unchanged ReadReg(rsp, 1).ShouldBe((ushort)1234); // BCD decoded ReadReg(rsp, 2).ShouldBe((ushort)0xDEAD); // non-BCD, unchanged ctx.Counters.Snapshot().RewrittenSlots.ShouldBe(1); } [Fact] public void FC03_BadNibble_At16BitBcdSlot_PassesThroughRaw_AndIncrementsInvalidBcd() { // 0x12A4 has nibble 'A' which is not a valid BCD digit. var ctx = MakeContext(BcdTag.Create(100, 16)); var req = Fc03Request(100, 1); var rsp = Fc03Response(0x12A4); SendRequestThenProcessResponse(ctx, req, rsp); ReadReg(rsp, 0).ShouldBe((ushort)0x12A4); // unchanged ctx.Counters.Snapshot().InvalidBcdWarnings.ShouldBe(1); ctx.Counters.Snapshot().RewrittenSlots.ShouldBe(0); } // ── FC04 response tests ────────────────────────────────────────────────── [Fact] public void FC04_Single16BitBcd_AtReadAddress_DecodesNibbles() { var ctx = MakeContext(BcdTag.Create(200, 16)); // FC04 request: same shape as FC03 but fc=04 var req = new byte[] { 0x04, 0x00, 0xC8, 0x00, 0x01 }; // addr=200, qty=1 var rsp = Fc04Response(0x9876); SendRequestThenProcessResponse(ctx, req, rsp); ReadReg(rsp, 0).ShouldBe((ushort)9876); ctx.Counters.Snapshot().RewrittenSlots.ShouldBe(1); } // ── FC06 request tests ─────────────────────────────────────────────────── [Fact] public void FC06_Write16BitBcd_EncodesClientBinaryToNibbles() { // Client writes binary 1234 → PLC should receive BCD 0x1234 var ctx = MakeContext(BcdTag.Create(300, 16)); var pdu = Fc06Request(300, 1234); // client sends binary 1234 Pipeline.Process(MbapDirection.RequestToBackend, ReadOnlySpan.Empty, pdu.AsSpan(), ctx); ushort sentValue = (ushort)((pdu[3] << 8) | pdu[4]); sentValue.ShouldBe((ushort)0x1234); // BCD nibbles ctx.Counters.Snapshot().RewrittenSlots.ShouldBe(1); } [Fact] public void FC06_WriteToLowAddrOf32BitPair_PassesThroughRaw_WithPartialWarning() { // FC06 can only write 1 register; if the target is the LOW addr of a 32-bit pair, // that's a partial write — pass through raw. var ctx = MakeContext(BcdTag.Create(400, 32)); var pdu = Fc06Request(400, 9999); // 400 is the low address of the 32-bit pair Pipeline.Process(MbapDirection.RequestToBackend, ReadOnlySpan.Empty, pdu.AsSpan(), ctx); ushort sentValue = (ushort)((pdu[3] << 8) | pdu[4]); sentValue.ShouldBe((ushort)9999); // unchanged (raw binary) ctx.Counters.Snapshot().PartialBcdWarnings.ShouldBe(1); ctx.Counters.Snapshot().RewrittenSlots.ShouldBe(0); } [Fact] public void FC06_WriteToHighAddrOf32BitPair_PassesThroughRaw_WithPartialWarning() { // Writing to address 401 when the 32-bit pair is at 400/401 — high register only. var ctx = MakeContext(BcdTag.Create(400, 32)); var pdu = Fc06Request(401, 0x1234); // 401 is the high address Pipeline.Process(MbapDirection.RequestToBackend, ReadOnlySpan.Empty, pdu.AsSpan(), ctx); ushort sentValue = (ushort)((pdu[3] << 8) | pdu[4]); sentValue.ShouldBe((ushort)0x1234); // unchanged ctx.Counters.Snapshot().PartialBcdWarnings.ShouldBe(1); ctx.Counters.Snapshot().RewrittenSlots.ShouldBe(0); } [Fact] public void FC06_WriteValueOutsideRange_InvalidBcd_PassesThroughRaw() { // Binary 10000 cannot be represented as 4-digit BCD (max 9999). var ctx = MakeContext(BcdTag.Create(300, 16)); var pdu = Fc06Request(300, 10000); // 10000 > 9999 Pipeline.Process(MbapDirection.RequestToBackend, ReadOnlySpan.Empty, pdu.AsSpan(), ctx); ushort sentValue = (ushort)((pdu[3] << 8) | pdu[4]); sentValue.ShouldBe((ushort)10000); // raw passthrough ctx.Counters.Snapshot().InvalidBcdWarnings.ShouldBe(1); ctx.Counters.Snapshot().RewrittenSlots.ShouldBe(0); } // ── FC16 request tests ─────────────────────────────────────────────────── [Fact] public void FC16_WriteSingle16BitBcd_InMultiWrite_EncodesBcdSlotOnly() { // Registers 500, 501, 502, 503: only 502 is a BCD tag. // Non-BCD registers should pass through unchanged. var ctx = MakeContext(BcdTag.Create(502, 16)); var pdu = Fc16Request(500, 0x0010, 0x0020, 1234, 0x0040); Pipeline.Process(MbapDirection.RequestToBackend, ReadOnlySpan.Empty, pdu.AsSpan(), ctx); // Register at offset 0 (addr 500): unchanged ushort r0 = (ushort)((pdu[6] << 8) | pdu[7]); r0.ShouldBe((ushort)0x0010); // Register at offset 2 (addr 502): binary 1234 → BCD 0x1234 ushort r2 = (ushort)((pdu[10] << 8) | pdu[11]); r2.ShouldBe((ushort)0x1234); // Register at offset 3 (addr 503): unchanged ushort r3 = (ushort)((pdu[12] << 8) | pdu[13]); r3.ShouldBe((ushort)0x0040); ctx.Counters.Snapshot().RewrittenSlots.ShouldBe(1); } [Fact] public void FC16_WriteFull32BitBcdPair_EncodesAsNibbles() { // 32-bit BCD pair at 600/601: client sends 12345678 as CDAB binary. // The proxy should encode to low=0x5678, high=0x1234. // Client sends: low-4-digits=5678, high-4-digits=1234 (in CDAB order) var ctx = MakeContext(BcdTag.Create(600, 32)); // Client sends binary: low register = low 4 digits = 5678, high register = high 4 digits = 1234 // But actually the pipeline needs to reconstruct the value: // decoded = clientHigh * 10000 + clientLow = 1234 * 10000 + 5678 = 12345678 // Then encode: (bcdLow=0x5678, bcdHigh=0x1234) var pdu = Fc16Request(600, 5678, 1234); // [0]=low-word=5678, [1]=high-word=1234 Pipeline.Process(MbapDirection.RequestToBackend, ReadOnlySpan.Empty, pdu.AsSpan(), ctx); // After encoding: low=BCD(5678)=0x5678, high=BCD(1234)=0x1234 ushort sentLow = (ushort)((pdu[6] << 8) | pdu[7]); ushort sentHigh = (ushort)((pdu[8] << 8) | pdu[9]); sentLow.ShouldBe((ushort)0x5678); sentHigh.ShouldBe((ushort)0x1234); ctx.Counters.Snapshot().RewrittenSlots.ShouldBe(2); } [Fact] public void FC16_WritePartiallyOverlapping32BitPair_PassesThroughRaw_WithPartialWarning() { // Write range 700–701 (2 regs), but 32-bit BCD tag is at 701/702. // Only the low register (701) is in range; high register (702) is not. var ctx = MakeContext(BcdTag.Create(701, 32)); var pdu = Fc16Request(700, 0xAAAA, 0xBBBB); // writes 700 and 701; tag needs 701 and 702 Pipeline.Process(MbapDirection.RequestToBackend, ReadOnlySpan.Empty, pdu.AsSpan(), ctx); // The low register (at offset 1 in pdu, i.e., addr 701) should be unchanged. ushort r1 = (ushort)((pdu[8] << 8) | pdu[9]); r1.ShouldBe((ushort)0xBBBB); ctx.Counters.Snapshot().PartialBcdWarnings.ShouldBe(1); ctx.Counters.Snapshot().RewrittenSlots.ShouldBe(0); } // ── Pass-through FCs ───────────────────────────────────────────────────── [Fact] public void FC01_Request_IsPassedThroughUnchanged() { var ctx = MakeContext(BcdTag.Create(100, 16)); var pdu = new byte[] { 0x01, 0x00, 0x64, 0x00, 0x08 }; // FC01 read coils byte[] original = [..pdu]; Pipeline.Process(MbapDirection.RequestToBackend, ReadOnlySpan.Empty, pdu.AsSpan(), ctx); pdu.ShouldBe(original); ctx.Counters.Snapshot().RewrittenSlots.ShouldBe(0); } [Fact] public void FC02_Request_IsPassedThroughUnchanged() { var ctx = MakeContext(BcdTag.Create(100, 16)); var pdu = new byte[] { 0x02, 0x00, 0x64, 0x00, 0x08 }; // FC02 read discrete inputs byte[] original = [..pdu]; Pipeline.Process(MbapDirection.RequestToBackend, ReadOnlySpan.Empty, pdu.AsSpan(), ctx); pdu.ShouldBe(original); ctx.Counters.Snapshot().RewrittenSlots.ShouldBe(0); } [Fact] public void FC05_Request_IsPassedThroughUnchanged() { var ctx = MakeContext(BcdTag.Create(100, 16)); var pdu = new byte[] { 0x05, 0x00, 0x64, 0xFF, 0x00 }; // FC05 write single coil byte[] original = [..pdu]; Pipeline.Process(MbapDirection.RequestToBackend, ReadOnlySpan.Empty, pdu.AsSpan(), ctx); pdu.ShouldBe(original); ctx.Counters.Snapshot().RewrittenSlots.ShouldBe(0); } [Fact] public void FC15_Request_IsPassedThroughUnchanged() { var ctx = MakeContext(BcdTag.Create(100, 16)); var pdu = new byte[] { 0x0F, 0x00, 0x64, 0x00, 0x08, 0x01, 0xAB }; // FC15 write multiple coils byte[] original = [..pdu]; Pipeline.Process(MbapDirection.RequestToBackend, ReadOnlySpan.Empty, pdu.AsSpan(), ctx); pdu.ShouldBe(original); ctx.Counters.Snapshot().RewrittenSlots.ShouldBe(0); } // ── Exception response test ────────────────────────────────────────────── [Fact] public void FC03_ExceptionResponse_PassesThroughRaw_LogsPassthrough_IncrementsBackendException() { var ctx = MakeContext(BcdTag.Create(100, 16)); // Exception response: [fc|0x80=0x83][exceptionCode=02] var pdu = new byte[] { 0x83, 0x02 }; byte[] original = [..pdu]; Pipeline.Process(MbapDirection.ResponseToClient, ReadOnlySpan.Empty, pdu.AsSpan(), ctx); pdu.ShouldBe(original); // bytes unchanged ctx.Counters.Snapshot().BackendException02.ShouldBe(1); ctx.Counters.Snapshot().RewrittenSlots.ShouldBe(0); } // ── Empty BcdTagMap tests ──────────────────────────────────────────────── [Fact] public void EmptyTagMap_FC03Response_ProducesZeroRewrites() { var ctx = MakeContext(/* no tags */); var req = Fc03Request(100, 3); var rsp = Fc03Response(0x1234, 0x5678, 0x9ABC); byte[] original = [..rsp]; SendRequestThenProcessResponse(ctx, req, rsp); rsp.ShouldBe(original); ctx.Counters.Snapshot().RewrittenSlots.ShouldBe(0); } [Fact] public void EmptyTagMap_FC06Request_ProducesZeroRewrites() { var ctx = MakeContext(/* no tags */); var pdu = Fc06Request(300, 1234); byte[] original = [..pdu]; Pipeline.Process(MbapDirection.RequestToBackend, ReadOnlySpan.Empty, pdu.AsSpan(), ctx); pdu.ShouldBe(original); ctx.Counters.Snapshot().RewrittenSlots.ShouldBe(0); } // ── Counter snapshot accuracy ──────────────────────────────────────────── [Fact] public void CounterSnapshot_ReflectsIncrementsExactly() { // Process 3 FC03 responses with one 16-bit BCD slot each, plus one bad-nibble. var ctx = MakeContext(BcdTag.Create(100, 16)); for (int i = 0; i < 3; i++) { var req = Fc03Request(100, 1); var rsp = Fc03Response(0x1234); SendRequestThenProcessResponse(ctx, req, rsp); } // One with a bad nibble. { var req = Fc03Request(100, 1); var rsp = Fc03Response(0x12A4); SendRequestThenProcessResponse(ctx, req, rsp); } var snap = ctx.Counters.Snapshot(); snap.RewrittenSlots.ShouldBe(3); // 3 successful decodes snap.InvalidBcdWarnings.ShouldBe(1); // 1 bad-nibble pass-through // PdusForwarded = 4 requests + 4 responses = 8 snap.PdusForwarded.ShouldBe(8); snap.Fc03.ShouldBe(8); // both request and response increment by FC (request FC03) } // ── PDU length invariant ───────────────────────────────────────────────── [Fact] public void PduLength_IsNeverChangedByRewriting() { // Build a response with two 16-bit BCD tags. After rewriting, the PDU must be // exactly the same byte count as before. var ctx = MakeContext(BcdTag.Create(100, 16), BcdTag.Create(101, 16)); var req = Fc03Request(100, 2); var rsp = Fc03Response(0x1234, 0x5678); int originalLength = rsp.Length; SendRequestThenProcessResponse(ctx, req, rsp); rsp.Length.ShouldBe(originalLength); // MBAP transparency contract ctx.Counters.Snapshot().RewrittenSlots.ShouldBe(2); } // ── FC counter tracking ────────────────────────────────────────────────── [Fact] public void FcCounters_IncrementCorrectly_ForEachFunctionCode() { var ctx = MakeContext(BcdTag.Create(100, 16)); // FC03 request Pipeline.Process(MbapDirection.RequestToBackend, ReadOnlySpan.Empty, Fc03Request(100, 1).AsSpan(), ctx); // FC04 request Pipeline.Process(MbapDirection.RequestToBackend, ReadOnlySpan.Empty, new byte[] { 0x04, 0x00, 0x64, 0x00, 0x01 }.AsSpan(), ctx); // FC06 request Pipeline.Process(MbapDirection.RequestToBackend, ReadOnlySpan.Empty, Fc06Request(300, 1234).AsSpan(), ctx); // FC16 request Pipeline.Process(MbapDirection.RequestToBackend, ReadOnlySpan.Empty, Fc16Request(100, 0x1234).AsSpan(), ctx); // FC01 (Other) Pipeline.Process(MbapDirection.RequestToBackend, ReadOnlySpan.Empty, new byte[] { 0x01, 0x00, 0x00, 0x00, 0x01 }.AsSpan(), ctx); var snap = ctx.Counters.Snapshot(); snap.Fc03.ShouldBe(1); snap.Fc04.ShouldBe(1); snap.Fc06.ShouldBe(1); snap.Fc16.ShouldBe(1); snap.FcOther.ShouldBe(1); snap.PdusForwarded.ShouldBe(5); } // ── Extra coverage: backend exception codes ────────────────────────────── [Fact] public void BackendExceptions_AllCodes_TrackSeparately() { var ctx = MakeContext(); // Codes 1–4 get individual counters; code 5 goes to Other. Pipeline.Process(MbapDirection.ResponseToClient, ReadOnlySpan.Empty, new byte[] { 0x81, 0x01 }.AsSpan(), ctx); Pipeline.Process(MbapDirection.ResponseToClient, ReadOnlySpan.Empty, new byte[] { 0x81, 0x02 }.AsSpan(), ctx); Pipeline.Process(MbapDirection.ResponseToClient, ReadOnlySpan.Empty, new byte[] { 0x81, 0x03 }.AsSpan(), ctx); Pipeline.Process(MbapDirection.ResponseToClient, ReadOnlySpan.Empty, new byte[] { 0x81, 0x04 }.AsSpan(), ctx); Pipeline.Process(MbapDirection.ResponseToClient, ReadOnlySpan.Empty, new byte[] { 0x81, 0x05 }.AsSpan(), ctx); // code 5 → Other var snap = ctx.Counters.Snapshot(); snap.BackendException01.ShouldBe(1); snap.BackendException02.ShouldBe(1); snap.BackendException03.ShouldBe(1); snap.BackendException04.ShouldBe(1); snap.BackendExceptionOther.ShouldBe(1); } // ── Plain PduContext (no BCD context) → no-op ──────────────────────────── [Fact] public void PlainPduContext_IsPassedThroughWithoutError() { // If a plain PduContext is passed (not PerPlcContext), the pipeline must // return cleanly without throwing, leaving bytes unchanged. var ctx = new PduContext { PlcName = "Test" }; var pdu = Fc03Response(0x1234); byte[] original = [..pdu]; Pipeline.Process(MbapDirection.ResponseToClient, ReadOnlySpan.Empty, pdu.AsSpan(), ctx); pdu.ShouldBe(original); } }