Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/Runtime/SubscriptionRegistryTests.cs
Joseph Doherty 9f7ae20995 fix(driver-galaxy): resolve Low code-review findings (Driver.Galaxy-005,010,012,013)
- Driver.Galaxy-005: rewrite the EventPump BoundedChannelOptions comment
  to honestly describe the Wait+TryWrite pattern.
- Driver.Galaxy-010: ResolveApiKey now warns when a literal API key is
  used in production wiring; added an explicit dev: prefix for known
  cleartext-in-dev cases and rewrote the GalaxyGatewayOptions doc.
- Driver.Galaxy-012: O(1) reverse-lookup for SubscriptionRegistry
  dispatch via per-entry FullRefByItemHandle map; immutable hash-set for
  the cross-binding reverse map; SubscribeAsync / ReadViaSubscribeOnce
  use BuildResultIndex for per-reference correlation.
- Driver.Galaxy-013: ReinitializeAsync now validates the incoming JSON
  against the running options; ReplayOnSessionLost honoured by the
  Replay path; class summary rewritten to describe the shipped surface.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 07:45:08 -04:00

242 lines
9.4 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests.Runtime;
/// <summary>
/// Tests for <see cref="SubscriptionRegistry"/> — the bookkeeping the EventPump
/// uses to fan one OnDataChange event out to every driver subscription that
/// observes the changed item handle.
/// </summary>
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);
}
// ===== Driver.Galaxy-008 regression: reconnect replay rebinds with fresh handles =====
[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);
}
[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");
}
[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 });
}
[Fact]
public void Rebind_UnknownSubscription_IsNoOp()
{
var registry = new SubscriptionRegistryAccess();
Should.NotThrow(() => registry.Rebind(999, [new TagBindingAccess("X", 1)]));
registry.ResolveSubscribers(1).ShouldBeEmpty();
}
[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 =====
[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<TagBindingAccess>(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.
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<TagBindingAccess> bindings)
=> _inner.Register(id, [.. bindings.Select(b => new TagBinding(b.FullReference, b.ItemHandle))]);
public IReadOnlyList<TagBindingAccess>? 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);
public void Rebind(long id, IReadOnlyList<TagBindingAccess> bindings)
=> _inner.Rebind(id, [.. bindings.Select(b => new TagBinding(b.FullReference, b.ItemHandle))]);
public IReadOnlyList<(long SubscriptionId, IReadOnlyList<TagBindingAccess> Bindings)> SnapshotEntries()
=> [.. _inner.SnapshotEntries().Select(e =>
(e.SubscriptionId,
(IReadOnlyList<TagBindingAccess>)[.. e.Bindings.Select(b => new TagBindingAccess(b.FullReference, b.ItemHandle))]))];
}
private sealed record TagBindingAccess(string FullReference, int ItemHandle);
}