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:
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user