Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/Health/PerPlatformProbeWatcherRegistryTests.cs
T
Joseph Doherty 1b3169273d fix(galaxy): wire ScanState probing, single-sweep discovery, liveness stamp, dispatch-loop fault
Review 2026-10-21 G-4, G-7, G-11, G-10.

- G-4: PerPlatformProbeWatcher publishes its probe handles into the
  driver's SubscriptionRegistry under the reserved ProbeSubscriptionId
  (-1), so the EventPump's fan-out finally reaches the watcher — every
  ScanState event used to be dropped as "stale after unsubscribe" and
  per-platform host status never updated. ResubscribeAllAsync delegates
  the reserved entry to the watcher's new ResubscribeAsync, so the probe
  survives a reconnect; StopAsync removes the entry. Probe traffic is
  routed by the reserved handle and never reaches the public OnDataChange.
  The watcher is also built on the seam path (subscriber only), which is
  what makes the wiring testable without a gateway.
- G-7: GalaxyDiscoverer.DiscoverAsync returns the hierarchy it fetched and
  the driver selects its $WinPlatform/$AppEngine probe targets from that
  same sweep instead of asking the gateway for the whole galaxy twice.
- G-11: OnPumpDataChange stamps LastSuccessfulRead from inbound stream
  data, throttled (1 s default), so the comms-loss watchdog has honest
  liveness evidence under a Degraded supervisor.
- G-10: an EventPump dispatch-loop exit that is not a cancellation now
  reports a stream fault so the supervisor recreates the pump, instead of
  leaving a deaf pump reporting Healthy.

Refs #735

Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
2026-10-22 06:35:48 -04:00

113 lines
5.4 KiB
C#

using System.Runtime.CompilerServices;
using Shouldly;
using Xunit;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Health;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests.Health;
/// <summary>
/// Review 2026-10-21 G-4: the watcher's probe handles must live in the driver's
/// <see cref="SubscriptionRegistry"/> under the reserved id, follow the platform set, be
/// rebound on <see cref="PerPlatformProbeWatcher.ResubscribeAsync"/>, and leave on stop.
/// </summary>
public sealed class PerPlatformProbeWatcherRegistryTests
{
private sealed class FakeSubscriber : IGalaxySubscriber
{
private int _next;
public List<int[]> Unsubscribes { get; } = [];
public Task<IReadOnlyList<SubscribeResult>> SubscribeBulkAsync(
IReadOnlyList<string> fullReferences, int bufferedUpdateIntervalMs, CancellationToken cancellationToken)
=> Task.FromResult<IReadOnlyList<SubscribeResult>>(
[.. fullReferences.Select(r => new SubscribeResult { TagAddress = r, ItemHandle = ++_next, WasSuccessful = true })]);
public Task UnsubscribeBulkAsync(IReadOnlyList<int> itemHandles, CancellationToken cancellationToken)
{
Unsubscribes.Add([.. itemHandles]);
return Task.CompletedTask;
}
public async IAsyncEnumerable<MxEvent> StreamEventsAsync([EnumeratorCancellation] CancellationToken cancellationToken)
{
await Task.Delay(Timeout.Infinite, cancellationToken);
yield break;
}
}
private static (long Id, string Ref)[] Resolve(SubscriptionRegistry registry, int handle) =>
[.. registry.ResolveSubscribers(handle)];
/// <summary>Synced platforms resolve through the registry under the reserved id; removed ones stop resolving.</summary>
[Fact]
public async Task Sync_registers_probe_handles_under_the_reserved_id_and_tracks_removals()
{
var registry = new SubscriptionRegistry();
using var watcher = new PerPlatformProbeWatcher(new FakeSubscriber(), new HostStatusAggregator(), registry);
await watcher.SyncPlatformsAsync(["A", "B"], CancellationToken.None);
registry.Contains(SubscriptionRegistry.ProbeSubscriptionId).ShouldBeTrue();
var bindings = watcher.CurrentBindings;
bindings.Count.ShouldBe(2);
foreach (var b in bindings)
{
Resolve(registry, b.ItemHandle).ShouldBe([(SubscriptionRegistry.ProbeSubscriptionId, b.FullReference)]);
}
var bHandle = bindings.Single(b => b.FullReference == "B.ScanState").ItemHandle;
await watcher.SyncPlatformsAsync(["A"], CancellationToken.None);
Resolve(registry, bHandle).ShouldBeEmpty("a removed platform's handle must leave the fan-out map");
var aHandle = bindings.Single(b => b.FullReference == "A.ScanState").ItemHandle;
Resolve(registry, aHandle).ShouldHaveSingleItem("positive control: the kept platform still resolves");
}
/// <summary>Resubscribe replaces the handles and rebinds: old handles stop resolving, new ones resolve.</summary>
[Fact]
public async Task Resubscribe_rebinds_the_registry_to_the_fresh_handles()
{
var registry = new SubscriptionRegistry();
using var watcher = new PerPlatformProbeWatcher(new FakeSubscriber(), new HostStatusAggregator(), registry);
await watcher.SyncPlatformsAsync(["A"], CancellationToken.None);
var oldHandle = watcher.CurrentBindings.Single().ItemHandle;
(await watcher.ResubscribeAsync(CancellationToken.None)).ShouldBe(1);
var newHandle = watcher.CurrentBindings.Single().ItemHandle;
newHandle.ShouldNotBe(oldHandle);
Resolve(registry, oldHandle).ShouldBeEmpty();
Resolve(registry, newHandle).ShouldBe([(SubscriptionRegistry.ProbeSubscriptionId, "A.ScanState")]);
registry.TrackedSubscriptionCount.ShouldBe(1);
}
/// <summary>StopAsync unsubscribes the live handles and removes the reserved entry.</summary>
[Fact]
public async Task StopAsync_unsubscribes_and_removes_the_reserved_entry()
{
var registry = new SubscriptionRegistry();
var subscriber = new FakeSubscriber();
var watcher = new PerPlatformProbeWatcher(subscriber, new HostStatusAggregator(), registry);
await watcher.SyncPlatformsAsync(["A", "B"], CancellationToken.None);
var handles = watcher.CurrentBindings.Select(b => b.ItemHandle).OrderBy(h => h).ToArray();
await watcher.StopAsync(CancellationToken.None);
subscriber.Unsubscribes.ShouldHaveSingleItem().OrderBy(h => h).ShouldBe(handles);
registry.Contains(SubscriptionRegistry.ProbeSubscriptionId).ShouldBeFalse();
registry.TrackedItemHandleCount.ShouldBe(0);
await Should.NotThrowAsync(() => watcher.StopAsync(CancellationToken.None)); // idempotent
}
/// <summary>A watcher without a registry (the decoding-table unit tests) still works and registers nothing.</summary>
[Fact]
public async Task Without_a_registry_the_watcher_keeps_its_handles_private()
{
using var watcher = new PerPlatformProbeWatcher(new FakeSubscriber(), new HostStatusAggregator());
await watcher.SyncPlatformsAsync(["A"], CancellationToken.None);
watcher.CurrentBindings.ShouldHaveSingleItem();
(await watcher.ResubscribeAsync(CancellationToken.None)).ShouldBe(1);
}
}