mirror of
https://github.com/OPCFoundation/UA-.NETStandard.git
synced 2026-10-18 22:55:08 +08:00
9b034f8291
# Description Nullable reference types are now enabled once, in `common.props`, and every project in the repository builds warning-clean with them. **Configuration** - `<Nullable>enable</Nullable>` lives in `common.props`. All per-project `<Nullable>` settings are removed: 178 project files (enable, annotations, and the `disable` in the three Aot test projects). - 419 per-file `#nullable enable`/`restore` directives and the 4 `#nullable disable` directives are removed. - Kept on purpose: - The 5 `<auto-generated>` schema files (`UANodeSet.cs`, `UA Type Dictionary.cs`, `OPCBinarySchema.cs`, `SecuredApplication.cs`, `PInvokeHelper.cs`) keep `#nullable enable`. The compiler ignores the project setting in generated code, so the directive is required there. - `tests/Opc.Ua.Interop.LegacyPeer` keeps `disable`. It builds against the 1.5.378 packages and does not import the repository props. - `docs/DeveloperGuide.md` and `.github/copilot-instructions.md` now describe the central setting. **Migration** (the 43 unset and 13 annotations-only projects, roughly 10k diagnostics) - **Production code: annotated at the declarations.** Covers `tools/Opc.Ua.SourceGeneration.Core`, `src/Opc.Ua.Lds.Server` and the LDS sample. - Members, parameters and returns are nullable where null actually flows. - Null-check helpers carry `[NotNullWhen]` (`IsNull`, `IsValidXmlName`, `IsMethodTypeDesign`). - `CoreUtils.Format`, `Utils.Format`, `ITemplateWriter.WriteLine` take `params object?[]`. `Template.AddReplacement` takes `string?` (rendering already skips null replacements). - `IRegisteredServerStore.RegisterAsync` returns `Task<RegistrationEntry?>`: it returns null when a registration is removed. - Dereferences backed by an invariant (for example, the validated model) use `!`, so runtime behaviour is unchanged. - **Behavioural changes in production code** (small, deliberate): - `NodeSetToModelDesign` throws `InvalidOperationException` when the file is not a NodeSet. Before, it threw a NullReferenceException on the next line. - `ModelDesignValidator` starts its lookup tables empty instead of null. A redundant `if (m_nodes == null) return;` guard is gone. - **Test code: minimal annotations.** - `?` on values that can be null, `!` where the test asserts non-null. - `= null!` for fixture members assigned in SetUp. - `TryGetStructure<T>(out T? x)` where the inferred `T?` broke the `IEncodeable` constraint. - A few redundant `== null` checks on structs (`NodeId`, `ArrayOf<T>`) folded into the adjacent `IsNull`/`Count` checks. **Notes for reviewers** - The diff is large (~1,500 files) but almost entirely mechanical: `?`, `!` and directive removal. The non-annotation changes are listed above. - Known follow-up, not done here: `IModelDesign.Namespaces/TargetNamespace/NamespaceUris` stay non-nullable even though the validator returns null before it loads a dictionary. Making them nullable would ripple through every generator call site. ## Related Issues - Fixes #4439 ## Checklist - [ ] 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. The "no new warnings" box covers net10.0 and net48: full non-incremental builds of `UA.slnx`, `tools/SourceGeneration.slnx`, `tools/MigrationAnalyzer.slnx` and the Aot test projects finished with 0 warnings and 0 errors under the repository's warnings-as-errors settings. net9.0 and net8.0 also had no nullable diagnostics, but they were not rebuilt after the final fixes. Tests have not been run locally yet. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
572 lines
26 KiB
C#
572 lines
26 KiB
C#
/* ========================================================================
|
|
* 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
|
|
{
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
[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/";
|
|
|
|
/// <summary>
|
|
/// 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().
|
|
/// </summary>
|
|
[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 + ")"));
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
[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)));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Floats: see <see cref="DoubleDefaultRoundTrips"/>.
|
|
/// </summary>
|
|
[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)));
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
[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<IServiceMessageContext>().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)));
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
[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)));
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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).
|
|
/// </summary>
|
|
[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));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Regression: a negative enumeration default was emitted as
|
|
/// "(E)-1", which C# parses as a subtraction (CS0075/CS0119).
|
|
/// </summary>
|
|
[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<IServiceMessageContext>().Object);
|
|
|
|
Assert.That(expression, Is.EqualTo("(ProbeEnum)(" + value + ")"));
|
|
Assert.That(
|
|
(int)Evaluate(expression, extraCode: "public enum ProbeEnum { Minus = -1, Zero = 0 }"),
|
|
Is.EqualTo(value));
|
|
}
|
|
|
|
/// <summary>
|
|
/// A design-authored value numbers OPC UA 0 and then the design's own
|
|
/// namespaces in declaration order.
|
|
/// </summary>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
[Test]
|
|
public void NodeSetValuesKeepTheirNamespaces()
|
|
{
|
|
Dictionary<string, string> 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\"));"));
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
[Test]
|
|
public void VariableTypeDefaultValuesKeepTheirNamespaces()
|
|
{
|
|
Dictionary<string, string> 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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
[Test]
|
|
public void ExpandedNodeIdServerIndexIsMappedThroughTheServerUri()
|
|
{
|
|
Dictionary<string, string> 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));"
|
|
}));
|
|
}
|
|
|
|
/// <summary>
|
|
/// N2b: the emitted server-index mapping compiles and names the server
|
|
/// the NodeSet meant in the run-time ServerUris table.
|
|
/// </summary>
|
|
[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<IServiceMessageContext>().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<IServiceMessageContext>().Object);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Compiles the expression into a method and returns what it evaluates
|
|
/// to. <c>namespaceUris</c> is in scope as a NamespaceTable parameter.
|
|
/// </summary>
|
|
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<string, string>("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<string, string> 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 = $"""
|
|
<?xml version="1.0" encoding="utf-8"?>
|
|
<UANodeSet xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
|
xmlns:xsd="http://www.w3.org/2001/XMLSchema"
|
|
xmlns="http://opcfoundation.org/UA/2011/03/UANodeSet.xsd">
|
|
<NamespaceUris>
|
|
<Uri>{OtherUri}</Uri>
|
|
</NamespaceUris>
|
|
<Models>
|
|
<Model ModelUri="{OtherUri}" PublicationDate="2026-08-12T00:00:00Z" Version="1.0.0" />
|
|
</Models>
|
|
<Aliases>
|
|
<Alias Alias="HasSubtype">i=45</Alias>
|
|
</Aliases>
|
|
<UAObjectType NodeId="ns=1;i=5000" BrowseName="1:OtherType">
|
|
<DisplayName>OtherType</DisplayName>
|
|
<References>
|
|
<Reference ReferenceType="HasSubtype" IsForward="false">i=58</Reference>
|
|
</References>
|
|
</UAObjectType>
|
|
</UANodeSet>
|
|
""";
|
|
|
|
// The dependency is listed first, so ns=1 is OtherUri and ns=2 the target.
|
|
private const string TargetNodeSet = $"""
|
|
<?xml version="1.0" encoding="utf-8"?>
|
|
<UANodeSet xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
|
xmlns:xsd="http://www.w3.org/2001/XMLSchema"
|
|
xmlns:uax="http://opcfoundation.org/UA/2008/02/Types.xsd"
|
|
xmlns="http://opcfoundation.org/UA/2011/03/UANodeSet.xsd">
|
|
<NamespaceUris>
|
|
<Uri>{OtherUri}</Uri>
|
|
<Uri>{TargetUri}</Uri>
|
|
</NamespaceUris>
|
|
<ServerUris>
|
|
<Uri>urn:remote:server</Uri>
|
|
</ServerUris>
|
|
<Models>
|
|
<Model ModelUri="{TargetUri}" PublicationDate="2026-08-12T00:00:00Z" Version="1.0.0">
|
|
<RequiredModel ModelUri="{OtherUri}" Version="1.0.0" PublicationDate="2026-08-12T00:00:00Z" />
|
|
</Model>
|
|
</Models>
|
|
<Aliases>
|
|
<Alias Alias="NodeId">i=17</Alias>
|
|
<Alias Alias="ExpandedNodeId">i=18</Alias>
|
|
<Alias Alias="QualifiedName">i=20</Alias>
|
|
<Alias Alias="HasModellingRule">i=37</Alias>
|
|
<Alias Alias="HasTypeDefinition">i=40</Alias>
|
|
<Alias Alias="HasSubtype">i=45</Alias>
|
|
<Alias Alias="HasProperty">i=46</Alias>
|
|
</Aliases>
|
|
<UAObjectType NodeId="ns=2;i=1000" BrowseName="2:S12Type">
|
|
<DisplayName>S12Type</DisplayName>
|
|
<References>
|
|
<Reference ReferenceType="HasSubtype" IsForward="false">i=58</Reference>
|
|
<Reference ReferenceType="HasProperty">ns=2;i=1001</Reference>
|
|
<Reference ReferenceType="HasProperty">ns=2;i=1002</Reference>
|
|
<Reference ReferenceType="HasProperty">ns=2;i=1003</Reference>
|
|
<Reference ReferenceType="HasProperty">ns=2;i=1004</Reference>
|
|
</References>
|
|
</UAObjectType>
|
|
<UAVariable NodeId="ns=2;i=1001" BrowseName="2:Target" ParentNodeId="ns=2;i=1000" DataType="NodeId">
|
|
<DisplayName>Target</DisplayName>
|
|
<References>
|
|
<Reference ReferenceType="HasTypeDefinition">i=68</Reference>
|
|
<Reference ReferenceType="HasModellingRule">i=78</Reference>
|
|
</References>
|
|
<Value>
|
|
<uax:NodeId><uax:Identifier>ns=1;i=5000</uax:Identifier></uax:NodeId>
|
|
</Value>
|
|
</UAVariable>
|
|
<UAVariable NodeId="ns=2;i=1002" BrowseName="2:Name" ParentNodeId="ns=2;i=1000" DataType="QualifiedName">
|
|
<DisplayName>Name</DisplayName>
|
|
<References>
|
|
<Reference ReferenceType="HasTypeDefinition">i=68</Reference>
|
|
<Reference ReferenceType="HasModellingRule">i=78</Reference>
|
|
</References>
|
|
<Value>
|
|
<uax:QualifiedName><uax:NamespaceIndex>2</uax:NamespaceIndex><uax:Name>Foo</uax:Name></uax:QualifiedName>
|
|
</Value>
|
|
</UAVariable>
|
|
<UAVariableType NodeId="ns=2;i=2000" BrowseName="2:S12NodeIdVariableType" DataType="NodeId">
|
|
<DisplayName>S12NodeIdVariableType</DisplayName>
|
|
<References>
|
|
<Reference ReferenceType="HasSubtype" IsForward="false">i=63</Reference>
|
|
</References>
|
|
<Value>
|
|
<uax:NodeId><uax:Identifier>ns=1;i=5000</uax:Identifier></uax:NodeId>
|
|
</Value>
|
|
</UAVariableType>
|
|
<UAVariableType NodeId="ns=2;i=2001" BrowseName="2:S12NameVariableType" DataType="QualifiedName">
|
|
<DisplayName>S12NameVariableType</DisplayName>
|
|
<References>
|
|
<Reference ReferenceType="HasSubtype" IsForward="false">i=63</Reference>
|
|
</References>
|
|
<Value>
|
|
<uax:QualifiedName><uax:NamespaceIndex>1</uax:NamespaceIndex><uax:Name>Bar</uax:Name></uax:QualifiedName>
|
|
</Value>
|
|
</UAVariableType>
|
|
<UAVariable NodeId="ns=2;i=1004" BrowseName="2:Remote" ParentNodeId="ns=2;i=1000" DataType="ExpandedNodeId">
|
|
<DisplayName>Remote</DisplayName>
|
|
<References>
|
|
<Reference ReferenceType="HasTypeDefinition">i=68</Reference>
|
|
<Reference ReferenceType="HasModellingRule">i=78</Reference>
|
|
</References>
|
|
<Value>
|
|
<uax:ExpandedNodeId><uax:Identifier>svr=1;nsu=urn:remote:ns;i=42</uax:Identifier></uax:ExpandedNodeId>
|
|
</Value>
|
|
</UAVariable>
|
|
<UAVariable NodeId="ns=2;i=1003" BrowseName="2:Expanded" ParentNodeId="ns=2;i=1000" DataType="ExpandedNodeId">
|
|
<DisplayName>Expanded</DisplayName>
|
|
<References>
|
|
<Reference ReferenceType="HasTypeDefinition">i=68</Reference>
|
|
<Reference ReferenceType="HasModellingRule">i=78</Reference>
|
|
</References>
|
|
<Value>
|
|
<uax:ExpandedNodeId><uax:Identifier>ns=1;i=5000</uax:Identifier></uax:ExpandedNodeId>
|
|
</Value>
|
|
</UAVariable>
|
|
</UANodeSet>
|
|
""";
|
|
}
|
|
}
|