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);
}