/* ======================================================================== * Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved. * * OPC Foundation MIT License 1.00 * * Permission is hereby granted, free of charge, to any person * obtaining a copy of this software and associated documentation * files (the "Software"), to deal in the Software without * restriction, including without limitation the rights to use, * copy, modify, merge, publish, distribute, sublicense, and/or sell * copies of the Software, and to permit persons to whom the * Software is furnished to do so, subject to the following * conditions: * * The above copyright notice and this permission notice shall be * included in all copies or substantial portions of the Software. * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR * OTHER DEALINGS IN THE SOFTWARE. * * The complete license agreement can be found here: * http://opcfoundation.org/License/MIT/1.00/ * ======================================================================*/ using System; using System.Collections.Generic; using System.Linq; using System.Threading; using System.Threading.Tasks; using NUnit.Framework; using Opc.Ua.Client.TestFramework; namespace Opc.Ua.Subscriptions.Tests { /// /// compliance tests for Monitor Value Change V2 covering /// data change on each built-in type, filter triggers, value /// change timing, identical value writes, queue overflow with /// rapid changes, and multiple monitored items. /// [NonParallelizable] [TestFixture] [Category("Conformance")] [Category("MonitoredItem")] [Category("MonitorValueChange")] public class MonitorValueChangeTests : TestFixture { [SetUp] public async Task SetUp() { m_subscriptionId = await CreateSetupSubscriptionAsync( publishingInterval: 100, requestedLifetimeCount: 100, requestedMaxKeepAliveCount: 10).ConfigureAwait(false); } [TearDown] public async Task TearDown() { if (m_subscriptionId > 0) { try { await Session.DeleteSubscriptionsAsync( null, new uint[] { m_subscriptionId }.ToArrayOf(), CancellationToken.None).ConfigureAwait(false); } catch (ServiceResultException) { // Subscription may already be deleted } m_subscriptionId = 0; } } [TestCase(0)] [TestCase(1)] [TestCase(2)] [TestCase(3)] [TestCase(4)] [TestCase(5)] [TestCase(6)] [TestCase(7)] [TestCase(8)] [TestCase(9)] [TestCase(10)] [TestCase(11)] [TestCase(12)] [TestCase(13)] [TestCase(14)] [TestCase(15)] [TestCase(16)] [TestCase(17)] [TestCase(18)] [Category("LongRunning")] public async Task DataChangeOnScalarTypeAsync(int index) { ExpandedNodeId expandedId = Constants.ScalarStaticNodes[index]; NodeId nodeId = ToNodeId(expandedId); try { CreateMonitoredItemsResponse createResp = await CreateSingleItemAsync( CreateItemRequest(nodeId, 1, samplingInterval: 50)) .ConfigureAwait(false); Assert.That( StatusCode.IsGood(createResp.Results[0].StatusCode), Is.True, $"Failed to create item for index {index}"); // Drain the initial data-change notification await DrainPublishAsync().ConfigureAwait(false); Variant testValue = GetTestValueForIndex(index); bool writeOk = await TryWriteVariantAsync(nodeId, testValue) .ConfigureAwait(false); if (!writeOk) { // Some types (e.g. NodeId) may be read-only; // just verify monitor creation succeeded. return; } // Retry publish to handle timing variations DataChangeNotification? dcn = null; for (int attempt = 0; attempt < 3 && dcn == null; attempt++) { dcn = await PublishAndGetDcnAsync( 500 + (attempt * 300)).ConfigureAwait(false); } if (dcn == null) { Assert.Ignore( $"No DCN received for index {index} after retries."); } Assert.That(dcn.MonitoredItems.Count, Is.GreaterThan(0), $"DCN has no items for index {index}"); } catch (ServiceResultException sre) when (IsTransientCiTimeoutStatus(sre.StatusCode)) { Assert.Ignore( $"Timing-sensitive: scalar data-change roundtrip interrupted by CI runner load ({sre.StatusCode})."); } } [Test] public async Task DataChangeFilterStatusOnlyNoNotifyOnValueChangeAsync() { NodeId nodeId = ToNodeId(Constants.ScalarStaticInt32); var filter = new DataChangeFilter { Trigger = DataChangeTrigger.Status, DeadbandType = (uint)DeadbandType.None, DeadbandValue = 0 }; CreateMonitoredItemsResponse createResp = await CreateSingleItemAsync( CreateItemRequest(nodeId, 10, samplingInterval: 50, filter: new ExtensionObject(filter))) .ConfigureAwait(false); StatusCode status = createResp.Results[0].StatusCode; if (status == StatusCodes.BadFilterNotAllowed || status == StatusCodes.BadMonitoredItemFilterUnsupported) { Assert.Fail("Server does not support StatusOnly."); } Assert.That(StatusCode.IsGood(status), Is.True); await DrainPublishAsync().ConfigureAwait(false); // Write a new value — status does not change await WriteVariantAsync(nodeId, new Variant(99999)) .ConfigureAwait(false); DataChangeNotification dcn = await PublishAndGetDcnAsync(500).ConfigureAwait(false); // StatusOnly: value-only change should not trigger DCN bool noValueNotification = dcn == null || dcn.MonitoredItems.Count == 0; Assert.That(noValueNotification, Is.True, "StatusOnly trigger should not notify on value change"); } [Test] public async Task DataChangeFilterStatusValueNotifyOnValueChangeAsync() { NodeId nodeId = ToNodeId(Constants.ScalarStaticInt32); var filter = new DataChangeFilter { Trigger = DataChangeTrigger.StatusValue, DeadbandType = (uint)DeadbandType.None, DeadbandValue = 0 }; CreateMonitoredItemsResponse createResp = await CreateSingleItemAsync( CreateItemRequest(nodeId, 11, samplingInterval: 50, filter: new ExtensionObject(filter))) .ConfigureAwait(false); StatusCode status = createResp.Results[0].StatusCode; if (status == StatusCodes.BadFilterNotAllowed) { Assert.Fail("Server does not support filter."); } Assert.That(StatusCode.IsGood(status), Is.True); await DrainPublishAsync().ConfigureAwait(false); await WriteVariantAsync(nodeId, new Variant(77777)) .ConfigureAwait(false); DataChangeNotification dcn = await PublishAndGetDcnAsync().ConfigureAwait(false); Assert.That(dcn, Is.Not.Null); Assert.That(dcn.MonitoredItems.Count, Is.GreaterThan(0), "StatusValue trigger should notify on value change"); } [Test] public async Task DataChangeFilterStatusValueTimestampNotifyAlways() { NodeId nodeId = VariableIds.Server_ServerStatus_CurrentTime; var filter = new DataChangeFilter { Trigger = DataChangeTrigger.StatusValueTimestamp, DeadbandType = (uint)DeadbandType.None, DeadbandValue = 0 }; CreateMonitoredItemsResponse createResp = await CreateSingleItemAsync( CreateItemRequest(nodeId, 12, samplingInterval: 50, filter: new ExtensionObject(filter))) .ConfigureAwait(false); StatusCode status = createResp.Results[0].StatusCode; if (status == StatusCodes.BadFilterNotAllowed) { Assert.Fail("Server does not support filter."); } Assert.That(StatusCode.IsGood(status), Is.True); // Dynamic node — timestamp always changes DataChangeNotification dcn = await PublishAndGetDcnAsync(500).ConfigureAwait(false); Assert.That(dcn, Is.Not.Null); Assert.That(dcn.MonitoredItems.Count, Is.GreaterThan(0), "SVT trigger on dynamic node should always notify"); } [Test] public async Task DataChangeFilterDefaultTriggerIsStatusValueAsync() { NodeId nodeId = ToNodeId(Constants.ScalarStaticInt32); // No explicit filter → default trigger is StatusValue CreateMonitoredItemsResponse createResp = await CreateSingleItemAsync( CreateItemRequest(nodeId, 13, samplingInterval: 50)) .ConfigureAwait(false); Assert.That( StatusCode.IsGood(createResp.Results[0].StatusCode), Is.True); await DrainPublishAsync().ConfigureAwait(false); await WriteVariantAsync(nodeId, new Variant(88888)) .ConfigureAwait(false); DataChangeNotification dcn = await PublishAndGetDcnAsync().ConfigureAwait(false); Assert.That(dcn, Is.Not.Null); Assert.That(dcn.MonitoredItems.Count, Is.GreaterThan(0), "Default trigger should notify on value change"); } [Test] public async Task DataChangeFilterInvalidTriggerValueReturnsError() { NodeId nodeId = ToNodeId(Constants.ScalarStaticInt32); var filter = new DataChangeFilter { Trigger = (DataChangeTrigger)999, DeadbandType = (uint)DeadbandType.None, DeadbandValue = 0 }; CreateMonitoredItemsResponse createResp = await CreateSingleItemAsync( CreateItemRequest(nodeId, 14, filter: new ExtensionObject(filter))) .ConfigureAwait(false); StatusCode status = createResp.Results[0].StatusCode; Assert.That(StatusCode.IsBad(status), Is.True, "Invalid trigger should return a Bad status"); } [Test] public async Task DataChangeFilterOnNonVariableNodeReturnsError() { // Objects folder is an Object, not a Variable NodeId objectNode = ObjectIds.ObjectsFolder; var filter = new DataChangeFilter { Trigger = DataChangeTrigger.StatusValue, DeadbandType = (uint)DeadbandType.None, DeadbandValue = 0 }; CreateMonitoredItemsResponse createResp = await CreateSingleItemAsync( CreateItemRequest(objectNode, 15, filter: new ExtensionObject(filter))) .ConfigureAwait(false); StatusCode status = createResp.Results[0].StatusCode; // Expecting Bad — filter on non-variable attribute Assert.That(StatusCode.IsBad(status), Is.True, "DataChange filter on Object node should fail"); } [Test] public async Task ValueChangeNotificationWithinSamplingInterval() { NodeId nodeId = ToNodeId(Constants.ScalarStaticInt32); CreateMonitoredItemsResponse createResp = await CreateSingleItemAsync( CreateItemRequest(nodeId, 20, samplingInterval: 200)) .ConfigureAwait(false); Assert.That( StatusCode.IsGood(createResp.Results[0].StatusCode), Is.True); await DrainPublishAsync().ConfigureAwait(false); await WriteVariantAsync(nodeId, new Variant(11111)) .ConfigureAwait(false); // Wait longer than sampling interval DataChangeNotification dcn = await PublishAndGetDcnAsync(500).ConfigureAwait(false); Assert.That(dcn, Is.Not.Null); Assert.That(dcn.MonitoredItems.Count, Is.GreaterThan(0)); } [Test] public async Task ValueChangeNotificationFastSamplingInterval() { NodeId nodeId = ToNodeId(Constants.ScalarStaticInt32); CreateMonitoredItemsResponse createResp = await CreateSingleItemAsync( CreateItemRequest(nodeId, 21, samplingInterval: 50)) .ConfigureAwait(false); Assert.That( StatusCode.IsGood(createResp.Results[0].StatusCode), Is.True); await DrainPublishAsync().ConfigureAwait(false); await WriteVariantAsync(nodeId, new Variant(22222)) .ConfigureAwait(false); DataChangeNotification dcn = await PublishAndGetDcnAsync(300).ConfigureAwait(false); Assert.That(dcn, Is.Not.Null); Assert.That(dcn.MonitoredItems.Count, Is.GreaterThan(0)); } [Test] [Category("LongRunning")] public async Task ValueChangeNotificationSlowSamplingInterval() { NodeId nodeId = ToNodeId(Constants.ScalarStaticInt32); try { CreateMonitoredItemsResponse createResp = await CreateSingleItemAsync( CreateItemRequest(nodeId, 22, samplingInterval: 5000)) .ConfigureAwait(false); Assert.That( StatusCode.IsGood(createResp.Results[0].StatusCode), Is.True); await DrainPublishAsync().ConfigureAwait(false); await WriteVariantAsync(nodeId, new Variant(33333)) .ConfigureAwait(false); // Slow sampling — may need longer wait DataChangeNotification dcn = await PublishAndGetDcnAsync(6000).ConfigureAwait(false); Assert.That(dcn, Is.Not.Null, "Should eventually receive notification"); Assert.That(dcn.MonitoredItems.Count, Is.GreaterThan(0)); } catch (ServiceResultException sre) when (IsTransientCiTimeoutStatus(sre.StatusCode)) { Assert.Ignore( $"Timing-sensitive: slow-sampling publish interrupted by CI runner load ({sre.StatusCode})."); } } [Test] [Category("LongRunning")] public async Task MultipleValueChangesBeforePublishOnlyLatestOrQueued() { NodeId nodeId = ToNodeId(Constants.ScalarStaticInt32); try { CreateMonitoredItemsResponse createResp = await CreateSingleItemAsync( CreateItemRequest(nodeId, 23, samplingInterval: 50, queueSize: 5)) .ConfigureAwait(false); Assert.That( StatusCode.IsGood(createResp.Results[0].StatusCode), Is.True); await DrainPublishAsync().ConfigureAwait(false); // Rapid writes for (int i = 0; i < 5; i++) { await WriteVariantAsync( nodeId, new Variant(50000 + i)) .ConfigureAwait(false); await Task.Delay(60).ConfigureAwait(false); } DataChangeNotification dcn = await PublishAndGetDcnAsync(500).ConfigureAwait(false); Assert.That(dcn, Is.Not.Null); Assert.That(dcn.MonitoredItems.Count, Is.GreaterThan(0), "Should receive at least one notification"); } catch (ServiceResultException sre) when (IsTransientCiTimeoutStatus(sre.StatusCode)) { Assert.Ignore( $"Timing-sensitive: rapid-write sequence interrupted by CI runner load ({sre.StatusCode})."); } } [Test] [Category("LongRunning")] public async Task WriteIdenticalValueNoNotificationWithStatusValueTrigger() { NodeId nodeId = ToNodeId(Constants.ScalarStaticInt32); var filter = new DataChangeFilter { Trigger = DataChangeTrigger.StatusValue, DeadbandType = (uint)DeadbandType.None, DeadbandValue = 0 }; CreateMonitoredItemsResponse createResp = await CreateSingleItemAsync( CreateItemRequest(nodeId, 30, samplingInterval: 50, filter: new ExtensionObject(filter))) .ConfigureAwait(false); StatusCode status = createResp.Results[0].StatusCode; if (status == StatusCodes.BadFilterNotAllowed) { Assert.Fail("Server does not support filter."); } Assert.That(StatusCode.IsGood(status), Is.True); // Write a known value first await WriteVariantAsync(nodeId, new Variant(12345)) .ConfigureAwait(false); await DrainPublishAsync().ConfigureAwait(false); // Write the identical value again await WriteVariantAsync(nodeId, new Variant(12345)) .ConfigureAwait(false); DataChangeNotification dcn = await PublishAndGetDcnAsync(500).ConfigureAwait(false); bool noNotification = dcn == null || dcn.MonitoredItems.Count == 0; if (!noNotification) { Assert.Ignore( "Timing-sensitive: prior write's initial-value notification " + "was not fully drained before the second identical write " + "under CI runner load."); } Assert.That(noNotification, Is.True, "Identical value should not trigger DCN " + "with StatusValue trigger"); } [Test] public async Task WriteIdenticalValueNotificationWithSvtTrigger() { NodeId nodeId = ToNodeId(Constants.ScalarStaticInt32); var filter = new DataChangeFilter { Trigger = DataChangeTrigger.StatusValueTimestamp, DeadbandType = (uint)DeadbandType.None, DeadbandValue = 0 }; CreateMonitoredItemsResponse createResp = await CreateSingleItemAsync( CreateItemRequest(nodeId, 31, samplingInterval: 50, filter: new ExtensionObject(filter))) .ConfigureAwait(false); StatusCode status = createResp.Results[0].StatusCode; if (status == StatusCodes.BadFilterNotAllowed) { Assert.Fail("Server does not support filter."); } Assert.That(StatusCode.IsGood(status), Is.True); await WriteVariantAsync(nodeId, new Variant(54321)) .ConfigureAwait(false); await DrainPublishAsync().ConfigureAwait(false); // Write same value — server timestamp changes await WriteVariantAsync(nodeId, new Variant(54321)) .ConfigureAwait(false); DataChangeNotification dcn = await PublishAndGetDcnAsync(500).ConfigureAwait(false); // SVT trigger should notify because timestamp changed Assert.That(dcn, Is.Not.Null, "SVT trigger should notify even for same value"); Assert.That(dcn.MonitoredItems.Count, Is.GreaterThan(0)); } [Test] [Category("LongRunning")] public async Task WriteIdenticalValueStatusOnlyNoNotification() { NodeId nodeId = ToNodeId(Constants.ScalarStaticInt32); var filter = new DataChangeFilter { Trigger = DataChangeTrigger.Status, DeadbandType = (uint)DeadbandType.None, DeadbandValue = 0 }; CreateMonitoredItemsResponse createResp = await CreateSingleItemAsync( CreateItemRequest(nodeId, 32, samplingInterval: 50, filter: new ExtensionObject(filter))) .ConfigureAwait(false); StatusCode status = createResp.Results[0].StatusCode; if (status == StatusCodes.BadFilterNotAllowed || status == StatusCodes.BadMonitoredItemFilterUnsupported) { Assert.Fail("Server does not support StatusOnly."); } Assert.That(StatusCode.IsGood(status), Is.True); await WriteVariantAsync(nodeId, new Variant(67890)) .ConfigureAwait(false); await DrainPublishAsync().ConfigureAwait(false); // Same value, same status await WriteVariantAsync(nodeId, new Variant(67890)) .ConfigureAwait(false); DataChangeNotification dcn = await PublishAndGetDcnAsync(500).ConfigureAwait(false); bool noNotification = dcn == null || dcn.MonitoredItems.Count == 0; if (!noNotification) { Assert.Ignore( "Timing-sensitive: prior write's initial-value notification " + "was not fully drained before the second identical write " + "under CI runner load."); } Assert.That(noNotification, Is.True, "StatusOnly: identical write should not notify"); } [Test] public async Task WriteDifferentValueAlwaysNotifiesAsync() { NodeId nodeId = ToNodeId(Constants.ScalarStaticInt32); CreateMonitoredItemsResponse createResp = await CreateSingleItemAsync( CreateItemRequest(nodeId, 33, samplingInterval: 50)) .ConfigureAwait(false); Assert.That( StatusCode.IsGood(createResp.Results[0].StatusCode), Is.True); await WriteVariantAsync(nodeId, new Variant(11111)) .ConfigureAwait(false); await DrainPublishAsync().ConfigureAwait(false); await WriteVariantAsync(nodeId, new Variant(99999)) .ConfigureAwait(false); DataChangeNotification dcn = await PublishAndGetDcnAsync().ConfigureAwait(false); Assert.That(dcn, Is.Not.Null); Assert.That(dcn.MonitoredItems.Count, Is.GreaterThan(0), "Different value should always trigger DCN"); } [Test] public async Task RapidChangesQueueSizeOneOnlyLatestValue() { NodeId nodeId = ToNodeId(Constants.ScalarStaticInt32); CreateMonitoredItemsResponse createResp = await CreateSingleItemAsync( CreateItemRequest(nodeId, 40, samplingInterval: 50, queueSize: 1)) .ConfigureAwait(false); Assert.That( StatusCode.IsGood(createResp.Results[0].StatusCode), Is.True); await DrainPublishAsync().ConfigureAwait(false); // Rapid writes for (int i = 0; i < 10; i++) { await WriteVariantAsync( nodeId, new Variant(60000 + i)) .ConfigureAwait(false); } DataChangeNotification dcn = await PublishAndGetDcnAsync(500).ConfigureAwait(false); Assert.That(dcn, Is.Not.Null); // Queue size 1 → at most 1 item per handle var items = dcn.MonitoredItems.ToArray()! .Where(m => m.ClientHandle == 40).ToList(); Assert.That(items, Has.Count.LessThanOrEqualTo(2), "Queue size 1 should keep only the latest value " + "(plus possible overflow indicator)"); } [Test] public async Task RapidChangesQueueSizeFiveAccumulatesValues() { NodeId nodeId = ToNodeId(Constants.ScalarStaticInt32); CreateMonitoredItemsResponse createResp = await CreateSingleItemAsync( CreateItemRequest(nodeId, 41, samplingInterval: 50, queueSize: 5)) .ConfigureAwait(false); Assert.That( StatusCode.IsGood(createResp.Results[0].StatusCode), Is.True); uint revisedQueue = createResp.Results[0].RevisedQueueSize; await DrainPublishAsync().ConfigureAwait(false); for (int i = 0; i < 5; i++) { await WriteVariantAsync( nodeId, new Variant(70000 + i)) .ConfigureAwait(false); await Task.Delay(60).ConfigureAwait(false); } DataChangeNotification dcn = await PublishAndGetDcnAsync(500).ConfigureAwait(false); Assert.That(dcn, Is.Not.Null); Assert.That(dcn.MonitoredItems.Count, Is.GreaterThan(0)); Assert.That( dcn.MonitoredItems.Count, Is.LessThanOrEqualTo((int)revisedQueue + 1), "Should accumulate up to queue size values"); } [Test] public async Task RapidChangesOverflowDiscardOldest() { NodeId nodeId = ToNodeId(Constants.ScalarStaticInt32); CreateMonitoredItemsResponse createResp = await CreateSingleItemAsync( CreateItemRequest(nodeId, 42, samplingInterval: 50, queueSize: 2, discardOldest: true)) .ConfigureAwait(false); Assert.That( StatusCode.IsGood(createResp.Results[0].StatusCode), Is.True); await DrainPublishAsync().ConfigureAwait(false); // Write more values than queue can hold for (int i = 0; i < 5; i++) { await WriteVariantAsync( nodeId, new Variant(80000 + i)) .ConfigureAwait(false); await Task.Delay(60).ConfigureAwait(false); } DataChangeNotification dcn = await PublishAndGetDcnAsync(500).ConfigureAwait(false); Assert.That(dcn, Is.Not.Null); Assert.That(dcn.MonitoredItems.Count, Is.GreaterThan(0), "Should get notifications after overflow"); } [Test] public async Task RapidChangesOverflowDiscardNewest() { NodeId nodeId = ToNodeId(Constants.ScalarStaticInt32); CreateMonitoredItemsResponse createResp = await CreateSingleItemAsync( CreateItemRequest(nodeId, 43, samplingInterval: 50, queueSize: 2, discardOldest: false)) .ConfigureAwait(false); Assert.That( StatusCode.IsGood(createResp.Results[0].StatusCode), Is.True); await DrainPublishAsync().ConfigureAwait(false); for (int i = 0; i < 5; i++) { await WriteVariantAsync( nodeId, new Variant(90000 + i)) .ConfigureAwait(false); await Task.Delay(60).ConfigureAwait(false); } DataChangeNotification dcn = await PublishAndGetDcnAsync(500).ConfigureAwait(false); Assert.That(dcn, Is.Not.Null); Assert.That(dcn.MonitoredItems.Count, Is.GreaterThan(0), "Should get notifications with discard-newest"); } [Test] public async Task OverflowBitSetOnQueueOverflowAsync() { NodeId nodeId = ToNodeId(Constants.ScalarStaticInt32); CreateMonitoredItemsResponse createResp = await CreateSingleItemAsync( CreateItemRequest(nodeId, 44, samplingInterval: 50, queueSize: 1, discardOldest: true)) .ConfigureAwait(false); Assert.That( StatusCode.IsGood(createResp.Results[0].StatusCode), Is.True); await DrainPublishAsync().ConfigureAwait(false); // Force overflow with many rapid writes for (int i = 0; i < 20; i++) { await WriteVariantAsync( nodeId, new Variant(100000 + i)) .ConfigureAwait(false); } DataChangeNotification dcn = await PublishAndGetDcnAsync(500).ConfigureAwait(false); Assert.That(dcn, Is.Not.Null); Assert.That(dcn.MonitoredItems.Count, Is.GreaterThan(0)); // Check if any item has the overflow bit set bool hasOverflow = dcn.MonitoredItems.ToArray()!.Any( m => m.Value.StatusCode.Overflow); // Server may or may not set overflow on queue size 1; // just verify we got data without error Assert.That( dcn.MonitoredItems.Count, Is.GreaterThan(0), "Should have notification items after overflow"); if (hasOverflow) { Assert.Pass("Overflow bit correctly set."); } } [Test] public async Task FiveItemsOnDifferentNodesAllNotify() { var nodeIds = new NodeId[] { ToNodeId(Constants.ScalarStaticInt32), ToNodeId(Constants.ScalarStaticDouble), ToNodeId(Constants.ScalarStaticString), ToNodeId(Constants.ScalarStaticBoolean), ToNodeId(Constants.ScalarStaticFloat) }; var items = new List(); for (int i = 0; i < nodeIds.Length; i++) { items.Add(CreateItemRequest( nodeIds[i], (uint)(50 + i), samplingInterval: 50)); } CreateMonitoredItemsResponse createResp = await Session.CreateMonitoredItemsAsync( null, m_subscriptionId, TimestampsToReturn.Both, items.ToArray().ToArrayOf(), CancellationToken.None).ConfigureAwait(false); Assert.That( createResp.Results.Count, Is.EqualTo(5)); foreach (MonitoredItemCreateResult r in createResp.Results) { Assert.That( StatusCode.IsGood(r.StatusCode), Is.True); } await DrainPublishAsync().ConfigureAwait(false); // Write to all nodes var writeValues = new WriteValue[] { new() { NodeId = nodeIds[0], AttributeId = Attributes.Value, Value = new DataValue(new Variant(111)) }, new() { NodeId = nodeIds[1], AttributeId = Attributes.Value, Value = new DataValue(new Variant(2.22)) }, new() { NodeId = nodeIds[2], AttributeId = Attributes.Value, Value = new DataValue( new Variant("multi_" + DateTime.UtcNow.Ticks)) }, new() { NodeId = nodeIds[3], AttributeId = Attributes.Value, Value = new DataValue(new Variant(true)) }, new() { NodeId = nodeIds[4], AttributeId = Attributes.Value, Value = new DataValue(new Variant(3.14f)) } }; WriteResponse writeResp = await Session.WriteAsync( null, writeValues.ToArrayOf(), CancellationToken.None).ConfigureAwait(false); foreach (StatusCode sc in writeResp.Results) { Assert.That(StatusCode.IsGood(sc), Is.True); } DataChangeNotification dcn = await PublishAndGetDcnAsync(500).ConfigureAwait(false); Assert.That(dcn, Is.Not.Null); Assert.That(dcn.MonitoredItems.Count, Is.GreaterThan(0), "All five items should have notified"); } [Test] public async Task TwoItemsOnSameNodeBothNotifyAsync() { NodeId nodeId = ToNodeId(Constants.ScalarStaticInt32); var items = new MonitoredItemCreateRequest[] { CreateItemRequest(nodeId, 60, samplingInterval: 50), CreateItemRequest(nodeId, 61, samplingInterval: 50) }; CreateMonitoredItemsResponse createResp = await Session.CreateMonitoredItemsAsync( null, m_subscriptionId, TimestampsToReturn.Both, items.ToArrayOf(), CancellationToken.None).ConfigureAwait(false); Assert.That(createResp.Results.Count, Is.EqualTo(2)); Assert.That( StatusCode.IsGood(createResp.Results[0].StatusCode), Is.True); Assert.That( StatusCode.IsGood(createResp.Results[1].StatusCode), Is.True); await DrainPublishAsync().ConfigureAwait(false); await WriteVariantAsync(nodeId, new Variant(44444)) .ConfigureAwait(false); DataChangeNotification dcn = await PublishAndGetDcnAsync(500).ConfigureAwait(false); Assert.That(dcn, Is.Not.Null); // Both monitored items should have notifications var handles = dcn.MonitoredItems.ToArray()! .Select(m => m.ClientHandle).ToList(); Assert.That(handles, Does.Contain(60u), "First item should be notified"); Assert.That(handles, Does.Contain(61u), "Second item should be notified"); } [Test] public async Task ItemsWithDifferentSamplingIntervals() { NodeId nodeId = ToNodeId(Constants.ScalarStaticInt32); var items = new MonitoredItemCreateRequest[] { CreateItemRequest(nodeId, 70, samplingInterval: 50), CreateItemRequest(nodeId, 71, samplingInterval: 5000) }; CreateMonitoredItemsResponse createResp = await Session.CreateMonitoredItemsAsync( null, m_subscriptionId, TimestampsToReturn.Both, items.ToArrayOf(), CancellationToken.None).ConfigureAwait(false); Assert.That(createResp.Results.Count, Is.EqualTo(2)); Assert.That( StatusCode.IsGood(createResp.Results[0].StatusCode), Is.True); Assert.That( StatusCode.IsGood(createResp.Results[1].StatusCode), Is.True); double revisedFast = createResp.Results[0].RevisedSamplingInterval; double revisedSlow = createResp.Results[1].RevisedSamplingInterval; // The server should honor or revise differently Assert.That(revisedSlow, Is.GreaterThanOrEqualTo(revisedFast), "Slow interval should be >= fast interval"); } [Test] public async Task ItemsWithDifferentClientHandlesAsync() { NodeId nodeId1 = ToNodeId(Constants.ScalarStaticInt32); NodeId nodeId2 = ToNodeId(Constants.ScalarStaticDouble); var items = new MonitoredItemCreateRequest[] { CreateItemRequest(nodeId1, 80, samplingInterval: 50), CreateItemRequest(nodeId2, 81, samplingInterval: 50) }; CreateMonitoredItemsResponse createResp = await Session.CreateMonitoredItemsAsync( null, m_subscriptionId, TimestampsToReturn.Both, items.ToArrayOf(), CancellationToken.None).ConfigureAwait(false); Assert.That(createResp.Results.Count, Is.EqualTo(2)); await DrainPublishAsync().ConfigureAwait(false); await WriteVariantAsync(nodeId1, new Variant(55555)) .ConfigureAwait(false); await WriteVariantAsync(nodeId2, new Variant(6.66)) .ConfigureAwait(false); DataChangeNotification dcn = await PublishAndGetDcnAsync(500).ConfigureAwait(false); Assert.That(dcn, Is.Not.Null); foreach (MonitoredItemNotification item in dcn.MonitoredItems) { Assert.That( item.ClientHandle, Is.AnyOf(80u, 81u), "ClientHandle must match requested value"); } } [Test] public async Task CreateAndDeleteItemRepeatedlyAsync() { NodeId nodeId = ToNodeId(Constants.ScalarStaticInt32); for (int round = 0; round < 3; round++) { CreateMonitoredItemsResponse createResp = await CreateSingleItemAsync( CreateItemRequest( nodeId, (uint)(90 + round), samplingInterval: 50)) .ConfigureAwait(false); Assert.That( StatusCode.IsGood( createResp.Results[0].StatusCode), Is.True, $"Round {round}: create failed"); uint monitoredItemId = createResp.Results[0].MonitoredItemId; // Get initial notification DataChangeNotification dcn = await PublishAndGetDcnAsync(300) .ConfigureAwait(false); Assert.That(dcn, Is.Not.Null, $"Round {round}: no initial DCN"); // Delete the item DeleteMonitoredItemsResponse deleteResp = await Session.DeleteMonitoredItemsAsync( null, m_subscriptionId, new uint[] { monitoredItemId }.ToArrayOf(), CancellationToken.None) .ConfigureAwait(false); Assert.That( StatusCode.IsGood(deleteResp.Results[0]), Is.True, $"Round {round}: delete failed"); } } private MonitoredItemCreateRequest CreateItemRequest( NodeId nodeId, uint clientHandle, double samplingInterval = 100, uint queueSize = 10, MonitoringMode mode = MonitoringMode.Reporting, uint attributeId = Attributes.Value, bool discardOldest = true, ExtensionObject filter = default) { return new MonitoredItemCreateRequest { ItemToMonitor = new ReadValueId { NodeId = nodeId, AttributeId = attributeId }, MonitoringMode = mode, RequestedParameters = new MonitoringParameters { ClientHandle = clientHandle, SamplingInterval = samplingInterval, Filter = filter, DiscardOldest = discardOldest, QueueSize = queueSize } }; } private async Task WriteVariantAsync( NodeId nodeId, Variant value) { WriteResponse writeResp = await Session.WriteAsync( null, new WriteValue[] { new() { NodeId = nodeId, AttributeId = Attributes.Value, Value = new DataValue(value) } }.ToArrayOf(), CancellationToken.None).ConfigureAwait(false); Assert.That( StatusCode.IsGood(writeResp.Results[0]), Is.True, $"Write to {nodeId} failed: {writeResp.Results[0]}"); } private async Task TryWriteVariantAsync( NodeId nodeId, Variant value) { WriteResponse writeResp = await Session.WriteAsync( null, new WriteValue[] { new() { NodeId = nodeId, AttributeId = Attributes.Value, Value = new DataValue(value) } }.ToArrayOf(), CancellationToken.None).ConfigureAwait(false); return StatusCode.IsGood(writeResp.Results[0]); } private async Task PublishAndGetDcnAsync( int delayMs = 300) { await Task.Delay(delayMs).ConfigureAwait(false); PublishResponse pubResp = await Session.PublishWithTimeoutAsync().ConfigureAwait(false); Assert.That( StatusCode.IsGood(pubResp.ResponseHeader.ServiceResult), Is.True); if (pubResp.NotificationMessage.NotificationData.Count == 0) { return null!; } return (ExtensionObject.ToEncodeable( pubResp.NotificationMessage.NotificationData[0]) as DataChangeNotification)!; } /// /// Drain all pending publish responses so subsequent tests /// start from a clean state. /// private async Task DrainPublishAsync() { try { await Task.Delay(200).ConfigureAwait(false); await Session.PublishWithTimeoutAsync().ConfigureAwait(false); } catch (ServiceResultException) { // ignore } } private Variant GetTestValueForIndex(int index) { return index switch { 0 => new Variant(true), 1 => new Variant((sbyte)42), 2 => new Variant((byte)42), 3 => new Variant((short)4200), 4 => new Variant((ushort)4200), 5 => new Variant(42000), 6 => new Variant(42000u), 7 => new Variant(420000L), 8 => new Variant(420000UL), 9 => new Variant(42.5f), 10 => new Variant(42.5), 11 => new Variant("TestValue_" + DateTime.UtcNow.Ticks), 12 => new Variant(DateTime.UtcNow), 13 => new Variant(new Uuid(Guid.NewGuid())), 14 => new Variant(new byte[] { 1, 2, 3 }), 15 => new Variant(new NodeId("test", 0)), 16 => new Variant(new LocalizedText("en", "test")), 17 => new Variant(new QualifiedName("test")), 18 => new Variant(123), _ => throw new ArgumentOutOfRangeException(nameof(index)) }; } private uint m_subscriptionId; private async Task CreateSingleItemAsync(MonitoredItemCreateRequest item) { return await Session.CreateMonitoredItemsAsync( null, m_subscriptionId, TimestampsToReturn.Both, new MonitoredItemCreateRequest[] { item }.ToArrayOf(), CancellationToken.None).ConfigureAwait(false); } } }