Files
wwtools/mbproxy/tests/Mbproxy.Tests/Bcd/BcdCodecTests.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.1 KiB
C#

using Mbproxy.Bcd;
using Shouldly;
using Xunit;
namespace Mbproxy.Tests.Bcd;
/// <summary>
/// Unit tests for <see cref="BcdCodec"/> — the allocation-free BCD nibble codec.
///
/// NOTE on allocation profile:
/// BcdCodec is a purely static class operating on value types (ushort, int, tuples).
/// It allocates only when constructing exception objects (the error path), never on
/// the success path. TryGet / hot-path decode callers in Phase 04 will be
/// allocation-free for valid BCD registers.
/// </summary>
[Trait("Category", "Unit")]
public sealed class BcdCodecTests
{
// ── Encode16 ────────────────────────────────────────────────────────────
[Fact]
public void Encode16_1234_Returns_0x1234()
=> BcdCodec.Encode16(1234).ShouldBe((ushort)0x1234);
[Fact]
public void Encode16_0_Returns_0x0000()
=> BcdCodec.Encode16(0).ShouldBe((ushort)0x0000);
[Fact]
public void Encode16_9999_Returns_0x9999()
=> BcdCodec.Encode16(9999).ShouldBe((ushort)0x9999);
[Fact]
public void Encode16_10000_Throws_OutOfRange()
{
Should.Throw<ArgumentOutOfRangeException>(() => BcdCodec.Encode16(10_000))
.ParamName.ShouldBe("value");
}
[Fact]
public void Encode16_Negative_Throws_OutOfRange()
{
Should.Throw<ArgumentOutOfRangeException>(() => BcdCodec.Encode16(-1))
.ParamName.ShouldBe("value");
}
// ── Decode16 ────────────────────────────────────────────────────────────
[Fact]
public void Decode16_0x1234_Returns_1234()
=> BcdCodec.Decode16(0x1234).ShouldBe(1234);
[Fact]
public void Decode16_0x0000_Returns_0()
=> BcdCodec.Decode16(0x0000).ShouldBe(0);
[Fact]
public void Decode16_0x9999_Returns_9999()
=> BcdCodec.Decode16(0x9999).ShouldBe(9999);
[Fact]
public void Decode16_0x123A_Throws_Format()
{
// Nibble 'A' (10) is not a valid BCD digit; message must contain the raw hex value.
var ex = Should.Throw<FormatException>(() => BcdCodec.Decode16(0x123A));
ex.Message.ShouldContain("0x123A", Case.Insensitive);
}
[Fact]
public void Decode16_0x12FA_TwoBadNibbles_Throws_Format()
{
// Two bad nibbles in one register — still throws once with the raw value.
var ex = Should.Throw<FormatException>(() => BcdCodec.Decode16(0x12FA));
ex.Message.ShouldContain("0x12FA", Case.Insensitive);
}
// ── Encode32 ────────────────────────────────────────────────────────────
[Fact]
public void Encode32_12345678_Returns_LowHigh_5678_1234()
{
var (low, high) = BcdCodec.Encode32(12_345_678);
low.ShouldBe((ushort)0x5678);
high.ShouldBe((ushort)0x1234);
}
[Fact]
public void Encode32_0_Returns_LowHigh_0_0()
{
var (low, high) = BcdCodec.Encode32(0);
low.ShouldBe((ushort)0x0000);
high.ShouldBe((ushort)0x0000);
}
[Fact]
public void Encode32_99999999_Returns_LowHigh_9999_9999()
{
var (low, high) = BcdCodec.Encode32(99_999_999);
low.ShouldBe((ushort)0x9999);
high.ShouldBe((ushort)0x9999);
}
[Fact]
public void Encode32_100000000_Throws_OutOfRange()
{
Should.Throw<ArgumentOutOfRangeException>(() => BcdCodec.Encode32(100_000_000))
.ParamName.ShouldBe("value");
}
// ── Decode32 ────────────────────────────────────────────────────────────
[Fact]
public void Decode32_LowHigh_5678_1234_Returns_12345678()
=> BcdCodec.Decode32(0x5678, 0x1234).ShouldBe(12_345_678);
[Fact]
public void Decode32_BadNibble_InLow_Throws()
{
// Low word has a bad nibble; Decode32 must propagate the FormatException.
Should.Throw<FormatException>(() => BcdCodec.Decode32(0xABCD, 0x1234));
}
[Fact]
public void Decode32_BadNibble_InHigh_Throws()
{
Should.Throw<FormatException>(() => BcdCodec.Decode32(0x5678, 0xABCD));
}
// ── Round-trip 16-bit ────────────────────────────────────────────────────
/// <summary>
/// Dense round-trip: boundary values plus every 100th value in [0, 9999].
/// Ensures Decode16(Encode16(v)) == v for all practical inputs.
/// </summary>
[Theory]
[MemberData(nameof(RoundTrip16Values))]
public void RoundTrip16_AllValuesUnder10000(int value)
=> BcdCodec.Decode16(BcdCodec.Encode16(value)).ShouldBe(value);
public static IEnumerable<object[]> RoundTrip16Values()
{
// Every 100th value (0, 100, 200, … 9900) — covers 0 as boundary automatically
for (int v = 0; v <= 9999; v += 100)
yield return [v];
// Additional boundary values not already hit by the stride-100 loop
yield return [1];
yield return [9];
yield return [99];
yield return [999];
yield return [9999];
// Some spot-check midpoints
yield return [1234];
yield return [5678];
yield return [4321];
}
// ── Round-trip 32-bit ────────────────────────────────────────────────────
[Theory]
[InlineData(0)]
[InlineData(1)]
[InlineData(9999)]
[InlineData(10_000)]
[InlineData(99_999_999)]
[InlineData(12_345_678)]
[InlineData(5_000_000)]
public void RoundTrip32_RepresentativeValues(int value)
{
var (low, high) = BcdCodec.Encode32(value);
BcdCodec.Decode32(low, high).ShouldBe(value);
}
}