- 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>
242 lines
9.4 KiB
C#
242 lines
9.4 KiB
C#
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);
|
||
}
|