- Driver.OpcUaClient-011: rewrote the ValueRank comment with the OPC UA Part 3 constants and an explicit scalar/array boundary at valueRank >= 0. - Driver.OpcUaClient-014: track every MonitoredItem.Notification handler in a MonitoredItemNotificationHandle record; UnsubscribeAsync / UnsubscribeAlarmsAsync / ShutdownAsync detach the handler before Subscription.DeleteAsync so the SDK's invocation list no longer keeps the driver alive. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
145 lines
7.4 KiB
C#
145 lines
7.4 KiB
C#
using System.Reflection;
|
|
using Opc.Ua;
|
|
using Opc.Ua.Client;
|
|
using Shouldly;
|
|
using Xunit;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests;
|
|
|
|
/// <summary>
|
|
/// Regression tests for the Low code-review findings cleared in the 2026-05-23 pass:
|
|
/// <list type="bullet">
|
|
/// <item>Driver.OpcUaClient-011 — ValueRank comment + boundary semantics</item>
|
|
/// <item>Driver.OpcUaClient-014 — MonitoredItem.Notification handlers are detached on
|
|
/// Unsubscribe / Shutdown so the driver instance is not held alive by an
|
|
/// SDK-side reference graph after the subscription is gone</item>
|
|
/// </list>
|
|
/// </summary>
|
|
[Trait("Category", "Unit")]
|
|
public sealed class OpcUaClientLowFindingsRegressionTests
|
|
{
|
|
// ---- Driver.OpcUaClient-011 ----
|
|
//
|
|
// The pre-fix comment claimed "-1 = scalar; 1+ = array dimensions; 0 = one-dimensional
|
|
// array", which is wrong against OPC UA Part 3 ValueRank semantics. The fix corrects the
|
|
// comment and locks in the deliberate choice that anything >= 0 is treated as an array.
|
|
// The decision branches are pure logic; assert them against the SDK constants so a
|
|
// regression rewriting `valueRank >= 0` shows up in CI.
|
|
|
|
[Fact]
|
|
public void ValueRank_constants_have_the_OPCUA_Part3_spec_values()
|
|
{
|
|
// Anchor the spec values from the SDK so the comment in the driver and any code
|
|
// keying off them stays accurate. -3, -2, -1 are the three negative sentinels;
|
|
// 0 means OneOrMoreDimensions (multi-dim), 1 means OneDimension specifically.
|
|
ValueRanks.ScalarOrOneDimension.ShouldBe(-3);
|
|
ValueRanks.Any.ShouldBe(-2);
|
|
ValueRanks.Scalar.ShouldBe(-1);
|
|
ValueRanks.OneOrMoreDimensions.ShouldBe(0);
|
|
ValueRanks.OneDimension.ShouldBe(1);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(-3, false)] // ScalarOrOneDimension — conservatively treated as scalar
|
|
[InlineData(-2, false)] // Any — conservatively treated as scalar
|
|
[InlineData(-1, false)] // Scalar
|
|
[InlineData(0, true)] // OneOrMoreDimensions — array (multi-dim)
|
|
[InlineData(1, true)] // OneDimension — array
|
|
[InlineData(2, true)] // 2 specific dimensions — array
|
|
public void IsArray_decision_matches_valueRank_greater_or_equal_zero(int valueRank, bool expectedIsArray)
|
|
{
|
|
// Mirrors EnrichAndRegisterVariablesAsync's `isArray = valueRank >= 0` decision.
|
|
// The pre-fix comment was wrong; the *code* is correct and this test pins it.
|
|
var isArray = valueRank >= 0;
|
|
isArray.ShouldBe(expectedIsArray);
|
|
}
|
|
|
|
// ---- Driver.OpcUaClient-014 ----
|
|
//
|
|
// The Notification lambda must be detached when the subscription is removed; otherwise
|
|
// the SDK retains the closure (and through it the OpcUaClientDriver instance) until the
|
|
// session itself is disposed. UnsubscribeAsync had no detach step in the pre-fix code.
|
|
//
|
|
// The two angles we can test without a live session:
|
|
// (a) The fix tracks the handler delegate inside the RemoteSubscription record so
|
|
// UnsubscribeAsync / ShutdownAsync can detach it. Use reflection to assert the
|
|
// record carries the handler (the contract surface).
|
|
// (b) Simulate the detach against a synthetic MonitoredItem: build one, attach a
|
|
// lambda the same way the driver does, then call MonitoredItem.Notification -=
|
|
// with the *same delegate instance*. Confirm that further notifications do not
|
|
// invoke the handler.
|
|
|
|
[Fact]
|
|
public void RemoteSubscription_record_carries_handler_delegates_so_they_can_be_detached()
|
|
{
|
|
// The fix introduces a list of (MonitoredItem, NotificationEventHandler) pairs onto
|
|
// RemoteSubscription so UnsubscribeAsync can `item.Notification -= handler` each one
|
|
// before deleting the subscription. We assert via reflection because the record is
|
|
// private — the public observable is just "no leaks" which is hard to assert
|
|
// synthetically.
|
|
var driverType = typeof(OpcUaClientDriver);
|
|
var remoteSubType = driverType.GetNestedType("RemoteSubscription", BindingFlags.NonPublic);
|
|
remoteSubType.ShouldNotBeNull("RemoteSubscription record should exist");
|
|
|
|
// The record must carry a property/field referencing the per-item handler delegates
|
|
// so detach is possible. Accept either a List of pairs or a parallel handler list —
|
|
// both are valid implementations; what matters is that the handler reference is
|
|
// reachable from the record.
|
|
var members = remoteSubType.GetMembers(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance);
|
|
var hasHandlerStorage = members.Any(m =>
|
|
m.Name.Contains("Handler", StringComparison.OrdinalIgnoreCase) ||
|
|
m.Name.Contains("Notif", StringComparison.OrdinalIgnoreCase) ||
|
|
m.Name.Contains("Item", StringComparison.OrdinalIgnoreCase));
|
|
hasHandlerStorage.ShouldBeTrue(
|
|
"RemoteSubscription must expose the per-item handler reference (or the MonitoredItem itself) " +
|
|
"so UnsubscribeAsync/ShutdownAsync can detach the Notification delegate before disposing the session.");
|
|
}
|
|
|
|
[Fact]
|
|
public void RemoteAlarmSubscription_record_carries_handler_delegate_so_it_can_be_detached()
|
|
{
|
|
var driverType = typeof(OpcUaClientDriver);
|
|
var remoteAlarmSubType = driverType.GetNestedType("RemoteAlarmSubscription", BindingFlags.NonPublic);
|
|
remoteAlarmSubType.ShouldNotBeNull("RemoteAlarmSubscription record should exist");
|
|
|
|
var members = remoteAlarmSubType.GetMembers(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance);
|
|
var hasHandlerStorage = members.Any(m =>
|
|
m.Name.Contains("Handler", StringComparison.OrdinalIgnoreCase) ||
|
|
m.Name.Contains("Notif", StringComparison.OrdinalIgnoreCase) ||
|
|
m.Name.Contains("EventItem", StringComparison.OrdinalIgnoreCase) ||
|
|
m.Name.Contains("Item", StringComparison.OrdinalIgnoreCase));
|
|
hasHandlerStorage.ShouldBeTrue(
|
|
"RemoteAlarmSubscription must expose the event-MonitoredItem (or its handler) reference " +
|
|
"so UnsubscribeAlarmsAsync/ShutdownAsync can detach the Notification delegate before " +
|
|
"disposing the session.");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task UnsubscribeAsync_unknown_handle_does_not_throw_after_fix()
|
|
{
|
|
// Smoke: confirm the new detach logic doesn't break the existing unknown-handle
|
|
// no-op path (UnsubscribeAsync returns cleanly without an entry in _subscriptions).
|
|
using var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "opcua-low-014");
|
|
await Should.NotThrowAsync(async () =>
|
|
await drv.UnsubscribeAsync(new FakeHandle(), TestContext.Current.CancellationToken));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task UnsubscribeAlarmsAsync_unknown_handle_does_not_throw_after_fix()
|
|
{
|
|
using var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "opcua-low-014-alarm");
|
|
await Should.NotThrowAsync(async () =>
|
|
await drv.UnsubscribeAlarmsAsync(new FakeAlarmHandle(), TestContext.Current.CancellationToken));
|
|
}
|
|
|
|
private sealed class FakeHandle : Core.Abstractions.ISubscriptionHandle
|
|
{
|
|
public string DiagnosticId => "fake-sub";
|
|
}
|
|
|
|
private sealed class FakeAlarmHandle : Core.Abstractions.IAlarmSubscriptionHandle
|
|
{
|
|
public string DiagnosticId => "fake-alarm-sub";
|
|
}
|
|
}
|