@@ -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<string>, ...)</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";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user