Driver.Galaxy-002 — DataTypeMap.Map had no Int64 arm though MxValueDecoder/ MxValueEncoder both fully support Int64. Galaxy attributes with the Int64 mx_data_type code fell through to the String default, creating a String address-space node while runtime reads decoded a boxed long. Added `6 => DriverDataType.Int64`, extending the contiguous 0..5 scheme so the type map agrees with the decoder/encoder on all seven Galaxy data types. Driver.Galaxy-008 — after a stream fault the EventPump's StreamEvents consumer loop exited and its channel completed; EventPump.Start() is a no-op on a completed-but-non-null loop, so a replayed subscription had no consumer and ReplayAsync never re-registered the post-reconnect item handles. ReplayAsync now recreates the EventPump (RestartEventPumpForReplay) and rebinds the SubscriptionRegistry per subscription with the fresh item handles returned by the post-reconnect SubscribeBulkAsync, via new SubscriptionRegistry.SnapshotEntries and Rebind APIs. Regression tests: DataTypeMapTests (every code incl. Int64), SubscriptionRegistry Tests (Rebind/SnapshotEntries), EventPumpStreamFaultTests (faulted pump dead, fresh pump resumes dispatch). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
212 lines
8.1 KiB
C#
212 lines
8.1 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();
|
|
}
|
|
|
|
// 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);
|
|
}
|