Files
wwtools/mbproxy/tests/Mbproxy.Tests/Proxy/MbapFrameTests.cs
T
Joseph Doherty 56eee3c563 mbproxy: initial commit through Phase 9 (TxId multiplexing)
Adds the mbproxy service end-to-end. Phases 00-08 implement the
production-ready single-listener / 1:1-backend transparent Modbus TCP
proxy with bidirectional BCD rewriting for the ~54-PLC DL205/DL260
fleet. Phase 9 replaces the connection layer with a single backend
socket per PLC plus MBAP TxId rewriting, lifting the H2-ECOM100's
4-concurrent-client cap as an operational ceiling.

Phase 9 additions of note:
- PlcMultiplexer + UpstreamPipe + TxIdAllocator + CorrelationMap
- InFlightRequest with IReadOnlyList<InterestedParty> (load-bearing
  for Phase 10 read coalescing — do not collapse to a single field)
- Per-request watchdog: surfaces Modbus exception 0x0B to upstream
  on BackendRequestTimeoutMs, defending against lost responses,
  dead-PLC paths, and pymodbus 3.13.0's concurrent-multiplexed-
  request bug (its ServerRequestHandler.last_pdu state race)
- Status DTO + HTML gain inFlight / maxInFlight / txIdWraps /
  disconnectCascades / queueDepth (Tier 1.6 in docs/kpi.md)

Tests: 263 unit + 38 E2E. Multiplexer correctness under truly
concurrent backend traffic is proved against a stub backend in
PlcMultiplexerTests; MultiplexerE2ETests paces requests so pymodbus
3.13's single-PDU framer stays in known-good mode.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 01:49:35 -04:00

175 lines
6.6 KiB
C#

