7466a46aa7
The standalone design.md, kpi.md, operations.md, and the docs/plan/ phase tree were point-in-time planning artefacts now superseded by the topic-organized docs/ tree (Architecture/, Features/, Operations/, Reference/, Testing/). The DL260/ folder mixed a device-reference doc, a test fixture, a sample test, and a screenshot; its contents now live in their natural homes (dl205.md + mbtcp_settings.JPG under docs/Reference/, dl205.json next to its launcher in tests/sim/, sample test dropped). All cross-references in the surviving docs, README, CLAUDE.md, the config template, and source comments are repointed to the new locations. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
741 lines
32 KiB
C#
741 lines
32 KiB
C#
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;
|
||
|
||
/// <summary>
|
||
/// Unit tests for <see cref="BcdPduPipeline"/> using synthetic PDU byte arrays.
|
||
/// No network, no simulator. Each test builds a hand-rolled <see cref="BcdTagMap"/>,
|
||
/// calls <see cref="BcdPduPipeline.Process"/>, and asserts resulting bytes + counter deltas.
|
||
/// </summary>
|
||
[Trait("Category", "Unit")]
|
||
public sealed class BcdPduPipelineTests
|
||
{
|
||
private static readonly BcdPduPipeline Pipeline = new();
|
||
|
||
// ── Factories ────────────────────────────────────────────────────────────
|
||
|
||
/// <summary>
|
||
/// Builds a <see cref="PerPlcContext"/> from a set of BcdTag entries.
|
||
/// The context has a fresh <see cref="ProxyCounters"/> instance.
|
||
/// </summary>
|
||
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,
|
||
};
|
||
}
|
||
|
||
/// <summary>
|
||
/// The rewriter consumes <see cref="PerPlcContext.CurrentRequest"/>. Tests build a
|
||
/// synthetic <see cref="InFlightRequest"/> to drive response decoding.
|
||
/// </summary>
|
||
private static InFlightRequest MakeInFlight(byte fc, ushort startAddress, ushort qty)
|
||
=> new(
|
||
UnitId: 1,
|
||
Fc: fc,
|
||
StartAddress: startAddress,
|
||
Qty: qty,
|
||
// We don't have a real UpstreamPipe in pipeline unit tests; the rewriter
|
||
// never dereferences the party list, so an empty placeholder is safe.
|
||
InterestedParties: Array.Empty<InterestedParty>(),
|
||
SentAtUtc: DateTimeOffset.UtcNow);
|
||
|
||
/// <summary>FC03 response PDU: [fc=03][byteCount][reg0Hi][reg0Lo]...</summary>
|
||
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;
|
||
}
|
||
|
||
/// <summary>FC04 response PDU: same shape as FC03 but fc=04.</summary>
|
||
private static byte[] Fc04Response(params ushort[] registers)
|
||
{
|
||
var pdu = Fc03Response(registers);
|
||
pdu[0] = 0x04;
|
||
return pdu;
|
||
}
|
||
|
||
/// <summary>FC03 request PDU: [fc=03][addrHi][addrLo][qtyHi][qtyLo]</summary>
|
||
private static byte[] Fc03Request(ushort address, ushort qty)
|
||
=> [0x03, (byte)(address >> 8), (byte)(address & 0xFF), (byte)(qty >> 8), (byte)(qty & 0xFF)];
|
||
|
||
/// <summary>FC06 request PDU: [fc=06][addrHi][addrLo][valHi][valLo]</summary>
|
||
private static byte[] Fc06Request(ushort address, ushort value)
|
||
=> [0x06, (byte)(address >> 8), (byte)(address & 0xFF), (byte)(value >> 8), (byte)(value & 0xFF)];
|
||
|
||
/// <summary>FC16 request PDU: [fc=10][startHi][startLo][qtyHi][qtyLo][byteCount][reg data...]</summary>
|
||
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;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Simulate sending an FC03/04 request then reading the response. Builds an
|
||
/// <see cref="InFlightRequest"/> matching the request and attaches it to the
|
||
/// response-call context.
|
||
/// </summary>
|
||
private void SendRequestThenProcessResponse(
|
||
PerPlcContext ctx,
|
||
byte[] requestPdu,
|
||
byte[] responsePdu)
|
||
{
|
||
Pipeline.Process(MbapDirection.RequestToBackend, ReadOnlySpan<byte>.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<byte>.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<byte>.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<byte>.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<byte>.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<byte>.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<byte>.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<byte>.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);
|
||
}
|
||
|
||
/// <summary>
|
||
/// A client writing a 32-bit BCD value where either word exceeds 9999 must NOT be
|
||
/// silently mutated by the `high*10000+low` reconstruction. Validation rejects the
|
||
/// slot, increments invalidBcdWarnings, and passes the raw bytes through. (Otherwise
|
||
/// the codec would accept e.g. (high=9999, low=9999) → 99_989_999 → re-encode as
|
||
/// (high=9998, low=9999), silently losing 1 from the high word.)
|
||
/// </summary>
|
||
[Fact]
|
||
public void FC16_32Bit_ClientHighOrLowAbove9999_PassesThroughRaw_WithInvalidBcdWarning()
|
||
{
|
||
var ctx = MakeContext(BcdTag.Create(800, 32));
|
||
// qty=2, low at offset 0, high at offset 1; both at 0xFFFF (= 65535, > 9999).
|
||
var pdu = Fc16Request(800, 0xFFFF, 0xFFFF);
|
||
byte[] original = [..pdu];
|
||
|
||
Pipeline.Process(MbapDirection.RequestToBackend, ReadOnlySpan<byte>.Empty, pdu.AsSpan(), ctx);
|
||
|
||
pdu.ShouldBe(original, "OOR client values must pass through raw, not be silently mutated");
|
||
ctx.Counters.Snapshot().InvalidBcdWarnings.ShouldBe(1);
|
||
ctx.Counters.Snapshot().RewrittenSlots.ShouldBe(0);
|
||
}
|
||
|
||
/// <summary>
|
||
/// A malformed FC16 request that claims qty=N but ships fewer than 6+N*2 bytes must
|
||
/// NOT be partially rewritten. Without the up-front length check, each individual
|
||
/// slot's per-slot bounds check would skip the OOB slot, leaving early slots rewritten
|
||
/// and late slots untouched (a half-rewritten request reaching the PLC).
|
||
/// </summary>
|
||
[Fact]
|
||
public void FC16_TruncatedRegisterData_PassesThroughRaw_NoPartialRewrite()
|
||
{
|
||
var ctx = MakeContext(BcdTag.Create(900, 16));
|
||
// Build a normal 1-register write, then trim 1 byte off the end so qty=1 but only
|
||
// 1 byte of register data remains.
|
||
var pdu = Fc16Request(900, 1234);
|
||
byte[] truncated = pdu.AsSpan(0, pdu.Length - 1).ToArray();
|
||
byte[] original = [..truncated];
|
||
|
||
Pipeline.Process(MbapDirection.RequestToBackend, ReadOnlySpan<byte>.Empty, truncated.AsSpan(), ctx);
|
||
|
||
truncated.ShouldBe(original, "truncated FC16 must pass through raw");
|
||
ctx.Counters.Snapshot().RewrittenSlots.ShouldBe(0);
|
||
}
|
||
|
||
/// <summary>
|
||
/// DL205/DL260 caps FC03/FC04 reads at qty=128 (above Modbus spec's 125; documented
|
||
/// in docs/Reference/dl205.md). The proxy must NOT truncate the qty field — a request with
|
||
/// qty > 128 at non-BCD addresses must pass through unchanged so the PLC's own
|
||
/// validator returns exception 03 to the client. This is the transparent-pass-through
|
||
/// contract for FCs and addresses the rewriter doesn't own.
|
||
/// </summary>
|
||
[Fact]
|
||
public void FC03_Request_QtyAbove128_AtNonBcdAddress_PassesThroughUnchanged()
|
||
{
|
||
var ctx = MakeContext(BcdTag.Create(1024, 16)); // tag elsewhere; not in this read
|
||
var req = Fc03Request(address: 5000, qty: 200);
|
||
byte[] original = [..req];
|
||
|
||
Pipeline.Process(MbapDirection.RequestToBackend, ReadOnlySpan<byte>.Empty, req.AsSpan(), ctx);
|
||
|
||
req.ShouldBe(original, "qty must NOT be truncated; the PLC validates and returns ex03");
|
||
ctx.Counters.Snapshot().RewrittenSlots.ShouldBe(0);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Symmetric inverse of the existing partial-overlap test: the write range starts ON
|
||
/// the high register of a 32-bit pair (low word is BEFORE the write range). Must also
|
||
/// be passed through raw with a partial warning, not half-rewritten.
|
||
/// </summary>
|
||
[Fact]
|
||
public void FC16_WriteStartsOnHighWord_Of32BitPair_PassesThroughRaw_WithPartialWarning()
|
||
{
|
||
// 32-bit BCD tag at 700/701; write range 701–702 (qty=2). Low (700) is OUT of
|
||
// range; high (701) is IN range — partial overlap on the high side.
|
||
var ctx = MakeContext(BcdTag.Create(700, 32));
|
||
var pdu = Fc16Request(701, 0xCCCC, 0xDDDD);
|
||
byte[] original = [..pdu];
|
||
|
||
Pipeline.Process(MbapDirection.RequestToBackend, ReadOnlySpan<byte>.Empty, pdu.AsSpan(), ctx);
|
||
|
||
pdu.ShouldBe(original, "high-only partial overlap must pass through raw");
|
||
ctx.Counters.Snapshot().PartialBcdWarnings.ShouldBe(1);
|
||
ctx.Counters.Snapshot().RewrittenSlots.ShouldBe(0);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Mixed slots in a single FC03 read: a 16-bit BCD tag, a 32-bit BCD pair, and an
|
||
/// unconfigured register. Each slot should be handled independently — the 16-bit and
|
||
/// 32-bit rewritten, the unconfigured register passed through unchanged.
|
||
/// </summary>
|
||
[Fact]
|
||
public void FC03_Mixed_16Bit_32Bit_AndNonBcd_InOneRead_OnlyConfiguredSlotsRewritten()
|
||
{
|
||
// Layout:
|
||
// addr 100: 16-bit BCD → wire 0x1234 → decoded 1234 (= 0x04D2 binary)
|
||
// addr 101: unconfigured → passes through 0x9999
|
||
// addr 102: 32-bit BCD low → wire 0x5678 (BCD digits 5,6,7,8 → 5678)
|
||
// addr 103: 32-bit BCD high→ wire 0x1234 (BCD digits 1,2,3,4 → 1234)
|
||
// decoded = 1234*10_000 + 5678 = 12_345_678
|
||
// emitted as base-10000 binary CDAB:
|
||
// low = 12_345_678 % 10_000 = 5678 (binary 0x162E)
|
||
// high = 12_345_678 / 10_000 = 1234 (binary 0x04D2)
|
||
var ctx = MakeContext(BcdTag.Create(100, 16), BcdTag.Create(102, 32));
|
||
var inFlight = MakeInFlight(0x03, startAddress: 100, qty: 4);
|
||
var responseCtx = ctx.WithCurrentRequest(inFlight);
|
||
|
||
var pdu = Fc03Response(0x1234, 0x9999, 0x5678, 0x1234);
|
||
|
||
Pipeline.Process(MbapDirection.ResponseToClient, ReadOnlySpan<byte>.Empty, pdu.AsSpan(), responseCtx);
|
||
|
||
// pdu[0]=fc, pdu[1]=byteCount, pdu[2..] = register bytes (2 per register).
|
||
ushort reg100 = (ushort)((pdu[2] << 8) | pdu[3]);
|
||
ushort reg101 = (ushort)((pdu[4] << 8) | pdu[5]);
|
||
ushort reg102 = (ushort)((pdu[6] << 8) | pdu[7]);
|
||
ushort reg103 = (ushort)((pdu[8] << 8) | pdu[9]);
|
||
|
||
reg100.ShouldBe((ushort)1234, "16-bit BCD slot must decode to 1234");
|
||
reg101.ShouldBe((ushort)0x9999, "unconfigured register must pass through unchanged");
|
||
reg102.ShouldBe((ushort)5678, "32-bit pair low must emit decimal 5678 as binary");
|
||
reg103.ShouldBe((ushort)1234, "32-bit pair high must emit decimal 1234 as binary");
|
||
// Slot count: 1 from 16-bit + 2 from 32-bit pair = 3 rewritten slots.
|
||
ctx.Counters.Snapshot().RewrittenSlots.ShouldBe(3);
|
||
}
|
||
|
||
/// <summary>
|
||
/// FC16 response handling. The response carries no register values (just an echo of
|
||
/// [fc][start][qty]) so the rewriter must pass it through unchanged regardless of
|
||
/// tag-map content.
|
||
/// </summary>
|
||
[Fact]
|
||
public void FC16_Response_PassesThroughUnchanged_RegardlessOfTagMap()
|
||
{
|
||
var ctx = MakeContext(BcdTag.Create(100, 16), BcdTag.Create(200, 32));
|
||
// FC16 response: [fc=10][startHi][startLo][qtyHi][qtyLo] = 5 bytes total.
|
||
var pdu = new byte[] { 0x10, 0x00, 0x64, 0x00, 0x05 };
|
||
byte[] original = [..pdu];
|
||
|
||
Pipeline.Process(MbapDirection.ResponseToClient, ReadOnlySpan<byte>.Empty, pdu.AsSpan(), ctx);
|
||
|
||
pdu.ShouldBe(original, "FC16 response carries no register data and must pass through");
|
||
ctx.Counters.Snapshot().RewrittenSlots.ShouldBe(0);
|
||
}
|
||
|
||
[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<byte>.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<byte>.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<byte>.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<byte>.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<byte>.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<byte>.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<byte>.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<byte>.Empty, Fc03Request(100, 1).AsSpan(), ctx);
|
||
// FC04 request
|
||
Pipeline.Process(MbapDirection.RequestToBackend, ReadOnlySpan<byte>.Empty,
|
||
new byte[] { 0x04, 0x00, 0x64, 0x00, 0x01 }.AsSpan(), ctx);
|
||
// FC06 request
|
||
Pipeline.Process(MbapDirection.RequestToBackend, ReadOnlySpan<byte>.Empty, Fc06Request(300, 1234).AsSpan(), ctx);
|
||
// FC16 request
|
||
Pipeline.Process(MbapDirection.RequestToBackend, ReadOnlySpan<byte>.Empty, Fc16Request(100, 0x1234).AsSpan(), ctx);
|
||
// FC01 (Other)
|
||
Pipeline.Process(MbapDirection.RequestToBackend, ReadOnlySpan<byte>.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<byte>.Empty,
|
||
new byte[] { 0x81, 0x01 }.AsSpan(), ctx);
|
||
Pipeline.Process(MbapDirection.ResponseToClient, ReadOnlySpan<byte>.Empty,
|
||
new byte[] { 0x81, 0x02 }.AsSpan(), ctx);
|
||
Pipeline.Process(MbapDirection.ResponseToClient, ReadOnlySpan<byte>.Empty,
|
||
new byte[] { 0x81, 0x03 }.AsSpan(), ctx);
|
||
Pipeline.Process(MbapDirection.ResponseToClient, ReadOnlySpan<byte>.Empty,
|
||
new byte[] { 0x81, 0x04 }.AsSpan(), ctx);
|
||
Pipeline.Process(MbapDirection.ResponseToClient, ReadOnlySpan<byte>.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<byte>.Empty, pdu.AsSpan(), ctx);
|
||
|
||
pdu.ShouldBe(original);
|
||
}
|
||
}
|