mirror of
https://github.com/OPCFoundation/UA-.NETStandard.git
synced 2026-10-19 07:02:16 +08:00
15d0ce7bd5
# Description Implement OPC UA Part 10 Programs across `Opc.Ua.Core`/`Core.Types`, `Opc.Ua.Server`, and `Opc.Ua.Client`, reusing the existing state-machine, source-generation, node-management, auditing, and streaming-subscription infrastructure. - Add asynchronous provider-backed execution, standard controls, internal/autonomous transitions, generation checks, and nested-state activation/resumption. - Add checked creation/adoption, vendor Create adapters, deletion, recycling and instance limits, auto-delete, DI, and direct-construction paths. - Maintain ProgramDiagnostic2 and retained final results; publish captured intermediate results and concrete transition/audit events with access controls. - Add generated SDK models/proxies, typed client discovery and reads, event streams, and subscribe-before-call readiness handling. - Add unit, connected client/server, generated Annex A-style model, and NativeAOT coverage. Document usage and migration in `docs/Programs.md` and related guides. ### Compatibility and design decisions The linked Part 10 document is v1.05.00; namespace-zero definitions remain aligned with 1.05.07. Documentation distinguishes normative requirements from conflicting specification text and SDK conventions. - Correct state numbers to Halted=11, Ready=12, Running=13, Suspended=14. Generated subtypes retain inherited namespace-zero state/transition identifiers. - Managed Reset is Halted-to-Ready only. Preserve the existing low-level Reset-from-Suspended behavior for compatibility. - Preserve UInt32 limit types; use `UInt32.MaxValue` as a documented SDK unlimited convention. RecycleCount overflow reports BadOutOfRange without stopping unlimited execution. - In-memory default with injectable providers. Durable recovery, distributed execution/failover, and a production Annex A download engine are excluded. ## Checklist _Put an `x` in the boxes that apply. You can complete these step by step after opening the PR._ - [ ] I have signed the [CLA](https://opcfoundation.org/license/cla/ContributorLicenseAgreementv1.0.pdf) and read the [CONTRIBUTING](https://github.com/OPCFoundation/UA-.NETStandard/blob/master/CONTRIBUTING.md) doc. - [ ] I have added tests that prove my fix is effective or that my feature works and increased code coverage. - [x] I have added all necessary documentation. - [x] I have verified that my changes do not introduce (new) build or analyzer warnings. - [ ] I ran **all** tests locally using the **UA.slnx** solution against at least .net **framework** and .net **10**, and all passed. - [ ] I fixed **all** failing and flaky tests in the CI pipelines and **all** CodeQL warnings. - [ ] I have addressed **all** PR feedback received. Tests and focused coverage evidence are above; the combined tests/coverage checkbox remains open because a whole-repository coverage increase has not been established. CLA status, CI/CodeQL results, tracking-issue approval, and PR feedback are not asserted.
557 lines
25 KiB
C#
557 lines
25 KiB
C#
/* ========================================================================
|
|
* 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
|
|
{
|
|
/// <summary>
|
|
/// Captured Program event payloads, filtering, readiness and explicit loss/failure behavior.
|
|
/// </summary>
|
|
[TestFixture]
|
|
[Category("Programs")]
|
|
[Parallelizable(ParallelScope.Fixtures)]
|
|
public sealed class ProgramStreamTests
|
|
{
|
|
/// <summary>
|
|
/// Creates a strict session and remapped SDK event identifiers.
|
|
/// </summary>
|
|
[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<ISessionClient>(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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Captured progress events remain distinct even when their target state is unchanged.
|
|
/// </summary>
|
|
[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<ProgramTransitionEventTypeRecord>();
|
|
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<Ua.Programs.ManagedProgramTransitionEventTypeRecord>());
|
|
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<RequestHeader>(), It.IsAny<double>(),
|
|
It.IsAny<TimestampsToReturn>(), It.IsAny<ArrayOf<ReadValueId>>(), It.IsAny<CancellationToken>()),
|
|
Times.Never);
|
|
}
|
|
|
|
/// <summary>
|
|
/// BaseDataType state identifiers from vendor event models remain intact in typed records.
|
|
/// </summary>
|
|
[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));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Concrete audit events decode through their generated model registration.
|
|
/// </summary>
|
|
[Test]
|
|
public async Task AuditStreamUsesGeneratedRecordAndCapturedTransitionNumberAsync()
|
|
{
|
|
var stream = new ScriptedStreaming(filter => [Fields(filter, m_auditType, s_invocation, 0)]);
|
|
var received = new List<AuditProgramTransitionEventTypeRecord>();
|
|
await foreach (AuditProgramTransitionEventTypeRecord record in m_proxy.ObserveProgramAuditsAsync(
|
|
stream, ObjectIds.Server).ConfigureAwait(false))
|
|
{
|
|
received.Add(record);
|
|
}
|
|
Assert.Multiple(() =>
|
|
{
|
|
Assert.That(received.Single(), Is.InstanceOf<Ua.Programs.ManagedProgramAuditEventTypeRecord>());
|
|
Assert.That(received[0].TransitionNumber, Is.EqualTo(2u));
|
|
Assert.That(received[0].Status, Is.True);
|
|
});
|
|
}
|
|
|
|
/// <summary>
|
|
/// A control cannot run before its event monitored item exists on the server.
|
|
/// </summary>
|
|
[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<RequestHeader>(),
|
|
It.Is<ArrayOf<CallMethodRequest>>(r => r[0].MethodId == MethodIds.ProgramStateMachineType_Start),
|
|
It.IsAny<CancellationToken>()))
|
|
.Returns(() =>
|
|
{
|
|
Assert.That(stream.ReadinessInvoked, Is.True);
|
|
Assert.That(stream.Item.Created, Is.True);
|
|
invoked = true;
|
|
return new ValueTask<CallResponse>(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);
|
|
});
|
|
}
|
|
|
|
/// <summary>
|
|
/// A custom streaming implementation must explicitly support readiness for control-and-wait.
|
|
/// </summary>
|
|
[Test]
|
|
public void ReadinessUnsupportedFailsWithoutIssuingControl()
|
|
{
|
|
var stream = new Mock<IStreamingSubscription>(MockBehavior.Strict);
|
|
bool called = false;
|
|
Assert.ThrowsAsync<NotSupportedException>(async () =>
|
|
await m_proxy.WaitForProgramTransitionAsync(stream.Object, ObjectIds.Server, ProgramState.Running,
|
|
_ =>
|
|
{
|
|
called = true;
|
|
return default;
|
|
}).ConfigureAwait(false));
|
|
Assert.That(called, Is.False);
|
|
}
|
|
|
|
/// <summary>
|
|
/// A failed server monitored-item creation aborts the wait before control dispatch.
|
|
/// </summary>
|
|
[Test]
|
|
public void MonitoredItemCreationFailureDoesNotIssueControl()
|
|
{
|
|
var item = new Mock<IMonitoredItem>();
|
|
item.SetupGet(i => i.Created).Returns(false);
|
|
item.SetupGet(i => i.Error).Returns(new ServiceResult(StatusCodes.BadUserAccessDenied));
|
|
item.As<IMonitoredItemApplyState>().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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Server overflow events bypass invocation filtering and report loss explicitly.
|
|
/// </summary>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// The concrete streaming implementation's overflow counter invalidates a pending wait.
|
|
/// </summary>
|
|
[Test]
|
|
public async Task LocalOverflowFailsWaitInsteadOfClaimingCompleteObservationAsync()
|
|
{
|
|
var monitoredItem = new Mock<IMonitoredItem>();
|
|
monitoredItem.SetupGet(i => i.ClientHandle).Returns(17);
|
|
monitoredItem.SetupGet(i => i.Created).Returns(true);
|
|
IMonitoredItem? item = monitoredItem.Object;
|
|
var items = new Mock<IMonitoredItemCollection>();
|
|
items.Setup(i => i.TryAdd(It.IsAny<string>(), It.IsAny<IOptionsMonitor<MonitoringOptions>>(), out item))
|
|
.Returns(true);
|
|
var subscription = new Mock<ISubscription>();
|
|
subscription.SetupGet(s => s.MonitoredItems).Returns(items.Object);
|
|
var manager = new Mock<ISubscriptionManager>();
|
|
manager.Setup(m => m.Add(It.IsAny<ISubscriptionNotificationHandler>(),
|
|
It.IsAny<IOptionsMonitor<Subscriptions.SubscriptionOptions>>()))
|
|
.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));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Required event fields are validated before the permissive generated decoder runs.
|
|
/// </summary>
|
|
[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<Variant>.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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// A custom decoder failure remains observable and is never converted to an empty stream.
|
|
/// </summary>
|
|
[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<InvalidOperationException>(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"));
|
|
}
|
|
|
|
/// <summary>
|
|
/// An explicitly registered decoder returning no record does not trigger a success-shaped fallback.
|
|
/// </summary>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Cancellation stops readiness and does not perform a state read or control.
|
|
/// </summary>
|
|
[Test]
|
|
public void CancelledWaitDoesNotReadStateOrRunControl()
|
|
{
|
|
var stream = new ScriptedStreaming(_ => []);
|
|
using var cancellation = new CancellationTokenSource();
|
|
cancellation.Cancel();
|
|
bool called = false;
|
|
Assert.ThrowsAsync<OperationCanceledException>(async () =>
|
|
await m_proxy.WaitForProgramTransitionAsync(stream, ObjectIds.Server, ProgramState.Running,
|
|
_ =>
|
|
{
|
|
called = true;
|
|
return default;
|
|
}, ct: cancellation.Token).ConfigureAwait(false));
|
|
Assert.That(called, Is.False);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Unexpected stream completion is not confused with reaching the target state.
|
|
/// </summary>
|
|
[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<Variant> 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<Task> action, StatusCode expected)
|
|
{
|
|
ServiceResultException exception = Assert.ThrowsAsync<ServiceResultException>(action)!;
|
|
Assert.That(exception.StatusCode, Is.EqualTo(expected));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Scripted caller-owned streaming subscription with the real readiness contract.
|
|
/// </summary>
|
|
private sealed class ScriptedStreaming : IStreamingSubscription, IStreamingSubscriptionReadiness
|
|
{
|
|
/// <summary>
|
|
/// Creates a scripted stream with a caller-provided monitored-item result.
|
|
/// </summary>
|
|
public ScriptedStreaming(
|
|
Func<EventFilter, ArrayOf<ArrayOf<Variant>>> script,
|
|
IMonitoredItem? item = null)
|
|
{
|
|
m_script = script;
|
|
if (item is null)
|
|
{
|
|
var mock = new Mock<IMonitoredItem>();
|
|
mock.SetupGet(i => i.Created).Returns(true);
|
|
item = mock.Object;
|
|
}
|
|
Item = item;
|
|
}
|
|
|
|
/// <summary>
|
|
/// The item made available to the readiness callback.
|
|
/// </summary>
|
|
public IMonitoredItem Item { get; }
|
|
|
|
/// <summary>
|
|
/// The most recently requested server event filter.
|
|
/// </summary>
|
|
public EventFilter? LastFilter { get; private set; }
|
|
|
|
/// <summary>
|
|
/// Whether the consumer disposed its event enumerator.
|
|
/// </summary>
|
|
public bool EnumeratorDisposed { get; private set; }
|
|
|
|
/// <summary>
|
|
/// Whether readiness was invoked before yielding notifications.
|
|
/// </summary>
|
|
public bool ReadinessInvoked { get; private set; }
|
|
|
|
/// <summary>
|
|
/// The number of attempts to dispose the caller-owned subscription.
|
|
/// </summary>
|
|
public int DisposeCalls { get; private set; }
|
|
|
|
/// <inheritdoc/>
|
|
public IAsyncEnumerable<EventNotification> SubscribeEventsAsync(
|
|
NodeId notifierId, EventFilter filter,
|
|
MonitoringOptions? options = null, CancellationToken ct = default)
|
|
{
|
|
LastFilter = filter;
|
|
return EnumerateAsync(filter, null, ct);
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
public IAsyncEnumerable<EventNotification> SubscribeEventsAsync(
|
|
NodeId notifierId, EventFilter filter, MonitoringOptions? options,
|
|
Func<IMonitoredItem, CancellationToken, ValueTask> onMonitoredItemReady,
|
|
CancellationToken ct = default)
|
|
{
|
|
LastFilter = filter;
|
|
return EnumerateAsync(filter, onMonitoredItemReady, ct);
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
public IAsyncEnumerable<DataValueChange> SubscribeDataChangesAsync(
|
|
NodeId nodeId, MonitoringOptions? options = null, CancellationToken ct = default)
|
|
{
|
|
throw new NotSupportedException();
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
public IAsyncEnumerable<DataValueChange> SubscribeDataChangesAsync(
|
|
IReadOnlyList<NodeId> nodeIds, MonitoringOptions? options = null, CancellationToken ct = default)
|
|
{
|
|
throw new NotSupportedException();
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
public ValueTask DisposeAsync()
|
|
{
|
|
DisposeCalls++;
|
|
return default;
|
|
}
|
|
|
|
private async IAsyncEnumerable<EventNotification> EnumerateAsync(
|
|
EventFilter filter,
|
|
Func<IMonitoredItem, CancellationToken, ValueTask>? ready,
|
|
[EnumeratorCancellation] CancellationToken ct)
|
|
{
|
|
try
|
|
{
|
|
ct.ThrowIfCancellationRequested();
|
|
if (ready is not null)
|
|
{
|
|
ReadinessInvoked = true;
|
|
await ready(Item, ct).ConfigureAwait(false);
|
|
}
|
|
ArrayOf<ArrayOf<Variant>> 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<EventFilter, ArrayOf<ArrayOf<Variant>>> m_script;
|
|
}
|
|
|
|
private static readonly NodeId s_invocation = new("program", 2);
|
|
private Mock<ISessionClient> m_session = null!;
|
|
private ProgramStateMachineTypeClient m_proxy = null!;
|
|
private NodeId m_transitionType;
|
|
private NodeId m_auditType;
|
|
}
|
|
}
|