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