Files
romanett 9b034f8291 Enable nullable reference types centrally; migrate all unset and annotations-only projects (#4700)
# 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>
2026-10-08 08:48:13 +02:00

2033 lines
94 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 System.Text.RegularExpressions;
using System.Threading.Tasks;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.Extensions.Logging;
using Moq;
using NUnit.Framework;
using Opc.Ua.Schema.Model;
using Opc.Ua.SourceGeneration.Api.Tests;
using Opc.Ua.Tests;
namespace Opc.Ua.SourceGeneration.Generator.Tests
{
/// <summary>
/// Unit tests for the NodeStateGenerator class.
/// </summary>
[TestFixture]
[Category("Generator")]
[SetCulture("en-us")]
[SetUICulture("en-us")]
[Parallelizable]
public class NodeStateGeneratorTests
{
[SetUp]
public void SetUp()
{
m_mockFileSystem = new Mock<IFileSystem>();
m_mockModelDesign = new Mock<IModelDesign>();
m_mockTelemetry = new Mock<ITelemetryContext>();
// Setup default namespace
var targetNamespace = new Namespace
{
Value = "http://test.org/UA/",
Prefix = "Test",
Name = "TestNamespace"
};
m_mockModelDesign.Setup(m => m.TargetNamespace).Returns(targetNamespace);
m_mockModelDesign.Setup(m => m.Namespaces).Returns([targetNamespace]);
}
/// <summary>
/// Tests that the constructor throws ArgumentNullException when context is null.
/// </summary>
[Test]
public void Constructor_NullContext_ThrowsArgumentNullException()
{
// Arrange
GeneratorContext? context = null;
// Act & Assert
Assert.Throws<ArgumentNullException>(() => new NodeStateGenerator(context!));
}
/// <summary>
/// Tests that constructor creates instance with valid context.
/// </summary>
[Test]
public void Constructor_ValidContext_CreatesInstance()
{
// Arrange
m_context = new GeneratorContext
{
FileSystem = m_mockFileSystem.Object,
OutputFolder = "TestOutput",
ModelDesign = m_mockModelDesign.Object,
Telemetry = m_mockTelemetry.Object,
Options = new GeneratorOptions()
};
// Act
var generator = new NodeStateGenerator(m_context);
// Assert
Assert.That(generator, Is.Not.Null);
}
[TestCase(ModellingRule.Mandatory, true)]
[TestCase(ModellingRule.Optional, true)]
[TestCase(ModellingRule.None, false)]
[TestCase(ModellingRule.OptionalPlaceholder, false)]
public void HasFixedChildSlotRequiresFixedModellingRule(
ModellingRule modellingRule,
bool expected)
{
var type = new ObjectTypeDesign
{
Children = new ListOfChildren
{
Items =
[
new VariableDesign
{
SymbolicName = new System.Xml.XmlQualifiedName(
"DefaultInstanceBrowseName",
Types.Namespaces.OpcUa),
ModellingRule = modellingRule
}
]
}
};
Assert.That(
NodeStateGenerator.HasFixedChildSlot(
type,
"DefaultInstanceBrowseName"),
Is.EqualTo(expected));
}
/// <summary>
/// A subtype that declares its own state elements reports the
/// namespace of its declaring model.
/// </summary>
[TestCase("StateType")]
[TestCase("InitialStateType")]
[TestCase("TransitionType")]
public void FindElementNamespaceUriUsesTheDeclaringTypesNamespace(
string elementTypeName)
{
const string vendorNs = "http://vendor.test/UA/";
ObjectTypeDesign machine = CreateFsmSubtype(
vendorNs, DeclareElement(elementTypeName));
Assert.Multiple(() =>
{
Assert.That(
NodeStateGenerator.IsFiniteStateMachineSubtype(machine),
Is.True);
Assert.That(
NodeStateGenerator.FindElementNamespaceUri(machine),
Is.EqualTo(vendorNs));
});
}
/// <summary>
/// A behaviour-only subtype inherits its elements — and their
/// namespace — from the nearest base that declares them.
/// </summary>
[Test]
public void FindElementNamespaceUriWalksUpToTheDeclaringBase()
{
const string baseNs = "http://base.test/UA/";
const string derivedNs = "http://derived.test/UA/";
ObjectTypeDesign baseMachine = CreateFsmSubtype(
baseNs, DeclareElement("StateType"));
var derived = new ObjectTypeDesign
{
SymbolicName = new System.Xml.XmlQualifiedName(
"DerivedMachineType", derivedNs),
BaseTypeNode = baseMachine
};
Assert.Multiple(() =>
{
Assert.That(
NodeStateGenerator.IsFiniteStateMachineSubtype(derived),
Is.True);
Assert.That(
NodeStateGenerator.FindElementNamespaceUri(derived),
Is.EqualTo(baseNs));
});
}
/// <summary>
/// A subtype whose elements are declared by the standard model
/// resolves to the OPC UA namespace — for which no override is
/// emitted, because the base class already returns it.
/// </summary>
[Test]
public void FindElementNamespaceUriResolvesInheritedStandardElements()
{
ObjectTypeDesign standardMachine = CreateFsmSubtype(
Types.Namespaces.OpcUa, DeclareElement("StateType"));
var vendorSubtype = new ObjectTypeDesign
{
SymbolicName = new System.Xml.XmlQualifiedName(
"VendorProgramType", "http://vendor.test/UA/"),
BaseTypeNode = standardMachine
};
Assert.That(
NodeStateGenerator.FindElementNamespaceUri(vendorSubtype),
Is.EqualTo(Types.Namespaces.OpcUa));
}
/// <summary>
/// Types outside the FiniteStateMachineType hierarchy get no
/// override, and a hierarchy with no declared elements resolves
/// to nothing.
/// </summary>
[Test]
public void FindElementNamespaceUriIgnoresNonStateMachineTypes()
{
var plainType = new ObjectTypeDesign
{
SymbolicName = new System.Xml.XmlQualifiedName(
"PlainType", "http://vendor.test/UA/")
};
ObjectTypeDesign elementFreeMachine = CreateFsmSubtype(
"http://vendor.test/UA/", children: null!);
Assert.Multiple(() =>
{
Assert.That(
NodeStateGenerator.IsFiniteStateMachineSubtype(plainType),
Is.False);
Assert.That(
NodeStateGenerator.IsFiniteStateMachineSubtype(elementFreeMachine),
Is.True);
Assert.That(
NodeStateGenerator.FindElementNamespaceUri(elementFreeMachine),
Is.Null);
});
}
/// <summary>
/// A subtype that merely re-declares an inherited state (to
/// attach a description or modelling rule) is not the type that
/// declares the elements — the namespace walk must continue to
/// its base.
/// </summary>
[Test]
public void FindElementNamespaceUriSkipsOverriddenElements()
{
const string baseNs = "http://base.test/UA/";
ObjectTypeDesign baseMachine = CreateFsmSubtype(
baseNs, DeclareElement("StateType"));
ObjectDesign overriddenState = DeclareElement("StateType");
overriddenState.OveriddenNode = DeclareElement("StateType");
var derived = new ObjectTypeDesign
{
SymbolicName = new System.Xml.XmlQualifiedName(
"OverridingMachineType", "http://derived.test/UA/"),
BaseTypeNode = baseMachine,
Children = new ListOfChildren { Items = [overriddenState] }
};
Assert.That(
NodeStateGenerator.FindElementNamespaceUri(derived),
Is.EqualTo(baseNs));
}
/// <summary>
/// Builds a subtype of the standard FiniteStateMachineType in
/// <paramref name="namespaceUri"/>, optionally declaring
/// state-machine element children.
/// </summary>
private static ObjectTypeDesign CreateFsmSubtype(
string namespaceUri,
params InstanceDesign[] children)
{
var fsmRoot = new ObjectTypeDesign
{
SymbolicName = new System.Xml.XmlQualifiedName(
"FiniteStateMachineType", Types.Namespaces.OpcUa)
};
return new ObjectTypeDesign
{
SymbolicName = new System.Xml.XmlQualifiedName(
"TestMachineType", namespaceUri),
BaseTypeNode = fsmRoot,
Children = children == null || children.Length == 0
? null
: new ListOfChildren { Items = children }
};
}
private static ObjectDesign DeclareElement(string elementTypeName)
{
return new ObjectDesign
{
SymbolicName = new System.Xml.XmlQualifiedName(
"SomeElement", "http://vendor.test/UA/"),
TypeDefinition = new System.Xml.XmlQualifiedName(
elementTypeName, Types.Namespaces.OpcUa)
};
}
/// <summary>
/// Tests that Emit returns early without creating files when no node state classes exist.
/// </summary>
[Test]
public void Emit_NoNodeStateClasses_ReturnsEarlyWithoutCreatingFiles()
{
// Arrange
m_mockModelDesign.Setup(m => m.GetNodeDesigns()).Returns([]);
m_context = new GeneratorContext
{
FileSystem = m_mockFileSystem.Object,
OutputFolder = "TestOutput",
ModelDesign = m_mockModelDesign.Object,
Telemetry = m_mockTelemetry.Object,
Options = new GeneratorOptions()
};
var generator = new NodeStateGenerator(m_context);
// Act
generator.Emit();
// Assert - OpenWrite should not be called when there are no node state classes
m_mockFileSystem.Verify(
fs => fs.OpenWrite(It.IsAny<string>()),
Times.Never,
"OpenWrite should not be called when there are no node state classes");
}
[Test]
public void GenerateNodeStateGeneratorCodeTest()
{
// Arrange
ITelemetryContext telemetry = NUnitTelemetryContext.Create(logLevel: LogLevel.Error);
using var fileSystem = new VirtualFileSystem();
// Act - Generate full stack
Generators.GenerateStack(StackGenerationType.All, fileSystem, string.Empty, telemetry);
// Assert - NodeState file should be created
var generatedFiles = fileSystem.CreatedFiles
.Where(c => c.EndsWith(".NodeStates.ex.g.cs", StringComparison.Ordinal))
.ToList();
Assert.That(generatedFiles, Is.Not.Empty,
"NodeStates.ex.g.cs file should be generated");
foreach (string file in generatedFiles)
{
string content = Encoding.UTF8.GetString(fileSystem.Get(file));
TestContext.Out.WriteLine("Generated file: {0} ({1} bytes)", file, content.Length);
// Verify basic structure
Assert.That(content, Does.Contain("// <auto-generated />"),
"Generated code should have auto-generated header");
Assert.That(content, Does.Contain("public static partial class OpcUaExtensions"),
"Generated code should contain OpcUaExtensions class");
Assert.That(content, Does.Contain("public static global::Opc.Ua.NodeStateCollection AddOpcUa"),
"Generated code should contain AddOpcUa method");
}
}
[Test]
public void GeneratedNodeStateGeneratorCodeCompilesTest()
{
// Arrange
ITelemetryContext telemetry = NUnitTelemetryContext.Create(logLevel: LogLevel.Error);
using var fileSystem = new VirtualFileSystem();
// Act - Generate stack. The test compilation only provides
// Core stubs via WithOpcUaCoreStubs(), no Opc.Ua.Server
// reference, so suppress fluent-builder emission.
Generators.GenerateStack(StackGenerationType.All, fileSystem, string.Empty, telemetry,
new GeneratorOptions
{
OmitFluentApi = true,
OmitEventRecords = true
});
// Get all generated C# files
var generatedText = fileSystem.CreatedFiles
.Where(c => Path.GetExtension(c) == ".cs")
.ToDictionary(c => c, c => Encoding.UTF8.GetString(fileSystem.Get(c)));
// Verify generated code compiles
using var peStream = new MemoryStream();
bool success = OptimizationLevel.Debug
.CreateCompilation()
.AddCode(generatedText.WithOpcUaCoreStubs(), LanguageVersion.Latest)
.Emit(peStream)
.Check(TestContext.Out, out int errorCount, out int warnCount);
// Assert
Assert.That(success, Is.True,
$"Generated NodeStates should compile without errors. Errors: {errorCount}, Warnings: {warnCount}");
}
[Test]
public void NodeSetMethodArgumentsGenerateTypedDelegatesResultsAndPlumbing()
{
ITelemetryContext telemetry = NUnitTelemetryContext.Create(logLevel: LogLevel.Error);
IModelDesign model = OpenNodeSetModel(
"TypedMethodArguments.NodeSet2.xml",
telemetry);
ObjectTypeDesign controllerDesign = model.Nodes
.OfType<ObjectTypeDesign>()
.Single(type => type.SymbolicId.Name == "ControllerType");
MethodDesign executeDesign = controllerDesign.Children.Items
.OfType<MethodDesign>()
.Single(method => method.BrowseName == "Execute");
MethodDesign calibrateDesign = controllerDesign.Children.Items
.OfType<MethodDesign>()
.Single(method => method.BrowseName == "Calibrate");
Assert.Multiple(() =>
{
Assert.That(
MethodDesignArgumentResolver.ResolveMethodInputs(executeDesign),
Has.Length.EqualTo(4));
Assert.That(
MethodDesignArgumentResolver.ResolveMethodOutputs(executeDesign),
Has.Length.EqualTo(4));
Assert.That(
MethodDesignArgumentResolver.ResolveMethodInputs(calibrateDesign),
Has.Length.EqualTo(1));
Assert.That(
MethodDesignArgumentResolver.ResolveMethodOutputs(calibrateDesign),
Has.Length.EqualTo(1));
});
Dictionary<string, string> files = GenerateFromNodeSet(
"TypedMethodArguments.NodeSet2.xml",
telemetry);
string nodeStates = files.Single(
kv => kv.Key.EndsWith(".NodeStates.g.cs", StringComparison.Ordinal)).Value;
Assert.Multiple(() =>
{
Assert.That(nodeStates, Does.Contain(
"_inputArguments[0].TryGetValue(out string name);"));
Assert.That(nodeStates, Does.Contain(
"_inputArguments[1].TryGetValue(out global::Opc.Ua.NodeId targetId);"));
Assert.That(nodeStates, Does.Contain(
"_inputArguments[2].TryGetValue(out short mode);"));
Assert.That(nodeStates, Does.Contain(
"_inputArguments[3].TryGetValue(out " +
"global::Opc.Ua.ArrayOf<global::Opc.Ua.NodeId> targets);"));
Assert.That(nodeStates, Does.Contain(
"_outputArguments[0] = global::Opc.Ua.Variant.From(_result.Status);"));
Assert.That(nodeStates, Does.Contain(
"_outputArguments[3] = global::Opc.Ua.Variant.From(_result.RelatedIds);"));
Assert.That(nodeStates, Does.Not.Contain("class PingMethodState"));
});
Assembly assembly = CompileGeneratedAssembly(files);
Type executeDelegate = GetGeneratedType(
assembly,
"ExecuteMethodStateMethodCallHandler");
ParameterInfo[] executeParameters =
executeDelegate.GetMethod("Invoke")!.GetParameters();
Assert.Multiple(() =>
{
Assert.That(executeParameters, Has.Length.EqualTo(11));
Assert.That(executeParameters[3].ParameterType, Is.EqualTo(typeof(string)));
Assert.That(executeParameters[4].ParameterType.FullName, Is.EqualTo("Opc.Ua.NodeId"));
Assert.That(executeParameters[5].ParameterType, Is.EqualTo(typeof(short)));
AssertArrayOfNodeIds(executeParameters[6].ParameterType);
Assert.That(executeParameters[7].ParameterType, Is.EqualTo(typeof(short).MakeByRefType()));
Assert.That(
executeParameters[8].ParameterType.GetElementType()?.FullName,
Is.EqualTo("Opc.Ua.NodeId"));
Assert.That(
executeParameters[9].ParameterType,
Is.EqualTo(typeof(string).MakeByRefType()));
AssertArrayOfNodeIds(executeParameters[10].ParameterType.GetElementType()!);
});
Type asyncDelegate = GetGeneratedType(
assembly,
"ExecuteMethodStateMethodAsyncCallHandler");
MethodInfo asyncInvoke = asyncDelegate.GetMethod("Invoke")!;
Assert.Multiple(() =>
{
Assert.That(asyncInvoke.GetParameters(), Has.Length.EqualTo(8));
Assert.That(asyncInvoke.ReturnType.IsGenericType, Is.True);
Assert.That(
asyncInvoke.ReturnType.GetGenericTypeDefinition(),
Is.EqualTo(typeof(ValueTask<>)));
Assert.That(
asyncInvoke.ReturnType.GetGenericArguments()[0].Name,
Is.EqualTo("ExecuteMethodStateResult"));
});
Type result = GetGeneratedType(assembly, "ExecuteMethodStateResult");
Assert.Multiple(() =>
{
Assert.That(result.GetProperty("ServiceResult"), Is.Not.Null);
Assert.That(result.GetProperty("Status")?.PropertyType, Is.EqualTo(typeof(short)));
Assert.That(
result.GetProperty("SelectedId")?.PropertyType.FullName,
Is.EqualTo("Opc.Ua.NodeId"));
Assert.That(result.GetProperty("Message")?.PropertyType, Is.EqualTo(typeof(string)));
AssertArrayOfNodeIds((result.GetProperty("RelatedIds")?.PropertyType)!);
});
Type controller = GetGeneratedType(assembly, "ControllerState");
Assert.Multiple(() =>
{
Assert.That(
controller.GetProperty("Calibrate")?.PropertyType.Name,
Is.EqualTo("SharedCalibrationMethodState"));
Assert.That(
controller.GetProperty("Ping")?.PropertyType.FullName,
Is.EqualTo("Opc.Ua.MethodState"));
});
}
[Test]
public void DuplicateMethodNamesReuseOnlyMatchingSignatures()
{
ITelemetryContext telemetry = NUnitTelemetryContext.Create(logLevel: LogLevel.Error);
IModelDesign focusedModel = OpenNodeSetModel(
"DuplicateMethodNames.NodeSet2.xml",
telemetry);
MethodDesign alpha = GetMethod(focusedModel, "AlphaType", "Execute");
MethodDesign beta = GetMethod(focusedModel, "BetaType", "Execute");
MethodDesign gamma = GetMethod(focusedModel, "GammaType", "Execute");
Assert.Multiple(() =>
{
Assert.That(alpha.MethodDeclarationNode, Is.SameAs(beta.MethodDeclarationNode));
Assert.That(gamma.MethodDeclarationNode, Is.Not.SameAs(alpha.MethodDeclarationNode));
Assert.That(
alpha.MethodDeclarationNode!.SymbolicId.Name,
Is.EqualTo("ExecuteAlphaTypeMethodType"));
Assert.That(
gamma.MethodDeclarationNode!.SymbolicId.Name,
Is.EqualTo("ExecuteGammaTypeMethodType"));
});
Dictionary<string, string> files = GenerateFromNodeSet(
"DuplicateMethodNames.NodeSet2.xml",
telemetry);
Assembly assembly = CompileGeneratedAssembly(files);
Type sharedDelegate = GetGeneratedType(
assembly,
"ExecuteAlphaTypeMethodStateMethodCallHandler");
Type stringDelegate = GetGeneratedType(
assembly,
"ExecuteGammaTypeMethodStateMethodCallHandler");
Type alphaState = GetGeneratedType(assembly, "AlphaState");
Type betaState = GetGeneratedType(assembly, "BetaState");
Type gammaState = GetGeneratedType(assembly, "GammaState");
Assert.Multiple(() =>
{
Assert.That(
sharedDelegate.GetMethod("Invoke")!.GetParameters()[3].ParameterType,
Is.EqualTo(typeof(int)));
Assert.That(
stringDelegate.GetMethod("Invoke")!.GetParameters()[3].ParameterType,
Is.EqualTo(typeof(string)));
Assert.That(
alphaState.GetProperty("Execute")?.PropertyType.Name,
Is.EqualTo("ExecuteAlphaTypeMethodState"));
Assert.That(
betaState.GetProperty("Execute")?.PropertyType.Name,
Is.EqualTo("ExecuteAlphaTypeMethodState"));
Assert.That(
gammaState.GetProperty("Execute")?.PropertyType.Name,
Is.EqualTo("ExecuteGammaTypeMethodState"));
});
}
[Test]
public void DiGetUpdateBehaviorMethodsUseDistinctDeclarations()
{
ITelemetryContext telemetry = NUnitTelemetryContext.Create(logLevel: LogLevel.Error);
IModelDesign model = OpenNodeSetModel("Opc.Ua.Di.NodeSet2.xml", telemetry);
MethodDesign cached = GetMethod(model, "CachedLoadingType", "GetUpdateBehavior");
MethodDesign fileSystem = GetMethod(
model,
"FileSystemLoadingType",
"GetUpdateBehavior");
Assert.Multiple(() =>
{
Assert.That(cached.MethodDeclarationNode, Is.Not.Null);
Assert.That(fileSystem.MethodDeclarationNode, Is.Not.Null);
Assert.That(
cached.MethodDeclarationNode,
Is.Not.SameAs(fileSystem.MethodDeclarationNode));
Assert.That(
cached.MethodDeclarationNode!.SymbolicId.Name,
Is.EqualTo("GetUpdateBehaviorCachedLoadingTypeMethodType"));
Assert.That(
fileSystem.MethodDeclarationNode!.SymbolicId.Name,
Is.EqualTo("GetUpdateBehaviorFileSystemLoadingTypeMethodType"));
Assert.That(
MethodDesignArgumentResolver.ResolveMethodInputs(cached),
Has.Length.EqualTo(3));
Assert.That(
MethodDesignArgumentResolver.ResolveMethodInputs(fileSystem),
Has.Length.EqualTo(1));
});
}
[Test]
public void DerivedMethodTypeOverrideUsesStringDefinitionEverywhere()
{
ITelemetryContext telemetry = NUnitTelemetryContext.Create(logLevel: LogLevel.Error);
IModelDesign model = OpenModelDesign(
"MethodTypeOverride.ModelDesign.xml",
telemetry);
ObjectTypeDesign derivedType = model.Nodes
.OfType<ObjectTypeDesign>()
.Single(type => type.SymbolicId.Name == "StringControllerType");
MethodDesign stringMethodType = model.Nodes
.OfType<MethodDesign>()
.Single(method => method.SymbolicId.Name == "StringConvertMethodType");
Assert.That(stringMethodType.InputArguments, Has.Length.EqualTo(1));
Assert.That(stringMethodType.OutputArguments, Has.Length.EqualTo(1));
MethodDesign mergedMethod = (MethodDesign)derivedType.Hierarchy.Nodes["Convert"].Instance!;
Parameter[] inputs =
MethodDesignArgumentResolver.ResolveMethodInputs(mergedMethod!);
Parameter[] outputs =
MethodDesignArgumentResolver.ResolveMethodOutputs(mergedMethod);
Assert.That(inputs, Has.Length.EqualTo(1));
Assert.That(outputs, Has.Length.EqualTo(1));
Assert.Multiple(() =>
{
Assert.That(
mergedMethod.TypeDefinition.Name,
Is.EqualTo("StringConvertMethodType"));
Assert.That(
mergedMethod.MethodType!.SymbolicId.Name,
Is.EqualTo("StringConvertMethodType"));
Assert.That(
mergedMethod.MethodDeclarationNode!.SymbolicId.Name,
Is.EqualTo("StringControllerType_Convert"));
Assert.That(
mergedMethod.MethodDeclarationNode,
Is.SameAs(mergedMethod));
Assert.That(mergedMethod.NumericIdSpecified, Is.True);
Assert.That(
inputs[0].DataType.Name,
Is.EqualTo("String"));
Assert.That(
outputs[0].DataType.Name,
Is.EqualTo("String"));
});
Dictionary<string, string> files = GenerateFromModelDesign(
"MethodTypeOverride.ModelDesign.xml",
telemetry);
string extensions = files.Single(
file => file.Key.EndsWith(".NodeStates.ex.g.cs", StringComparison.Ordinal)).Value;
string factory = ExtractMethodBody(
extensions,
"CreateStringControllerType_Convert");
Assert.Multiple(() =>
{
Assert.That(
factory,
Does.Contain("global::MethodTypeOverride.StringConvertMethodState"));
Assert.That(
factory,
Does.Contain(
$"baseState.MethodDeclarationId = global::Opc.Ua.NodeId.Create({mergedMethod.NumericId}u"));
Assert.That(
factory,
Does.Not.Contain("global::MethodTypeOverride.IntegerConvertMethodState"));
});
Assembly assembly = CompileGeneratedAssembly(files);
Type derivedState = GetGeneratedType(assembly, "StringControllerState");
Type handler = GetGeneratedType(
assembly,
"StringConvertMethodStateMethodCallHandler");
ParameterInfo[] parameters = handler.GetMethod("Invoke")!.GetParameters();
Assert.Multiple(() =>
{
Assert.That(
derivedState.GetProperty(
"Convert",
BindingFlags.Public |
BindingFlags.Instance |
BindingFlags.DeclaredOnly)?.PropertyType.Name,
Is.EqualTo("StringConvertMethodState"));
Assert.That(parameters[3].ParameterType, Is.EqualTo(typeof(string)));
Assert.That(
parameters[4].ParameterType,
Is.EqualTo(typeof(string).MakeByRefType()));
});
}
/// <summary>
/// A method that overrides the supertype's method with another method
/// state class is held in a field of the subtype, and the supertype's
/// field stays empty. Unless the subtype enumerates that field, the
/// method is missing from every instance: it is neither browsed nor
/// given a per-instance NodeId.
/// </summary>
[Test]
public void DerivedMethodTypeOverrideIsEnumeratedAsChild()
{
ITelemetryContext telemetry = NUnitTelemetryContext.Create(logLevel: LogLevel.Error);
Dictionary<string, string> files = GenerateFromModelDesign(
"MethodTypeOverride.ModelDesign.xml",
telemetry);
string nodeStates = files.Single(
file => file.Key.EndsWith(".NodeStates.g.cs", StringComparison.Ordinal)).Value;
string derivedClass = ExtractClassBody(nodeStates, "StringControllerState");
const string fieldDeclaration =
"private global::MethodTypeOverride.StringConvertMethodState? ";
int field = derivedClass.IndexOf(fieldDeclaration, StringComparison.Ordinal);
Assert.That(field, Is.GreaterThanOrEqualTo(0),
"The overriding method has no field of its own.");
int nameStart = field + fieldDeclaration.Length;
string fieldName = derivedClass[nameStart..derivedClass.IndexOf(';', nameStart)];
Assert.Multiple(() =>
{
Assert.That(
ExtractMemberBody(derivedClass, "public override void GetChildren("),
Does.Contain($"children.Add({fieldName});"));
Assert.That(
ExtractMemberBody(
derivedClass,
"protected override void RemoveExplicitlyDefinedChild("),
Does.Contain($"{fieldName} = null;"));
});
}
[Test]
public void NodeStateGeneratorCodeGeneratesCorrectMethodSignatures()
{
// Arrange
ITelemetryContext telemetry = NUnitTelemetryContext.Create(logLevel: LogLevel.Error);
using var fileSystem = new VirtualFileSystem();
// Act
Generators.GenerateStack(StackGenerationType.All, fileSystem, string.Empty, telemetry);
// Find NodeStateGenerator files
var predefinedNodesFiles = fileSystem.CreatedFiles
.Where(c => c.EndsWith(".NodeStates.ex.g.cs", StringComparison.Ordinal))
.ToList();
string generatedCode = string.Join("\n", fileSystem.CreatedFiles
.Where(c => Path.GetExtension(c) == ".cs")
.Select(c => Encoding.UTF8.GetString(fileSystem.Get(c))));
Assert.That(predefinedNodesFiles, Is.Not.Empty);
Assert.That(generatedCode, Does.Contain(
"global::Opc.Ua.NodeId previousNodeId = nodeState.NodeId;"));
Assert.That(generatedCode, Does.Contain(
"nodeState.NodeId.Equals("));
Assert.That(generatedCode, Does.Not.Contain(
"((global::Opc.Ua.NodeState)state)"));
foreach (string file in predefinedNodesFiles)
{
string content = Encoding.UTF8.GetString(fileSystem.Get(file));
// Check for proper method signatures
Assert.That(content, Does.Contain("global::Opc.Ua.ISystemContext context"),
"Methods should use ISystemContext parameter");
Assert.That(content, Does.Contain("global::Opc.Ua.NodeState nodeState = state;"),
"Code should use a base-typed NodeState local");
Assert.That(content, Does.Contain("nodeState.NodeId ="),
"Code should set NodeId property");
Assert.That(content, Does.Contain("nodeState.BrowseName ="),
"Code should set BrowseName property");
}
}
/// <summary>
/// Verifies the standard-model source generator declares typed
/// <c>MatrixOf&lt;T&gt;</c> State classes for the matrix-rank
/// VariableType (<c>XYArrayItemType</c>) and matrix-rank
/// Property / Variable instances
/// (<c>EnumDictionaryEntries</c>,
/// <c>FailureSystemIdentifier</c>).
/// </summary>
[Test]
public void NodeStateGeneratorEmitsMatrixOfTemplateParameterForStandardModel()
{
// Arrange
ITelemetryContext telemetry = NUnitTelemetryContext.Create(logLevel: LogLevel.Error);
using var fileSystem = new VirtualFileSystem();
// Act
Generators.GenerateStack(StackGenerationType.All, fileSystem, string.Empty, telemetry,
new GeneratorOptions
{
OmitFluentApi = true
});
// Concatenate every generated source file so the snippet match
// is resilient to whichever file each declaration lives in.
string code = string.Join("\n", fileSystem.CreatedFiles
.Where(c => Path.GetExtension(c) == ".cs")
.Select(c => Encoding.UTF8.GetString(fileSystem.Get(c))));
Assert.Multiple(() =>
{
// VariableType template specialization: XYArrayItemType
// inherits the generic ArrayItemState chain with a
// MatrixOf<XVType> template parameter.
Assert.That(code, Does.Contain(
"global::Opc.Ua.ArrayItemState<global::Opc.Ua.MatrixOf<global::Opc.Ua.XVType>>"),
"XYArrayItemState should inherit ArrayItemState<MatrixOf<XVType>>.");
// Property-state instance matrix branch.
Assert.That(code, Does.Contain(
"global::Opc.Ua.PropertyState<global::Opc.Ua.MatrixOf<global::Opc.Ua.NodeId>>"),
"EnumDictionaryEntries should declare PropertyState<MatrixOf<NodeId>>.");
// BaseDataVariableState-instance matrix branch.
Assert.That(code, Does.Contain(
"global::Opc.Ua.BaseDataVariableState<global::Opc.Ua.MatrixOf<byte>>"),
"FailureSystemIdentifier should declare BaseDataVariableState<MatrixOf<byte>>.");
});
}
/// <summary>
/// Verifies Mandatory declarations on standard alarm ObjectTypes retain their
/// ModellingRule when the type definition is materialized.
/// </summary>
[Test]
public void StandardAlarmTypeDeclarationsRetainModellingRules()
{
ITelemetryContext telemetry = NUnitTelemetryContext.Create(logLevel: LogLevel.Error);
using var fileSystem = new VirtualFileSystem();
Generators.GenerateStack(
StackGenerationType.All,
fileSystem,
string.Empty,
telemetry,
new GeneratorOptions
{
OmitFluentApi = true
});
string code = string.Join("\n", fileSystem.CreatedFiles
.Where(c => Path.GetExtension(c) == ".cs")
.Select(c => Encoding.UTF8.GetString(fileSystem.Get(c))));
string certificateAlarmFactory = ExtractMethodBody(
code,
"CreateCertificateExpirationAlarmType");
string expirationDateFactory = ExtractMethodBody(
code,
"CreateCertificateExpirationAlarmType_ExpirationDate");
string trustListIdFactory = ExtractMethodBody(
code,
"CreateTrustListOutOfDateAlarmType_TrustListId");
Assert.Multiple(() =>
{
Assert.That(
certificateAlarmFactory,
Does.Contain(
"CreateCertificateExpirationAlarmType_ExpirationDate(" +
"context, state, forInstance: forInstance)"),
"The ObjectType factory must pass its declaration/instance mode to child factories.");
Assert.That(
expirationDateFactory,
Does.Contain("baseState.ModellingRuleId ="),
"CertificateExpirationAlarmType.ExpirationDate must retain its Mandatory rule.");
Assert.That(
trustListIdFactory,
Does.Contain("baseState.ModellingRuleId ="),
"TrustListOutOfDateAlarmType.TrustListId must retain its Mandatory rule.");
});
}
/// <summary>
/// Verifies the source generator emits the singleton-instance
/// dispatch inside type-level child factories for synthesized
/// method arguments. Both the top-level method NodeId and its
/// Mandatory <c>InputArguments</c> / <c>OutputArguments</c>
/// descendants must rebind to their well-known singleton-instance
/// NodeIds when the type-level factory is called with
/// <c>forInstance: true</c> for a known singleton owner. Without
/// this dispatch, lazy-added methods on the
/// <c>Server</c>/<c>WellKnownRole_*</c> singletons silently keep
/// the type-level child NodeIds (e.g. 11490 instead of 11493 for
/// <c>Server_GetMonitoredItems_InputArguments</c>), so reads
/// against the spec-reserved well-known instance NodeIds return
/// <c>BadNodeIdUnknown</c>.
/// </summary>
[Test]
public void NodeStateGeneratorEmitsSingletonInstanceDispatchInTypeLevelFactories()
{
// Arrange
ITelemetryContext telemetry = NUnitTelemetryContext.Create(logLevel: LogLevel.Error);
using var fileSystem = new VirtualFileSystem();
// Act
Generators.GenerateStack(StackGenerationType.All, fileSystem, string.Empty, telemetry,
new GeneratorOptions
{
OmitFluentApi = true
});
string code = string.Join("\n", fileSystem.CreatedFiles
.Where(c => Path.GetExtension(c) == ".cs")
.Select(c => Encoding.UTF8.GetString(fileSystem.Get(c))));
Assert.Multiple(() =>
{
// ServerType (single singleton: Server, NodeId 2253).
// The type-level factory must dispatch the synthesized
// InputArguments / OutputArguments children through the
// singleton-instance child factories (Server_*).
Assert.That(code, Does.Contain(
"if (parent.NodeId.Equals(global::Opc.Ua.NodeId.Create(" +
"2253u, global::Opc.Ua.Namespaces.OpcUa, context.NamespaceUris)))"),
"CreateServerType_GetMonitoredItems should dispatch on the Server singleton NodeId.");
Assert.That(code, Does.Contain(
"state.CreateOrReplaceInputArguments(context, " +
"CreateServer_GetMonitoredItems_InputArguments(" +
"context, state, forInstance: true), assignInstanceNodeIds: false);"),
"The Server singleton branch should call the singleton-instance InputArguments factory.");
Assert.That(code, Does.Contain(
"state.CreateOrReplaceOutputArguments(context, " +
"CreateServer_GetMonitoredItems_OutputArguments(" +
"context, state, forInstance: true), assignInstanceNodeIds: false);"),
"The Server singleton branch should call the singleton-instance OutputArguments factory.");
// RoleType (multi-singleton: WellKnownRole_Observer = 15668,
// WellKnownRole_Operator = 15680, …). The type-level factory
// must dispatch on parent.NodeId across every singleton
// whose corresponding child factory has been collected.
Assert.That(code, Does.Contain(
"state.CreateOrReplaceInputArguments(context, " +
"CreateWellKnownRole_Observer_AddIdentity_InputArguments(" +
"context, state, forInstance: true), assignInstanceNodeIds: false);"),
"RoleType_AddIdentity should dispatch to WellKnownRole_Observer's InputArguments factory.");
Assert.That(code, Does.Contain(
"state.CreateOrReplaceInputArguments(context, " +
"CreateWellKnownRole_SecurityAdmin_AddIdentity_InputArguments(" +
"context, state, forInstance: true), assignInstanceNodeIds: false);"),
"RoleType_AddIdentity should dispatch to WellKnownRole_SecurityAdmin's InputArguments factory.");
// The top-level NodeId override re-binds nodeState.NodeId from
// the type-level constant to the singleton-instance NodeId
// when the dispatch matches. For GetMonitoredItems the
// override rewrites 11489 → 11492 under the Server
// singleton.
Assert.That(code, Does.Contain(
"nodeState.NodeId = global::Opc.Ua.NodeId.Create(" +
"11492u, global::Opc.Ua.Namespaces.OpcUa, context.NamespaceUris);"),
"The Server singleton dispatch should override nodeState.NodeId " +
"to Server_GetMonitoredItems (11492).");
});
}
/// <summary>
/// Regression test for issue #3964. A concrete predefined instance
/// (<c>AnalogDevice</c>) declares a <c>BaseAnalogType</c> variable
/// (<c>Measurement</c>) whose <c>EURange</c> / <c>EngineeringUnits</c>
/// property children are explicitly present on the instance but only
/// resolve to <c>Optional</c> (inherited from <c>BaseAnalogType</c>,
/// not restated as Mandatory on the instance). Before the fix the
/// generated instance-level factory emitted the creation of those
/// children inside the <c>if (!forInstance)</c> type-template block,
/// so — because the factory is always invoked with
/// <c>forInstance: true</c> — the properties were dropped from the
/// actual instance. They must instead be created unconditionally,
/// exactly like the type-level factory does.
/// </summary>
[Test]
public void ConcreteInstanceAnalogChildren_AreMaterializedUnconditionally()
{
// Arrange
ITelemetryContext telemetry = NUnitTelemetryContext.Create(logLevel: LogLevel.Error);
// Act
Dictionary<string, string> files = GenerateFromNodeSet(
"AnalogInstance.NodeSet2.xml", telemetry);
// Assert
string ex = files.Single(
kv => kv.Key.EndsWith(".NodeStates.ex.g.cs", StringComparison.Ordinal)).Value;
// The concrete-instance factory for the analog variable
// (Create<Instance>_<Var>) is distinct from the type-level
// factory (Create<Type>_<Var>). Only the instance one regressed.
string instanceFactory = ExtractMethodBody(ex, "CreateAnalogDevice_Measurement");
// The variable must be recognised as an analog state so that the
// typed EngineeringUnits / EURange children (and the fluent
// WithEURange / WithEngineeringUnits helpers) are available.
Assert.That(instanceFactory,
Does.Contain("global::Opc.Ua.BaseAnalogState"),
"Measurement should be generated as a BaseAnalogState.");
Assert.Multiple(() =>
{
Assert.That(instanceFactory, Does.Contain("CreateOrReplaceEURange"),
"Instance factory must create the EURange child.");
Assert.That(instanceFactory, Does.Contain("CreateOrReplaceEngineeringUnits"),
"Instance factory must create the EngineeringUnits child.");
});
int idxGate = instanceFactory.IndexOf(
"if (!forInstance)", StringComparison.Ordinal);
int idxEuRange = instanceFactory.IndexOf(
"CreateOrReplaceEURange", StringComparison.Ordinal);
int idxEngineeringUnits = instanceFactory.IndexOf(
"CreateOrReplaceEngineeringUnits", StringComparison.Ordinal);
if (idxGate >= 0)
{
Assert.Multiple(() =>
{
Assert.That(idxEuRange, Is.LessThan(idxGate),
"EURange must be created before the `if (!forInstance)` gate " +
"so it materialises on the actual instance (issue #3964).");
Assert.That(idxEngineeringUnits, Is.LessThan(idxGate),
"EngineeringUnits must be created before the `if (!forInstance)` " +
"gate so it materialises on the actual instance (issue #3964).");
});
}
}
/// <summary>
/// Verifies DataTypeEncoding objects remain independent predefined
/// nodes when a NodeSet exporter authors a same-namespace
/// <c>ParentNodeId</c> pointing at the owning DataType. Absorbing the
/// encoding into the DataType child collection drops it from the
/// generated NodeManager and leaves the server TypeTree without the
/// encoding-to-DataType relationship required for structured values.
/// </summary>
[Test]
public void SameNamespaceEncodingNodesAreEmittedAsPredefinedNodes()
{
ITelemetryContext telemetry = NUnitTelemetryContext.Create(logLevel: LogLevel.Error);
Dictionary<string, string> files = GenerateFromNodeSet(
"SameNamespaceEncoding.NodeSet2.xml",
telemetry);
string code = files.Single(
kv => kv.Key.EndsWith(".NodeStates.ex.g.cs", StringComparison.Ordinal)).Value;
Assert.Multiple(() =>
{
Assert.That(
code,
Does.Contain("CreateMyDataType_Encoding_DefaultBinary"),
"The Default Binary encoding factory must be emitted.");
Assert.That(
code,
Does.Contain("CreateMyDataType_Encoding_Default_JSON"),
"The Default JSON encoding factory must be emitted.");
Assert.That(
code,
Does.Contain(
"NodeState state = CreateMyDataType_Encoding_DefaultBinary(context);"),
"The Default Binary encoding must be registered as a predefined node.");
Assert.That(
code,
Does.Contain(
"NodeState state = CreateMyDataType_Encoding_Default_JSON(context);"),
"The Default JSON encoding must be registered as a predefined node.");
});
}
[TestCase("Device_Source", 49u, false, 3002u, false)]
[TestCase("Device_Target", 49u, true, 3001u, false)]
[TestCase("Device_Source", 47u, false, 85u, false)]
[TestCase("Device_Source", 35u, false, 85u, false)]
[TestCase("Device_Source", 35u, true, 85u, false)]
[TestCase("Device_Source", 35u, false, 85u, true)]
[TestCase("RecursiveType_Peer_Placeholder", 47u, false, 4001u, false)]
public void HierarchicalReferencesResolveInheritedTargetsWithoutDuplicates(
string source,
uint referenceTypeId,
bool inverse,
uint targetId,
bool standardNamespace)
{
ITelemetryContext telemetry = NUnitTelemetryContext.Create(logLevel: LogLevel.Error);
Dictionary<string, string> files = GenerateFromNodeSet("HierarchicalReferences.NodeSet2.xml", telemetry);
string code = files.Single(
file => file.Key.EndsWith(".NodeStates.ex.g.cs", StringComparison.Ordinal)).Value;
MethodDeclarationSyntax factory = CSharpSyntaxTree.ParseText(code).GetRoot()
.DescendantNodes().OfType<MethodDeclarationSyntax>()
.Single(method => method.Identifier.ValueText == "Create" + source);
string referenceTypeCode =
$"global::Opc.Ua.NodeId.Create({referenceTypeId}u, " +
"global::Opc.Ua.Namespaces.OpcUa, context.NamespaceUris)";
string targetNamespace = standardNamespace
? "global::Opc.Ua.Namespaces.OpcUa"
: "global::HierarchicalReferences.Namespaces.HierarchicalReferences";
string targetCode = $"global::Opc.Ua.NodeId.Create({targetId}u, {targetNamespace}, context.NamespaceUris)";
InvocationExpressionSyntax[] references =
[
.. factory.DescendantNodes()
.OfType<InvocationExpressionSyntax>()
.Where(invocation => invocation.Expression.ToString() == "state.AddReference")
];
Assert.Multiple(() =>
{
Assert.That(references.Select(reference => reference.ToString()), Is.Unique, factory.ToString());
Assert.That(
references.Count(reference =>
reference.ArgumentList.Arguments[0].ToString() == referenceTypeCode &&
reference.ArgumentList.Arguments[1].ToString() == (inverse ? "true" : "false") &&
reference.ArgumentList.Arguments[2].ToString() == targetCode),
Is.EqualTo(1),
factory.ToString());
});
}
[Test]
public void HierarchicalReferencesGenerateDeterministicCompilableCode()
{
ITelemetryContext telemetry = NUnitTelemetryContext.Create(logLevel: LogLevel.Error);
Dictionary<string, string> files = GenerateFromNodeSet("HierarchicalReferences.NodeSet2.xml", telemetry);
Dictionary<string, string> repeatedFiles =
GenerateFromNodeSet("HierarchicalReferences.NodeSet2.xml", telemetry);
Assert.That(repeatedFiles, Is.EquivalentTo(files));
Assert.That(CompileGeneratedAssembly(files), Is.Not.Null);
}
[Test]
public void MethodArgumentDataTypesUseRuntimeNamespaceTable()
{
ITelemetryContext telemetry = NUnitTelemetryContext.Create(logLevel: LogLevel.Error);
Dictionary<string, string> files = GenerateFromNodeSet(
"MethodArgumentNamespace.NodeSet2.xml",
telemetry);
string code = files.Single(
kv => kv.Key.EndsWith(".NodeStates.ex.g.cs", StringComparison.Ordinal)).Value;
string factory = ExtractMethodBody(
code,
"CreateMyObjectType_DoWork_InputArguments");
Assert.Multiple(() =>
{
Assert.That(
factory,
Does.Contain(
"DataType = global::Opc.Ua.NodeId.Create(100u, " +
"global::MethodArguments.Namespaces.MethodArguments, " +
"context.NamespaceUris)"));
Assert.That(factory, Does.Not.Contain("Variant.FromXml"));
});
}
[Test]
public void SameNamedMethodArgumentsUseDistinctSymbolsAndPreserveRuntimeNames()
{
ITelemetryContext telemetry = NUnitTelemetryContext.Create(logLevel: LogLevel.Error);
Dictionary<string, string> files = GenerateFromNodeSet(
"SameNamedMethodArguments.NodeSet2.xml",
telemetry);
Dictionary<string, string> repeatedFiles = GenerateFromNodeSet(
"SameNamedMethodArguments.NodeSet2.xml",
telemetry);
string nodeStates = files.Single(
kv => kv.Key.EndsWith(".NodeStates.ex.g.cs", StringComparison.Ordinal)).Value;
string nodeStateClasses = files.Single(
kv => kv.Key.EndsWith(".NodeStates.g.cs", StringComparison.Ordinal)).Value;
string proxies = files.Single(
kv => kv.Key.EndsWith(".TypeProxies.g.cs", StringComparison.Ordinal)).Value;
string generatedCode = string.Join("\n", files.Values);
string inputArgumentsFactory = ExtractMethodBody(
nodeStates,
"CreateRoundTripMethodType_InputArguments");
string outputArgumentsFactory = ExtractMethodBody(
nodeStates,
"CreateRoundTripMethodType_OutputArguments");
string localCollisionInputFactory = ExtractMethodBody(
nodeStates,
"CreateLocalCollisionMethodType_InputArguments");
string localCollisionOutputFactory = ExtractMethodBody(
nodeStates,
"CreateLocalCollisionMethodType_OutputArguments");
string[] inputRuntimeNames = ExtractStringLiteralValues(inputArgumentsFactory);
string[] outputRuntimeNames = ExtractStringLiteralValues(outputArgumentsFactory);
string[] localCollisionInputNames =
ExtractStringLiteralValues(localCollisionInputFactory);
string[] localCollisionOutputNames =
ExtractStringLiteralValues(localCollisionOutputFactory);
string[] proxyRuntimeStrings = ExtractStringLiteralValues(proxies);
string[] expectedInputNames =
[
"Foo",
"foo",
"Class",
"VersionId",
"Await",
"Ct",
"cT",
"CancellationToken",
"Context",
"ObjectId",
"Method",
"InputArguments",
"Results",
"_result",
"_foo",
"_",
"Nameof",
"__arglist",
"__makeref",
"__reftype",
"__refvalue"
];
string[] expectedOutputNames =
[
"VersionId",
"class",
"Changed",
"OutputArguments",
"ServiceResult",
"Quote\"Name",
"Back\\Slash",
"Line\nBreak",
"Δelta雪",
"Foo",
"RoundTripMethodStateResult",
"Next\u0085Line",
"Line\u2028Separator",
"Paragraph\u2029Separator"
];
Assert.Multiple(() =>
{
Assert.That(generatedCode, Does.Contain("string foo,"));
Assert.That(generatedCode, Does.Contain("string foo2,"));
Assert.That(generatedCode, Does.Contain("string @class,"));
Assert.That(generatedCode, Does.Contain("string versionId,"));
Assert.That(generatedCode, Does.Contain("string await2,"));
Assert.That(generatedCode, Does.Contain("string ct2,"));
Assert.That(generatedCode, Does.Contain("string cT3,"));
Assert.That(generatedCode, Does.Contain("string cancellationToken2,"));
Assert.That(generatedCode, Does.Contain("string context2,"));
Assert.That(generatedCode, Does.Contain("string objectId2,"));
Assert.That(generatedCode, Does.Contain("string method2,"));
Assert.That(generatedCode, Does.Contain("string inputArguments2,"));
Assert.That(generatedCode, Does.Contain("string results2,"));
Assert.That(nodeStateClasses, Does.Contain("string _result2,"));
Assert.That(nodeStateClasses, Does.Contain("string _foo,"));
Assert.That(nodeStateClasses, Does.Contain("string _2,"));
Assert.That(nodeStateClasses, Does.Contain("string nameof,"));
Assert.That(nodeStateClasses, Does.Contain("string @__arglist,"));
Assert.That(nodeStateClasses, Does.Contain("string @__makeref,"));
Assert.That(nodeStateClasses, Does.Contain("string @__reftype,"));
Assert.That(nodeStateClasses, Does.Contain("string @__refvalue,"));
Assert.That(generatedCode, Does.Contain("ref string versionIdOut"));
Assert.That(generatedCode, Does.Contain("ref string classOut"));
Assert.That(generatedCode, Does.Contain("ref string outputArgumentsOut"));
Assert.That(generatedCode, Does.Contain("ref string serviceResultOut"));
Assert.That(nodeStateClasses, Does.Contain("ref string fooOut"));
Assert.That(nodeStateClasses,
Does.Contain("ref string roundTripMethodStateResultOut"));
Assert.That(generatedCode, Does.Contain(
"public string VersionIdOut { get; set; }"));
Assert.That(generatedCode, Does.Contain(
"public string ClassOut { get; set; }"));
Assert.That(generatedCode, Does.Contain(
"public string OutputArgumentsOut { get; set; }"));
Assert.That(generatedCode, Does.Contain(
"public string ServiceResultOut { get; set; }"));
Assert.That(nodeStateClasses, Does.Contain(
"public string RoundTripMethodStateResultOut { get; set; }"));
Assert.That(generatedCode, Does.Contain("\\u0085"));
Assert.That(generatedCode, Does.Contain("\\u2028"));
Assert.That(generatedCode, Does.Contain("\\u2029"));
Assert.That(generatedCode, Does.Not.Contain("string await,"));
Assert.That(generatedCode, Does.Not.Contain("string cancellationToken,"));
Assert.That(inputArgumentsFactory,
Does.Not.Contain("Name = \"foo2\""));
Assert.That(inputArgumentsFactory,
Does.Not.Contain("Name = \"@class\""));
Assert.That(outputArgumentsFactory,
Does.Not.Contain("Name = \"VersionIdOut\""));
Assert.That(outputArgumentsFactory,
Does.Not.Contain("Name = \"classOut\""));
foreach (string expectedName in expectedInputNames)
{
Assert.That(inputRuntimeNames, Does.Contain(expectedName));
}
foreach (string expectedName in expectedOutputNames)
{
Assert.That(outputRuntimeNames, Does.Contain(expectedName));
}
Assert.That(localCollisionInputNames, Does.Contain("_foo"));
Assert.That(localCollisionOutputNames, Does.Contain("Foo"));
Assert.That(proxies, Does.Contain("ValueTask<("));
Assert.That(proxies, Does.Contain(
"ValueTask<string> LocalCollisionAsync("));
Assert.That(proxies, Does.Contain(
"string _fooOut;"));
Assert.That(proxies, Does.Contain(
"TryGetValue(out _fooOut)"));
Assert.That(proxies, Does.Contain("return _fooOut;"));
Assert.That(proxies, Does.Contain("string versionIdOut"));
Assert.That(proxies, Does.Contain("string classOut"));
Assert.That(proxies, Does.Contain("bool changed"));
Assert.That(proxies, Does.Contain("string outputArgumentsOut"));
Assert.That(proxies, Does.Contain("string serviceResultOut"));
Assert.That(proxies, Does.Contain("string quote_Name"));
Assert.That(proxies, Does.Contain("string back_Slash"));
Assert.That(proxies, Does.Contain("string lineBreak"));
Assert.That(proxies, Does.Contain("string δelta雪"));
Assert.That(proxies, Does.Contain("string fooOut"));
Assert.That(proxies,
Does.Contain("string roundTripMethodStateResult"));
Assert.That(proxies, Does.Contain("string nextLine"));
Assert.That(proxies, Does.Contain("string lineSeparator"));
Assert.That(proxies, Does.Contain("string paragraphSeparator"));
Assert.That(proxies, Does.Contain("string foo,"));
Assert.That(proxies, Does.Contain("string foo2,"));
Assert.That(proxies, Does.Contain("string @class,"));
Assert.That(proxies, Does.Contain("string versionId,"));
Assert.That(proxies, Does.Contain("string await2,"));
Assert.That(proxies, Does.Contain("string ct2,"));
Assert.That(proxies, Does.Contain("string cT3,"));
Assert.That(proxies, Does.Contain("string cancellationToken2,"));
Assert.That(proxies, Does.Contain("string context2,"));
Assert.That(proxies, Does.Contain("string objectId2,"));
Assert.That(proxies, Does.Contain("string method2,"));
Assert.That(proxies, Does.Contain("string inputArguments2,"));
Assert.That(proxies, Does.Contain("string results2,"));
Assert.That(proxies, Does.Contain("string _result,"));
Assert.That(proxies, Does.Contain("string _foo,"));
Assert.That(proxies, Does.Contain("string _2,"));
Assert.That(proxies, Does.Contain("string nameof2,"));
Assert.That(proxies, Does.Contain("string @__arglist,"));
Assert.That(proxies, Does.Contain("string @__makeref,"));
Assert.That(proxies, Does.Contain("string @__reftype,"));
Assert.That(proxies, Does.Contain("string @__refvalue,"));
Assert.That(proxies, Does.Contain("<param name=\"class\">"));
Assert.That(proxies, Does.Not.Contain("<param name=\"@class\">"));
Assert.That(proxies, Does.Contain("output 'VersionId'."));
Assert.That(proxies, Does.Contain("output 'class'."));
foreach (string expectedName in expectedOutputNames)
{
Assert.That(
proxyRuntimeStrings,
Does.Contain(
$"Method 'RoundTrip' returned an unexpected value " +
$"for output '{expectedName}'."));
}
Assert.That(repeatedFiles.Keys, Is.EquivalentTo(files.Keys));
foreach (string key in files.Keys)
{
Assert.That(repeatedFiles[key], Is.EqualTo(files[key]), key);
}
});
using var peStream = new MemoryStream();
bool success = OptimizationLevel.Debug
.CreateCompilation()
.AddCode(
files.WithOpcUaGeneratedStack(),
LanguageVersion.Latest)
.Emit(peStream)
.Check(TestContext.Out, out int errorCount, out int warnCount);
Assert.That(success, Is.True,
$"Generated code should compile. Errors: {errorCount}, Warnings: {warnCount}");
}
private static string[] ExtractStringLiteralValues(string source)
{
return
[
.. CSharpSyntaxTree.ParseText(source)
.GetRoot()
.DescendantNodes()
.OfType<LiteralExpressionSyntax>()
.Where(literal => literal.IsKind(SyntaxKind.StringLiteralExpression))
.Select(literal => literal.Token.ValueText)
];
}
/// <summary>
/// Regression: the copy-on-write clone in a typed VariableType's value
/// setter was cast to the data type's bare symbolic name. That only
/// resolves when the generated class happens to sit in the declaring
/// namespace - a cross-model structure produced CS0246.
/// </summary>
[Test]
public void GeneratedCopyOnWriteCastIsFullyQualified()
{
ITelemetryContext telemetry = NUnitTelemetryContext.Create(logLevel: LogLevel.Error);
Dictionary<string, string> files = GenerateStackTests.GenerateStack(
StackGenerationType.All,
telemetry,
out _);
string states = string.Join("\n", files.Values);
const string prefix = "CopyOnWrite ? (";
var castTypes = new List<string>();
for (int at = states.IndexOf(prefix, StringComparison.Ordinal);
at >= 0;
at = states.IndexOf(prefix, at + prefix.Length, StringComparison.Ordinal))
{
int start = at + prefix.Length;
int end = states.IndexOf(')', start);
castTypes.Add(states[start..end]);
}
Assert.That(castTypes, Is.Not.Empty, "the model must exercise a cloning value type");
Assert.That(
castTypes,
Is.All.StartsWith("global::"),
"every clone cast must name the type fully qualified: "
+ string.Join(", ", castTypes));
}
/// <summary>
/// Regression: NodeSet categories were emitted into the
/// <c>Categories</c> string array without escaping, so a category
/// containing a quote or backslash produced uncompilable code.
/// </summary>
[Test]
public void CategoriesWithQuotesAndBackslashesAreEscaped()
{
ITelemetryContext telemetry = NUnitTelemetryContext.Create(logLevel: LogLevel.Error);
Dictionary<string, string> files = GenerateFromNodeSet(
"QuotedCategory.NodeSet2.xml",
telemetry);
string code = string.Join("\n", files.Values);
Assert.That(
code,
Does.Contain(
"nodeState.Categories = new string[] { " +
"\"Profile \\\"Standard\\\" UA Server\", \"Folder\\\\Sub\" };"));
Assert.That(CompileGeneratedAssembly(files), Is.Not.Null);
}
/// <summary>
/// Regression: structure-typed method arguments whose value rank is
/// ScalarOrArray / ScalarOrOneDimension / Any are carried as Variant,
/// but the generated Call used the IEncodeable-constrained
/// TryGetValue/FromStructure overloads (CS0315).
/// </summary>
[Test]
public void ScalarOrArrayStructureMethodArgumentsCompile()
{
ITelemetryContext telemetry = NUnitTelemetryContext.Create(logLevel: LogLevel.Error);
Dictionary<string, string> files = GenerateFromNodeSet(
"ScalarOrArrayStructureArguments.NodeSet2.xml",
telemetry);
string code = string.Join("\n", files.Values);
Assert.That(
code,
Does.Contain("TryGetValue(out global::Opc.Ua.Variant options)"));
Assert.That(code, Does.Not.Contain("FromStructure(result)"));
Assert.That(CompileGeneratedAssembly(files), Is.Not.Null);
}
/// <summary>
/// Regression: AccessRestrictions and RolePermissions of type nodes
/// and of method declarations on types were wrapped in
/// <c>if (forInstance)</c>, but type factories are only ever called
/// with <c>forInstance: false</c>, so the attributes were never set.
/// </summary>
[Test]
public void TypeAndTypeMethodDeclarationsCarryAccessRestrictionsAndRolePermissions()
{
ITelemetryContext telemetry = NUnitTelemetryContext.Create(logLevel: LogLevel.Error);
Dictionary<string, string> files = GenerateFromNodeSet(
"TypeRolePermissions.NodeSet2.xml",
telemetry);
string code = files.Single(
kv => kv.Key.EndsWith(".NodeStates.ex.g.cs", StringComparison.Ordinal)).Value;
string typeFactory = ExtractMethodBody(code, "CreateRestrictedObjectType");
string methodFactory = ExtractMethodBody(code, "CreateRestrictedObjectType_Reset");
Assert.Multiple(() =>
{
Assert.That(typeFactory, Does.Contain("nodeState.AccessRestrictions = "));
Assert.That(typeFactory, Does.Contain("nodeState.RolePermissions = "));
Assert.That(methodFactory, Does.Contain("nodeState.RolePermissions = "));
foreach (string factory in new[] { typeFactory, methodFactory })
{
Assert.That(
factory,
Does.Not.Match(
@"if \(forInstance\)\s*\{\s*(nodeState\.AccessRestrictions|nodeState\.RolePermissions)"));
}
});
Assert.That(CompileGeneratedAssembly(files), Is.Not.Null);
}
/// <summary>
/// Regression: the VariableType value class addressed the structure
/// value by the authored field name, but the data type generator
/// renames fields that collide with the enclosing type name or with
/// reserved members (e.g. <c>Measurement</c> becomes
/// <c>MeasurementField</c>), producing CS1061.
/// </summary>
[Test]
public void VariableTypeValueUsesGeneratedStructurePropertyNames()
{
ITelemetryContext telemetry = NUnitTelemetryContext.Create(logLevel: LogLevel.Error);
Dictionary<string, string> files = GenerateFromNodeSet(
"VariableTypeRenamedField.NodeSet2.xml",
telemetry);
string code = string.Join("\n", files.Values);
Assert.Multiple(() =>
{
Assert.That(code, Does.Contain("m_value.MeasurementField"));
Assert.That(code, Does.Contain("m_value.TypeIdField"));
Assert.That(code, Does.Contain("m_value.Unit"));
Assert.That(code, Does.Contain("m_variable.Measurement"));
Assert.That(code, Does.Not.Match(@"m_value\.Measurement\b"));
Assert.That(code, Does.Not.Match(@"m_value\.TypeId\b"));
});
Assert.That(CompileGeneratedAssembly(files), Is.Not.Null);
}
/// <summary>
/// Regression: factories assigned node attributes through the local
/// typed as the generated class, so a child named like an attribute
/// (DataType, ValueRank, EventNotifier, TypeDefinitionId, ...) bound
/// the assignment to the child property instead (CS0029).
/// </summary>
[Test]
public void ChildrenNamedLikeNodeAttributesCompile()
{
ITelemetryContext telemetry = NUnitTelemetryContext.Create(logLevel: LogLevel.Error);
Dictionary<string, string> files = GenerateFromNodeSet(
"AttributeNamedChildren.NodeSet2.xml",
telemetry);
string code = string.Join("\n", files.Values);
Assert.That(code, Does.Contain("class ParameterState"));
Assert.That(code, Does.Contain("class SensorState"));
var diagnostics = new List<Diagnostic>();
Assert.That(CompileGeneratedAssembly(files, diagnostics), Is.Not.Null);
// The child properties must be declared "public new" exactly when
// they hide a member of the runtime base class.
string[] hidingWarnings = [.. diagnostics
.Where(d => d.Id is "CS0108" or "CS0109")
.Select(d => d.ToString())];
Assert.That(hidingWarnings, Is.Empty);
}
/// <summary>
/// The <c>public new</c> decision for child properties uses name lists of the
/// runtime base classes. Every member a generated class inherits (and only
/// those) must be in the list for its parent kind, otherwise the child hides
/// the member without <c>new</c> (CS0108) or uses <c>new</c> needlessly
/// (CS0109). Fails when the runtime gains a member the lists do not know.
/// </summary>
[TestCase(typeof(ObjectTypeDesign), typeof(BaseObjectState))]
[TestCase(typeof(ObjectDesign), typeof(BaseObjectState))]
[TestCase(typeof(VariableTypeDesign), typeof(BaseVariableState))]
[TestCase(typeof(VariableDesign), typeof(BaseVariableState))]
[TestCase(typeof(MethodDesign), typeof(MethodState))]
public void ChildHidingDecisionMatchesTheRuntimeBaseClass(Type parentKind, Type runtimeBase)
{
var parent = (NodeDesign)Activator.CreateInstance(parentKind)!;
HashSet<string> inherited = GetMembersVisibleToDerivedClasses(runtimeBase);
// A Variable child named Value is emitted as a plain public property.
if (parent is VariableDesign)
{
inherited.Remove("Value");
}
HashSet<string> candidates =
[
.. GetMembersVisibleToDerivedClasses(typeof(BaseObjectState)),
.. GetMembersVisibleToDerivedClasses(typeof(BaseVariableState)),
.. GetMembersVisibleToDerivedClasses(typeof(MethodState))
];
string[] missing = [.. inherited
.Where(n => !NodeStateGenerator.RequiresNewModifier(parent!, n))
.OrderBy(n => n, StringComparer.Ordinal)];
string[] extra = [.. candidates
.Where(n => !inherited.Contains(n) && n != "Value" &&
NodeStateGenerator.RequiresNewModifier(parent!, n))
.OrderBy(n => n, StringComparer.Ordinal)];
Assert.Multiple(() =>
{
Assert.That(missing, Is.Empty,
"Inherited members of " + runtimeBase.Name + " missing from the lists (CS0108)");
Assert.That(extra, Is.Empty,
"Names that " + runtimeBase.Name + " does not declare (CS0109)");
});
}
private static HashSet<string> GetMembersVisibleToDerivedClasses(Type type)
{
const BindingFlags kFlags = BindingFlags.Public | BindingFlags.NonPublic |
BindingFlags.Instance | BindingFlags.Static | BindingFlags.DeclaredOnly;
var names = new HashSet<string>(StringComparer.Ordinal);
for (Type current = type; current != null; current = current.BaseType!)
{
foreach (MemberInfo member in current.GetMembers(kFlags))
{
bool visible = member switch
{
ConstructorInfo => false,
MethodInfo m => !m.IsSpecialName && IsVisibleToDerived(m),
PropertyInfo p => p.GetIndexParameters().Length == 0 &&
p.GetAccessors(true).Any(IsVisibleToDerived),
EventInfo e => IsVisibleToDerived(e.AddMethod!),
FieldInfo f => !f.IsSpecialName &&
(f.IsPublic || f.IsFamily || f.IsFamilyOrAssembly),
Type t => t.IsNestedPublic || t.IsNestedFamily || t.IsNestedFamORAssem,
_ => false
};
if (visible && member.Name.All(c => char.IsLetterOrDigit(c) || c == '_'))
{
names.Add(member.Name);
}
}
}
return names;
}
private static bool IsVisibleToDerived(MethodBase method)
{
return method != null && (method.IsPublic || method.IsFamily || method.IsFamilyOrAssembly);
}
/// <summary>
/// Regression: GetDefaultTypeDefinitionId / GetDefaultDataTypeId and
/// DataTypeDefinitions references were emitted with the bare
/// namespace prefix. Inside <c>namespace Acme.Opc.Ua.Pumps</c> the
/// reference <c>Opc.Ua.DataTypes.Double</c> binds <c>Opc</c> to
/// <c>Acme.Opc</c> (CS0234).
/// </summary>
[Test]
public void NamespacePrefixReferencesAreGlobalQualified()
{
ITelemetryContext telemetry = NUnitTelemetryContext.Create(logLevel: LogLevel.Error);
Dictionary<string, string> files = GenerateFromModelDesign(
"NestedOpcPrefix.ModelDesign.xml",
telemetry);
string code = string.Join("\n", files.Values);
Assert.Multiple(() =>
{
Assert.That(code, Does.Contain("namespace Acme.Opc.Ua.Pumps"));
Assert.That(code, Does.Contain("global::Opc.Ua.DataTypes.Double,"));
Assert.That(code, Does.Contain("global::Acme.Opc.Ua.Pumps.VariableTypes.FlowVariableType,"));
Assert.That(code, Does.Contain("global::Acme.Opc.Ua.Pumps.ObjectTypes.PumpType,"));
Assert.That(
code,
Does.Contain("global::Acme.Opc.Ua.Pumps.DataTypeDefinitions.CreatePumpSettings("));
});
Assert.That(CompileGeneratedAssembly(files), Is.Not.Null);
}
/// <summary>
/// Regression: a hierarchy reference whose target is the hierarchy
/// root (TargetPath "") was emitted against the source node's
/// immediate parent. For a grandchild that is the intermediate
/// folder, not the type / top-level instance.
/// </summary>
[Test]
public void ReferenceToHierarchyRootFromGrandchildTargetsTheRoot()
{
ITelemetryContext telemetry = NUnitTelemetryContext.Create(logLevel: LogLevel.Error);
Dictionary<string, string> files = GenerateFromModelDesign(
"DeepRootReference.ModelDesign.xml",
telemetry);
string code = files.Single(
kv => kv.Key.EndsWith(".NodeStates.ex.g.cs", StringComparison.Ordinal)).Value;
string typeMotor = ExtractMethodBody(code, "CreateMachineType_Components_Motor");
string instanceMotor = ExtractMethodBody(code, "CreateMachine1_Components_Motor");
string allCode = string.Join("\n", files.Values);
// Inverse HasEventSource (i=36) from Motor to the given node.
string InverseEventSourceTo(string symbolicName)
{
System.Text.RegularExpressions.Match id = Regex.Match(
allCode,
@"\buint " + Regex.Escape(symbolicName) + @" = (\d+)u?;");
Assert.That(id.Success, Is.True, $"No numeric id for {symbolicName}.");
return "state.AddReference(global::Opc.Ua.NodeId.Create(36u, " +
"global::Opc.Ua.Namespaces.OpcUa, context.NamespaceUris), true, " +
$"global::Opc.Ua.NodeId.Create({id.Groups[1].Value}u, ";
}
Assert.Multiple(() =>
{
Assert.That(typeMotor, Does.Contain(InverseEventSourceTo("MachineType")));
Assert.That(
typeMotor,
Does.Not.Contain(InverseEventSourceTo("MachineType_Components")));
Assert.That(instanceMotor, Does.Contain(InverseEventSourceTo("Machine1")));
Assert.That(
instanceMotor,
Does.Not.Contain(InverseEventSourceTo("Machine1_Components")));
});
Assert.That(CompileGeneratedAssembly(files), Is.Not.Null);
}
/// <summary>
/// The source of a GeneratesEvent reference shall be an ObjectType, a VariableType
/// or a Method (Part 3 §7.15). An ObjectType or VariableType keeps the references it
/// declares, but its Object and Variable instances and instance declarations must not
/// inherit them (the CTT flagged
/// the GDS AuthorizationServiceType placeholder and the Default service).
/// </summary>
[Test]
public void TypeGeneratesEventReferencesAreNotCopiedToObjectInstances()
{
ITelemetryContext telemetry = NUnitTelemetryContext.Create(logLevel: LogLevel.Error);
Dictionary<string, string> files = GenerateFromModelDesign(
"TypeGeneratesEvent.ModelDesign.xml",
telemetry);
string code = files.Single(
kv => kv.Key.EndsWith(".NodeStates.ex.g.cs", StringComparison.Ordinal)).Value;
const string generatesEvent = "state.AddReference(global::Opc.Ua.NodeId.Create(41u, ";
const string alwaysGeneratesEvent = "state.AddReference(global::Opc.Ua.NodeId.Create(3065u, ";
// The public factory definition; call sites are qualified by "context.".
int instanceOf = code.IndexOf(" CreateInstanceOfThingType(", StringComparison.Ordinal);
Assert.That(instanceOf, Is.GreaterThanOrEqualTo(0));
string instanceOfThingType = code[instanceOf..code.IndexOf(
"return state;",
instanceOf,
StringComparison.Ordinal)];
Assert.Multiple(() =>
{
string type = ExtractMethodBody(code, "CreateThingType");
Assert.That(type, Does.Contain(generatesEvent));
Assert.That(type, Does.Contain(alwaysGeneratesEvent));
Assert.That(ExtractMethodBody(code, "CreateThingValueType"), Does.Contain(generatesEvent));
foreach (string instance in new[]
{
ExtractMethodBody(code, "CreateThingsFolderType_Thing_Placeholder"),
ExtractMethodBody(code, "CreateThing1"),
instanceOfThingType,
ExtractMethodBody(code, "CreateThingType_Value"),
ExtractMethodBody(code, "CreateThing1_Value"),
ExtractMethodBody(code, "CreateThingValue1")
})
{
Assert.That(instance, Does.Not.Contain(generatesEvent));
Assert.That(instance, Does.Not.Contain(alwaysGeneratesEvent));
}
Assert.That(ExtractMethodBody(code, "CreateThingType_Start"), Does.Contain(generatesEvent));
Assert.That(ExtractMethodBody(code, "CreateThing1_Start"), Does.Contain(generatesEvent));
Assert.That(ExtractMethodBody(code, "CreateExplicitSource"), Does.Contain(generatesEvent));
});
Assert.That(CompileGeneratedAssembly(files), Is.Not.Null);
}
private static Dictionary<string, string> GenerateFromNodeSet(
string nodeSetResource,
ITelemetryContext telemetry)
{
using var fileSystem = new VirtualFileSystem();
string path = Path.Combine(
Directory.GetCurrentDirectory(), "Resources", nodeSetResource);
var nodesets = new NodesetFileCollection(
[(path, new NodesetFileOptions())],
[],
fileSystem,
telemetry);
// The test compilation has no Opc.Ua.Server reference, so
// suppress fluent-builder emission (mirrors model-only projects).
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 static Dictionary<string, string> GenerateFromModelDesign(
string modelDesignResource,
ITelemetryContext telemetry)
{
using var fileSystem = new VirtualFileSystem();
string path = Path.Combine(
Directory.GetCurrentDirectory(),
"Resources",
modelDesignResource);
Generators.GenerateCode(
new DesignFileCollection
{
Targets = [path],
Options = new DesignFileOptions()
},
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 static IModelDesign OpenNodeSetModel(
string nodeSetResource,
ITelemetryContext telemetry)
{
using var fileSystem = new VirtualFileSystem();
string path = Path.Combine(
Directory.GetCurrentDirectory(), "Resources", nodeSetResource);
var nodesets = new NodesetFileCollection(
[(path, new NodesetFileOptions())],
[],
fileSystem,
telemetry);
string modelUri = nodesets.ModelUris.Single();
List<string> designFiles = nodesets.GetDesignFileListForModel(
modelUri,
out _)!;
IFileSystem sourceFileSystem = typeof(Generators).Assembly
.AsFileSystem("Opc.Ua.SourceGeneration.Design")
.WithFallback(fileSystem);
return sourceFileSystem.OpenModelDesign(
new DesignFileCollection { Targets = designFiles! },
[],
telemetry,
useAllowSubtypes: false);
}
private static IModelDesign OpenModelDesign(
string modelDesignResource,
ITelemetryContext telemetry)
{
using var fileSystem = new VirtualFileSystem();
string path = Path.Combine(
Directory.GetCurrentDirectory(),
"Resources",
modelDesignResource);
IFileSystem sourceFileSystem = typeof(Generators).Assembly
.AsFileSystem("Opc.Ua.SourceGeneration.Design")
.WithFallback(fileSystem);
return sourceFileSystem.OpenModelDesign(
new DesignFileCollection { Targets = [path] },
[],
telemetry,
useAllowSubtypes: false);
}
private static MethodDesign GetMethod(
IModelDesign model,
string ownerName,
string browseName)
{
ObjectTypeDesign owner = model.Nodes
.OfType<ObjectTypeDesign>()
.Single(type => type.SymbolicId.Name == ownerName);
return owner.Children.Items
.OfType<MethodDesign>()
.Single(method => method.BrowseName == browseName);
}
private static Assembly CompileGeneratedAssembly(
Dictionary<string, string> files,
List<Diagnostic>? diagnostics = null)
{
using var peStream = new MemoryStream();
ITelemetryContext telemetry = NUnitTelemetryContext.Create(logLevel: LogLevel.Error);
Dictionary<string, string> stackFiles = GenerateStackTests.GenerateStack(
StackGenerationType.Models,
telemetry,
out _);
IEnumerable<KeyValuePair<string, string>> generatedNodeStateFiles = stackFiles
.Concat(files)
.Where(file => !file.Key.EndsWith(
".TypeProxies.g.cs",
StringComparison.Ordinal));
var emitResult = OptimizationLevel.Debug
.CreateCompilation("TypedMethodArguments.Generated")
.AddCode(
generatedNodeStateFiles.WithOpcUaCoreStubs(),
LanguageVersion.Latest)
.Emit(peStream);
diagnostics?.AddRange(emitResult.Diagnostics);
bool success = emitResult.Check(TestContext.Out, out int errorCount, out int warningCount);
Assert.That(
success,
Is.True,
$"Generated code failed with {errorCount} errors and {warningCount} warnings.");
return Assembly.Load(peStream.ToArray());
}
private static Type GetGeneratedType(Assembly assembly, string name)
{
Type? type = assembly.GetTypes().SingleOrDefault(candidate => candidate.Name == name);
Assert.That(type, Is.Not.Null, $"Generated type '{name}' was not found.");
return type;
}
private static void AssertArrayOfNodeIds(Type type)
{
Assert.That(type, Is.Not.Null);
if (type == null)
{
return;
}
Assert.That(type.IsGenericType, Is.True);
if (!type.IsGenericType)
{
return;
}
Assert.Multiple(() =>
{
Assert.That(
type.GetGenericTypeDefinition().FullName,
Is.EqualTo("Opc.Ua.ArrayOf`1"));
Assert.That(
type.GetGenericArguments()[0].FullName,
Is.EqualTo("Opc.Ua.NodeId"));
});
}
/// <summary>
/// Extracts the source of the factory method whose signature begins
/// with <c>internal static … {methodName}(</c>. Returns the body from
/// the method definition up to (but not including) the next
/// static-method definition, so callers can assert on ordering within
/// a single method without matching call sites elsewhere in the file.
/// </summary>
private static string ExtractMethodBody(string code, string methodName)
{
System.Text.RegularExpressions.Match match = Regex.Match(
code,
@"internal static [^\r\n]*\b" + Regex.Escape(methodName) + @"\(");
Assert.That(match.Success, Is.True,
$"Method definition '{methodName}' not found in generated code.");
int start = match.Index;
int end = code.Length;
foreach (string marker in new[]
{
"\n internal static",
"\n public static",
"\n private static"
})
{
int idx = code.IndexOf(
marker, start + match.Length, StringComparison.Ordinal);
if (idx >= 0 && idx < end)
{
end = idx;
}
}
return code[start..end];
}
/// <summary>
/// Extracts a generated top-level class, from its declaration up to
/// the closing brace at class indentation.
/// </summary>
private static string ExtractClassBody(string code, string className)
{
System.Text.RegularExpressions.Match match = Regex.Match(
code,
@"partial class " + Regex.Escape(className) + @"\b");
Assert.That(match.Success, Is.True,
$"Class '{className}' not found in generated code.");
int end = code.IndexOf("\n }", match.Index, StringComparison.Ordinal);
return end < 0 ? code[match.Index..] : code[match.Index..end];
}
/// <summary>
/// Extracts a member of a generated class, from its signature up to
/// the closing brace at member indentation.
/// </summary>
private static string ExtractMemberBody(string classBody, string signature)
{
int start = classBody.IndexOf(signature, StringComparison.Ordinal);
Assert.That(start, Is.GreaterThanOrEqualTo(0),
$"Member '{signature}' not found in generated class.");
int end = classBody.IndexOf("\n }", start, StringComparison.Ordinal);
return end < 0 ? classBody[start..] : classBody[start..end];
}
private Mock<IFileSystem> m_mockFileSystem;
private Mock<IModelDesign> m_mockModelDesign;
private Mock<ITelemetryContext> m_mockTelemetry;
private GeneratorContext m_context = null!;
}
}