mirror of
https://github.com/OPCFoundation/UA-.NETStandard.git
synced 2026-10-28 22:55:49 +08:00
# 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>
1148 lines
53 KiB
C#
1148 lines
53 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.Collections.Immutable;
|
|
using System.IO;
|
|
using System.Linq;
|
|
using System.Text;
|
|
using System.Xml;
|
|
using Microsoft.Extensions.Logging;
|
|
using Moq;
|
|
using NUnit.Framework;
|
|
using Opc.Ua.Schema.Model;
|
|
using Opc.Ua.Tests;
|
|
|
|
namespace Opc.Ua.SourceGeneration.Generator.Tests
|
|
{
|
|
/// <summary>
|
|
/// Snapshot tests for the <see cref="FluentBuilderGenerator"/>: verify
|
|
/// the opt-in flag, the type-and-instance wrapper shape, the typed
|
|
/// IVariableBuilder accessors, and the typed sync/async OnCall
|
|
/// overloads on method wrappers.
|
|
/// </summary>
|
|
[TestFixture]
|
|
[Category("Generator")]
|
|
[SetCulture("en-us")]
|
|
[SetUICulture("en-us")]
|
|
[Parallelizable(ParallelScope.All)]
|
|
public class FluentBuilderGeneratorTests
|
|
{
|
|
[Test]
|
|
public void Emit_WithoutOptIn_ProducesNoFluentBuildersFile()
|
|
{
|
|
Dictionary<string, string> files = GenerateForTestModel(generateNodeManager: false);
|
|
|
|
Assert.That(files.Keys, Has.None.EndsWith(".FluentBuilders.g.cs"));
|
|
}
|
|
|
|
[Test]
|
|
public void Emit_WithOptIn_ProducesFluentBuildersFile()
|
|
{
|
|
Dictionary<string, string> files = GenerateForTestModel(generateNodeManager: true);
|
|
|
|
Assert.That(files.Keys, Has.Some.EndsWith(".FluentBuilders.g.cs"),
|
|
"Generator should emit a FluentBuilders file when GenerateNodeManager=true");
|
|
}
|
|
|
|
[Test]
|
|
public void EmittedFluentBuilders_HasTypedManagerInterfaceAndProxy()
|
|
{
|
|
string fb = GetFluentBuilders();
|
|
|
|
Assert.That(fb, Does.Match(
|
|
@"internal\s+interface\s+I\w+NodeManagerBuilder\s*:\s*" +
|
|
@"global::Opc\.Ua\.Server\.Fluent\.INodeManagerBuilder"),
|
|
"A typed IBuilder interface extending INodeManagerBuilder must be emitted");
|
|
|
|
Assert.That(fb, Does.Match(
|
|
@"internal\s+sealed\s+class\s+\w+NodeManagerTypedBuilder\s*:\s*I\w+NodeManagerBuilder"),
|
|
"A typed proxy implementing the IBuilder must be emitted");
|
|
|
|
Assert.That(fb, Does.Contain("private readonly global::Opc.Ua.Server.Fluent.INodeManagerBuilder __inner"),
|
|
"The proxy must wrap an INodeManagerBuilder");
|
|
}
|
|
|
|
[Test]
|
|
public void EmittedFluentBuilders_AreAutoGenerated_AndUseGlobalQualifiedTypes()
|
|
{
|
|
string fb = GetFluentBuilders();
|
|
|
|
Assert.That(fb, Does.StartWith("// <auto-generated />"),
|
|
"FluentBuilders file must start with the auto-generated marker");
|
|
Assert.That(fb, Does.Contain("[global::System.CodeDom.Compiler.GeneratedCodeAttribute("),
|
|
"FluentBuilders file must carry the GeneratedCode attribute");
|
|
Assert.That(fb, Does.Contain("#pragma warning disable"),
|
|
"FluentBuilders file must suppress warnings to survive strict consumer projects");
|
|
}
|
|
|
|
[Test]
|
|
public void EmittedFluentBuilders_WrappersAreInternalSealed()
|
|
{
|
|
string fb = GetFluentBuilders();
|
|
|
|
// Per design decision #8 — wrappers are internal sealed because
|
|
// Configure is a private partial. Public exposure would add a
|
|
// surface that can never be called outside the assembly.
|
|
Assert.That(fb, Does.Match(@"internal\s+sealed\s+class\s+\w+Builder\b"),
|
|
"Wrapper classes should be 'internal sealed'");
|
|
Assert.That(fb, Does.Not.Match(@"public\s+(sealed\s+)?class\s+\w+Builder\b"),
|
|
"Wrapper classes must not be public");
|
|
}
|
|
|
|
[Test]
|
|
public void EmittedFluentBuilders_VariableAccessorsReturnTypedIVariableBuilder()
|
|
{
|
|
string fb = GetFluentBuilders();
|
|
|
|
// TestModel's RestrictedObjectType has a Mandatory variable child
|
|
// 'Red' of type RestrictedVariableType (Int32). The wrapper for
|
|
// the predefined TestObject instance must surface that variable
|
|
// through a typed IVariableBuilder<int> accessor.
|
|
Assert.That(fb, Does.Match(
|
|
@"public\s+global::Opc\.Ua\.Server\.Fluent\.IVariableBuilder<\w+>\s+\w+\s*\{"),
|
|
"Variable children should expose IVariableBuilder<TValue> accessors");
|
|
|
|
// Each accessor lazily resolves the namespace index to support
|
|
// cross-namespace browse names (NamespaceUris.GetIndexOrAppend).
|
|
Assert.That(fb, Does.Contain("Context.NamespaceUris.GetIndexOrAppend("),
|
|
"Variable accessors should resolve the namespace via GetIndexOrAppend");
|
|
Assert.That(fb, Does.Match(
|
|
@"return\s+__node\.Variable<\w+>\(new global::Opc\.Ua\.QualifiedName\("),
|
|
"Variable accessors should fall back to INodeBuilder.Variable<T>()");
|
|
}
|
|
|
|
[Test]
|
|
public void EmittedFluentBuilders_MethodWrappersExposeSyncAndAsyncOnCall()
|
|
{
|
|
string fb = GetFluentBuilders();
|
|
|
|
// Every method wrapper must offer BOTH sync and async OnCall
|
|
// overloads so user code-behind can choose the natural shape.
|
|
Assert.That(fb, Does.Match(
|
|
@"public\s+\w+MethodBuilder\s+OnCall\s*\(\s*global::System\.Action\s+handler\s*\)"),
|
|
"Method wrappers should expose a sync OnCall(Action) overload");
|
|
Assert.That(fb, Does.Match(
|
|
@"public\s+\w+MethodBuilder\s+OnCall\s*\(\s*" +
|
|
@"global::System\.Func<global::System\.Threading\.CancellationToken,\s*" +
|
|
@"global::System\.Threading\.Tasks\.ValueTask>\s+handler\s*\)"),
|
|
"Method wrappers should expose an async OnCall(Func<CT,ValueTask>) overload");
|
|
}
|
|
|
|
[Test]
|
|
public void EmittedFluentBuilders_InstanceAccessorsAreLazyAndAllocateChildBuilder()
|
|
{
|
|
string fb = GetFluentBuilders();
|
|
|
|
// Child accessors should never cache — each get materializes a
|
|
// fresh wrapper around an INodeBuilder<TChildState>. Caching
|
|
// would be unsafe because INodeBuilder identity is per-resolution.
|
|
Assert.That(fb, Does.Not.Match(@"private\s+\w+Builder\s+__child"),
|
|
"Child accessors must not cache the wrapper");
|
|
Assert.That(fb, Does.Match(
|
|
@"return\s+new\s+\w+Builder\s*\(\s*__node\.Child<\w+"),
|
|
"Object child accessors should resolve via INodeBuilder.Child<T>()");
|
|
}
|
|
|
|
[Test]
|
|
public void EmittedFluentBuilders_TopLevelInstanceWrapperLivesAtNamespaceScope()
|
|
{
|
|
string fb = GetFluentBuilders();
|
|
|
|
// Top-level wrappers must sit directly under the namespace
|
|
// body — i.e. at column 4 (one level into the namespace block
|
|
// emitted by FluentBuilderTemplates.File). Use TestObject as
|
|
// an exemplar — TestModel.xml declares it as a top-level
|
|
// instance.
|
|
Assert.That(fb, Does.Match(@"(?m)^ internal sealed class TestObjectBuilder\b"),
|
|
"Top-level wrappers must be declared at namespace scope (column 4)");
|
|
}
|
|
|
|
[Test]
|
|
public void EmittedFluentBuildersObjectWrapperUsesTypeDefinitionState()
|
|
{
|
|
string fb = GetFluentBuilders();
|
|
|
|
Assert.That(
|
|
fb,
|
|
Does.Match(
|
|
@"(?ms)internal\s+sealed\s+class\s+TestObjectBuilder\b.*?" +
|
|
@"INodeBuilder<global::TestModel\.RestrictedObjectState>\s+__node;"),
|
|
"An object wrapper with a resolved TypeDefinition must use its concrete generated State type");
|
|
}
|
|
|
|
[Test]
|
|
public void EmittedFluentBuilders_MethodWrapperIsNestedInsideOwningObject()
|
|
{
|
|
string fb = GetFluentBuilders();
|
|
|
|
// TestObject's RestrictedObjectType has a Mandatory Method
|
|
// child 'Blue'. Its wrapper class must be nested one level
|
|
// inside TestObjectBuilder — i.e. declared at column 8.
|
|
Assert.That(fb, Does.Match(
|
|
@"(?m)^ internal sealed class BlueMethodBuilder\b"),
|
|
"Method wrappers must be nested inside the owning object's wrapper (column 8)");
|
|
}
|
|
|
|
[Test]
|
|
public void EmittedFluentBuilders_ChildAccessorReturnsSimpleLeafName()
|
|
{
|
|
string fb = GetFluentBuilders();
|
|
|
|
// Child accessors should return the simple leaf name only
|
|
// (e.g. 'BlueMethodBuilder') because the wrapper is nested
|
|
// inside the parent, not the old flat
|
|
// 'TestObject_BlueMethodBuilder' identifier.
|
|
Assert.That(fb, Does.Not.Match(@"public\s+TestObject_\w+Builder\s+\w+"),
|
|
"Child accessors must reference the simple leaf-name wrapper, not a flat dotted identifier");
|
|
Assert.That(fb, Does.Match(@"public\s+BlueMethodBuilder\s+Blue\b"),
|
|
"Child accessor for 'Blue' should return the simple-leaf-name 'BlueMethodBuilder'");
|
|
}
|
|
|
|
[Test]
|
|
public void MethodWithIntInputAndOutputEmitsTypedSyncOverload()
|
|
{
|
|
string fb = GetFluentBuilders();
|
|
|
|
// TestModel's MathInstance has a Mandatory method 'Compute'
|
|
// declared inline with InputArguments(x: Int32) and
|
|
// OutputArguments(result: Int32). The generator must surface
|
|
// a typed sync OnCall overload that maps Variant<-> int both
|
|
// ways without requiring the user to touch ArrayOf<Variant>.
|
|
Assert.That(fb, Does.Match(
|
|
@"public\s+ComputeMethodBuilder\s+OnCall\s*\(\s*global::System\.Func<int,\s*int>\s+handler\s*\)"),
|
|
"Compute(Int32 -> Int32) should expose a sync OnCall(Func<int,int>) overload");
|
|
}
|
|
|
|
[Test]
|
|
public void MethodWithIntInputAndOutputEmitsTypedAsyncOverload()
|
|
{
|
|
string fb = GetFluentBuilders();
|
|
|
|
// The async overload threads CancellationToken between the
|
|
// unpacked inputs and the ValueTask<TResult> return type.
|
|
Assert.That(fb, Does.Match(
|
|
@"public\s+ComputeMethodBuilder\s+OnCall\s*\(\s*" +
|
|
@"global::System\.Func<int,\s*" +
|
|
@"global::System\.Threading\.CancellationToken,\s*" +
|
|
@"global::System\.Threading\.Tasks\.ValueTask<int>>\s+handler\s*\)"),
|
|
"Compute(Int32 -> Int32) should expose an async OnCall(Func<int,CT,ValueTask<int>>) overload");
|
|
}
|
|
|
|
[Test]
|
|
public void MethodInputUnpackUsesVariantTryGetValue()
|
|
{
|
|
string fb = GetFluentBuilders();
|
|
|
|
// Inputs flow through Variant.TryGetValue<T>(out T) so the
|
|
// generated lambda short-circuits with BadInvalidArgument when
|
|
// the caller sends the wrong wire type.
|
|
Assert.That(fb, Does.Match(
|
|
@"if\s*\(!__inputs\[0\]\.TryGetValue\(out\s+int\s+__a0\)\)"),
|
|
"Input unpack should use Variant.TryGetValue<T>(out T) for primitive inputs");
|
|
Assert.That(fb, Does.Contain(
|
|
"return new global::Opc.Ua.ServiceResult(global::Opc.Ua.StatusCodes.BadInvalidArgument);"),
|
|
"Failed input unpack should return BadInvalidArgument");
|
|
Assert.That(fb, Does.Contain(
|
|
"return new global::Opc.Ua.ServiceResult(global::Opc.Ua.StatusCodes.BadArgumentsMissing);"),
|
|
"Missing input arg count should return BadArgumentsMissing");
|
|
}
|
|
|
|
[Test]
|
|
public void MethodOutputBoxUsesVariantFrom()
|
|
{
|
|
string fb = GetFluentBuilders();
|
|
|
|
// Outputs are boxed back through Variant.From<T>(value) and
|
|
// assigned by index into the __outputs list which the base
|
|
// MethodState dispatcher pre-populates with default values
|
|
// (one slot per declared output argument). Single-output
|
|
// methods bind the user handler's return value to '__r'.
|
|
Assert.That(fb, Does.Contain(
|
|
"__outputs[0] = global::Opc.Ua.Variant.From(__r);"),
|
|
"Single-output methods should box the handler result via __outputs[0] = Variant.From(__r)");
|
|
}
|
|
|
|
[Test]
|
|
public void MethodWithMultipleInputArgsEmitsCorrectArity()
|
|
{
|
|
string fb = GetFluentBuilders();
|
|
|
|
// TestModel's MathInstance.Add(a: Int32, b: Int32 -> sum: Int32)
|
|
// must produce a sync OnCall overload whose handler arity
|
|
// matches the input count (Func<int,int,int>) and an async
|
|
// overload that appends CancellationToken + ValueTask<int>.
|
|
Assert.That(fb, Does.Match(
|
|
@"public\s+AddMethodBuilder\s+OnCall\s*\(\s*global::System\.Func<int,\s*int,\s*int>\s+handler\s*\)"),
|
|
"Add(Int32,Int32 -> Int32) should expose a sync OnCall(Func<int,int,int>) overload");
|
|
Assert.That(fb, Does.Match(
|
|
@"public\s+AddMethodBuilder\s+OnCall\s*\(\s*" +
|
|
@"global::System\.Func<int,\s*int,\s*" +
|
|
@"global::System\.Threading\.CancellationToken,\s*" +
|
|
@"global::System\.Threading\.Tasks\.ValueTask<int>>\s+handler\s*\)"),
|
|
"Add(Int32,Int32 -> Int32) should expose an async OnCall(Func<int,int,CT,ValueTask<int>>) overload");
|
|
// Unpack arity: index 0 and index 1 must both be present.
|
|
Assert.That(fb, Does.Match(
|
|
@"if\s*\(!__inputs\[1\]\.TryGetValue\(out\s+int\s+__a1\)\)"),
|
|
"Multi-input methods should unpack each Variant by index (e.g. __inputs[1] -> __a1)");
|
|
}
|
|
|
|
[Test]
|
|
public void DeclarationBackedInputOnlyMethodEmitsTypedOnCallOverloads()
|
|
{
|
|
string fb = GenerateForDeclarationBackedModel();
|
|
const string methodWrapper = @"(?s)internal\s+sealed\s+class\s+AdjustMethodBuilder\b.*?";
|
|
|
|
Assert.Multiple(() =>
|
|
{
|
|
Assert.That(fb, Does.Match(
|
|
methodWrapper +
|
|
@"public\s+AdjustMethodBuilder\s+OnCall\s*\(" +
|
|
@"\s*global::System\.Action<float>\s+handler\s*\)"),
|
|
"The declaration-backed Adjust method should expose OnCall(Action<float>).");
|
|
Assert.That(fb, Does.Match(
|
|
methodWrapper +
|
|
@"public\s+AdjustMethodBuilder\s+OnCall\s*\(" +
|
|
@"\s*global::System\.Func<float,\s*" +
|
|
@"global::System\.Threading\.CancellationToken,\s*" +
|
|
@"global::System\.Threading\.Tasks\.ValueTask>\s+handler\s*\)"),
|
|
"The declaration-backed Adjust method should expose the async input-only OnCall overload.");
|
|
Assert.That(fb, Does.Match(
|
|
methodWrapper +
|
|
@"if\s*\(!__inputs\[0\]\.TryGetValue\(out\s+float\s+__a0\)\)"),
|
|
"Generated dispatch should unpack the declaration-backed Float input from the Variant list.");
|
|
Assert.That(fb, Does.Match(
|
|
methodWrapper + @"handler\(__a0\);"),
|
|
"Generated dispatch should pass the unpacked Float value to the sync handler.");
|
|
});
|
|
}
|
|
|
|
[Test]
|
|
public void DeclarationBackedNodeSetFixtureCarriesDeclarationInputArgument()
|
|
{
|
|
Dictionary<string, string> files = GenerateForDeclarationBackedNodeSet();
|
|
string values = files.Single(
|
|
kv => kv.Key.EndsWith(".NodeStates.ex.g.cs", StringComparison.Ordinal)).Value;
|
|
|
|
Assert.Multiple(() =>
|
|
{
|
|
Assert.That(values, Does.Contain("AdjustDeclaration_InputArguments"));
|
|
Assert.That(values, Does.Contain("Name = \"setpoint\""));
|
|
});
|
|
}
|
|
|
|
/// <summary>
|
|
/// A NodeSet2 encodes a Method's InputArguments Property as a list of Argument
|
|
/// ExtensionObjects, so the importer's value decoder must be able to resolve the Argument
|
|
/// encoding id. When it cannot, the arguments decode to an empty list and every generated
|
|
/// method wrapper silently loses its parameters while still reporting HasArguments. This
|
|
/// asserts the imported arguments actually reach the generated method surface.
|
|
/// </summary>
|
|
[Test]
|
|
public void DeclarationBackedNodeSetEmitsTypedMethodArguments()
|
|
{
|
|
Dictionary<string, string> files = GenerateForDeclarationBackedNodeSet();
|
|
string nodeStates = files
|
|
.Single(kv => kv.Key.EndsWith(".NodeStates.g.cs", StringComparison.Ordinal)).Value;
|
|
|
|
Assert.That(nodeStates, Does.Match(@"float\s+setpoint"),
|
|
"The Float input declared by the NodeSet2 method declaration must be imported " +
|
|
"and emitted as a typed parameter on the generated method handler.");
|
|
}
|
|
|
|
[Test]
|
|
public void EmittedNodeManager_InvokesBothPlainAndTypedConfigurePartials()
|
|
{
|
|
Dictionary<string, string> files = GenerateForTestModel(generateNodeManager: true);
|
|
string mgr = files.Single(kv => kv.Key.EndsWith(".NodeManager.g.cs", StringComparison.Ordinal)).Value;
|
|
|
|
// Both partials must be declared so users can implement either.
|
|
Assert.That(
|
|
mgr,
|
|
Does.Contain(
|
|
"partial void Configure(" +
|
|
"global::Opc.Ua.Server.Fluent.INodeManagerBuilder builder);"),
|
|
"Plain Configure(INodeManagerBuilder) partial declaration must be emitted");
|
|
Assert.That(mgr, Does.Match(@"partial\s+void\s+Configure\s*\(\s*I\w+NodeManagerBuilder\s+builder\s*\);"),
|
|
"Typed Configure(I{Manager}NodeManagerBuilder) partial declaration must be emitted");
|
|
|
|
// Both must also be invoked so a user that only implements one
|
|
// gets their wiring run; the runtime treats unimplemented
|
|
// partials as no-ops.
|
|
Assert.That(mgr, Does.Contain("Configure(__m_builder);"),
|
|
"Plain Configure call must be present in CreateAddressSpaceAsync");
|
|
Assert.That(mgr, Does.Match(@"Configure\(\s*new\s+\w+NodeManagerTypedBuilder\(__m_builder\)\s*\)"),
|
|
"Typed Configure call must be present in CreateAddressSpaceAsync");
|
|
}
|
|
|
|
[Test]
|
|
public void EmittedFluentBuilders_PublishOverloadEmittedOnSupportsEventsWrapper()
|
|
{
|
|
string fb = GetFluentBuilders();
|
|
|
|
// TestModel declares NotifierObject with SupportsEvents="true"
|
|
// (the design-XML form of EventNotifier=SubscribeToEvents). Its
|
|
// wrapper must expose typed Publish<TEvent> overloads bound to
|
|
// the wrapper's underlying state type and constrained to
|
|
// BaseEventState.
|
|
Assert.That(fb, Does.Match(
|
|
@"internal\s+sealed\s+class\s+NotifierObjectBuilder\b"),
|
|
"NotifierObject must produce a wrapper class");
|
|
Assert.That(fb, Does.Match(
|
|
@"public\s+global::Opc\.Ua\.Server\.Fluent\.INodeBuilder<" +
|
|
@"global::Opc\.Ua\.BaseObjectState>\s+Publish<TEvent>\(\s*" +
|
|
@"global::System\.Collections\.Generic\.IAsyncEnumerable<TEvent>\s+source"),
|
|
"NotifierObjectBuilder must expose Publish<TEvent>(IAsyncEnumerable<TEvent>, EventPublishOptions)");
|
|
Assert.That(fb, Does.Match(
|
|
@"public\s+global::Opc\.Ua\.Server\.Fluent\.INodeBuilder<" +
|
|
@"global::Opc\.Ua\.BaseObjectState>\s+Publish<TEvent>\(\s*" +
|
|
@"global::System\.Func<global::Opc\.Ua\.BaseObjectState,\s*" +
|
|
@"global::Opc\.Ua\.ISystemContext,\s*" +
|
|
@"global::System\.Threading\.CancellationToken,\s*" +
|
|
@"global::System\.Collections\.Generic\.IAsyncEnumerable<TEvent>>\s+factory"),
|
|
"NotifierObjectBuilder must expose the factory-style Publish<TEvent> overload");
|
|
Assert.That(fb, Does.Contain(
|
|
"where TEvent : global::Opc.Ua.BaseEventState"),
|
|
"Publish<TEvent> must be constrained to BaseEventState");
|
|
Assert.That(fb, Does.Contain(
|
|
"global::Opc.Ua.Server.Fluent.EventNotifierBuilderExtensions.Publish<" +
|
|
"global::Opc.Ua.BaseObjectState, TEvent>(__node,"),
|
|
"Publish overload must forward to EventNotifierBuilderExtensions.Publish " +
|
|
"with the wrapper's state type as TNotifier");
|
|
}
|
|
|
|
[Test]
|
|
public void EmittedFluentBuilders_PublishOverloadEmittedOnGeneratesEventWrapper()
|
|
{
|
|
string fb = GetFluentBuilders();
|
|
|
|
// EventEmittingObject does not set SupportsEvents but declares
|
|
// a forward GeneratesEvent reference. The wrapper must still
|
|
// expose Publish<TEvent>; the qualification logic walks both
|
|
// the SupportsEvents flag and outgoing GeneratesEvent /
|
|
// AlwaysGeneratesEvent references.
|
|
Assert.That(fb, Does.Match(
|
|
@"(?ms)internal\s+sealed\s+class\s+EventEmittingObjectBuilder\b.*?Publish<TEvent>"),
|
|
"EventEmittingObjectBuilder must expose Publish<TEvent> because of the GeneratesEvent reference");
|
|
}
|
|
|
|
[Test]
|
|
public void EmittedFluentBuilders_PublishOverloadAbsentOnPlainObjectWrapper()
|
|
{
|
|
string fb = GetFluentBuilders();
|
|
|
|
// TestObject (RestrictedObjectType) is a plain object: no
|
|
// SupportsEvents, no GeneratesEvent. It must NOT carry a
|
|
// typed Publish overload — the surface stays clean and only
|
|
// notifier candidates become discoverable through intellisense.
|
|
Assert.That(fb, Does.Not.Match(
|
|
@"(?ms)internal\s+sealed\s+class\s+TestObjectBuilder\b" +
|
|
@"(?:(?!internal\s+sealed\s+class).)*?Publish<TEvent>"),
|
|
"TestObjectBuilder must not expose Publish<TEvent> — it's not a notifier");
|
|
}
|
|
|
|
private static string GetFluentBuilders()
|
|
{
|
|
Dictionary<string, string> files = GenerateForTestModel(generateNodeManager: true);
|
|
return files.Single(kv => kv.Key.EndsWith(".FluentBuilders.g.cs", StringComparison.Ordinal)).Value;
|
|
}
|
|
|
|
// FB-3 phase 3: per-ObjectType IComponentAccessor/IPropertyAccessor
|
|
// extensions emitted into the same FluentBuilders file.
|
|
|
|
[Test]
|
|
public void EmittedFluentBuilders_HasPerObjectTypeComponentAccessorClass()
|
|
{
|
|
string fb = GetFluentBuilders();
|
|
|
|
// TestModel.RestrictedObjectType has Red (Variable), Pink
|
|
// (Variable), and Blue (Method) — all HasComponent. The
|
|
// generator must emit one static partial class containing
|
|
// an extension method per child on
|
|
// IComponentAccessor<RestrictedObjectState>.
|
|
Assert.That(fb, Does.Match(
|
|
@"public\s+static\s+partial\s+class\s+RestrictedObjectStateComponents"),
|
|
"Per-ObjectType component-accessor class must be emitted");
|
|
|
|
Assert.That(fb, Does.Match(
|
|
@"this\s+global::Opc\.Ua\.IComponentAccessor<global::[\w\.]+\.RestrictedObjectState>"),
|
|
"Accessor extensions must hang off Opc.Ua.IComponentAccessor<TState> (Core marker)");
|
|
|
|
Assert.That(fb, Does.Contain(
|
|
"new global::Opc.Ua.QualifiedName(\"Red\")"),
|
|
"Red child accessor must resolve via unqualified QualifiedName(\"Red\")");
|
|
|
|
Assert.That(fb, Does.Contain(
|
|
"new global::Opc.Ua.QualifiedName(\"Blue\")"),
|
|
"Blue method accessor must resolve via unqualified QualifiedName(\"Blue\")");
|
|
}
|
|
|
|
[Test]
|
|
public void EmittedFluentBuilders_AccessorMethodTypedReturnsNarrowState()
|
|
{
|
|
string fb = GetFluentBuilders();
|
|
|
|
// The Blue child is a MethodDesign with no TypeDefinition;
|
|
// the generator falls back to MethodState as the lowest-
|
|
// common-denominator return type.
|
|
Assert.That(fb, Does.Match(
|
|
@"global::Opc\.Ua\.Server\.Fluent\.INodeBuilder<global::Opc\.Ua\.MethodState>\s+Blue\("),
|
|
"Method child accessor must return INodeBuilder<MethodState>");
|
|
|
|
// Variable children (Red) sit under HasComponent in the
|
|
// TestModel; the generator must emit them as
|
|
// IVariableBuilder<T> on IComponentAccessor.
|
|
Assert.That(fb, Does.Match(
|
|
@"global::Opc\.Ua\.Server\.Fluent\.IVariableBuilder<\w+>\s+Red\("),
|
|
"Variable HasComponent child accessor must return IVariableBuilder<T>");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Regression: the placeholder child Pink_Placeholder (browse name
|
|
/// <c><Pink></c>) got an accessor walking to a child that no
|
|
/// instance carries, so it could never resolve.
|
|
/// </summary>
|
|
[Test]
|
|
public void EmittedFluentBuildersPlaceholderChildHasNoAccessor()
|
|
{
|
|
string fb = GetFluentBuilders();
|
|
|
|
Assert.That(fb, Does.Not.Contain("Pink_Placeholder("));
|
|
Assert.That(fb, Does.Match(@"\s+Red\("), "ordinary children keep their accessor");
|
|
}
|
|
|
|
[Test]
|
|
public void Emit_OmitFluentApi_SuppressesAccessorEmission()
|
|
{
|
|
// After the FB-R3-8 relocation, accessors emit by default
|
|
// (when GenerateNodeManager=true OR OmitFluentApi=false).
|
|
// Model-only consumers that don't reference Opc.Ua.Server
|
|
// explicitly opt OUT via the OmitFluentApi flag.
|
|
Dictionary<string, string> files = GenerateForTestModel(
|
|
generateNodeManager: false,
|
|
omitFluentApi: true);
|
|
|
|
KeyValuePair<string, string>? fb = files
|
|
.Where(kv => kv.Key.EndsWith(".FluentBuilders.g.cs", StringComparison.Ordinal))
|
|
.Cast<KeyValuePair<string, string>?>()
|
|
.FirstOrDefault();
|
|
Assert.That(fb, Is.Null,
|
|
"FluentBuilders file must be suppressed when OmitFluentApi=true and GenerateNodeManager=false");
|
|
}
|
|
|
|
[Test]
|
|
public void Emit_DefaultOmitFluentApiFalse_EmitsAccessorsWithoutNodeManager()
|
|
{
|
|
// Default opt-out semantics: accessors emit even without a
|
|
// node manager opt-in. NodeSet2-only consumers that don't
|
|
// override OmitFluentApi=true get the typed accessors for
|
|
// free (provided they reference Opc.Ua.Server).
|
|
Dictionary<string, string> files = GenerateForTestModel(
|
|
generateNodeManager: false,
|
|
omitFluentApi: false);
|
|
|
|
KeyValuePair<string, string> fb = files
|
|
.Single(kv => kv.Key.EndsWith(".FluentBuilders.g.cs", StringComparison.Ordinal));
|
|
Assert.That(fb.Value, Does.Not.Match(
|
|
@"internal\s+interface\s+I\w+NodeManagerBuilder"),
|
|
"Manager interface must be skipped when GenerateNodeManager=false");
|
|
Assert.That(fb.Value, Does.Match(
|
|
@"public\s+static\s+partial\s+class\s+RestrictedObjectStateComponents"),
|
|
"Per-type accessor must be emitted by default (OmitFluentApi=false)");
|
|
}
|
|
|
|
private static Dictionary<string, string> GenerateForTestModel(
|
|
bool generateNodeManager,
|
|
bool omitFluentApi = true)
|
|
{
|
|
const string designFile = "TestModel.xml";
|
|
ITelemetryContext telemetry = NUnitTelemetryContext.Create(logLevel: LogLevel.Error);
|
|
using var fileSystem = new VirtualFileSystem();
|
|
string resources = Path.Combine(Directory.GetCurrentDirectory(), "Resources");
|
|
|
|
Generators.GenerateCode(
|
|
new DesignFileCollection
|
|
{
|
|
Targets = [Path.Combine(resources, designFile)],
|
|
IdentifierFilePath = Path.Combine(
|
|
resources,
|
|
Path.GetFileNameWithoutExtension(designFile) + ".csv"),
|
|
Options = new DesignFileOptions
|
|
{
|
|
GenerateNodeManager = generateNodeManager
|
|
}
|
|
},
|
|
fileSystem,
|
|
string.Empty,
|
|
telemetry,
|
|
options: new GeneratorOptions
|
|
{
|
|
OmitFluentApi = omitFluentApi
|
|
});
|
|
|
|
return fileSystem.CreatedFiles
|
|
.Where(c => Path.GetExtension(c) == ".cs")
|
|
.ToDictionary(c => c, c => Encoding.UTF8.GetString(fileSystem.Get(c)));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Regression: the typed top-level accessor built the root's NodeId with the
|
|
/// namespace of its BrowseName instead of the namespace of its NodeId. A NodeSet
|
|
/// instance <c>ns=1;i=2000</c> whose BrowseName is in a companion namespace
|
|
/// (<c>2:Device</c>) then resolved <c>ns=Companion;i=2000</c>, a node that does
|
|
/// not exist.
|
|
/// </summary>
|
|
[Test]
|
|
public void TopLevelAccessorUsesTheNodeIdNamespaceNotTheBrowseNameNamespace()
|
|
{
|
|
const string companionUri = "http://test.org/UA/Companion/";
|
|
Dictionary<string, string> files = GenerateForDeclarationBackedNodeSet(
|
|
xml => xml
|
|
.Replace(
|
|
"<Uri>http://test.org/UA/DeclarationBackedMethod/</Uri>",
|
|
"<Uri>http://test.org/UA/DeclarationBackedMethod/</Uri>" +
|
|
"<Uri>" + companionUri + "</Uri>",
|
|
StringComparison.Ordinal)
|
|
.Replace(
|
|
"BrowseName=\"1:Device\"",
|
|
"BrowseName=\"2:Device\"",
|
|
StringComparison.Ordinal)
|
|
.Replace(
|
|
"<RequiredModel ModelUri=\"http://opcfoundation.org/UA/\"",
|
|
"<RequiredModel ModelUri=\"" + companionUri +
|
|
"\" PublicationDate=\"2024-01-01T00:00:00Z\" Version=\"1.0.0\"/>" +
|
|
"<RequiredModel ModelUri=\"http://opcfoundation.org/UA/\"",
|
|
StringComparison.Ordinal),
|
|
("Companion.NodeSet2.xml",
|
|
"<?xml version=\"1.0\" encoding=\"utf-8\"?>" +
|
|
"<UANodeSet xmlns=\"http://opcfoundation.org/UA/2011/03/UANodeSet.xsd\">" +
|
|
"<NamespaceUris><Uri>" + companionUri + "</Uri></NamespaceUris>" +
|
|
"<Models><Model ModelUri=\"" + companionUri +
|
|
"\" PublicationDate=\"2024-01-01T00:00:00Z\" Version=\"1.0.0\">" +
|
|
"<RequiredModel ModelUri=\"http://opcfoundation.org/UA/\"" +
|
|
" PublicationDate=\"2024-01-01T00:00:00Z\" Version=\"1.05.03\"/>" +
|
|
"</Model></Models>" +
|
|
"<UAObjectType NodeId=\"ns=1;i=1\" BrowseName=\"1:CompanionType\">" +
|
|
"<DisplayName>CompanionType</DisplayName><References>" +
|
|
"<Reference ReferenceType=\"i=45\" IsForward=\"false\">i=58</Reference>" +
|
|
"</References></UAObjectType></UANodeSet>"));
|
|
string fb = files
|
|
.Single(kv => kv.Key.EndsWith(".FluentBuilders.g.cs", StringComparison.Ordinal)).Value;
|
|
|
|
int nodeId = fb.IndexOf("(new global::Opc.Ua.NodeId(2000u, __ns))", StringComparison.Ordinal);
|
|
Assert.That(nodeId, Is.GreaterThan(0), "Typed root accessor for Device not found:\n" + fb);
|
|
const string lookup = "__inner.Context.NamespaceUris.GetIndexOrAppend(\"";
|
|
int start = fb.LastIndexOf(lookup, nodeId, StringComparison.Ordinal) + lookup.Length;
|
|
string ns = fb[start..fb.IndexOf('"', start)];
|
|
Assert.That(ns, Is.EqualTo("http://test.org/UA/DeclarationBackedMethod/"),
|
|
"The root NodeId must use the namespace of the NodeId, not of the BrowseName.");
|
|
}
|
|
|
|
private static Dictionary<string, string> GenerateForDeclarationBackedNodeSet(
|
|
Func<string, string>? transform = null,
|
|
(string FileName, string Content)? ignoredDependency = null)
|
|
{
|
|
const string nodeSetFile = "DeclarationBackedMethod.NodeSet2.xml";
|
|
const string namespaceUri = "http://test.org/UA/DeclarationBackedMethod/";
|
|
ITelemetryContext telemetry = NUnitTelemetryContext.Create(logLevel: LogLevel.Error);
|
|
using var fileSystem = new VirtualFileSystem();
|
|
string resources = Path.Combine(Directory.GetCurrentDirectory(), "Resources");
|
|
string path = Path.Combine(resources, nodeSetFile);
|
|
if (transform != null)
|
|
{
|
|
path = Path.Combine(resources, "Transformed." + nodeSetFile);
|
|
fileSystem.Add(
|
|
path,
|
|
Encoding.UTF8.GetBytes(transform(File.ReadAllText(
|
|
Path.Combine(resources, nodeSetFile)))));
|
|
}
|
|
|
|
ImmutableArray<(string, NodesetFileOptions)> inputs =
|
|
[
|
|
(path, new NodesetFileOptions
|
|
{
|
|
ModelUri = namespaceUri,
|
|
Name = "DeclarationBackedMethod",
|
|
Prefix = "DeclarationBackedMethod"
|
|
})
|
|
];
|
|
if (ignoredDependency is (string fileName, string content))
|
|
{
|
|
string dependencyPath = Path.Combine(resources, fileName);
|
|
fileSystem.Add(dependencyPath, Encoding.UTF8.GetBytes(content));
|
|
inputs = inputs.Add((dependencyPath, new NodesetFileOptions { Ignore = true }));
|
|
}
|
|
|
|
var nodesets = new NodesetFileCollection(
|
|
inputs,
|
|
[],
|
|
fileSystem,
|
|
telemetry);
|
|
|
|
nodesets.GenerateCode(
|
|
fileSystem,
|
|
string.Empty,
|
|
telemetry,
|
|
new GeneratorOptions
|
|
{
|
|
OmitFluentApi = false
|
|
},
|
|
nodeManagerBindings:
|
|
[
|
|
new NodeManagerAttributeBinding
|
|
{
|
|
NamespaceUri = namespaceUri,
|
|
TargetNamespace = "DeclarationBackedMethod",
|
|
TargetClassName = "DeclarationBackedMethodNodeManager",
|
|
GenerateFactory = false
|
|
}
|
|
]);
|
|
|
|
return fileSystem.CreatedFiles
|
|
.Where(c => Path.GetExtension(c) == ".cs")
|
|
.ToDictionary(c => c, c => Encoding.UTF8.GetString(fileSystem.Get(c)));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Regression: every generated instance wrapper declares Builder and
|
|
/// Node itself, so a child accessor sanitizing to one of those produced
|
|
/// a duplicate member (CS0102). The collision is reported with an
|
|
/// actionable message instead, the same way sibling collisions are.
|
|
/// </summary>
|
|
[TestCase("Node")]
|
|
[TestCase("Builder")]
|
|
public void EmitChildNamedAfterAWrapperMember_ReportsTheCollision(string childName)
|
|
{
|
|
InvalidOperationException ex = Assert.Throws<InvalidOperationException>(
|
|
() => GenerateWrapperWithChildNamed(childName));
|
|
|
|
Assert.That(ex.Message, Does.Contain(childName));
|
|
Assert.That(ex.Message, Does.Contain("already declares"));
|
|
}
|
|
|
|
/// <summary>
|
|
/// A child whose name does not collide still generates.
|
|
/// </summary>
|
|
[Test]
|
|
public void EmitChildWithANonCollidingName_Generates()
|
|
{
|
|
string? builders = null;
|
|
Assert.DoesNotThrow(() => builders = GenerateWrapperWithChildNamed("Setpoint"));
|
|
Assert.That(builders, Does.Contain("Setpoint"));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Regression: the wrapper collision check ran (and threw) even when no
|
|
/// manager wrappers are emitted, failing models that only get the
|
|
/// per-type accessors over names nothing uses.
|
|
/// </summary>
|
|
[Test]
|
|
public void EmitWithoutManagerWrappersDoesNotValidateWrapperNames()
|
|
{
|
|
Assert.DoesNotThrow(() => GenerateWrappers(
|
|
"Device",
|
|
[("Node", NewObject("Node"))],
|
|
generateManagerWrappers: false));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Regression: collisions that break compilation of the emitted wrappers
|
|
/// were not detected - a nested wrapper named after its enclosing wrapper
|
|
/// (CS0542), and a nested wrapper type colliding with a sibling accessor
|
|
/// or another nested type (CS0102).
|
|
/// </summary>
|
|
[Test]
|
|
public void EmitNestedWrapperCollisionsAreReported()
|
|
{
|
|
// Object Boiler inside object Boiler: nested BoilerBuilder in BoilerBuilder.
|
|
Assert.That(
|
|
() => GenerateWrappers("Plant", [("Boiler", NewObject("Boiler")), ("Boiler_Boiler", NewObject("Boiler"))]),
|
|
Throws.InvalidOperationException.With.Message.Contains("BoilerBuilder"));
|
|
// Variable FooBuilder next to object Foo: property and nested type FooBuilder.
|
|
Assert.That(
|
|
() => GenerateWrappers("Plant", [("Foo", NewObject("Foo")), ("FooBuilder", NewVariable("FooBuilder"))]),
|
|
Throws.InvalidOperationException.With.Message.Contains("FooBuilder"));
|
|
// Method Foo next to object FooMethod: both nest FooMethodBuilder.
|
|
Assert.That(
|
|
() => GenerateWrappers("Plant", [("Foo", NewMethod("Foo")), ("FooMethod", NewObject("FooMethod"))]),
|
|
Throws.InvalidOperationException.With.Message.Contains("FooMethodBuilder"));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Regression: a predefined instance named after a member the typed
|
|
/// manager builder forwards (e.g. <c>Context</c>) produced two members of
|
|
/// that name in the typed builder (CS0102).
|
|
/// </summary>
|
|
[TestCase("Context")]
|
|
[TestCase("AddObject")]
|
|
public void EmitRootNamedAfterATypedBuilderMemberReportsTheCollision(string rootName)
|
|
{
|
|
Assert.That(
|
|
() => GenerateWrappers(rootName, []),
|
|
Throws.InvalidOperationException.With.Message.Contains(rootName));
|
|
}
|
|
|
|
[Test]
|
|
public void EmitDistinctNestedWrappersGenerates()
|
|
{
|
|
string builders = GenerateWrappers(
|
|
"Plant",
|
|
[("Boiler", NewObject("Boiler")), ("Boiler_Pump", NewObject("Pump")), ("Start", NewMethod("Start"))]);
|
|
|
|
Assert.That(builders, Does.Contain("class PumpBuilder"));
|
|
Assert.That(builders, Does.Contain("class StartMethodBuilder"));
|
|
}
|
|
|
|
private const string kWrapperNamespaceUri = "http://test.org/UA/Wrappers/";
|
|
|
|
private static ObjectDesign NewObject(string name)
|
|
{
|
|
return new ObjectDesign
|
|
{
|
|
SymbolicName = new XmlQualifiedName(name, kWrapperNamespaceUri),
|
|
SymbolicId = new XmlQualifiedName("Id_" + name, kWrapperNamespaceUri),
|
|
BrowseName = name
|
|
};
|
|
}
|
|
|
|
private static MethodDesign NewMethod(string name)
|
|
{
|
|
return new MethodDesign
|
|
{
|
|
SymbolicName = new XmlQualifiedName(name, kWrapperNamespaceUri),
|
|
SymbolicId = new XmlQualifiedName("Id_" + name, kWrapperNamespaceUri),
|
|
BrowseName = name,
|
|
InputArguments = [],
|
|
OutputArguments = []
|
|
};
|
|
}
|
|
|
|
private static VariableDesign NewVariable(string name)
|
|
{
|
|
var floatType = new DataTypeDesign
|
|
{
|
|
SymbolicName = new XmlQualifiedName("Float", "http://opcfoundation.org/UA/"),
|
|
SymbolicId = new XmlQualifiedName("Float", "http://opcfoundation.org/UA/"),
|
|
BasicDataType = BasicDataType.Float
|
|
};
|
|
return new VariableDesign
|
|
{
|
|
SymbolicName = new XmlQualifiedName(name, kWrapperNamespaceUri),
|
|
SymbolicId = new XmlQualifiedName("Id_" + name, kWrapperNamespaceUri),
|
|
BrowseName = name,
|
|
DataTypeNode = floatType,
|
|
DataType = floatType.SymbolicId,
|
|
ValueRank = ValueRank.Scalar
|
|
};
|
|
}
|
|
|
|
private static string GenerateWrappers(
|
|
string rootName,
|
|
(string Path, NodeDesign Node)[] children,
|
|
bool generateManagerWrappers = true)
|
|
{
|
|
var targetNamespace = new Namespace
|
|
{
|
|
Value = kWrapperNamespaceUri,
|
|
Prefix = "Wrappers",
|
|
Name = "Wrappers"
|
|
};
|
|
var root = new ObjectDesign
|
|
{
|
|
SymbolicName = new XmlQualifiedName(rootName, kWrapperNamespaceUri),
|
|
SymbolicId = new XmlQualifiedName(rootName, kWrapperNamespaceUri),
|
|
BrowseName = rootName,
|
|
Hierarchy = new Hierarchy()
|
|
};
|
|
root.Hierarchy.Nodes[string.Empty] = new HierarchyNode
|
|
{
|
|
RelativePath = string.Empty,
|
|
Instance = root
|
|
};
|
|
foreach ((string path, NodeDesign node) in children)
|
|
{
|
|
root.Hierarchy.Nodes[path] = new HierarchyNode
|
|
{
|
|
RelativePath = path,
|
|
Instance = node
|
|
};
|
|
}
|
|
|
|
var model = new Mock<IModelDesign>();
|
|
model.Setup(m => m.TargetNamespace).Returns(targetNamespace);
|
|
model.Setup(m => m.Namespaces).Returns([targetNamespace]);
|
|
model.Setup(m => m.GetNodeDesigns()).Returns([root]);
|
|
model.Setup(m => m.IsExcluded(It.IsAny<NodeDesign>())).Returns(false);
|
|
|
|
using var fileSystem = new VirtualFileSystem();
|
|
var context = new GeneratorContext
|
|
{
|
|
FileSystem = fileSystem,
|
|
OutputFolder = string.Empty,
|
|
ModelDesign = model.Object,
|
|
Telemetry = NUnitTelemetryContext.Create(logLevel: LogLevel.Error),
|
|
Options = new GeneratorOptions()
|
|
};
|
|
new FluentBuilderGenerator(context)
|
|
{
|
|
GenerateManagerWrappers = generateManagerWrappers,
|
|
EmitFluentAccessors = false
|
|
}.Emit();
|
|
|
|
return fileSystem.CreatedFiles
|
|
.Where(c => c.EndsWith(".FluentBuilders.g.cs", StringComparison.Ordinal))
|
|
.Select(c => Encoding.UTF8.GetString(fileSystem.Get(c)))
|
|
.Single();
|
|
}
|
|
|
|
private static string GenerateWrapperWithChildNamed(string childName)
|
|
{
|
|
const string namespaceUri = "http://test.org/UA/WrapperCollision/";
|
|
var targetNamespace = new Namespace
|
|
{
|
|
Value = namespaceUri,
|
|
Prefix = "WrapperCollision",
|
|
Name = "WrapperCollision"
|
|
};
|
|
var floatType = new DataTypeDesign
|
|
{
|
|
SymbolicName = new XmlQualifiedName("Float", "http://opcfoundation.org/UA/"),
|
|
SymbolicId = new XmlQualifiedName("Float", "http://opcfoundation.org/UA/"),
|
|
BasicDataType = BasicDataType.Float
|
|
};
|
|
var child = new VariableDesign
|
|
{
|
|
SymbolicName = new XmlQualifiedName(childName, namespaceUri),
|
|
SymbolicId = new XmlQualifiedName("Device_" + childName, namespaceUri),
|
|
BrowseName = childName,
|
|
DataTypeNode = floatType,
|
|
DataType = floatType.SymbolicId,
|
|
ValueRank = ValueRank.Scalar
|
|
};
|
|
var root = new ObjectDesign
|
|
{
|
|
SymbolicName = new XmlQualifiedName("Device", namespaceUri),
|
|
SymbolicId = new XmlQualifiedName("Device", namespaceUri),
|
|
Hierarchy = new Hierarchy()
|
|
};
|
|
root.Hierarchy.Nodes[string.Empty] = new HierarchyNode
|
|
{
|
|
RelativePath = string.Empty,
|
|
Instance = root
|
|
};
|
|
root.Hierarchy.Nodes[childName] = new HierarchyNode
|
|
{
|
|
RelativePath = childName,
|
|
Instance = child
|
|
};
|
|
|
|
var model = new Mock<IModelDesign>();
|
|
model.Setup(m => m.TargetNamespace).Returns(targetNamespace);
|
|
model.Setup(m => m.Namespaces).Returns([targetNamespace]);
|
|
model.Setup(m => m.GetNodeDesigns()).Returns([root]);
|
|
model.Setup(m => m.IsExcluded(It.IsAny<NodeDesign>())).Returns(false);
|
|
|
|
using var fileSystem = new VirtualFileSystem();
|
|
var context = new GeneratorContext
|
|
{
|
|
FileSystem = fileSystem,
|
|
OutputFolder = string.Empty,
|
|
ModelDesign = model.Object,
|
|
Telemetry = NUnitTelemetryContext.Create(logLevel: LogLevel.Error),
|
|
Options = new GeneratorOptions()
|
|
};
|
|
new FluentBuilderGenerator(context)
|
|
{
|
|
GenerateManagerWrappers = true,
|
|
EmitFluentAccessors = false
|
|
}.Emit();
|
|
|
|
return fileSystem.CreatedFiles
|
|
.Where(c => c.EndsWith(".FluentBuilders.g.cs", StringComparison.Ordinal))
|
|
.Select(c => Encoding.UTF8.GetString(fileSystem.Get(c)))
|
|
.Single();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Regression: a per-type accessor was emitted for an Object child whose
|
|
/// TypeDefinition is excluded (e.g. a Draft type), returning
|
|
/// <c>INodeBuilder<DiagnosticsState></c> for a state class that is
|
|
/// never generated (CS0246).
|
|
/// </summary>
|
|
[Test]
|
|
public void EmitTypeAccessorsChildOfExcludedTypeIsSkipped()
|
|
{
|
|
const string namespaceUri = "http://test.org/UA/Excluded/";
|
|
var targetNamespace = new Namespace
|
|
{
|
|
Value = namespaceUri,
|
|
Prefix = "Excluded",
|
|
Name = "Excluded"
|
|
};
|
|
ObjectTypeDesign NewType(string name) => new()
|
|
{
|
|
SymbolicName = new XmlQualifiedName(name, namespaceUri),
|
|
SymbolicId = new XmlQualifiedName(name, namespaceUri)
|
|
};
|
|
ObjectDesign NewChild(string name, ObjectTypeDesign type) => new()
|
|
{
|
|
SymbolicName = new XmlQualifiedName(name, namespaceUri),
|
|
SymbolicId = new XmlQualifiedName("MachineType_" + name, namespaceUri),
|
|
BrowseName = name,
|
|
TypeDefinition = type.SymbolicName,
|
|
TypeDefinitionNode = type,
|
|
ModellingRule = ModellingRule.Mandatory
|
|
};
|
|
ObjectTypeDesign draftType = NewType("DiagnosticsType");
|
|
ObjectTypeDesign motorType = NewType("MotorType");
|
|
ObjectTypeDesign machineType = NewType("MachineType");
|
|
machineType.Children = new ListOfChildren
|
|
{
|
|
Items = [NewChild("Diagnostics", draftType), NewChild("Motor", motorType)]
|
|
};
|
|
|
|
var model = new Mock<IModelDesign>();
|
|
model.Setup(m => m.TargetNamespace).Returns(targetNamespace);
|
|
model.Setup(m => m.Namespaces).Returns([targetNamespace]);
|
|
model.Setup(m => m.GetNodeDesigns()).Returns([draftType, motorType, machineType]);
|
|
model.Setup(m => m.IsExcluded(It.IsAny<NodeDesign>())).Returns(false);
|
|
model.Setup(m => m.IsExcluded(draftType)).Returns(true);
|
|
|
|
using var fileSystem = new VirtualFileSystem();
|
|
var context = new GeneratorContext
|
|
{
|
|
FileSystem = fileSystem,
|
|
OutputFolder = string.Empty,
|
|
ModelDesign = model.Object,
|
|
Telemetry = NUnitTelemetryContext.Create(logLevel: LogLevel.Error),
|
|
Options = new GeneratorOptions()
|
|
};
|
|
new FluentBuilderGenerator(context)
|
|
{
|
|
GenerateManagerWrappers = false,
|
|
EmitFluentAccessors = true
|
|
}.Emit();
|
|
string fb = fileSystem.CreatedFiles
|
|
.Where(c => c.EndsWith(".FluentBuilders.g.cs", StringComparison.Ordinal))
|
|
.Select(c => Encoding.UTF8.GetString(fileSystem.Get(c)))
|
|
.Single();
|
|
|
|
Assert.Multiple(() =>
|
|
{
|
|
Assert.That(fb, Does.Not.Contain("DiagnosticsState"));
|
|
Assert.That(fb, Does.Contain("INodeBuilder<global::Excluded.MotorState> Motor("));
|
|
});
|
|
}
|
|
|
|
private static string GenerateForDeclarationBackedModel()
|
|
{
|
|
const string namespaceUri = "http://test.org/UA/DeclarationBackedMethod/";
|
|
var targetNamespace = new Namespace
|
|
{
|
|
Value = namespaceUri,
|
|
Prefix = "DeclarationBackedMethod",
|
|
Name = "DeclarationBackedMethod"
|
|
};
|
|
var floatType = new DataTypeDesign
|
|
{
|
|
SymbolicName = new XmlQualifiedName("Float", "http://opcfoundation.org/UA/"),
|
|
SymbolicId = new XmlQualifiedName("Float", "http://opcfoundation.org/UA/"),
|
|
BasicDataType = BasicDataType.Float
|
|
};
|
|
var declaration = new MethodDesign
|
|
{
|
|
SymbolicName = new XmlQualifiedName("AdjustDeclaration", namespaceUri),
|
|
SymbolicId = new XmlQualifiedName("AdjustDeclaration", namespaceUri),
|
|
InputArguments =
|
|
[
|
|
new Parameter
|
|
{
|
|
Name = "setpoint",
|
|
DataTypeNode = floatType,
|
|
DataType = floatType.SymbolicId,
|
|
ValueRank = ValueRank.Scalar
|
|
}
|
|
],
|
|
OutputArguments = []
|
|
};
|
|
var method = new MethodDesign
|
|
{
|
|
SymbolicName = new XmlQualifiedName("Adjust", namespaceUri),
|
|
SymbolicId = new XmlQualifiedName("Device_Adjust", namespaceUri),
|
|
InputArguments = [],
|
|
OutputArguments = [],
|
|
MethodDeclarationNode = declaration
|
|
};
|
|
var root = new ObjectDesign
|
|
{
|
|
SymbolicName = new XmlQualifiedName("Device", namespaceUri),
|
|
SymbolicId = new XmlQualifiedName("Device", namespaceUri),
|
|
Hierarchy = new Hierarchy()
|
|
};
|
|
root.Hierarchy.Nodes[string.Empty] = new HierarchyNode
|
|
{
|
|
RelativePath = string.Empty,
|
|
Instance = root
|
|
};
|
|
root.Hierarchy.Nodes["Adjust"] = new HierarchyNode
|
|
{
|
|
RelativePath = "Adjust",
|
|
Instance = method
|
|
};
|
|
|
|
var model = new Mock<IModelDesign>();
|
|
model.Setup(m => m.TargetNamespace).Returns(targetNamespace);
|
|
model.Setup(m => m.Namespaces).Returns([targetNamespace]);
|
|
model.Setup(m => m.GetNodeDesigns()).Returns([root, declaration]);
|
|
model.Setup(m => m.IsExcluded(It.IsAny<NodeDesign>())).Returns(false);
|
|
|
|
using var fileSystem = new VirtualFileSystem();
|
|
var context = new GeneratorContext
|
|
{
|
|
FileSystem = fileSystem,
|
|
OutputFolder = string.Empty,
|
|
ModelDesign = model.Object,
|
|
Telemetry = NUnitTelemetryContext.Create(logLevel: LogLevel.Error),
|
|
Options = new GeneratorOptions()
|
|
};
|
|
new FluentBuilderGenerator(context)
|
|
{
|
|
GenerateManagerWrappers = true,
|
|
EmitFluentAccessors = false
|
|
}.Emit();
|
|
|
|
return fileSystem.CreatedFiles
|
|
.Where(c => c.EndsWith(".FluentBuilders.g.cs", StringComparison.Ordinal))
|
|
.Select(c => Encoding.UTF8.GetString(fileSystem.Get(c)))
|
|
.Single();
|
|
}
|
|
}
|
|
}
|