using Mbproxy.Proxy;
using Xunit;
namespace Mbproxy.Tests.Proxy;
/// <summary>
/// Unit tests for <see cref="MbapFrame"/> header parsing and frame-length helpers.
/// All tests are pure in-memory; no network, no simulator required.
/// </summary>
[Trait("Category", "Unit")]
public sealed class MbapFrameTests
{
// ── 1. TryParseHeader — too-short buffers ────────────────────────────────────────────
[Fact]
public void TryParseHeader_TooShort_ReturnsFalse()
{
// A buffer of only 6 bytes is one byte short of the 7-byte header.
byte[] buf = [0x00, 0x01, 0x00, 0x00, 0x00, 0x06];
bool result = MbapFrame.TryParseHeader(buf, out _, out _, out _, out _);
Assert.False(result, "Buffer shorter than 7 bytes must return false.");
}
[Fact]
public void TryParseHeader_EmptyBuffer_ReturnsFalse()
{
bool result = MbapFrame.TryParseHeader(ReadOnlySpan<byte>.Empty, out _, out _, out _, out _);
Assert.False(result);
}
// ── 2. TryParseHeader — valid frame parses all fields ──────────────────────────────
[Fact]
public void TryParseHeader_ValidFrame_ParsesAllFields()
{
// TxId=0x0042, ProtocolId=0x0000, Length=0x0006, UnitId=0x01
byte[] header = [0x00, 0x42, 0x00, 0x00, 0x00, 0x06, 0x01];
bool ok = MbapFrame.TryParseHeader(header, out ushort txId, out ushort protocolId,
out ushort length, out byte unitId);
Assert.True(ok);
Assert.Equal(0x0042, txId);
Assert.Equal(0x0000, protocolId);
Assert.Equal(6, length);
Assert.Equal(1, unitId);
}
// ── 3. Non-zero ProtocolId still parses (PLC's job to reject it) ─────────────────
[Fact]
public void TryParseHeader_ProtocolId_NotZero_StillParses()
{
// ProtocolId = 0x0001 (non-standard but we don't filter it).
byte[] header = [0x00, 0x01, 0x00, 0x01, 0x00, 0x06, 0xFF];
bool ok = MbapFrame.TryParseHeader(header, out _, out ushort protocolId, out _, out _);
Assert.True(ok);
Assert.Equal(0x0001, protocolId);
}
// ── 4. TotalFrameLength — known good values ──────────────────────────────────────
[Fact]
public void TotalFrameLength_LengthField7_Returns13()
{
// 6 fixed prefix bytes + 7 = 13
Assert.Equal(13, MbapFrame.TotalFrameLength(7));
}
[Fact]
public void TotalFrameLength_LengthFieldMax_Returns_LengthFieldPlus6()
{
// The formula is always lengthField + 6.
ushort max = ushort.MaxValue; // 65535
Assert.Equal(max + 6, MbapFrame.TotalFrameLength(max));
}
// ── 5. Round-trip: FC03 read-holding-registers request ───────────────────────────
[Fact]
public void RoundTrip_FC03_ReadHoldingRegisters_Request_ParsesCorrectly()
{
// FC03 request: TxId=1, ProtocolId=0, Length=6, UnitId=1, FC=0x03, Start=0x0430, Qty=0x0001
byte[] frame =
[
0x00, 0x01, // TxId = 1
0x00, 0x00, // ProtocolId = 0
0x00, 0x06, // Length = 6
0x01, // UnitId = 1
0x03, // FC 03
0x04, 0x30, // Start address = 0x0430 (decimal 1072)
0x00, 0x01, // Quantity = 1
];
bool ok = MbapFrame.TryParseHeader(frame.AsSpan(0, 7),
out ushort txId, out ushort protocolId, out ushort length, out byte unitId);
Assert.True(ok);
Assert.Equal(1, txId);
Assert.Equal(0, protocolId);
Assert.Equal(6, length);
Assert.Equal(1, unitId);
// Total frame = 6 + length = 12 bytes
Assert.Equal(12, MbapFrame.TotalFrameLength(length));
Assert.Equal(frame.Length, MbapFrame.TotalFrameLength(length));
}
// ── 6. Round-trip: FC16 write-multiple-registers request ─────────────────────────
[Fact]
public void RoundTrip_FC16_WriteMultipleRegisters_ParsesCorrectly()
{
// FC16 request: TxId=5, ProtocolId=0, Length=11, UnitId=1
// FC=0x10, Start=0x00C8 (200), Qty=2, ByteCount=4, Data=[0x00,0x0A, 0x00,0x14]
byte[] frame =
[
0x00, 0x05, // TxId = 5
0x00, 0x00, // ProtocolId = 0
0x00, 0x0B, // Length = 11
0x01, // UnitId = 1
0x10, // FC 16
0x00, 0xC8, // Start address = 200
0x00, 0x02, // Quantity = 2
0x04, // Byte count = 4
0x00, 0x0A, // Register 200 = 10
0x00, 0x14, // Register 201 = 20
];
bool ok = MbapFrame.TryParseHeader(frame.AsSpan(0, 7),
out ushort txId, out _, out ushort length, out byte unitId);
Assert.True(ok);
Assert.Equal(5, txId);
Assert.Equal(11, length);
Assert.Equal(1, unitId);
// Total frame = 6 + 11 = 17
Assert.Equal(17, MbapFrame.TotalFrameLength(length));
Assert.Equal(frame.Length, MbapFrame.TotalFrameLength(length));
}
// ── 7. Length < 2 — parsed but unusual (callers' responsibility) ───────────────────
[Fact]
public void TryParseHeader_LengthLessThan2_ParsedButUnusual()
{
// length=1 means only a UnitId byte follows the 6-byte prefix; PDU body = 0 bytes.
// The proxy does not reject this — that is the PLC's job. We parse and pass through.
byte[] header = [0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x01];
bool ok = MbapFrame.TryParseHeader(header, out _, out _, out ushort length, out _);
Assert.True(ok, "Header with length=1 should still parse; the proxy does not validate length semantics.");
Assert.Equal(1, length);
// TotalFrameLength still returns 6 + length = 7 (header only, no PDU body).
Assert.Equal(7, MbapFrame.TotalFrameLength(length));
}
// ── 8. Exactly 7 bytes — boundary case ─────────────────────────────────────────────
[Fact]
public void TryParseHeader_ExactlySevenBytes_ParsesOk()
{
byte[] header = [0xFF, 0xFE, 0x00, 0x00, 0x00, 0x06, 0x02];
bool ok = MbapFrame.TryParseHeader(header, out ushort txId, out _, out _, out byte unitId);
Assert.True(ok);
Assert.Equal(0xFFFE, txId);
Assert.Equal(2, unitId);
}
}