2545237973
Closes the 5 "easily addable" W3 test gaps left after the prior W3 commit; the 5 race-hard gaps remain documented as known omissions per the plan. Tests: 382 pass / 0 fail (baseline 378 + 4 net new methods — the supervisor runtime-fault test replaces the existing placeholder). #11 BcdCodecTests.Encode16_IntMinValue_Throws_OutOfRange_NoArithmeticSurprise Locks the (uint)value > Max16 boundary check against int.MinValue. The cast becomes 0x80000000 which is well above 9999, so the throw fires cleanly. Prevents regression to a two-sided int comparison that would underflow. #15 BcdPduPipelineTests.FC03_Request_QtyAbove128_AtNonBcdAddress_PassesThroughUnchanged DL205/DL260 caps FC03/FC04 at qty=128 (DL260/dl205.md). The proxy must NOT truncate the qty field — passing through unchanged lets the PLC's own validator return exception 03 to the client (transparent contract for FCs/addresses the rewriter doesn't own). #4 SupervisorTests.Supervisor_RuntimeFault_OnRunningListener_RecoversAndRebinds Replaces the previous placeholder. Genuinely faults the running listener mid-life by stopping its underlying TcpListener via reflection (the single externally-observable hook to force the accept loop's AcceptAsync to throw ObjectDisposedException). Asserts the supervisor transitions to Recovering, re-binds via the Polly pipeline, and bumps RecoveryAttempts. #10 HotReloadE2ETests.E2E_ReadCoalescingEnabled_FlipAtRuntime_PropagatesToOptionsMonitor Validates that flipping Mbproxy.Resilience.ReadCoalescing.Enabled at runtime via hot-reload propagates through the live IOptionsMonitor. The W2.1 fix wires the accessor through to add/restart supervisors; the multiplexer reads it per-PDU (unit-tested separately). Proving IOptionsMonitor sees the new value is sufficient for the contract. #16 ConfigReconcilerTests.Apply_ManyConcurrentReloads_With_PlcChurn_NoCorruption Stress-tests the W2.3 ConcurrentDictionary fix. 16 concurrent applies cycle through 8 distinct PLC rosters, driving Add+Remove churn against the live supervisor dict. Without W2.3 the inner Task.WhenAll continuations would corrupt Dictionary<,> and crash with KeyNotFoundException / ArgumentException. Asserts every apply succeeds, no orphan supervisors remain, and the reload counter equals 16. The 5 deterministically-race-hard gaps (#5 TxId saturation propagation, #6 coalescing factory leak under saturation, #7 backend-reader head-of-line block, #8 watchdog↔response race, #9 cascade↔new-accept race) remain open by design — reproducing those races deterministically requires test seams in production code or stress-style tests that flake on slow CI. The Wave-1 fixes are still verified at the unit-contract level (UpstreamPipeTests.TrySendResponse_WhenChannelFull, etc.). This closes everything actionable in codereviews/2026-05-14/RemediationPlan.md. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
189 lines
6.7 KiB
C#
189 lines
6.7 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");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Phase 12 (W3 test gap #11) — locks the boundary contract for the `(uint)value > Max16`
|
|
/// range check. `int.MinValue` cast to `uint` becomes `0x80000000`, which is well above
|
|
/// `Max16` (= 9999), so the throw fires cleanly without arithmetic surprise. Prevents
|
|
/// regressions if the bounds check is ever rewritten with a two-sided int comparison
|
|
/// that would underflow on extreme negatives.
|
|
/// </summary>
|
|
[Fact]
|
|
public void Encode16_IntMinValue_Throws_OutOfRange_NoArithmeticSurprise()
|
|
{
|
|
Should.Throw<ArgumentOutOfRangeException>(() => BcdCodec.Encode16(int.MinValue))
|
|
.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);
|
|
}
|
|
}
|