/* ========================================================================
* 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.IO;
using System.Linq;
using System.Reflection;
using System.Text;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.Emit;
using Microsoft.Extensions.Logging;
using Moq;
using NUnit.Framework;
using Opc.Ua.SourceGeneration;
using Opc.Ua.Tests;
namespace Opc.Ua.Schema.Model.Tests
{
///
/// Default values are emitted as C# expressions. These tests pin the exact
/// expression and compile and evaluate it, so an expression that does not
/// compile or does not evaluate to the modelled value is caught here rather
/// than in a consumer's build.
///
[TestFixture]
[Category("ModelDesign")]
[SetCulture("en-us")]
[SetUICulture("en-us")]
public class DefaultValueCodeTests
{
private const string OtherUri = "http://test.org/UA/S12Other/";
private const string TargetUri = "http://test.org/UA/S12/";
///
/// Regression: a non-Good StatusCode default was emitted as
/// "(global::Opc.Ua.StatusCode.StatusCode)BadNodeIdUnknown [0x80340000]" -
/// a type that does not exist followed by StatusCode.ToString().
///
[TestCase(0x80340000u, "new global::Opc.Ua.StatusCode(0x80340000u)")]
[TestCase(0x80000000u, "new global::Opc.Ua.StatusCode(0x80000000u)")]
[TestCase(0x40000000u, "new global::Opc.Ua.StatusCode(0x40000000u)")]
public void StatusCodeDefaultIsACompilableExpression(uint code, string expected)
{
string expression = Scalar(BasicDataType.StatusCode, new StatusCode(code));
Assert.That(expression, Is.EqualTo(expected));
Assert.That(((StatusCode)Evaluate(expression)).Code, Is.EqualTo(code));
Assert.That(
Scalar(BasicDataType.StatusCode, new StatusCode(code), valueAsVariant: true),
Is.EqualTo("global::Opc.Ua.Variant.From(" + expected + ")"));
}
///
/// Regression: doubles were formatted with "G", which on .NET Framework
/// (Visual Studio) is 15 digits - double.MaxValue became an out-of-range
/// literal and other values lost precision. The emitted digits must be the
/// same on every runtime and evaluate to exactly the modelled value.
///
[TestCase(double.MaxValue, "(double)1.7976931348623157E+308")]
[TestCase(double.MinValue, "(double)-1.7976931348623157E+308")]
[TestCase(double.Epsilon, "(double)4.94065645841247E-324")]
[TestCase(0.30000000000000004, "(double)0.30000000000000004")]
[TestCase(3.14159, "(double)3.14159")]
[TestCase(0.0, "(double)0")]
[TestCase(-0.0, "(double)-0.0")]
public void DoubleDefaultRoundTrips(double value, string expected)
{
string expression = Scalar(BasicDataType.Double, value);
Assert.That(expression, Is.EqualTo(expected));
Assert.That(
BitConverter.DoubleToInt64Bits((double)Evaluate(expression)),
Is.EqualTo(BitConverter.DoubleToInt64Bits(value)));
}
///
/// Floats: see .
///
[TestCase(float.MaxValue, "(float)3.4028235E+38")]
[TestCase(float.MinValue, "(float)-3.4028235E+38")]
[TestCase(float.Epsilon, "(float)1.4013E-45")]
[TestCase(0.1f, "(float)0.1")]
[TestCase(16777217f, "(float)16777216")]
[TestCase(-0.0f, "(float)-0.0")]
public void FloatDefaultRoundTrips(float value, string expected)
{
string expression = Scalar(BasicDataType.Float, value);
Assert.That(expression, Is.EqualTo(expected));
Assert.That(
BitConverter.DoubleToInt64Bits((float)Evaluate(expression)),
Is.EqualTo(BitConverter.DoubleToInt64Bits(value)));
}
///
/// Regression: the namespace index of a decoded NodeId was used as an
/// index into the design's Namespaces array, which is ordered differently
/// from the table the value was decoded with. The index now resolves
/// through the decoding table and is looked up by URI at run time.
///
[Test]
public void NodeIdDefaultResolvesTheNamespaceOfTheDecodingTable()
{
var decodingTable = new NamespaceTable();
decodingTable.Append(OtherUri);
decodingTable.Append(TargetUri);
// The design lists the target first - index 1 of that array is not
// the namespace the value's index 1 names.
Namespace[] namespaces =
[
new Namespace { Value = TargetUri },
new Namespace { Value = Ua.Types.Namespaces.OpcUa },
new Namespace { Value = OtherUri }
];
string expression = new DataTypeDesign { BasicDataType = BasicDataType.NodeId }
.GetValueAsCode(
ValueRank.Scalar,
null!,
new NodeId(5000, 1),
false,
TargetUri,
namespaces,
new Mock().Object,
null!,
decodingTable,
"namespaceUris");
Assert.That(
expression,
Is.EqualTo(
"global::Opc.Ua.NodeId.Parse(\"i=5000\").WithNamespaceIndex(" +
"namespaceUris.GetIndexOrAppend(\"" + OtherUri + "\"))"));
var runtimeTable = new NamespaceTable();
runtimeTable.Append("urn:unrelated");
runtimeTable.Append(OtherUri);
Assert.That(Evaluate(expression, runtimeTable), Is.EqualTo(new NodeId(5000, 2)));
}
///
/// Without a namespace table in scope (a structure field initializer)
/// the NodeId can only be emitted literally - it must still compile.
/// It used to reference a "context" local that does not exist there.
///
[Test]
public void NodeIdDefaultWithoutANamespaceTableInScopeCompiles()
{
string expression = Scalar(BasicDataType.NodeId, new NodeId(5000, 1));
Assert.That(expression, Is.EqualTo("global::Opc.Ua.NodeId.Parse(\"ns=1;i=5000\")"));
Assert.That(Evaluate(expression), Is.EqualTo(new NodeId(5000, 1)));
}
///
/// Regression: the literal NodeId / QualifiedName fallback spliced the
/// value into a C# string literal unescaped, so a backslash or a quote
/// in a string identifier or name did not compile (or silently changed
/// the value through an escape sequence).
///
[Test]
public void NodeIdAndQualifiedNameFallbacksEscapeTheLiteral()
{
var nodeId = new NodeId(@"C:\temp\x""y""", 1);
string nodeIdExpression = Scalar(BasicDataType.NodeId, nodeId);
Assert.That(Evaluate(nodeIdExpression), Is.EqualTo(nodeId));
var qualifiedName = new QualifiedName(@"A""B\C", 1);
string qualifiedNameExpression = Scalar(BasicDataType.QualifiedName, qualifiedName);
Assert.That(Evaluate(qualifiedNameExpression), Is.EqualTo(qualifiedName));
}
///
/// Regression: a negative enumeration default was emitted as
/// "(E)-1", which C# parses as a subtraction (CS0075/CS0119).
///
[TestCase(-1)]
[TestCase(0)]
[TestCase(7)]
public void NegativeEnumerationDefaultCompiles(int value)
{
var dataType = new DataTypeDesign
{
BasicDataType = BasicDataType.Enumeration,
SymbolicId = new System.Xml.XmlQualifiedName("ProbeEnum", TargetUri),
SymbolicName = new System.Xml.XmlQualifiedName("ProbeEnum", TargetUri),
BaseTypeNode = new TypeDesign
{
SymbolicId = new System.Xml.XmlQualifiedName("Enumeration", Ua.Types.Namespaces.OpcUa)
},
Fields = [new Parameter { Name = "Minus" }]
};
string expression = dataType.GetValueAsCode(
ValueRank.Scalar,
null!,
value,
false,
TargetUri,
[],
new Mock().Object);
Assert.That(expression, Is.EqualTo("(ProbeEnum)(" + value + ")"));
Assert.That(
(int)Evaluate(expression, extraCode: "public enum ProbeEnum { Minus = -1, Zero = 0 }"),
Is.EqualTo(value));
}
///
/// A design-authored value numbers OPC UA 0 and then the design's own
/// namespaces in declaration order.
///
[Test]
public void DesignAuthoredIndexesUseTheDesignNamespaces()
{
Namespace[] namespaces =
[
new Namespace { Value = TargetUri },
new Namespace { Value = Ua.Types.Namespaces.OpcUa },
new Namespace { Value = OtherUri }
];
Assert.That(
ModelDesignExtensions.ResolveDecodedNamespaceUri(1, null!, namespaces),
Is.EqualTo(TargetUri));
Assert.That(
ModelDesignExtensions.ResolveDecodedNamespaceUri(2, null!, namespaces),
Is.EqualTo(OtherUri));
Assert.That(
ModelDesignExtensions.ResolveDecodedNamespaceUri(3, null!, namespaces),
Is.Null);
}
///
/// End to end: a NodeSet whose own namespace table lists a dependency
/// first. The NodeId, QualifiedName and ExpandedNodeId values must name
/// the namespaces the NodeSet meant, not whatever sits at the same index
/// of the design's namespace list or of the server's table.
///
[Test]
public void NodeSetValuesKeepTheirNamespaces()
{
Dictionary files = GenerateTwoModels();
string[] lines = [.. files
.Where(f => f.Key.Contains("S12.", StringComparison.Ordinal))
.SelectMany(f => f.Value.Split('\n'))
.Select(l => l.Trim())
.Where(l => l.StartsWith("baseState.WrappedValue", StringComparison.Ordinal))];
Assert.That(lines, Has.Some.EqualTo(
"baseState.WrappedValue = global::Opc.Ua.Variant.From(" +
"global::Opc.Ua.NodeId.Parse(\"i=5000\").WithNamespaceIndex(" +
"context.NamespaceUris.GetIndexOrAppend(\"" + OtherUri + "\")));"));
Assert.That(lines, Has.Some.EqualTo(
"baseState.WrappedValue = global::Opc.Ua.Variant.From(" +
"new global::Opc.Ua.QualifiedName(\"Foo\", " +
"context.NamespaceUris.GetIndexOrAppend(\"" + TargetUri + "\")));"));
Assert.That(lines, Has.Some.EqualTo(
"baseState.WrappedValue = global::Opc.Ua.Variant.From(" +
"global::Opc.Ua.ExpandedNodeId.Parse(\"nsu=" + OtherUri + ";i=5000\"));"));
}
///
/// N2a (OPC 10000-6 F.14): a VariableType default value names the
/// NodeSet's own namespace table like a Variable value does. No
/// generated code may carry the NodeSet-local index "ns=1" as a literal,
/// which the server would read against its own table.
///
[Test]
public void VariableTypeDefaultValuesKeepTheirNamespaces()
{
Dictionary files = GenerateTwoModels();
string[] lines = [.. files
.Where(f => f.Key.Contains("S12.", StringComparison.Ordinal))
.SelectMany(f => f.Value.Split('\n'))
.Select(l => l.Trim())];
Assert.That(lines, Has.Some.EqualTo(
"baseState.WrappedValue = global::Opc.Ua.Variant.From(" +
"global::Opc.Ua.NodeId.Parse(\"i=5000\").WithNamespaceIndex(" +
"context.NamespaceUris.GetIndexOrAppend(\"" + OtherUri + "\")));"));
Assert.That(lines, Has.Some.EqualTo(
"baseState.WrappedValue = global::Opc.Ua.Variant.From(" +
"new global::Opc.Ua.QualifiedName(\"Bar\", " +
"context.NamespaceUris.GetIndexOrAppend(\"" + OtherUri + "\")));"));
Assert.That(
lines.Where(l => l.Contains("ns=1;i=5000", StringComparison.Ordinal) ||
l.Contains("QualifiedName(\"Bar\", 1)", StringComparison.Ordinal)),
Is.Empty);
}
///
/// N2b (OPC 10000-6 F.2, F.14): svr=1 in a NodeSet value names
/// ServerUris[0] of the NodeSet. The value used to be emitted with the
/// generator-local server index, which the server reads against its own
/// ServerArray. It is now mapped through the server URI at run time.
///
[Test]
public void ExpandedNodeIdServerIndexIsMappedThroughTheServerUri()
{
Dictionary files = GenerateTwoModels();
string[] lines = [.. files
.Where(f => f.Key.Contains("S12.", StringComparison.Ordinal))
.SelectMany(f => f.Value.Split('\n'))
.Select(l => l.Trim())
.Where(l => l.Contains("urn:remote:ns", StringComparison.Ordinal))];
Assert.That(lines, Is.EqualTo(new[]
{
"baseState.WrappedValue = global::Opc.Ua.Variant.From(" +
"global::Opc.Ua.ExpandedNodeId.Parse(\"nsu=urn:remote:ns;i=42\")" +
".WithServerIndex(context.ServerUris?.GetIndexOrAppend(\"urn:remote:server\") ?? 1u));"
}));
}
///
/// N2b: the emitted server-index mapping compiles and names the server
/// the NodeSet meant in the run-time ServerUris table.
///
[Test]
public void ExpandedNodeIdServerIndexExpressionMapsAtRunTime()
{
var serverUris = new StringTable();
serverUris.Append("urn:local");
serverUris.Append("urn:remote:server");
var decodingTable = new DecodedValueNamespaceTable(serverUris);
string expression = new DataTypeDesign { BasicDataType = BasicDataType.ExpandedNodeId }
.GetValueAsCode(
ValueRank.Scalar,
null!,
new ExpandedNodeId(42u, "urn:remote:ns", 1),
false,
TargetUri,
[],
new Mock().Object,
null!,
decodingTable,
"Ctx.NamespaceUris");
const string context =
"public static class Ctx { " +
"public static global::Opc.Ua.NamespaceTable NamespaceUris = new(); " +
"public static global::Opc.Ua.StringTable ServerUris = " +
"new(new[] { \"urn:local\", \"urn:other\", \"urn:remote:server\" }); }";
var value = (ExpandedNodeId)Evaluate(expression, extraCode: context);
Assert.That(value.ServerIndex, Is.EqualTo(2u));
Assert.That(value.NamespaceUri, Is.EqualTo("urn:remote:ns"));
Assert.That(value.InnerNodeId, Is.EqualTo(new NodeId(42u)));
}
private static string Scalar(
BasicDataType basicDataType,
object value,
bool valueAsVariant = false)
{
return new DataTypeDesign { BasicDataType = basicDataType }.GetValueAsCode(
ValueRank.Scalar,
null!,
value,
valueAsVariant,
TargetUri,
[],
new Mock().Object);
}
///
/// Compiles the expression into a method and returns what it evaluates
/// to. namespaceUris is in scope as a NamespaceTable parameter.
///
private static object Evaluate(
string expression,
NamespaceTable? namespaceUris = null,
string? extraCode = null)
{
string code =
"public static class Probe { public static object Value(" +
"global::Opc.Ua.NamespaceTable namespaceUris) => " + expression + "; }" +
extraCode;
CSharpCompilation compilation = OptimizationLevel.Debug
.CreateCompilation()
.AddCode([new KeyValuePair("Probe.cs", code)], LanguageVersion.Latest);
using var pe = new MemoryStream();
EmitResult result = compilation.Emit(pe);
Assert.That(
result.Success,
Is.True,
expression + Environment.NewLine + string.Join(
Environment.NewLine,
result.Diagnostics.Where(d => d.Severity == DiagnosticSeverity.Error)));
Assembly assembly = Assembly.Load(pe.ToArray());
return assembly.GetType("Probe")!
.GetMethod("Value")!
.Invoke(null, [namespaceUris ?? new NamespaceTable()])!;
}
private static Dictionary GenerateTwoModels()
{
ITelemetryContext telemetry = NUnitTelemetryContext.Create(logLevel: LogLevel.Error);
using var fileSystem = new VirtualFileSystem();
const string otherPath = "memory://S12Other.NodeSet2.xml";
const string targetPath = "memory://S12.NodeSet2.xml";
fileSystem.Add(otherPath, Encoding.UTF8.GetBytes(OtherNodeSet));
fileSystem.Add(targetPath, Encoding.UTF8.GetBytes(TargetNodeSet));
var nodesets = new NodesetFileCollection(
[(otherPath, new NodesetFileOptions()), (targetPath, new NodesetFileOptions())],
[],
fileSystem,
telemetry);
nodesets.GenerateCode(
fileSystem,
string.Empty,
telemetry,
new GeneratorOptions { OmitFluentApi = true });
return fileSystem.CreatedFiles
.Where(c => Path.GetExtension(c) == ".cs")
.ToDictionary(c => c, c => Encoding.UTF8.GetString(fileSystem.Get(c)));
}
private const string OtherNodeSet = $"""
{OtherUri}
i=45
OtherType
i=58
""";
// The dependency is listed first, so ns=1 is OtherUri and ns=2 the target.
private const string TargetNodeSet = $"""
{OtherUri}
{TargetUri}
urn:remote:server
i=17
i=18
i=20
i=37
i=40
i=45
i=46
S12Type
i=58
ns=2;i=1001
ns=2;i=1002
ns=2;i=1003
ns=2;i=1004
Target
i=68
i=78
ns=1;i=5000
Name
i=68
i=78
2Foo
S12NodeIdVariableType
i=63
ns=1;i=5000
S12NameVariableType
i=63
1Bar
Remote
i=68
i=78
svr=1;nsu=urn:remote:ns;i=42
Expanded
i=68
i=78
ns=1;i=5000
""";
}
}