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>
This commit is contained in:
@@ -0,0 +1,158 @@
|
||||
using Mbproxy.Configuration;
|
||||
using Mbproxy.Options;
|
||||
using Xunit;
|
||||
|
||||
namespace Mbproxy.Tests.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="ReloadValidator.Validate"/>.
|
||||
/// Each test covers one specific failure mode or the happy path.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class ReloadValidatorTests
|
||||
{
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────────────
|
||||
|
||||
private static PlcOptions MakePlc(string name, int listenPort, string host = "127.0.0.1")
|
||||
=> new() { Name = name, ListenPort = listenPort, Host = host, Port = 502 };
|
||||
|
||||
private static MbproxyOptions MakeOptions(
|
||||
PlcOptions[] plcs,
|
||||
int adminPort = 8080,
|
||||
BcdTagListOptions? global = null)
|
||||
=> new()
|
||||
{
|
||||
Plcs = plcs,
|
||||
AdminPort = adminPort,
|
||||
BcdTags = global ?? new BcdTagListOptions(),
|
||||
};
|
||||
|
||||
// ── 1. Duplicate PLC name → fails ────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Validate_DuplicatePlcName_Fails()
|
||||
{
|
||||
var opts = MakeOptions([
|
||||
MakePlc("PLC-A", 5020),
|
||||
MakePlc("PLC-A", 5021), // same name
|
||||
]);
|
||||
|
||||
bool valid = ReloadValidator.Validate(opts, out var errors);
|
||||
|
||||
Assert.False(valid);
|
||||
Assert.Contains(errors, e => e.Contains("PLC-A") && e.Contains("uplicate"));
|
||||
}
|
||||
|
||||
// ── 2. Duplicate ListenPort → fails ──────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Validate_DuplicateListenPort_Fails()
|
||||
{
|
||||
var opts = MakeOptions([
|
||||
MakePlc("PLC-A", 5020),
|
||||
MakePlc("PLC-B", 5020), // same port
|
||||
]);
|
||||
|
||||
bool valid = ReloadValidator.Validate(opts, out var errors);
|
||||
|
||||
Assert.False(valid);
|
||||
Assert.Contains(errors, e => e.Contains("5020") && e.Contains("uplicate"));
|
||||
}
|
||||
|
||||
// ── 3. AdminPort collides with a PLC's ListenPort → fails ────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Validate_AdminPortCollidesWith_PlcListenPort_Fails()
|
||||
{
|
||||
var opts = MakeOptions(
|
||||
plcs: [MakePlc("PLC-A", 5020)],
|
||||
adminPort: 5020); // collides with PLC-A
|
||||
|
||||
bool valid = ReloadValidator.Validate(opts, out var errors);
|
||||
|
||||
Assert.False(valid);
|
||||
Assert.Contains(errors, e => e.Contains("AdminPort") && e.Contains("5020"));
|
||||
}
|
||||
|
||||
// ── 4. Per-PLC BCD map build error → fails ────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Validate_PerPlc_BcdMapBuildError_Fails()
|
||||
{
|
||||
// A 32-bit tag at address 100 and a 16-bit tag at 101 collide on high register.
|
||||
var global = new BcdTagListOptions
|
||||
{
|
||||
Global =
|
||||
[
|
||||
new BcdTagOptions { Address = 100, Width = 32 },
|
||||
new BcdTagOptions { Address = 101, Width = 16 }, // overlaps 100's high register
|
||||
],
|
||||
};
|
||||
var opts = MakeOptions([MakePlc("PLC-A", 5020)], global: global);
|
||||
|
||||
bool valid = ReloadValidator.Validate(opts, out var errors);
|
||||
|
||||
Assert.False(valid);
|
||||
Assert.Contains(errors, e => e.Contains("PLC-A"));
|
||||
}
|
||||
|
||||
// ── 5. Port out of range → fails ─────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Validate_PortOutOfRange_Fails()
|
||||
{
|
||||
// ListenPort 0 is below the valid [1, 65535] range.
|
||||
var opts = MakeOptions([MakePlc("PLC-A", 0)]);
|
||||
|
||||
bool valid = ReloadValidator.Validate(opts, out var errors);
|
||||
|
||||
Assert.False(valid);
|
||||
Assert.Contains(errors, e => e.Contains("0") && e.Contains("range"));
|
||||
}
|
||||
|
||||
// ── 5b. AdminPort out of range → fails ───────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Validate_AdminPortOutOfRange_Fails()
|
||||
{
|
||||
var opts = MakeOptions([MakePlc("PLC-A", 5020)], adminPort: 70000);
|
||||
|
||||
bool valid = ReloadValidator.Validate(opts, out var errors);
|
||||
|
||||
Assert.False(valid);
|
||||
Assert.Contains(errors, e => e.Contains("70000") && e.Contains("range"));
|
||||
}
|
||||
|
||||
// ── 6. Happy path → passes ───────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Validate_HappyPath_Passes()
|
||||
{
|
||||
var global = new BcdTagListOptions
|
||||
{
|
||||
Global = [new BcdTagOptions { Address = 1072, Width = 16 }],
|
||||
};
|
||||
var opts = MakeOptions(
|
||||
plcs: [MakePlc("PLC-A", 5020), MakePlc("PLC-B", 5021)],
|
||||
adminPort: 8080,
|
||||
global: global);
|
||||
|
||||
bool valid = ReloadValidator.Validate(opts, out var errors);
|
||||
|
||||
Assert.True(valid);
|
||||
Assert.Empty(errors);
|
||||
}
|
||||
|
||||
// ── 7. Empty PLC name → fails ────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Validate_EmptyPlcName_Fails()
|
||||
{
|
||||
var opts = MakeOptions([MakePlc("", 5020)]);
|
||||
|
||||
bool valid = ReloadValidator.Validate(opts, out var errors);
|
||||
|
||||
Assert.False(valid);
|
||||
Assert.Contains(errors, e => e.Contains("non-empty"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user