using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime; namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests.Runtime; /// /// Tests for — the bookkeeping the EventPump /// uses to fan one OnDataChange event out to every driver subscription that /// observes the changed item handle. /// public sealed class SubscriptionRegistryTests { /// Verifies NextSubscriptionId() increments monotonically. [Fact] public void NextSubscriptionId_IsMonotonic() { var registry = new SubscriptionRegistryAccess(); registry.NextSubscriptionId().ShouldBe(1); registry.NextSubscriptionId().ShouldBe(2); registry.NextSubscriptionId().ShouldBe(3); } /// Verifies Register() and ResolveSubscribers() correctly store and return a single subscription. [Fact] public void Register_OneSubscription_OneTag_ResolvesSingleSubscriber() { var registry = new SubscriptionRegistryAccess(); registry.Register(42, [new TagBindingAccess("Tank.Level", 100)]); var subs = registry.ResolveSubscribers(100); subs.Count.ShouldBe(1); subs[0].SubscriptionId.ShouldBe(42); subs[0].FullReference.ShouldBe("Tank.Level"); } /// Verifies multiple subscriptions to the same tag are both indexed for fan-out. [Fact] public void Register_TwoSubscriptions_SameTag_FanOutToBoth() { var registry = new SubscriptionRegistryAccess(); registry.Register(1, [new TagBindingAccess("Tank.Level", 100)]); registry.Register(2, [new TagBindingAccess("Tank.Level", 100)]); var subs = registry.ResolveSubscribers(100); subs.Count.ShouldBe(2); subs.Select(s => s.SubscriptionId).OrderBy(x => x).ShouldBe(new[] { 1L, 2L }); } /// Verifies failed item handles (rejected by gateway) are not indexed for fan-out. [Fact] public void Register_FailedItemHandle_NotIndexedForFanOut() { var registry = new SubscriptionRegistryAccess(); registry.Register(1, [ new TagBindingAccess("Good", 100), new TagBindingAccess("Bad", 0), // gw rejected this tag ]); registry.ResolveSubscribers(100).Count.ShouldBe(1); registry.ResolveSubscribers(0).ShouldBeEmpty(); } /// Verifies Remove() drops all bindings and returns them. [Fact] public void Remove_DropsAllBindings_AndReturnsThemForUnsubscribe() { var registry = new SubscriptionRegistryAccess(); registry.Register(1, [ new TagBindingAccess("A", 100), new TagBindingAccess("B", 200), ]); var removed = registry.Remove(1); removed.ShouldNotBeNull(); removed!.Count.ShouldBe(2); registry.ResolveSubscribers(100).ShouldBeEmpty(); registry.ResolveSubscribers(200).ShouldBeEmpty(); } /// Verifies removing one subscription of multiple leaves others intact. [Fact] public void Remove_OneOfTwoSubscriptions_LeavesOtherIntact() { var registry = new SubscriptionRegistryAccess(); registry.Register(1, [new TagBindingAccess("A", 100)]); registry.Register(2, [new TagBindingAccess("A", 100)]); registry.Remove(1); var subs = registry.ResolveSubscribers(100); subs.Count.ShouldBe(1); subs[0].SubscriptionId.ShouldBe(2); } /// Verifies removing an unknown subscription returns null without error. [Fact] public void Remove_UnknownSubscription_IsNullSentinel() { var registry = new SubscriptionRegistryAccess(); registry.Remove(999).ShouldBeNull(); } /// Verifies TrackedSubscriptionCount and TrackedItemHandleCount reflect registrations and removals. [Fact] public void TrackedCounts_ReflectAdditionsAndRemovals() { var registry = new SubscriptionRegistryAccess(); registry.TrackedSubscriptionCount.ShouldBe(0); registry.Register(1, [new TagBindingAccess("A", 100)]); registry.Register(2, [new TagBindingAccess("A", 100), new TagBindingAccess("B", 200)]); registry.TrackedSubscriptionCount.ShouldBe(2); registry.TrackedItemHandleCount.ShouldBe(2); registry.Remove(1); registry.TrackedSubscriptionCount.ShouldBe(1); registry.TrackedItemHandleCount.ShouldBe(2); // sub 2 still observes both handles registry.Remove(2); registry.TrackedSubscriptionCount.ShouldBe(0); registry.TrackedItemHandleCount.ShouldBe(0); } // ===== Driver.Galaxy-008 regression: reconnect replay rebinds with fresh handles ===== /// Verifies SnapshotEntries() groups bindings correctly by subscription ID. [Fact] public void SnapshotEntries_GroupsBindingsBySubscriptionId() { var registry = new SubscriptionRegistryAccess(); registry.Register(1, [new TagBindingAccess("A", 100)]); registry.Register(2, [new TagBindingAccess("B", 200), new TagBindingAccess("C", 300)]); var entries = registry.SnapshotEntries(); entries.Count.ShouldBe(2); entries.Single(e => e.SubscriptionId == 1).Bindings.Count.ShouldBe(1); entries.Single(e => e.SubscriptionId == 2).Bindings.Count.ShouldBe(2); } /// Verifies Rebind() replaces stale handles with fresh post-reconnect handles. [Fact] public void Rebind_ReplacesStaleItemHandles_WithThePostReconnectHandles() { // Before reconnect the gw assigned handle 100; after reconnect it issues 555. var registry = new SubscriptionRegistryAccess(); registry.Register(1, [new TagBindingAccess("Tank.Level", 100)]); registry.Rebind(1, [new TagBindingAccess("Tank.Level", 555)]); // The stale handle no longer fans out; the fresh handle does. registry.ResolveSubscribers(100).ShouldBeEmpty(); var subs = registry.ResolveSubscribers(555); subs.Count.ShouldBe(1); subs[0].SubscriptionId.ShouldBe(1); subs[0].FullReference.ShouldBe("Tank.Level"); } /// Verifies Rebind() on one subscription does not affect others on the same old handle. [Fact] public void Rebind_LeavesOtherSubscriptionsOnTheSameOldHandleIntact() { var registry = new SubscriptionRegistryAccess(); registry.Register(1, [new TagBindingAccess("Tank.Level", 100)]); registry.Register(2, [new TagBindingAccess("Tank.Level", 100)]); // Only subscription 1 replays onto a fresh handle. registry.Rebind(1, [new TagBindingAccess("Tank.Level", 555)]); registry.ResolveSubscribers(100).Select(s => s.SubscriptionId).ShouldBe(new[] { 2L }); registry.ResolveSubscribers(555).Select(s => s.SubscriptionId).ShouldBe(new[] { 1L }); } /// Verifies Rebind() on an unknown subscription is a no-op. [Fact] public void Rebind_UnknownSubscription_IsNoOp() { var registry = new SubscriptionRegistryAccess(); Should.NotThrow(() => registry.Rebind(999, [new TagBindingAccess("X", 1)])); registry.ResolveSubscribers(1).ShouldBeEmpty(); } /// Verifies Rebind() does not index rejected item handles. [Fact] public void Rebind_FailedItemHandle_NotIndexedForFanOut() { var registry = new SubscriptionRegistryAccess(); registry.Register(1, [new TagBindingAccess("Tag", 100)]); // Post-reconnect the gw rejected the tag — handle 0. registry.Rebind(1, [new TagBindingAccess("Tag", 0)]); registry.ResolveSubscribers(100).ShouldBeEmpty(); registry.ResolveSubscribers(0).ShouldBeEmpty(); } // ===== Driver.Galaxy-012 regression: ResolveSubscribers is O(1) per binding ===== /// Verifies ResolveSubscribers() correctly dispatches in a large binding set without linear scan. [Fact] public void ResolveSubscribers_LargeBindingSet_DispatchesCorrectly() { // 5000-tag subscription. ResolveSubscribers must still return the right // full-reference for any item handle without a linear scan of the entire // binding list — the old FirstOrDefault(b => b.ItemHandle == h) was O(n) // per dispatch, so 50k tags × 1Hz fan-out was 50k linear scans per second. var registry = new SubscriptionRegistryAccess(); const int tagCount = 5000; var bindings = new List(tagCount); for (var i = 0; i < tagCount; i++) { bindings.Add(new TagBindingAccess($"Tag.{i}", 1000 + i)); } registry.Register(1, bindings); // Pull the last entry — the worst case for a linear scan. var subs = registry.ResolveSubscribers(1000 + tagCount - 1); subs.Count.ShouldBe(1); subs[0].FullReference.ShouldBe($"Tag.{tagCount - 1}"); // Mid-range entry too — proves the index isn't position-dependent. var mid = registry.ResolveSubscribers(1000 + tagCount / 2); mid.Count.ShouldBe(1); mid[0].FullReference.ShouldBe($"Tag.{tagCount / 2}"); } // Internal types are accessed via friend assembly (InternalsVisibleTo); these // wrapper aliases keep the test code readable. /// Wrapper for accessing SubscriptionRegistry in tests via internal visibility. private sealed class SubscriptionRegistryAccess { /// The underlying SubscriptionRegistry instance. private readonly SubscriptionRegistry _inner = new(); /// Gets the count of tracked subscriptions. public int TrackedSubscriptionCount => _inner.TrackedSubscriptionCount; /// Gets the count of tracked item handles. public int TrackedItemHandleCount => _inner.TrackedItemHandleCount; /// Gets the next subscription ID. public long NextSubscriptionId() => _inner.NextSubscriptionId(); /// Registers a subscription with the given bindings. /// The subscription ID. /// The tag bindings to register. public void Register(long id, IReadOnlyList bindings) => _inner.Register(id, [.. bindings.Select(b => new TagBinding(b.FullReference, b.ItemHandle))]); /// Removes a subscription and returns its bindings. /// The subscription ID. /// The bindings that were removed, or null if not found. public IReadOnlyList? Remove(long id) { var removed = _inner.Remove(id); return removed is null ? null : [.. removed.Select(b => new TagBindingAccess(b.FullReference, b.ItemHandle))]; } /// Resolves subscribers for the given item handle. /// The item handle to look up. /// The list of subscriptions observing this handle. public IReadOnlyList<(long SubscriptionId, string FullReference)> ResolveSubscribers(int handle) => _inner.ResolveSubscribers(handle); /// Rebinds a subscription to new item handles. /// The subscription ID. /// The new tag bindings. public void Rebind(long id, IReadOnlyList bindings) => _inner.Rebind(id, [.. bindings.Select(b => new TagBinding(b.FullReference, b.ItemHandle))]); /// Snapshots all subscription entries grouped by ID. /// All subscription entries with their bindings. public IReadOnlyList<(long SubscriptionId, IReadOnlyList Bindings)> SnapshotEntries() => [.. _inner.SnapshotEntries().Select(e => (e.SubscriptionId, (IReadOnlyList)[.. e.Bindings.Select(b => new TagBindingAccess(b.FullReference, b.ItemHandle))]))]; } private sealed record TagBindingAccess(string FullReference, int ItemHandle); }