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 the recording hooks in /// . Verifies that an armed capture records raw PLC-side /// and decoded client-side values, and — as a regression guard — that a disarmed or /// absent capture leaves the rewrite behaviour byte-identical. /// [Trait("Category", "Unit")] public sealed class BcdPduPipelineCaptureTests { private static readonly BcdPduPipeline Pipeline = new(); private static BcdTagMap BuildMap(params BcdTag[] tags) { var frozen = tags.ToDictionary(t => t.Address).ToFrozenDictionary(); return frozen.Count > 0 ? new BcdTagMap(frozen) : BcdTagMap.Empty; } private static PerPlcContext MakeContext(TagValueCapture? capture, params BcdTag[] tags) => new() { PlcName = "TestPLC", TagMap = BuildMap(tags), Counters = new ProxyCounters(), Logger = NullLogger.Instance, Capture = capture, }; private static InFlightRequest MakeInFlight(byte fc, ushort start, ushort qty) => new(1, fc, start, qty, Array.Empty(), DateTimeOffset.UtcNow); private static byte[] Fc03Response(params ushort[] regs) { var pdu = new byte[2 + regs.Length * 2]; pdu[0] = 0x03; pdu[1] = (byte)(regs.Length * 2); for (int i = 0; i < regs.Length; i++) { pdu[2 + i * 2] = (byte)(regs[i] >> 8); pdu[2 + i * 2 + 1] = (byte)(regs[i] & 0xFF); } return pdu; } private static byte[] Fc06Request(ushort address, ushort value) => [0x06, (byte)(address >> 8), (byte)(address & 0xFF), (byte)(value >> 8), (byte)(value & 0xFF)]; private static byte[] Fc16Request(ushort start, params ushort[] regs) { var pdu = new byte[6 + regs.Length * 2]; pdu[0] = 0x10; pdu[1] = (byte)(start >> 8); pdu[2] = (byte)(start & 0xFF); pdu[3] = (byte)((ushort)regs.Length >> 8); pdu[4] = (byte)(regs.Length & 0xFF); pdu[5] = (byte)(regs.Length * 2); for (int i = 0; i < regs.Length; i++) { pdu[6 + i * 2] = (byte)(regs[i] >> 8); pdu[6 + i * 2 + 1] = (byte)(regs[i] & 0xFF); } return pdu; } private static void ProcessFc03Response(PerPlcContext ctx, ushort start, ushort qty, byte[] response) { var responseCtx = ctx.WithCurrentRequest(MakeInFlight(0x03, start, qty)); Pipeline.Process(MbapDirection.ResponseToClient, ReadOnlySpan.Empty, response.AsSpan(), responseCtx); } private static ushort ReadReg(byte[] pdu, int offsetWords) => (ushort)((pdu[2 + offsetWords * 2] << 8) | pdu[2 + offsetWords * 2 + 1]); // ── Read path (FC03/FC04 response) ─────────────────────────────────────── [Fact] public void FC03_16Bit_ArmedCapture_RecordsRawAndDecoded() { var capture = new TagValueCapture([BcdTag.Create(100, 16)]); capture.Arm(); var ctx = MakeContext(capture, BcdTag.Create(100, 16)); ProcessFc03Response(ctx, 100, 1, Fc03Response(0x1234)); var slot = capture.Snapshot().ShouldHaveSingleItem(); slot.Address.ShouldBe((ushort)100); slot.RawLow.ShouldBe((ushort)0x1234); // BCD nibbles on the PLC wire slot.DecodedValue.ShouldBe(1234); // binary the client receives slot.Direction.ShouldBe(CaptureDirection.Read); slot.UpdatedAtUtc.ShouldNotBeNull(); } [Fact] public void FC03_32Bit_ArmedCapture_RecordsBothRawWords() { var capture = new TagValueCapture([BcdTag.Create(100, 32)]); capture.Arm(); var ctx = MakeContext(capture, BcdTag.Create(100, 32)); // CDAB: low word 0x5678, high word 0x1234 → decoded 1234*10000 + 5678. ProcessFc03Response(ctx, 100, 2, Fc03Response(0x5678, 0x1234)); var slot = capture.Snapshot().ShouldHaveSingleItem(); slot.Width.ShouldBe((byte)32); slot.RawLow.ShouldBe((ushort)0x5678); slot.RawHigh.ShouldBe((ushort)0x1234); slot.DecodedValue.ShouldBe(12345678); slot.Direction.ShouldBe(CaptureDirection.Read); } // ── Write path (FC06 / FC16 request) ───────────────────────────────────── [Fact] public void FC06_ArmedCapture_RecordsEncodedBcdAndClientValue() { var capture = new TagValueCapture([BcdTag.Create(100, 16)]); capture.Arm(); var ctx = MakeContext(capture, BcdTag.Create(100, 16)); // Client writes binary 1234; proxy encodes to BCD 0x1234 for the PLC. var req = Fc06Request(100, 1234); Pipeline.Process(MbapDirection.RequestToBackend, ReadOnlySpan.Empty, req.AsSpan(), ctx); var slot = capture.Snapshot().ShouldHaveSingleItem(); slot.RawLow.ShouldBe((ushort)0x1234); // BCD nibbles sent to the PLC slot.DecodedValue.ShouldBe(1234); // binary the client wrote slot.Direction.ShouldBe(CaptureDirection.Write); } [Fact] public void FC16_16Bit_ArmedCapture_RecordsWrite() { var capture = new TagValueCapture([BcdTag.Create(100, 16)]); capture.Arm(); var ctx = MakeContext(capture, BcdTag.Create(100, 16)); var req = Fc16Request(100, 4321); Pipeline.Process(MbapDirection.RequestToBackend, ReadOnlySpan.Empty, req.AsSpan(), ctx); var slot = capture.Snapshot().ShouldHaveSingleItem(); slot.RawLow.ShouldBe((ushort)0x4321); slot.DecodedValue.ShouldBe(4321); slot.Direction.ShouldBe(CaptureDirection.Write); } // ── Regression guards: disarmed / absent capture ───────────────────────── [Fact] public void FC03_DisarmedCapture_StillRewrites_ButCapturesNothing() { var capture = new TagValueCapture([BcdTag.Create(100, 16)]); // Not armed. var ctx = MakeContext(capture, BcdTag.Create(100, 16)); var rsp = Fc03Response(0x1234); ProcessFc03Response(ctx, 100, 1, rsp); ReadReg(rsp, 0).ShouldBe((ushort)1234); // rewrite still happened capture.Snapshot().ShouldHaveSingleItem().UpdatedAtUtc.ShouldBeNull(); } [Fact] public void FC03_NullCapture_DoesNotThrow_AndStillRewrites() { var ctx = MakeContext(capture: null, BcdTag.Create(100, 16)); var rsp = Fc03Response(0x1234); Should.NotThrow(() => ProcessFc03Response(ctx, 100, 1, rsp)); ReadReg(rsp, 0).ShouldBe((ushort)1234); } }