/* ======================================================================== * 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; } }