mbproxy: replace status page with a live SignalR web dashboard
The single auto-refreshing zero-JS status page gave operators a 25-column wall and no way to drill into one connection. This adds a Bootstrap fleet dashboard (filterable/sortable KPI table) and a per-PLC detail page with a real-time debug view of raw PLC-side BCD vs. decoded client-side values, streamed live over a SignalR feed. The debug view is fed by an on-demand per-tag value capture, armed only while a detail page is open. All assets (Bootstrap, SignalR client, fonts) are embedded so the UI works unchanged on firewalled networks; GET /status.json is untouched for scrapers. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -168,10 +168,10 @@ public sealed class AdminEndpointTests
|
||||
after.ShouldBeGreaterThan(before, "partialBcdWarnings should increment after partial overlap read");
|
||||
}
|
||||
|
||||
// ── 4. GET / returns 200 text/html with meta-refresh ─────────────────────
|
||||
// ── 4. GET / and GET /plc/{name} serve the embedded SPA shells ───────────
|
||||
|
||||
[Fact(Timeout = 5_000)]
|
||||
public async Task Get_Root_ReturnsHtml_WithMetaRefresh()
|
||||
public async Task Get_Root_ReturnsDashboardShell()
|
||||
{
|
||||
int adminPort = PickFreePort();
|
||||
int proxyPort = PickFreePort();
|
||||
@@ -190,8 +190,79 @@ public sealed class AdminEndpointTests
|
||||
response.Content.Headers.ContentType?.MediaType.ShouldBe("text/html");
|
||||
|
||||
string body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
|
||||
body.ShouldContain("<meta http-equiv=\"refresh\" content=\"5\">");
|
||||
body.ShouldContain("<!DOCTYPE html>");
|
||||
body.ShouldContain("<!doctype html>");
|
||||
body.ShouldContain("/assets/dashboard.js");
|
||||
}
|
||||
|
||||
[Fact(Timeout = 5_000)]
|
||||
public async Task Get_PlcDetailRoute_ReturnsDetailShell()
|
||||
{
|
||||
int adminPort = PickFreePort();
|
||||
int proxyPort = PickFreePort();
|
||||
|
||||
var host = BuildHost(adminPort: adminPort, simHost: "127.0.0.1", simPort: 502,
|
||||
proxyPort: proxyPort, bcd16Addresses: []);
|
||||
await using var _ = new AsyncHostDispose(host);
|
||||
using var startCts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
||||
await host.StartAsync(startCts.Token);
|
||||
|
||||
await WaitForAdminAsync(adminPort);
|
||||
|
||||
var response = await HttpClient.GetAsync($"http://127.0.0.1:{adminPort}/plc/anything",
|
||||
TestContext.Current.CancellationToken);
|
||||
response.StatusCode.ShouldBe(System.Net.HttpStatusCode.OK);
|
||||
response.Content.Headers.ContentType?.MediaType.ShouldBe("text/html");
|
||||
|
||||
string body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
|
||||
body.ShouldContain("/assets/detail.js");
|
||||
}
|
||||
|
||||
[Theory(Timeout = 5_000)]
|
||||
[InlineData("bootstrap.min.css", "text/css")]
|
||||
[InlineData("signalr.min.js", "text/javascript")]
|
||||
[InlineData("dashboard.js", "text/javascript")]
|
||||
[InlineData("theme.css", "text/css")]
|
||||
[InlineData("ibm-plex-mono-500.woff2", "font/woff2")]
|
||||
public async Task Get_Asset_ReturnsCorrectContentType(string file, string expectedType)
|
||||
{
|
||||
int adminPort = PickFreePort();
|
||||
int proxyPort = PickFreePort();
|
||||
|
||||
var host = BuildHost(adminPort: adminPort, simHost: "127.0.0.1", simPort: 502,
|
||||
proxyPort: proxyPort, bcd16Addresses: []);
|
||||
await using var _ = new AsyncHostDispose(host);
|
||||
using var startCts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
||||
await host.StartAsync(startCts.Token);
|
||||
|
||||
await WaitForAdminAsync(adminPort);
|
||||
|
||||
var response = await HttpClient.GetAsync($"http://127.0.0.1:{adminPort}/assets/{file}",
|
||||
TestContext.Current.CancellationToken);
|
||||
response.StatusCode.ShouldBe(System.Net.HttpStatusCode.OK);
|
||||
response.Content.Headers.ContentType?.MediaType.ShouldBe(expectedType);
|
||||
response.Headers.CacheControl?.ToString().ShouldContain("immutable");
|
||||
|
||||
var bytes = await response.Content.ReadAsByteArrayAsync(TestContext.Current.CancellationToken);
|
||||
bytes.Length.ShouldBeGreaterThan(0);
|
||||
}
|
||||
|
||||
[Fact(Timeout = 5_000)]
|
||||
public async Task Get_UnknownAsset_Returns404()
|
||||
{
|
||||
int adminPort = PickFreePort();
|
||||
int proxyPort = PickFreePort();
|
||||
|
||||
var host = BuildHost(adminPort: adminPort, simHost: "127.0.0.1", simPort: 502,
|
||||
proxyPort: proxyPort, bcd16Addresses: []);
|
||||
await using var _ = new AsyncHostDispose(host);
|
||||
using var startCts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
||||
await host.StartAsync(startCts.Token);
|
||||
|
||||
await WaitForAdminAsync(adminPort);
|
||||
|
||||
var response = await HttpClient.GetAsync($"http://127.0.0.1:{adminPort}/assets/no-such-file.js",
|
||||
TestContext.Current.CancellationToken);
|
||||
response.StatusCode.ShouldBe(System.Net.HttpStatusCode.NotFound);
|
||||
}
|
||||
|
||||
// ── 5. AdminPort collision → proxy still runs + bind.failed logged ────────
|
||||
@@ -334,9 +405,9 @@ public sealed class AdminEndpointTests
|
||||
|
||||
/// <summary>
|
||||
/// Verifies the admin endpoint rejects non-GET methods (POST / PUT / DELETE)
|
||||
/// with HTTP 405 Method Not Allowed. The design intentionally exposes only `GET /`
|
||||
/// and `GET /status.json`; this test guards against an accidental MapPost/Map* being
|
||||
/// added later.
|
||||
/// against the read-only routes `GET /` and `GET /status.json` with HTTP 405.
|
||||
/// (The SignalR hub at `/hub/status` legitimately accepts POST and is not tested
|
||||
/// here.) Guards against an accidental MapPost/Map* being added later.
|
||||
/// </summary>
|
||||
[Theory(Timeout = 5_000)]
|
||||
[InlineData("POST")]
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Security.Claims;
|
||||
using Mbproxy.Admin;
|
||||
using Microsoft.AspNetCore.Http.Features;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
|
||||
namespace Mbproxy.Tests.Admin;
|
||||
|
||||
/// <summary>
|
||||
/// Minimal hand-written test doubles for the SignalR surface <see cref="StatusHub"/>
|
||||
/// and <see cref="StatusBroadcaster"/> touch. The project carries no mocking framework,
|
||||
/// so these record just enough to assert behaviour.
|
||||
/// </summary>
|
||||
internal sealed class FakeHubCallerContext : HubCallerContext
|
||||
{
|
||||
public FakeHubCallerContext(string connectionId) => ConnectionId = connectionId;
|
||||
|
||||
public override string ConnectionId { get; }
|
||||
public override string? UserIdentifier => null;
|
||||
public override ClaimsPrincipal? User => null;
|
||||
public override IDictionary<object, object?> Items { get; } = new Dictionary<object, object?>();
|
||||
public override IFeatureCollection Features { get; } = new FeatureCollection();
|
||||
public override CancellationToken ConnectionAborted => CancellationToken.None;
|
||||
public override void Abort() { }
|
||||
}
|
||||
|
||||
/// <summary>Records every group join/leave so tests can assert membership changes.</summary>
|
||||
internal sealed class FakeGroupManager : IGroupManager
|
||||
{
|
||||
public List<(string ConnectionId, string Group)> Added { get; } = [];
|
||||
public List<(string ConnectionId, string Group)> Removed { get; } = [];
|
||||
|
||||
public Task AddToGroupAsync(string connectionId, string groupName, CancellationToken cancellationToken = default)
|
||||
{
|
||||
Added.Add((connectionId, groupName));
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task RemoveFromGroupAsync(string connectionId, string groupName, CancellationToken cancellationToken = default)
|
||||
{
|
||||
Removed.Add((connectionId, groupName));
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Records every push so <see cref="StatusBroadcaster"/> tests can assert routing.</summary>
|
||||
internal sealed class FakeStatusPushSink : IStatusPushSink
|
||||
{
|
||||
private readonly ConcurrentBag<StatusResponse> _fleet = [];
|
||||
private readonly ConcurrentBag<(string Plc, PlcDetailResponse Detail)> _plc = [];
|
||||
|
||||
public IReadOnlyCollection<StatusResponse> FleetPushes => _fleet;
|
||||
public IReadOnlyCollection<(string Plc, PlcDetailResponse Detail)> PlcPushes => _plc;
|
||||
|
||||
public Task PushFleetAsync(StatusResponse snapshot, CancellationToken ct)
|
||||
{
|
||||
_fleet.Add(snapshot);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task PushPlcAsync(string plcName, PlcDetailResponse detail, CancellationToken ct)
|
||||
{
|
||||
_plc.Add((plcName, detail));
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
using Mbproxy.Admin;
|
||||
using Mbproxy.Bcd;
|
||||
using Mbproxy.Options;
|
||||
using Mbproxy.Proxy;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Serilog;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace Mbproxy.Tests.Admin;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="StatusBroadcaster"/>'s push-cycle logic — fleet always
|
||||
/// pushed, per-PLC pushed only for PLCs with a detail-page subscriber, and every
|
||||
/// capture disarmed on stop. The SignalR sink is faked; a real
|
||||
/// <see cref="StatusSnapshotBuilder"/> is resolved from a minimal in-process host.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class StatusBroadcasterTests
|
||||
{
|
||||
private sealed record Harness(
|
||||
IHost Host,
|
||||
StatusBroadcaster Broadcaster,
|
||||
FakeStatusPushSink Sink,
|
||||
StatusSnapshotBuilder Builder,
|
||||
TagCaptureRegistry Registry,
|
||||
PlcSubscriptionTracker Tracker) : IAsyncDisposable
|
||||
{
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await Broadcaster.DisposeAsync();
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
try { await Host.StopAsync(cts.Token); } catch { }
|
||||
Host.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<Harness> BuildAsync()
|
||||
{
|
||||
var hostBuilder = Host.CreateApplicationBuilder();
|
||||
hostBuilder.Configuration.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["Mbproxy:AdminPort"] = "0",
|
||||
});
|
||||
hostBuilder.Services.AddSerilog(
|
||||
new LoggerConfiguration().MinimumLevel.Fatal().CreateLogger(), dispose: false);
|
||||
hostBuilder.AddMbproxyOptions();
|
||||
hostBuilder.Services.AddSingleton<IPduPipeline, NoopPduPipeline>();
|
||||
hostBuilder.Services.AddSingleton<ProxyWorker>();
|
||||
hostBuilder.Services.AddHostedService(sp => sp.GetRequiredService<ProxyWorker>());
|
||||
hostBuilder.Services.AddSingleton<AssemblyVersionAccessor>();
|
||||
hostBuilder.Services.AddSingleton<StatusSnapshotBuilder>();
|
||||
|
||||
var host = hostBuilder.Build();
|
||||
using var startCts = new CancellationTokenSource(TimeSpan.FromSeconds(15));
|
||||
await host.StartAsync(startCts.Token);
|
||||
|
||||
var builder = host.Services.GetRequiredService<StatusSnapshotBuilder>();
|
||||
var registry = host.Services.GetRequiredService<TagCaptureRegistry>();
|
||||
var options = host.Services.GetRequiredService<IOptionsMonitor<MbproxyOptions>>();
|
||||
var tracker = new PlcSubscriptionTracker();
|
||||
var sink = new FakeStatusPushSink();
|
||||
|
||||
var broadcaster = new StatusBroadcaster(
|
||||
sink, builder, tracker, registry, options, NullLogger.Instance);
|
||||
|
||||
return new Harness(host, broadcaster, sink, builder, registry, tracker);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PushOnce_AlwaysPushesFleet()
|
||||
{
|
||||
await using var h = await BuildAsync();
|
||||
|
||||
await h.Broadcaster.PushOnceAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
h.Sink.FleetPushes.Count.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PushOnce_NoActivePlcs_SkipsPerPlcPush()
|
||||
{
|
||||
await using var h = await BuildAsync();
|
||||
|
||||
await h.Broadcaster.PushOnceAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
h.Sink.PlcPushes.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PushOnce_ActivePlc_PushesDetailWithDebugSnapshot()
|
||||
{
|
||||
await using var h = await BuildAsync();
|
||||
h.Registry.GetOrCreate("plc-x", BcdTagMap.Empty);
|
||||
h.Tracker.Add("conn-1", "plc-x");
|
||||
|
||||
await h.Broadcaster.PushOnceAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
var push = h.Sink.PlcPushes.ShouldHaveSingleItem();
|
||||
push.Plc.ShouldBe("plc-x");
|
||||
push.Detail.Debug.ShouldNotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StopAsync_DisarmsEveryCapture()
|
||||
{
|
||||
await using var h = await BuildAsync();
|
||||
h.Registry.GetOrCreate("plc-x", BcdTagMap.Empty);
|
||||
h.Registry.Arm("plc-x");
|
||||
|
||||
await h.Broadcaster.StopAsync();
|
||||
|
||||
h.Registry.TryGet("plc-x", out var capture).ShouldBeTrue();
|
||||
capture.IsArmed.ShouldBeFalse();
|
||||
}
|
||||
}
|
||||
@@ -1,128 +0,0 @@
|
||||
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, 0),
|
||||
Backend: new PlcBackendStatus(
|
||||
ConnectsSuccess: 0, ConnectsFailed: 0,
|
||||
ExceptionsByCode: new ExceptionCounts(1, 0, 0, 0, 0),
|
||||
LastRoundTripMs: 3.5,
|
||||
InFlight: 0, MaxInFlight: 0, TxIdWraps: 0,
|
||||
DisconnectCascades: 0, QueueDepth: 0,
|
||||
CoalescedHitCount: 0, CoalescedMissCount: 0,
|
||||
CoalescedResponseToDeadUpstream: 0,
|
||||
CacheHitCount: 0, CacheMissCount: 0,
|
||||
CacheInvalidations: 0, CacheEntryCount: 0, CacheBytes: 0,
|
||||
BackendHeartbeatsSent: 0, BackendHeartbeatsFailed: 0,
|
||||
BackendIdleDisconnects: 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");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
using Mbproxy.Admin;
|
||||
using Mbproxy.Bcd;
|
||||
using Mbproxy.Proxy;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace Mbproxy.Tests.Admin;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="StatusHub"/> — group joins and on-demand capture
|
||||
/// arming. Uses hand-written SignalR test doubles (see <see cref="SignalRFakes"/>);
|
||||
/// no SignalR host is started.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class StatusHubTests
|
||||
{
|
||||
private static StatusHub MakeHub(
|
||||
string connectionId,
|
||||
PlcSubscriptionTracker tracker,
|
||||
TagCaptureRegistry registry,
|
||||
out FakeGroupManager groups)
|
||||
{
|
||||
groups = new FakeGroupManager();
|
||||
return new StatusHub(tracker, registry)
|
||||
{
|
||||
Context = new FakeHubCallerContext(connectionId),
|
||||
Groups = groups,
|
||||
};
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SubscribeFleet_JoinsFleetGroup()
|
||||
{
|
||||
var hub = MakeHub("conn-1", new PlcSubscriptionTracker(), new TagCaptureRegistry(), out var groups);
|
||||
|
||||
await hub.SubscribeFleet();
|
||||
|
||||
groups.Added.ShouldContain(("conn-1", StatusHub.FleetGroup));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SubscribePlc_JoinsPlcGroup_AndArmsCapture()
|
||||
{
|
||||
var registry = new TagCaptureRegistry();
|
||||
registry.GetOrCreate("plc-1", BcdTagMap.Empty);
|
||||
var hub = MakeHub("conn-1", new PlcSubscriptionTracker(), registry, out var groups);
|
||||
|
||||
await hub.SubscribePlc("plc-1");
|
||||
|
||||
groups.Added.ShouldContain(("conn-1", StatusHub.PlcGroup("plc-1")));
|
||||
registry.TryGet("plc-1", out var capture).ShouldBeTrue();
|
||||
capture.IsArmed.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SecondSubscriber_FirstLeaveKeepsArmed_LastLeaveDisarms()
|
||||
{
|
||||
var tracker = new PlcSubscriptionTracker();
|
||||
var registry = new TagCaptureRegistry();
|
||||
registry.GetOrCreate("plc-1", BcdTagMap.Empty);
|
||||
|
||||
var hub1 = MakeHub("conn-1", tracker, registry, out _);
|
||||
var hub2 = MakeHub("conn-2", tracker, registry, out _);
|
||||
|
||||
await hub1.SubscribePlc("plc-1");
|
||||
await hub2.SubscribePlc("plc-1");
|
||||
|
||||
registry.TryGet("plc-1", out var capture).ShouldBeTrue();
|
||||
capture.IsArmed.ShouldBeTrue();
|
||||
|
||||
// First viewer leaves — a second viewer remains, so capture stays armed.
|
||||
await hub1.OnDisconnectedAsync(null);
|
||||
capture.IsArmed.ShouldBeTrue("capture must stay armed while another detail page is open");
|
||||
|
||||
// Last viewer leaves — capture disarms.
|
||||
await hub2.OnDisconnectedAsync(null);
|
||||
capture.IsArmed.ShouldBeFalse("capture must disarm when the last viewer leaves");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SubscribePlc_UnknownPlc_DoesNotThrow_AndArmsNothing()
|
||||
{
|
||||
var registry = new TagCaptureRegistry(); // no captures registered
|
||||
var hub = MakeHub("conn-1", new PlcSubscriptionTracker(), registry, out var groups);
|
||||
|
||||
await Should.NotThrowAsync(async () => await hub.SubscribePlc("ghost"));
|
||||
|
||||
groups.Added.ShouldContain(("conn-1", StatusHub.PlcGroup("ghost")));
|
||||
registry.TryGet("ghost", out _).ShouldBeFalse();
|
||||
}
|
||||
}
|
||||
@@ -218,18 +218,60 @@ public sealed class StatusSnapshotBuilderTests
|
||||
result.Service.ConfigReloadCount.ShouldBe(1);
|
||||
}
|
||||
|
||||
// ── 7. BuildDebug: unknown PLC → empty, disarmed snapshot ────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task BuildDebug_UnknownPlc_ReturnsEmptyDisarmedSnapshot()
|
||||
{
|
||||
var (host, builder) = await BuildAsync([]);
|
||||
await using var _ = new AsyncHostDispose(host);
|
||||
|
||||
var debug = builder.BuildDebug("no-such-plc");
|
||||
|
||||
debug.CaptureArmed.ShouldBeFalse();
|
||||
debug.Tags.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
// ── 8. BuildDebug: configured PLC → one row per BCD tag, no traffic ──────
|
||||
|
||||
[Fact]
|
||||
public async Task BuildDebug_ConfiguredPlc_ReturnsTagRows_DisarmedByDefault()
|
||||
{
|
||||
int port = PickFreePort();
|
||||
var (host, builder) = await BuildAsync([("PLC-A", port)], bcd16Address: 1072);
|
||||
await using var _ = new AsyncHostDispose(host);
|
||||
|
||||
await WaitForAsync(() => CanConnect(port), TimeSpan.FromSeconds(5), "PLC-A should bind");
|
||||
|
||||
var debug = builder.BuildDebug("PLC-A");
|
||||
|
||||
debug.CaptureArmed.ShouldBeFalse(); // no detail page open
|
||||
var tag = debug.Tags.ShouldHaveSingleItem();
|
||||
tag.Address.ShouldBe(1072);
|
||||
tag.Width.ShouldBe(16);
|
||||
tag.HasValue.ShouldBeFalse();
|
||||
tag.RawHex.ShouldBe("—");
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
private static async Task<(IHost host, StatusSnapshotBuilder builder)> BuildAsync(
|
||||
(string name, int port)[] plcs,
|
||||
int startupWaitMs = 200,
|
||||
int backendPort = 502)
|
||||
int backendPort = 502,
|
||||
int? bcd16Address = null)
|
||||
{
|
||||
var config = new Dictionary<string, string?>
|
||||
{
|
||||
["Mbproxy:AdminPort"] = "0", // disable admin for unit tests
|
||||
};
|
||||
|
||||
if (bcd16Address is { } addr)
|
||||
{
|
||||
config["Mbproxy:BcdTags:Global:0:Address"] = addr.ToString();
|
||||
config["Mbproxy:BcdTags:Global:0:Width"] = "16";
|
||||
}
|
||||
|
||||
for (int i = 0; i < plcs.Length; i++)
|
||||
{
|
||||
config[$"Mbproxy:Plcs:{i}:Name"] = plcs[i].name;
|
||||
|
||||
Reference in New Issue
Block a user