e719dd51c1
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>
92 lines
3.0 KiB
C#
92 lines
3.0 KiB
C#
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();
|
|
}
|
|
}
|