/* ========================================================================
* Copyright (c) 2005-2026 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.Runtime.CompilerServices;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Options;
using Moq;
using NUnit.Framework;
using Opc.Ua.Client.Programs;
using Opc.Ua.Client.Subscriptions;
using Opc.Ua.Client.Subscriptions.MonitoredItems;
using Opc.Ua.Client.Subscriptions.Streaming;
using Opc.Ua.Tests;
using ProgramState = Opc.Ua.Programs.ProgramState;
using ProgramStateMachineModel = Opc.Ua.Programs.ProgramStateMachineModel;
using MonitoringOptions = Opc.Ua.Client.Subscriptions.MonitoredItems.MonitoredItemOptions;
namespace Opc.Ua.Client.Tests.Programs
{
///
/// Captured Program event payloads, filtering, readiness and explicit loss/failure behavior.
///
[TestFixture]
[Category("Programs")]
[Parallelizable(ParallelScope.Fixtures)]
public sealed class ProgramStreamTests
{
///
/// Creates a strict session and remapped SDK event identifiers.
///
[SetUp]
public void SetUp()
{
ITelemetryContext telemetry = NUnitTelemetryContext.Create();
ServiceMessageContext context = ServiceMessageContext.Create(telemetry);
context.NamespaceUris.GetIndexOrAppend("urn:unrelated");
context.NamespaceUris.GetIndexOrAppend(Ua.Programs.Namespaces.OpcUaPrograms);
m_session = new Mock(MockBehavior.Strict);
m_session.SetupGet(s => s.MessageContext).Returns(context);
m_proxy = new ProgramStateMachineTypeClient(m_session.Object, s_invocation, telemetry);
m_transitionType = ExpandedNodeId.ToNodeId(
Ua.Programs.ObjectTypeIds.ManagedProgramTransitionEventType, context.NamespaceUris);
m_auditType = ExpandedNodeId.ToNodeId(
Ua.Programs.ObjectTypeIds.ManagedProgramAuditEventType, context.NamespaceUris);
}
///
/// Captured progress events remain distinct even when their target state is unchanged.
///
[Test]
public async Task TransitionStreamRetainsSameStateProgressAndFiltersSourceAsync()
{
var stream = new ScriptedStreaming(filter =>
[
Fields(filter, m_transitionType, new NodeId("different-program", 2), 99),
Fields(filter, m_transitionType, s_invocation, 1),
Fields(filter, m_transitionType, s_invocation, 2)
]);
var received = new List();
await foreach (ProgramTransitionEventTypeRecord record in m_proxy.ObserveProgramTransitionsAsync(
stream, ObjectIds.Server).ConfigureAwait(false))
{
received.Add(record);
}
Assert.Multiple(() =>
{
Assert.That(received, Has.Count.EqualTo(2));
Assert.That(received[0], Is.InstanceOf());
Assert.That(received[0].IntermediateResult, Is.EqualTo(Variant.From(1)));
Assert.That(received[1].IntermediateResult, Is.EqualTo(Variant.From(2)));
Assert.That(received[1].ToStateId,
Is.EqualTo(Variant.From(ProgramStateMachineModel.GetStateId(ProgramState.Running))));
Assert.That(stream.LastFilter!.WhereClause.Elements[0].FilterOperator, Is.EqualTo(FilterOperator.Or));
Assert.That(stream.LastFilter.WhereClause.Elements.ToArray()!
.Any(e => e.FilterOperator == FilterOperator.Equals),
Is.True);
Assert.That(stream.EnumeratorDisposed, Is.True);
Assert.That(stream.DisposeCalls, Is.Zero);
});
m_session.Verify(s => s.ReadAsync(It.IsAny(), It.IsAny(),
It.IsAny(), It.IsAny>(), It.IsAny()),
Times.Never);
}
///
/// BaseDataType state identifiers from vendor event models remain intact in typed records.
///
[Test]
public async Task VendorStateIdentifiersArePreservedWithoutGuessingNodeIdsAsync()
{
var stream = new ScriptedStreaming(filter =>
{
Variant[] fields = Fields(filter, m_transitionType, s_invocation, 1).ToArray()!;
for (int index = 0; index < filter.SelectClauses.Count; index++)
{
if (Path(filter.SelectClauses[index]) == "ToState/Id")
{
fields[index] = Variant.From(42u);
}
}
return [ArrayOf.Wrapped(fields)];
});
int received = 0;
await foreach (ProgramTransitionEventTypeRecord record in m_proxy.ObserveProgramTransitionsAsync(
stream, ObjectIds.Server).ConfigureAwait(false))
{
Assert.That(record.ToStateId, Is.EqualTo(Variant.From(42u)));
received++;
}
Assert.That(received, Is.EqualTo(1));
}
///
/// Concrete audit events decode through their generated model registration.
///
[Test]
public async Task AuditStreamUsesGeneratedRecordAndCapturedTransitionNumberAsync()
{
var stream = new ScriptedStreaming(filter => [Fields(filter, m_auditType, s_invocation, 0)]);
var received = new List();
await foreach (AuditProgramTransitionEventTypeRecord record in m_proxy.ObserveProgramAuditsAsync(
stream, ObjectIds.Server).ConfigureAwait(false))
{
received.Add(record);
}
Assert.Multiple(() =>
{
Assert.That(received.Single(), Is.InstanceOf());
Assert.That(received[0].TransitionNumber, Is.EqualTo(2u));
Assert.That(received[0].Status, Is.True);
});
}
///
/// A control cannot run before its event monitored item exists on the server.
///
[Test]
public async Task WaitRunsControlOnlyAfterServerMonitoredItemIsCreatedAsync()
{
var stream = new ScriptedStreaming(filter => [Fields(filter, m_transitionType, s_invocation, 7)]);
bool invoked = false;
m_session.Setup(s => s.CallAsync(It.IsAny(),
It.Is>(r => r[0].MethodId == MethodIds.ProgramStateMachineType_Start),
It.IsAny()))
.Returns(() =>
{
Assert.That(stream.ReadinessInvoked, Is.True);
Assert.That(stream.Item.Created, Is.True);
invoked = true;
return new ValueTask(new CallResponse
{
ResponseHeader = new ResponseHeader(),
Results = [new CallMethodResult()]
});
});
ProgramTransitionEventTypeRecord result = await m_proxy.WaitForProgramTransitionAsync(
stream, ObjectIds.Server, ProgramState.Running, token => m_proxy.StartAsync(token))
.ConfigureAwait(false);
Assert.Multiple(() =>
{
Assert.That(invoked, Is.True);
Assert.That(result.IntermediateResult, Is.EqualTo(Variant.From(7)));
Assert.That(stream.EnumeratorDisposed, Is.True);
Assert.That(stream.DisposeCalls, Is.Zero);
});
}
///
/// A custom streaming implementation must explicitly support readiness for control-and-wait.
///
[Test]
public void ReadinessUnsupportedFailsWithoutIssuingControl()
{
var stream = new Mock(MockBehavior.Strict);
bool called = false;
Assert.ThrowsAsync(async () =>
await m_proxy.WaitForProgramTransitionAsync(stream.Object, ObjectIds.Server, ProgramState.Running,
_ =>
{
called = true;
return default;
}).ConfigureAwait(false));
Assert.That(called, Is.False);
}
///
/// A failed server monitored-item creation aborts the wait before control dispatch.
///
[Test]
public void MonitoredItemCreationFailureDoesNotIssueControl()
{
var item = new Mock();
item.SetupGet(i => i.Created).Returns(false);
item.SetupGet(i => i.Error).Returns(new ServiceResult(StatusCodes.BadUserAccessDenied));
item.As().SetupGet(i => i.HasPendingChanges).Returns(false);
var stream = new ScriptedStreaming(_ => [], item.Object);
bool called = false;
AssertStatus(async () =>
await m_proxy.WaitForProgramTransitionAsync(stream, ObjectIds.Server, ProgramState.Running,
_ =>
{
called = true;
return default;
}).ConfigureAwait(false), StatusCodes.BadUserAccessDenied);
Assert.That(called, Is.False);
}
///
/// Server overflow events bypass invocation filtering and report loss explicitly.
///
[Test]
public void ServerOverflowIsNeverSilentlyDiscardedBySourceFiltering()
{
var stream = new ScriptedStreaming(filter =>
[Fields(filter, ObjectTypeIds.EventQueueOverflowEventType, NodeId.Null, 0)]);
AssertStatus(async () =>
{
await foreach (ProgramTransitionEventTypeRecord _ in m_proxy.ObserveProgramTransitionsAsync(
stream, ObjectIds.Server).ConfigureAwait(false))
{
Assert.Fail("An overflow notification cannot be returned as a Program transition.");
}
}, StatusCodes.BadDataLost);
}
///
/// The concrete streaming implementation's overflow counter invalidates a pending wait.
///
[Test]
public async Task LocalOverflowFailsWaitInsteadOfClaimingCompleteObservationAsync()
{
var monitoredItem = new Mock();
monitoredItem.SetupGet(i => i.ClientHandle).Returns(17);
monitoredItem.SetupGet(i => i.Created).Returns(true);
IMonitoredItem? item = monitoredItem.Object;
var items = new Mock();
items.Setup(i => i.TryAdd(It.IsAny(), It.IsAny>(), out item))
.Returns(true);
var subscription = new Mock();
subscription.SetupGet(s => s.MonitoredItems).Returns(items.Object);
var manager = new Mock();
manager.Setup(m => m.Add(It.IsAny(),
It.IsAny>()))
.Returns(subscription.Object);
await using var streaming = new StreamingSubscription(manager.Object);
AssertStatus(async () =>
await m_proxy.WaitForProgramTransitionAsync(
streaming, ObjectIds.Server, ProgramState.Running,
_ =>
{
var notification = new EventNotification(item, []);
streaming.DispatchEvent(notification);
streaming.DispatchEvent(notification);
return default;
},
new MonitoringOptions { QueueSize = 1, DiscardOldest = true }).ConfigureAwait(false),
StatusCodes.BadDataLost);
Assert.That(streaming.DroppedNotificationCount, Is.EqualTo(1));
}
///
/// Required event fields are validated before the permissive generated decoder runs.
///
[TestCase("field-count")]
[TestCase("to-state")]
[TestCase("event-type")]
[TestCase("source")]
public void MalformedEventsFailInsteadOfBecomingDefaultRecords(string invalid)
{
var stream = new ScriptedStreaming(filter =>
{
if (invalid == "field-count")
{
return [ArrayOf.Empty];
}
Variant[] fields = Fields(filter, m_transitionType, s_invocation, 1).ToArray()!;
string target = invalid switch
{
"to-state" => "ToState/Id",
"event-type" => "EventType",
_ => "SourceNode"
};
int index = filter.SelectClauses.ToArray()!.Select((c, i) => (Path: Path(c), Index: i))
.Single(p => p.Path == target).Index;
fields[index] = invalid == "to-state" ? Variant.Null : Variant.From(123);
return [ArrayOf.Wrapped(fields)];
});
AssertStatus(async () =>
{
await foreach (ProgramTransitionEventTypeRecord _ in m_proxy.ObserveProgramTransitionsAsync(
stream, ObjectIds.Server).ConfigureAwait(false))
{
Assert.Fail("Malformed event was accepted.");
}
}, StatusCodes.BadDecodingError);
}
///
/// A custom decoder failure remains observable and is never converted to an empty stream.
///
[Test]
public void DecoderExceptionsArePropagated()
{
var registry = new EventRecordDecoderRegistry();
registry.Register(m_transitionType, ProgramTransitionEventTypeRecord.Decoder.StandardFields,
_ => throw new InvalidOperationException("decoder-failure"));
var stream = new ScriptedStreaming(filter => [Fields(filter, m_transitionType, s_invocation, 1)]);
InvalidOperationException error = Assert.ThrowsAsync(async () =>
{
await foreach (ProgramTransitionEventTypeRecord _ in m_proxy.ObserveProgramTransitionsAsync(
stream, ObjectIds.Server, registry).ConfigureAwait(false))
{
Assert.Fail("Decoder error was swallowed.");
}
})!;
Assert.That(error.Message, Is.EqualTo("decoder-failure"));
}
///
/// An explicitly registered decoder returning no record does not trigger a success-shaped fallback.
///
[Test]
public void NullCustomDecoderResultIsAnExplicitDecodingFailure()
{
var registry = new EventRecordDecoderRegistry();
registry.Register(m_transitionType, ProgramTransitionEventTypeRecord.Decoder.StandardFields, _ => null);
var stream = new ScriptedStreaming(filter => [Fields(filter, m_transitionType, s_invocation, 1)]);
AssertStatus(async () =>
{
await foreach (ProgramTransitionEventTypeRecord _ in m_proxy.ObserveProgramTransitionsAsync(
stream, ObjectIds.Server, registry).ConfigureAwait(false))
{
Assert.Fail("A null decoder result was silently replaced.");
}
}, StatusCodes.BadDecodingError);
}
///
/// Cancellation stops readiness and does not perform a state read or control.
///
[Test]
public void CancelledWaitDoesNotReadStateOrRunControl()
{
var stream = new ScriptedStreaming(_ => []);
using var cancellation = new CancellationTokenSource();
cancellation.Cancel();
bool called = false;
Assert.ThrowsAsync(async () =>
await m_proxy.WaitForProgramTransitionAsync(stream, ObjectIds.Server, ProgramState.Running,
_ =>
{
called = true;
return default;
}, ct: cancellation.Token).ConfigureAwait(false));
Assert.That(called, Is.False);
}
///
/// Unexpected stream completion is not confused with reaching the target state.
///
[Test]
public void EarlyStreamEndIsReportedAsNoData()
{
var stream = new ScriptedStreaming(_ => []);
AssertStatus(async () =>
await m_proxy.WaitForProgramTransitionAsync(stream, ObjectIds.Server, ProgramState.Halted)
.ConfigureAwait(false), StatusCodes.BadNoData);
}
private static ArrayOf Fields(EventFilter filter, NodeId type, NodeId source, int progress)
{
return ArrayOf.Wrapped(filter.SelectClauses.ToArray()!.Select(clause => Path(clause) switch
{
"EventId" => Variant.From(ByteString.From([1, 2, 3])),
"EventType" => Variant.From(type),
"SourceNode" => Variant.From(source),
"SourceName" => Variant.From("Program"),
"Time" or "ReceiveTime" or "ActionTimeStamp" => Variant.From(DateTimeUtc.Now),
"Message" => Variant.From(new LocalizedText("progress")),
"Severity" => Variant.From((ushort)1),
"FromState" or "ToState" => Variant.From(new LocalizedText("Running")),
"Transition" => Variant.From(new LocalizedText("RunningToRunning")),
"FromState/Id" or "ToState/Id" or "OldStateId" or "NewStateId" =>
Variant.From(ProgramStateMachineModel.GetStateId(ProgramState.Running)),
"Transition/Id" => Variant.From(new NodeId("vendor-progress", 2)),
"IntermediateResult" => Variant.From(progress),
"TransitionNumber" => Variant.From(2u),
"Status" => Variant.From(true),
_ => Variant.Null
}).ToArray());
}
private static string Path(SimpleAttributeOperand clause)
{
return string.Join("/", clause.BrowsePath.ToArray()!.Select(p => p.Name));
}
private static void AssertStatus(Func action, StatusCode expected)
{
ServiceResultException exception = Assert.ThrowsAsync(action)!;
Assert.That(exception.StatusCode, Is.EqualTo(expected));
}
///
/// Scripted caller-owned streaming subscription with the real readiness contract.
///
private sealed class ScriptedStreaming : IStreamingSubscription, IStreamingSubscriptionReadiness
{
///
/// Creates a scripted stream with a caller-provided monitored-item result.
///
public ScriptedStreaming(
Func>> script,
IMonitoredItem? item = null)
{
m_script = script;
if (item is null)
{
var mock = new Mock();
mock.SetupGet(i => i.Created).Returns(true);
item = mock.Object;
}
Item = item;
}
///
/// The item made available to the readiness callback.
///
public IMonitoredItem Item { get; }
///
/// The most recently requested server event filter.
///
public EventFilter? LastFilter { get; private set; }
///
/// Whether the consumer disposed its event enumerator.
///
public bool EnumeratorDisposed { get; private set; }
///
/// Whether readiness was invoked before yielding notifications.
///
public bool ReadinessInvoked { get; private set; }
///
/// The number of attempts to dispose the caller-owned subscription.
///
public int DisposeCalls { get; private set; }
///
public IAsyncEnumerable SubscribeEventsAsync(
NodeId notifierId, EventFilter filter,
MonitoringOptions? options = null, CancellationToken ct = default)
{
LastFilter = filter;
return EnumerateAsync(filter, null, ct);
}
///
public IAsyncEnumerable SubscribeEventsAsync(
NodeId notifierId, EventFilter filter, MonitoringOptions? options,
Func onMonitoredItemReady,
CancellationToken ct = default)
{
LastFilter = filter;
return EnumerateAsync(filter, onMonitoredItemReady, ct);
}
///
public IAsyncEnumerable SubscribeDataChangesAsync(
NodeId nodeId, MonitoringOptions? options = null, CancellationToken ct = default)
{
throw new NotSupportedException();
}
///
public IAsyncEnumerable SubscribeDataChangesAsync(
IReadOnlyList nodeIds, MonitoringOptions? options = null, CancellationToken ct = default)
{
throw new NotSupportedException();
}
///
public ValueTask DisposeAsync()
{
DisposeCalls++;
return default;
}
private async IAsyncEnumerable EnumerateAsync(
EventFilter filter,
Func? ready,
[EnumeratorCancellation] CancellationToken ct)
{
try
{
ct.ThrowIfCancellationRequested();
if (ready is not null)
{
ReadinessInvoked = true;
await ready(Item, ct).ConfigureAwait(false);
}
ArrayOf> events = m_script(filter);
for (int index = 0; index < events.Count; index++)
{
ct.ThrowIfCancellationRequested();
yield return new EventNotification(Item, events[index]);
}
}
finally
{
EnumeratorDisposed = true;
}
}
private readonly Func>> m_script;
}
private static readonly NodeId s_invocation = new("program", 2);
private Mock m_session = null!;
private ProgramStateMachineTypeClient m_proxy = null!;
private NodeId m_transitionType;
private NodeId m_auditType;
}
}