Auto: opcuaclient-2 — per-tag advanced subscription tuning

Closes #274
This commit is contained in:
Joseph Doherty
2026-04-25 15:25:20 -04:00
parent bf200e813e
commit fae00749ca
4 changed files with 400 additions and 25 deletions

View File

@@ -0,0 +1,175 @@
using Opc.Ua;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests;
/// <summary>
/// Unit tests for <see cref="MonitoredTagSpec"/> -> SDK <c>MonitoredItem</c> mapping.
/// Assertion-only — no live SDK session required, so the tests run on every CI without
/// a real OPC UA server fixture.
/// </summary>
[Trait("Category", "Unit")]
public sealed class OpcUaClientMonitoredTagSpecTests
{
private static readonly NodeId SampleNodeId = new("Demo", 2);
[Fact]
public void BuildMonitoredItem_with_all_defaults_matches_legacy_hard_coded_values()
{
// Spec with every per-tag knob null should behave identically to the legacy
// string-only SubscribeAsync path: Reporting / SamplingInterval=publishInterval /
// QueueSize=1 / DiscardOldest=true / no filter.
var spec = new MonitoredTagSpec("ns=2;s=Demo");
var item = OpcUaClientDriver.BuildMonitoredItem(spec, SampleNodeId, defaultIntervalMs: 250);
item.SamplingInterval.ShouldBe(250);
item.QueueSize.ShouldBe(1u);
item.DiscardOldest.ShouldBeTrue();
item.MonitoringMode.ShouldBe(MonitoringMode.Reporting);
item.Filter.ShouldBeNull();
item.Handle.ShouldBe("ns=2;s=Demo",
"the tag string is routed through Handle so the Notification callback can identify the changed tag without re-parsing DisplayName");
}
[Fact]
public void BuildMonitoredItem_applies_per_tag_sampling_interval_independent_of_publish_interval()
{
// Per-tag SamplingInterval lets the server sample faster than it publishes — useful
// for events that change between publish ticks. If the spec sets it explicitly, the
// mapping uses that value, not the publish-interval default.
var spec = new MonitoredTagSpec("ns=2;s=Fast", SamplingIntervalMs: 50);
var item = OpcUaClientDriver.BuildMonitoredItem(spec, SampleNodeId, defaultIntervalMs: 1000);
item.SamplingInterval.ShouldBe(50);
}
[Fact]
public void BuildMonitoredItem_applies_queue_size_and_discard_oldest_overrides()
{
var spec = new MonitoredTagSpec("ns=2;s=DeepQueue", QueueSize: 100, DiscardOldest: false);
var item = OpcUaClientDriver.BuildMonitoredItem(spec, SampleNodeId, defaultIntervalMs: 250);
item.QueueSize.ShouldBe(100u);
item.DiscardOldest.ShouldBeFalse(
"discard-oldest=false preserves earliest values — useful for audit-trail subscriptions where the first overflow sample is the most diagnostic");
}
[Theory]
[InlineData(SubscriptionMonitoringMode.Disabled, MonitoringMode.Disabled)]
[InlineData(SubscriptionMonitoringMode.Sampling, MonitoringMode.Sampling)]
[InlineData(SubscriptionMonitoringMode.Reporting, MonitoringMode.Reporting)]
public void BuildMonitoredItem_maps_each_monitoring_mode(SubscriptionMonitoringMode input, MonitoringMode expected)
{
var spec = new MonitoredTagSpec("ns=2;s=Mode", MonitoringMode: input);
var item = OpcUaClientDriver.BuildMonitoredItem(spec, SampleNodeId, defaultIntervalMs: 250);
item.MonitoringMode.ShouldBe(expected);
}
[Fact]
public void BuildMonitoredItem_with_absolute_deadband_emits_DataChangeFilter()
{
var spec = new MonitoredTagSpec(
"ns=2;s=Analog",
DataChangeFilter: new DataChangeFilterSpec(
Core.Abstractions.DataChangeTrigger.StatusValue,
Core.Abstractions.DeadbandType.Absolute,
DeadbandValue: 0.5));
var item = OpcUaClientDriver.BuildMonitoredItem(spec, SampleNodeId, defaultIntervalMs: 250);
var filter = item.Filter.ShouldBeOfType<DataChangeFilter>();
filter.Trigger.ShouldBe(Opc.Ua.DataChangeTrigger.StatusValue);
filter.DeadbandType.ShouldBe((uint)Opc.Ua.DeadbandType.Absolute);
filter.DeadbandValue.ShouldBe(0.5);
}
[Fact]
public void BuildMonitoredItem_with_percent_deadband_emits_percent_filter()
{
// PercentDeadband is calculated server-side as a fraction of EURange; the driver
// emits the filter unconditionally and lets the server return BadFilterNotAllowed
// if EURange isn't set on the variable. SubscribeAsync's catch-block swallows that
// status so other items in the batch still get created.
var spec = new MonitoredTagSpec(
"ns=2;s=Pct",
DataChangeFilter: new DataChangeFilterSpec(
Core.Abstractions.DataChangeTrigger.StatusValueTimestamp,
Core.Abstractions.DeadbandType.Percent,
DeadbandValue: 5.0));
var item = OpcUaClientDriver.BuildMonitoredItem(spec, SampleNodeId, defaultIntervalMs: 250);
var filter = item.Filter.ShouldBeOfType<DataChangeFilter>();
filter.Trigger.ShouldBe(Opc.Ua.DataChangeTrigger.StatusValueTimestamp);
filter.DeadbandType.ShouldBe((uint)Opc.Ua.DeadbandType.Percent);
filter.DeadbandValue.ShouldBe(5.0);
}
[Theory]
[InlineData(Core.Abstractions.DataChangeTrigger.Status, Opc.Ua.DataChangeTrigger.Status)]
[InlineData(Core.Abstractions.DataChangeTrigger.StatusValue, Opc.Ua.DataChangeTrigger.StatusValue)]
[InlineData(Core.Abstractions.DataChangeTrigger.StatusValueTimestamp, Opc.Ua.DataChangeTrigger.StatusValueTimestamp)]
public void MapTrigger_round_trips_each_enum_value(
Core.Abstractions.DataChangeTrigger input, Opc.Ua.DataChangeTrigger expected)
=> OpcUaClientDriver.MapTrigger(input).ShouldBe(expected);
[Theory]
[InlineData(Core.Abstractions.DeadbandType.None, Opc.Ua.DeadbandType.None)]
[InlineData(Core.Abstractions.DeadbandType.Absolute, Opc.Ua.DeadbandType.Absolute)]
[InlineData(Core.Abstractions.DeadbandType.Percent, Opc.Ua.DeadbandType.Percent)]
public void MapDeadbandType_round_trips_each_enum_value(
Core.Abstractions.DeadbandType input, Opc.Ua.DeadbandType expected)
=> OpcUaClientDriver.MapDeadbandType(input).ShouldBe(expected);
[Fact]
public async Task DefaultInterfaceImplementation_routes_through_legacy_overload()
{
// ISubscribable's default interface impl of the per-tag overload delegates to the
// simple-string overload, ignoring per-tag knobs. Drivers that DON'T override the
// new overload (Modbus / S7 / Galaxy / TwinCAT / FOCAS / AbCip / AbLegacy) still
// accept MonitoredTagSpec lists and just pass through the tag names — back-compat
// for ISubscribable consumers.
var stub = new StubSubscribableDriver();
var specs = new[]
{
new MonitoredTagSpec("Tag1", SamplingIntervalMs: 50, QueueSize: 5),
new MonitoredTagSpec("Tag2", DataChangeFilter: new DataChangeFilterSpec(
Core.Abstractions.DataChangeTrigger.StatusValue,
Core.Abstractions.DeadbandType.Absolute,
1.0)),
};
ISubscribable iface = stub;
_ = await iface.SubscribeAsync(specs, TimeSpan.FromMilliseconds(250), TestContext.Current.CancellationToken);
stub.LastTagNames.ShouldBe(["Tag1", "Tag2"]);
stub.LastPublishingInterval.ShouldBe(TimeSpan.FromMilliseconds(250));
}
/// <summary>
/// Test-double <see cref="ISubscribable"/> that records whatever the legacy
/// <c>SubscribeAsync(IReadOnlyList&lt;string&gt;, ...)</c> overload was called with.
/// Used to verify the default-impl per-tag overload routes correctly without needing
/// a real OPC UA session.
/// </summary>
private sealed class StubSubscribableDriver : ISubscribable
{
public IReadOnlyList<string>? LastTagNames { get; private set; }
public TimeSpan LastPublishingInterval { get; private set; }
public Task<ISubscriptionHandle> SubscribeAsync(
IReadOnlyList<string> fullReferences, TimeSpan publishingInterval, CancellationToken cancellationToken)
{
LastTagNames = fullReferences;
LastPublishingInterval = publishingInterval;
return Task.FromResult<ISubscriptionHandle>(new StubHandle());
}
public Task UnsubscribeAsync(ISubscriptionHandle handle, CancellationToken cancellationToken) => Task.CompletedTask;
#pragma warning disable CS0067 // event never used — the test only asserts the SubscribeAsync call routing
public event EventHandler<DataChangeEventArgs>? OnDataChange;
#pragma warning restore CS0067
}
private sealed record StubHandle() : ISubscriptionHandle
{
public string DiagnosticId => "stub";
}
}