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; /// /// Review 2026-10-21 G-4: the watcher's probe handles must live in the driver's /// under the reserved id, follow the platform set, be /// rebound on , and leave on stop. /// public sealed class PerPlatformProbeWatcherRegistryTests { private sealed class FakeSubscriber : IGalaxySubscriber { private int _next; public List Unsubscribes { get; } = []; public Task> SubscribeBulkAsync( IReadOnlyList fullReferences, int bufferedUpdateIntervalMs, CancellationToken cancellationToken) => Task.FromResult>( [.. fullReferences.Select(r => new SubscribeResult { TagAddress = r, ItemHandle = ++_next, WasSuccessful = true })]); public Task UnsubscribeBulkAsync(IReadOnlyList itemHandles, CancellationToken cancellationToken) { Unsubscribes.Add([.. itemHandles]); return Task.CompletedTask; } public async IAsyncEnumerable 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)]; /// Synced platforms resolve through the registry under the reserved id; removed ones stop resolving. [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"); } /// Resubscribe replaces the handles and rebinds: old handles stop resolving, new ones resolve. [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); } /// StopAsync unsubscribes the live handles and removes the reserved entry. [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 } /// A watcher without a registry (the decoding-table unit tests) still works and registers nothing. [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); } }