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>
@@ -366,4 +366,35 @@ public sealed class ReloadValidatorTests
Assert.False(valid);
Assert.Contains(errors, e => e.Contains("AdminPushIntervalMs"));
}
[Fact]
public void Validate_AdminPushIntervalMs_AboveUpperBound_Fails()
{
// The soft upper bound (60 s) catches a seconds-as-milliseconds typo that
// would make the "live" dashboard feed effectively non-live.
var opts = new MbproxyOptions
{
Plcs = [MakePlc("PLC-A", 5020)],
AdminPushIntervalMs = 60_001,
};
bool valid = ReloadValidator.Validate(opts, out var errors);
Assert.False(valid);
Assert.Contains(errors, e => e.Contains("AdminPushIntervalMs"));
}
[Fact]
public void Validate_AdminPushIntervalMs_AtUpperBound_Passes()
{
var opts = new MbproxyOptions
{
Plcs = [MakePlc("PLC-A", 5020)],
AdminPushIntervalMs = 60_000,
};
bool valid = ReloadValidator.Validate(opts, out var errors);
Assert.True(valid, string.Join("; ", errors));
}
}