/* ========================================================================
* 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.Threading;
using System.Threading.Tasks;
using Moq;
using NUnit.Framework;
using Opc.Ua.Machinery.Result;
using Opc.Ua.MachineTool;
using Opc.Ua.Tests;
namespace Opc.Ua.GMS.Tests
{
///
/// Tests generated GMS proxies through the session transport boundary without a client/server runtime dependency.
///
[TestFixture]
[Category("GMS")]
public sealed class ModelProxyTests
{
///
/// GMS child access uses each declaration's namespace and specialized proxy, not the root model namespace.
///
[TestCase("Identification", "http://opcfoundation.org/UA/DI/", typeof(GMSIdentificationTypeClient))]
[TestCase("Equipment", "http://opcfoundation.org/UA/MachineTool/", typeof(GMSEquipmentTypeClient))]
[TestCase("Monitoring", "http://opcfoundation.org/UA/MachineTool/", typeof(GMSMonitoringTypeClient))]
[TestCase("Notification", "http://opcfoundation.org/UA/MachineTool/", typeof(NotificationTypeClient))]
[TestCase("Production", "http://opcfoundation.org/UA/MachineTool/", typeof(ProductionTypeClient))]
[TestCase("ResultManagement", "http://opcfoundation.org/UA/GMS/", typeof(GMSResultManagementTypeClient))]
public async Task GmsChildProxiesPreserveQualifiedPathsAndCacheTheResolvedInstance(
string childName,
string namespaceUri,
Type proxyType)
{
ServiceMessageContext context = ModelTestContext.CreateMessageContext(reverseNamespaces: true);
var objectId = new NodeId("gms", 1);
var childId = new NodeId("gms/" + childName, 1);
var expectedName = new QualifiedName(childName, context.NamespaceUris.GetIndexOrAppend(namespaceUri));
using var cancellation = new CancellationTokenSource();
Mock session = CreateBrowsingSession(
context, objectId, expectedName, childId, cancellation.Token);
ITelemetryContext telemetry = NUnitTelemetryContext.Create();
var proxy = new GMSTypeClient(session.Object, objectId, telemetry);
BaseObjectTypeClient? first =
await GetChildAsync(proxy, childName, telemetry, cancellation.Token).ConfigureAwait(false);
BaseObjectTypeClient? second =
await GetChildAsync(proxy, childName, telemetry, cancellation.Token).ConfigureAwait(false);
Assert.Multiple(() =>
{
Assert.That(first, Is.TypeOf(proxyType));
Assert.That(first!.ObjectId, Is.EqualTo(childId));
Assert.That(first.Session, Is.SameAs(session.Object));
Assert.That(second, Is.SameAs(first));
Assert.That(proxy, Is.InstanceOf());
Assert.That(context.NamespaceUris.GetIndex(MachineToolModels.WmtpNamespaceUri), Is.EqualTo(-1));
});
session.Verify(client => client.TranslateBrowsePathsToNodeIdsAsync(
null, It.IsAny>(), cancellation.Token), Times.Once);
}
///
/// A missing namespace does not create a fake optional child or issue a service call.
///
[Test]
public async Task MissingGmsNamespaceLeavesOptionalCorrectionsAbsent()
{
ServiceMessageContext context = ModelTestContext.CreateMessageContext(registerModel: false);
context.NamespaceUris.GetIndexOrAppend(Machinery.Result.Namespaces.MachineryResult);
var session = new Mock(MockBehavior.Strict);
session.SetupGet(client => client.MessageContext).Returns(context);
var proxy = new GMSResultManagementTypeClient(
session.Object, new NodeId("results", 1), NUnitTelemetryContext.Create());
FolderTypeClient? folder =
await proxy.GetCorrectionsFolderAsync(NUnitTelemetryContext.Create()).ConfigureAwait(false);
Assert.That(folder, Is.Null);
Assert.That(context.NamespaceUris.GetIndex(GmsModel.NamespaceUri), Is.EqualTo(-1));
session.VerifyGet(client => client.MessageContext, Times.Once);
session.VerifyNoOtherCalls();
}
///
/// The result method uses its inherited declaration identity and preserves typed results and domain codes.
///
[TestCase(0)]
[TestCase(-17)]
public async Task ResultProxyUsesInheritedDeclarationAndPreservesDomainErrors(int error)
{
ServiceMessageContext context = ModelTestContext.CreateMessageContext(reverseNamespaces: true);
var result = new ResultDataType
{
ResultMetaData = new ResultMetaDataType { ResultId = "inspection-17" },
ResultContent = [new Variant(25.75)]
};
Mock session = CreateCallSession(
context, [new Variant(42u), Variant.FromStructure(result), new Variant(error)]);
var objectId = new NodeId("gms/results", 1);
var proxy = new GMSResultManagementTypeClient(session.Object, objectId, NUnitTelemetryContext.Create());
using var cancellation = new CancellationTokenSource();
(uint handle, ResultDataType actual, int actualError) =
await proxy.GetLatestResultAsync(1500, cancellation.Token).ConfigureAwait(false);
Assert.Multiple(() =>
{
Assert.That(handle, Is.EqualTo(42));
Assert.That(actual.IsEqual(result), Is.True);
Assert.That(actual.ResultMetaData!.ResultId, Is.EqualTo("inspection-17"));
Assert.That(actualError, Is.EqualTo(error));
});
var methodId = NodeId.Create(7008, Machinery.Result.Namespaces.MachineryResult, context.NamespaceUris);
session.Verify(client => client.CallAsync(
null,
It.Is>(requests =>
requests.Count == 1 &&
requests[0].ObjectId == objectId &&
requests[0].MethodId == methodId &&
requests[0].InputArguments.Count == 1 &&
requests[0].InputArguments[0] == new Variant(1500)),
cancellation.Token), Times.Once);
}
///
/// Releasing a result handle preserves the UInt32 value and inherited method identity.
///
[TestCase(0)]
[TestCase(-17)]
public async Task ResultProxyReleasesOnlyTheSuppliedHandle(int error)
{
ServiceMessageContext context = ModelTestContext.CreateMessageContext(reverseNamespaces: true);
Mock session = CreateCallSession(context, [new Variant(error)]);
var objectId = new NodeId("gms/results", 1);
var proxy = new GMSResultManagementTypeClient(session.Object, objectId, NUnitTelemetryContext.Create());
int actualError = await proxy.ReleaseResultHandleAsync(4000000001u).ConfigureAwait(false);
Assert.That(actualError, Is.EqualTo(error));
var methodId = NodeId.Create(7007, Machinery.Result.Namespaces.MachineryResult, context.NamespaceUris);
session.Verify(client => client.CallAsync(
null,
It.Is>(requests =>
requests.Count == 1 &&
requests[0].ObjectId == objectId &&
requests[0].MethodId == methodId &&
requests[0].InputArguments.Count == 1 &&
requests[0].InputArguments[0] == new Variant(4000000001u)),
It.IsAny()), Times.Once);
}
///
/// Missing or mistyped output arguments are bad responses rather than successful empty measurements.
///
[TestCase("Missing")]
[TestCase("Handle")]
[TestCase("Result")]
[TestCase("Error")]
public async Task ResultProxyRejectsMalformedOutput(string malformed)
{
ServiceMessageContext context = ModelTestContext.CreateMessageContext();
ArrayOf output = malformed switch
{
"Missing" => [],
"Handle" => [new Variant("not a handle"), Variant.FromStructure(new ResultDataType()), new Variant(0)],
"Result" => [new Variant(42u), new Variant("not a result"), new Variant(0)],
"Error" => [new Variant(42u), Variant.FromStructure(new ResultDataType()), new Variant("not an error")],
_ => throw new ArgumentOutOfRangeException(nameof(malformed))
};
Mock session = CreateCallSession(context, output);
var proxy = new GMSResultManagementTypeClient(
session.Object, new NodeId("results", 1), NUnitTelemetryContext.Create());
ServiceResultException exception = (await Assert.ThrowsAsync(
async () => await proxy.GetLatestResultAsync(0).ConfigureAwait(false)))!;
Assert.That(exception.StatusCode, Is.EqualTo(StatusCodes.BadUnexpectedError));
}
///
/// A method-level service failure is not converted into a domain error or a successful result.
///
[Test]
public async Task ResultProxyPreservesBadServiceStatus()
{
ServiceMessageContext context = ModelTestContext.CreateMessageContext();
Mock session = CreateCallSession(context, [], StatusCodes.BadUserAccessDenied);
var proxy = new GMSResultManagementTypeClient(
session.Object, new NodeId("results", 1), NUnitTelemetryContext.Create());
ServiceResultException exception = (await Assert.ThrowsAsync(
async () => await proxy.GetLatestResultAsync(0).ConfigureAwait(false)))!;
Assert.That(exception.StatusCode, Is.EqualTo(StatusCodes.BadUserAccessDenied));
}
private static Mock CreateCallSession(
ServiceMessageContext context,
ArrayOf outputArguments,
StatusCode status = default)
{
var session = new Mock(MockBehavior.Strict);
session.SetupGet(client => client.MessageContext).Returns(context);
session.Setup(client => client.CallAsync(
null, It.IsAny>(), It.IsAny()))
.Returns(new ValueTask(new CallResponse
{
ResponseHeader = new ResponseHeader(),
Results = [new CallMethodResult { StatusCode = status, OutputArguments = outputArguments }],
DiagnosticInfos = []
}));
return session;
}
private static Mock CreateBrowsingSession(
ServiceMessageContext context,
NodeId objectId,
QualifiedName childName,
NodeId childId,
CancellationToken cancellationToken)
{
var session = new Mock(MockBehavior.Strict);
session.SetupGet(client => client.MessageContext).Returns(context);
session.Setup(client => client.TranslateBrowsePathsToNodeIdsAsync(
null,
It.Is>(paths =>
paths.Count == 1 &&
paths[0].StartingNode == objectId &&
paths[0].RelativePath.Elements.Count == 1 &&
paths[0].RelativePath.Elements[0].TargetName == childName &&
paths[0].RelativePath.Elements[0].ReferenceTypeId == ReferenceTypeIds.HasComponent &&
paths[0].RelativePath.Elements[0].IncludeSubtypes &&
!paths[0].RelativePath.Elements[0].IsInverse),
cancellationToken))
.Returns(new ValueTask(
new TranslateBrowsePathsToNodeIdsResponse
{
ResponseHeader = new ResponseHeader(),
Results =
[
new BrowsePathResult
{
StatusCode = StatusCodes.Good,
Targets =
[
new BrowsePathTarget
{
TargetId = NodeId.ToExpandedNodeId(childId, context.NamespaceUris),
RemainingPathIndex = uint.MaxValue
}
]
}
],
DiagnosticInfos = []
}));
return session;
}
private static async ValueTask GetChildAsync(
GMSTypeClient proxy,
string child,
ITelemetryContext telemetry,
CancellationToken cancellationToken)
{
return child switch
{
"Identification" =>
await proxy.GetIdentificationAsync(telemetry, cancellationToken).ConfigureAwait(false),
"Equipment" =>
await proxy.GetEquipmentAsync(telemetry, cancellationToken).ConfigureAwait(false),
"Monitoring" =>
await proxy.GetMonitoringAsync(telemetry, cancellationToken).ConfigureAwait(false),
"Notification" =>
await proxy.GetNotificationAsync(telemetry, cancellationToken).ConfigureAwait(false),
"Production" =>
await proxy.GetProductionAsync(telemetry, cancellationToken).ConfigureAwait(false),
"ResultManagement" =>
await proxy.GetResultManagementAsync(telemetry, cancellationToken).ConfigureAwait(false),
_ => throw new ArgumentOutOfRangeException(nameof(child))
};
}
}
}