PR 4.7 — Host-connectivity probes (IHostConnectivityProbe scaffold)
HostStatusAggregator merges transport + per-platform host entries with change-event diffing (re-asserting same state is a no-op so a stable ScanState=Running burst doesn't fan out duplicates). PerPlatformProbeWatcher ports the legacy GalaxyRuntimeProbeManager state machine onto the gw subscription path: SubscribeBulk for `<tag>.ScanState`, idempotent SyncPlatformsAsync (subscribe new, unsubscribe dropped), and a DecodeState helper pinning bool/int/string ScanState values + bad-quality fallback. HostConnectivityForwarder is the skeleton for the gw-6 StreamSessionHealth signal — until that mxaccessgw RPC ships, PR 4.5's ReconnectSupervisor pushes transport state by calling SetTransport on session connect/disconnect. GalaxyDriver wiring (implement IHostConnectivityProbe, route OnDataChange to PerPlatformProbeWatcher, expose GetHostStatuses() / OnHostStatusChanged, push transport from supervisor) is deferred to PR 4.W to avoid conflict with the rest of the Phase 4 deferred wiring (4.5 supervisor + 4.6 DeployWatcher). Tests: 19 new - HostStatusAggregatorTests (9): empty snapshot, new-host change with Unknown predecessor, same-state silence, transition diff, snapshot reflects every host, case-insensitive host names, Remove returns true for tracked, Remove false for unknown, concurrent updates don't corrupt. - HostConnectivityForwarderTests (5): SetTransport routes under client name, transitions fire change, repeated same-state silent, empty client name throws, post-dispose throws. - PerPlatformProbeWatcherTests (5 + theory pinning DecodeState's full truth table): subscribe N platforms, idempotent re-sync, removed platforms unsubscribed + dropped from aggregator, OnProbeValueChanged routing for Running/Stopped/bad-quality/foreign-ref, Dispose unsubscribes everything. NOTE: build is currently broken because mxaccessgw/clients/dotnet/ has been removed from C:\Users\dohertj2\Desktop\mxaccessgw — this PR's source is internally consistent and isolated from the missing dependency, but the existing Driver.Galaxy code (PRs 4.1–4.6) can't compile until the .NET client is restored. Once it is, expect 116 + 19 = 135 tests in the Driver.Galaxy.Tests project. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,137 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Health;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests.Health;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for <see cref="HostStatusAggregator"/> — the merge + diff logic for the
|
||||
/// transport entry plus per-platform probe entries that
|
||||
/// <c>IHostConnectivityProbe.GetHostStatuses()</c> surfaces.
|
||||
/// </summary>
|
||||
public sealed class HostStatusAggregatorTests
|
||||
{
|
||||
private static HostConnectivityStatus Status(string host, HostState state) =>
|
||||
new(host, state, DateTime.UtcNow);
|
||||
|
||||
[Fact]
|
||||
public void Snapshot_Empty_WhenNothingTracked()
|
||||
{
|
||||
var agg = new HostStatusAggregator();
|
||||
agg.Snapshot().ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_NewHost_FiresChange_PreviousIsUnknown()
|
||||
{
|
||||
var agg = new HostStatusAggregator();
|
||||
var captured = new List<HostStatusChangedEventArgs>();
|
||||
agg.OnHostStatusChanged += (_, e) => captured.Add(e);
|
||||
|
||||
agg.Update(Status("PlatformA", HostState.Running));
|
||||
|
||||
captured.Count.ShouldBe(1);
|
||||
captured[0].HostName.ShouldBe("PlatformA");
|
||||
captured[0].OldState.ShouldBe(HostState.Unknown);
|
||||
captured[0].NewState.ShouldBe(HostState.Running);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_SameState_DoesNotFire()
|
||||
{
|
||||
var agg = new HostStatusAggregator();
|
||||
agg.Update(Status("PlatformA", HostState.Running));
|
||||
|
||||
var captured = new List<HostStatusChangedEventArgs>();
|
||||
agg.OnHostStatusChanged += (_, e) => captured.Add(e);
|
||||
|
||||
agg.Update(Status("PlatformA", HostState.Running));
|
||||
|
||||
captured.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_StateTransition_FiresChangeWithCorrectPreviousAndNew()
|
||||
{
|
||||
var agg = new HostStatusAggregator();
|
||||
agg.Update(Status("PlatformA", HostState.Running));
|
||||
|
||||
var captured = new List<HostStatusChangedEventArgs>();
|
||||
agg.OnHostStatusChanged += (_, e) => captured.Add(e);
|
||||
|
||||
agg.Update(Status("PlatformA", HostState.Stopped));
|
||||
|
||||
captured.Count.ShouldBe(1);
|
||||
captured[0].OldState.ShouldBe(HostState.Running);
|
||||
captured[0].NewState.ShouldBe(HostState.Stopped);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Snapshot_ReflectsEveryUpsertedHost()
|
||||
{
|
||||
var agg = new HostStatusAggregator();
|
||||
agg.Update(Status("Transport", HostState.Running));
|
||||
agg.Update(Status("PlatformA", HostState.Running));
|
||||
agg.Update(Status("PlatformB", HostState.Stopped));
|
||||
|
||||
var snap = agg.Snapshot();
|
||||
|
||||
snap.Count.ShouldBe(3);
|
||||
snap.Select(s => s.HostName).OrderBy(x => x).ShouldBe(new[] { "PlatformA", "PlatformB", "Transport" });
|
||||
snap.First(s => s.HostName == "PlatformB").State.ShouldBe(HostState.Stopped);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_HostNameComparison_IsCaseInsensitive()
|
||||
{
|
||||
var agg = new HostStatusAggregator();
|
||||
var captured = new List<HostStatusChangedEventArgs>();
|
||||
agg.OnHostStatusChanged += (_, e) => captured.Add(e);
|
||||
|
||||
agg.Update(Status("PlatformA", HostState.Running));
|
||||
agg.Update(Status("platforma", HostState.Stopped)); // same host, different case
|
||||
|
||||
captured.Count.ShouldBe(2);
|
||||
captured[1].OldState.ShouldBe(HostState.Running);
|
||||
captured[1].NewState.ShouldBe(HostState.Stopped);
|
||||
agg.Snapshot().Count.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Remove_TrackedHost_ReturnsTrue_AndDropsFromSnapshot()
|
||||
{
|
||||
var agg = new HostStatusAggregator();
|
||||
agg.Update(Status("PlatformA", HostState.Running));
|
||||
agg.Remove("PlatformA").ShouldBeTrue();
|
||||
agg.Snapshot().ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Remove_UnknownHost_ReturnsFalse()
|
||||
{
|
||||
var agg = new HostStatusAggregator();
|
||||
agg.Remove("Nope").ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConcurrentUpdates_DoNotCorruptDictionary()
|
||||
{
|
||||
var agg = new HostStatusAggregator();
|
||||
const int threadCount = 8;
|
||||
const int updatesPerThread = 250;
|
||||
|
||||
var tasks = Enumerable.Range(0, threadCount).Select(t => Task.Run(() =>
|
||||
{
|
||||
for (var i = 0; i < updatesPerThread; i++)
|
||||
{
|
||||
var hostName = $"Host{(t * updatesPerThread + i) % 32}";
|
||||
var state = i % 2 == 0 ? HostState.Running : HostState.Stopped;
|
||||
agg.Update(Status(hostName, state));
|
||||
}
|
||||
})).ToArray();
|
||||
|
||||
Task.WaitAll(tasks);
|
||||
agg.Snapshot().Count.ShouldBeLessThanOrEqualTo(32);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user