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 { [Fact] public void NextSubscriptionId_IsMonotonic() { var registry = new SubscriptionRegistryAccess(); registry.NextSubscriptionId().ShouldBe(1); registry.NextSubscriptionId().ShouldBe(2); registry.NextSubscriptionId().ShouldBe(3); } [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"); } [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 }); } [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(); } [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(); } [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); } [Fact] public void Remove_UnknownSubscription_IsNullSentinel() { var registry = new SubscriptionRegistryAccess(); registry.Remove(999).ShouldBeNull(); } [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); } // Internal types are accessed via friend assembly (InternalsVisibleTo); these // wrapper aliases keep the test code readable. private sealed class SubscriptionRegistryAccess { private readonly SubscriptionRegistry _inner = new(); public int TrackedSubscriptionCount => _inner.TrackedSubscriptionCount; public int TrackedItemHandleCount => _inner.TrackedItemHandleCount; public long NextSubscriptionId() => _inner.NextSubscriptionId(); public void Register(long id, IReadOnlyList bindings) => _inner.Register(id, [.. bindings.Select(b => new TagBinding(b.FullReference, b.ItemHandle))]); public IReadOnlyList? Remove(long id) { var removed = _inner.Remove(id); return removed is null ? null : [.. removed.Select(b => new TagBindingAccess(b.FullReference, b.ItemHandle))]; } public IReadOnlyList<(long SubscriptionId, string FullReference)> ResolveSubscribers(int handle) => _inner.ResolveSubscribers(handle); } private sealed record TagBindingAccess(string FullReference, int ItemHandle); }