mirror of
https://github.com/OPCFoundation/UA-.NETStandard.git
synced 2026-10-18 14:43: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>
2033 lines
94 KiB
C#
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<T></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!;
|
|
}
|
|
}
|