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,122 @@
|
||||
using Mbproxy.Admin;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace Mbproxy.Tests.Admin;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="StatusHtmlRenderer"/>.
|
||||
/// All tests are pure: no network, no host, no DI.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class StatusHtmlRendererTests
|
||||
{
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
private static StatusResponse MakeStatus(
|
||||
IReadOnlyList<PlcStatus>? plcs = null,
|
||||
int uptimeSeconds = 42,
|
||||
string version = "1.2.3")
|
||||
{
|
||||
var service = new ServiceFields(
|
||||
UptimeSeconds: uptimeSeconds,
|
||||
Version: version,
|
||||
ConfigLastReloadUtc: null,
|
||||
ConfigReloadCount: 0,
|
||||
ConfigReloadRejectedCount: 0);
|
||||
|
||||
var listeners = new ListenersAggregate(Bound: plcs?.Count ?? 0, Configured: plcs?.Count ?? 0);
|
||||
return new StatusResponse(service, listeners, plcs ?? []);
|
||||
}
|
||||
|
||||
private static PlcStatus MakePlc(
|
||||
string name = "PLC-A",
|
||||
string state = "bound",
|
||||
string? lastBindError = null,
|
||||
int recoveryAttempts = 0,
|
||||
IReadOnlyList<ClientSnapshot>? clients = null)
|
||||
{
|
||||
var noClients = (IReadOnlyList<ClientSnapshot>)[];
|
||||
return new PlcStatus(
|
||||
Name: name,
|
||||
Host: "10.0.0.1",
|
||||
ListenPort: 5020,
|
||||
Listener: new PlcListenerStatus(state, lastBindError, recoveryAttempts),
|
||||
Clients: new PlcClientsStatus(clients?.Count ?? 0, clients ?? noClients),
|
||||
Pdus: new PlcPdusStatus(100, new FcCounts(50, 10, 20, 15, 5), 30, 2),
|
||||
Backend: new PlcBackendStatus(
|
||||
ConnectsSuccess: 0, ConnectsFailed: 0,
|
||||
ExceptionsByCode: new ExceptionCounts(1, 0, 0, 0),
|
||||
LastRoundTripMs: 3.5,
|
||||
InFlight: 0, MaxInFlight: 0, TxIdWraps: 0,
|
||||
DisconnectCascades: 0, QueueDepth: 0),
|
||||
Bytes: new PlcBytesStatus(1024, 2048));
|
||||
}
|
||||
|
||||
// ── 1. Valid HTML with meta-refresh for a single PLC ─────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Render_OnePlc_ProducesValidHtml_WithMetaRefresh()
|
||||
{
|
||||
var status = MakeStatus([MakePlc("PLC-A", "bound")]);
|
||||
|
||||
string html = StatusHtmlRenderer.Render(status);
|
||||
|
||||
html.ShouldContain("<meta http-equiv=\"refresh\" content=\"5\">");
|
||||
html.ShouldContain("<!DOCTYPE html>");
|
||||
html.ShouldContain("</html>");
|
||||
html.ShouldContain("PLC-A");
|
||||
html.ShouldContain("bound");
|
||||
}
|
||||
|
||||
// ── 2. Recovering state highlights error ─────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Render_RecoveringPlc_HighlightsState()
|
||||
{
|
||||
var plc = MakePlc("PLC-B", "recovering", lastBindError: "Address already in use", recoveryAttempts: 3);
|
||||
var status = MakeStatus([plc]);
|
||||
|
||||
string html = StatusHtmlRenderer.Render(status);
|
||||
|
||||
// State should be orange.
|
||||
html.ShouldContain("class=\"recovering\"");
|
||||
html.ShouldContain("Address already in use");
|
||||
html.ShouldContain("attempt 3");
|
||||
}
|
||||
|
||||
// ── 3. Page weight under 50 KB for 54 PLCs ───────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Render_PageWeightUnder50KB_For54Plcs()
|
||||
{
|
||||
const int plcCount = 54;
|
||||
|
||||
// Build 54 realistic PLC rows with 2 clients each.
|
||||
var plcs = new List<PlcStatus>(plcCount);
|
||||
for (int i = 0; i < plcCount; i++)
|
||||
{
|
||||
var clients = new List<ClientSnapshot>
|
||||
{
|
||||
new ClientSnapshot($"10.0.0.{i + 1}:49123", DateTimeOffset.UtcNow, 42),
|
||||
new ClientSnapshot($"10.0.0.{i + 1}:49124", DateTimeOffset.UtcNow, 17),
|
||||
};
|
||||
|
||||
plcs.Add(MakePlc(
|
||||
name: $"Line{i / 10 + 1}-Station{i % 10 + 1:D2}",
|
||||
state: i % 5 == 0 ? "recovering" : "bound",
|
||||
lastBindError: i % 5 == 0 ? "EADDRINUSE" : null,
|
||||
recoveryAttempts: i % 5 == 0 ? 2 : 0,
|
||||
clients: clients));
|
||||
}
|
||||
|
||||
var status = MakeStatus(plcs);
|
||||
|
||||
string html = StatusHtmlRenderer.Render(status);
|
||||
int byteCount = System.Text.Encoding.UTF8.GetByteCount(html);
|
||||
|
||||
// Assert ≤ 50 KB.
|
||||
byteCount.ShouldBeLessThanOrEqualTo(50 * 1024,
|
||||
$"Page weight {byteCount} bytes exceeds 50 KB limit for {plcCount} PLCs");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user