mbproxy: close out the dashboard code-review minor findings

Resolves the remaining Minor items from the 2026-05-15 review so the
web-UI dashboard work has no open follow-ups: a real-HubConnection
end-to-end test for the SignalR feed, stable mbproxy.admin.broadcast.*
log-event names, keyboard/aria accessibility on the fleet table,
frontend JS hardening (URL-decode guard, NaN guards, shared util.js),
reconciler<->capture-registry coverage, throwing-sink and embedded-asset
tests, broadcaster polish, and a soft upper bound on AdminPushIntervalMs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-05-16 16:36:39 -04:00
parent 374eecd205
commit 0308490aef
21 changed files with 576 additions and 67 deletions
@@ -68,13 +68,14 @@ public sealed class ConfigReconcilerTests : IAsyncDisposable
private ConfigReconciler BuildReconciler(
IOptionsMonitor<MbproxyOptions> monitor,
ServiceCounters? counters = null)
ServiceCounters? counters = null,
Mbproxy.Proxy.TagCaptureRegistry? captureRegistry = null)
{
return new ConfigReconciler(
monitor,
NullLoggerFactory.Instance,
counters ?? new ServiceCounters(),
new Mbproxy.Proxy.TagCaptureRegistry());
captureRegistry ?? new Mbproxy.Proxy.TagCaptureRegistry());
}
// The reconciler and supervisors tracked for cleanup.
@@ -347,6 +348,53 @@ public sealed class ConfigReconcilerTests : IAsyncDisposable
foreach (var s in supervisors.Values)
_supervisors.Add(s);
}
// ── Test 6: Reconciler ↔ TagCaptureRegistry wiring ────────────────────────────────────
/// <summary>
/// The reconciler owns the tag-capture lifecycle for hot-reload: a PLC added by a
/// reload must get a capture entry (<c>GetOrCreate</c>), and a PLC removed by a
/// reload must have its capture entry dropped (<c>Remove</c>). Holds a real
/// <see cref="Mbproxy.Proxy.TagCaptureRegistry"/> and asserts <c>TryGet</c> tracks
/// the roster across an add reload and a remove reload.
/// </summary>
[Fact]
public async Task Apply_AddThenRemovePlc_TagCaptureRegistryTracksRoster()
{
int portA = PickFreePort();
int portB = PickFreePort();
var plcA = MakePlc("A", portA);
var plcB = MakePlc("B", portB);
var initial = MakeOptions([plcA]);
var withB = MakeOptions([plcA, plcB]);
var supA = BuildSupervisor(plcA);
_supervisors.Add(supA);
await supA.StartAsync(CancellationToken.None);
var supervisors = new ConcurrentDictionary<string, PlcListenerSupervisor>(StringComparer.Ordinal)
{
["A"] = supA,
};
var registry = new Mbproxy.Proxy.TagCaptureRegistry();
var monitor = new FakeOptionsMonitor(initial);
var reconciler = BuildReconciler(monitor, captureRegistry: registry);
_reconcilers.Add(reconciler);
reconciler.Attach(supervisors, initial);
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
// Reload that adds PLC-B → the registry must gain a capture for B.
Assert.True(await reconciler.ApplyAsync(withB, cts.Token));
Assert.True(registry.TryGet("B", out _), "adding PLC-B must create its tag-value capture");
_supervisors.Add(supervisors["B"]);
// Reload that removes PLC-B → the registry must drop B's capture.
Assert.True(await reconciler.ApplyAsync(initial, cts.Token));
Assert.False(registry.TryGet("B", out _), "removing PLC-B must drop its tag-value capture");
}
}
/// <summary>