/* ======================================================================== * 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 { /// /// Snapshot tests for the : 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. /// [TestFixture] [Category("Generator")] [SetCulture("en-us")] [SetUICulture("en-us")] [Parallelizable(ParallelScope.All)] public class FluentBuilderGeneratorTests { [Test] public void Emit_WithoutOptIn_ProducesNoFluentBuildersFile() { Dictionary files = GenerateForTestModel(generateNodeManager: false); Assert.That(files.Keys, Has.None.EndsWith(".FluentBuilders.g.cs")); } [Test] public void Emit_WithOptIn_ProducesFluentBuildersFile() { Dictionary 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("// "), "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 accessor. Assert.That(fb, Does.Match( @"public\s+global::Opc\.Ua\.Server\.Fluent\.IVariableBuilder<\w+>\s+\w+\s*\{"), "Variable children should expose IVariableBuilder 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()"); } [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\s+handler\s*\)"), "Method wrappers should expose an async OnCall(Func) overload"); } [Test] public void EmittedFluentBuilders_InstanceAccessorsAreLazyAndAllocateChildBuilder() { string fb = GetFluentBuilders(); // Child accessors should never cache — each get materializes a // fresh wrapper around an INodeBuilder. 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()"); } [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\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. Assert.That(fb, Does.Match( @"public\s+ComputeMethodBuilder\s+OnCall\s*\(\s*global::System\.Func\s+handler\s*\)"), "Compute(Int32 -> Int32) should expose a sync OnCall(Func) overload"); } [Test] public void MethodWithIntInputAndOutputEmitsTypedAsyncOverload() { string fb = GetFluentBuilders(); // The async overload threads CancellationToken between the // unpacked inputs and the ValueTask return type. Assert.That(fb, Does.Match( @"public\s+ComputeMethodBuilder\s+OnCall\s*\(\s*" + @"global::System\.Func>\s+handler\s*\)"), "Compute(Int32 -> Int32) should expose an async OnCall(Func>) overload"); } [Test] public void MethodInputUnpackUsesVariantTryGetValue() { string fb = GetFluentBuilders(); // Inputs flow through Variant.TryGetValue(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(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(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) and an async // overload that appends CancellationToken + ValueTask. Assert.That(fb, Does.Match( @"public\s+AddMethodBuilder\s+OnCall\s*\(\s*global::System\.Func\s+handler\s*\)"), "Add(Int32,Int32 -> Int32) should expose a sync OnCall(Func) overload"); Assert.That(fb, Does.Match( @"public\s+AddMethodBuilder\s+OnCall\s*\(\s*" + @"global::System\.Func>\s+handler\s*\)"), "Add(Int32,Int32 -> Int32) should expose an async OnCall(Func>) 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\s+handler\s*\)"), "The declaration-backed Adjust method should expose OnCall(Action)."); Assert.That(fb, Does.Match( methodWrapper + @"public\s+AdjustMethodBuilder\s+OnCall\s*\(" + @"\s*global::System\.Func\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 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\"")); }); } /// /// 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. /// [Test] public void DeclarationBackedNodeSetEmitsTypedMethodArguments() { Dictionary 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 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 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\(\s*" + @"global::System\.Collections\.Generic\.IAsyncEnumerable\s+source"), "NotifierObjectBuilder must expose Publish(IAsyncEnumerable, EventPublishOptions)"); Assert.That(fb, Does.Match( @"public\s+global::Opc\.Ua\.Server\.Fluent\.INodeBuilder<" + @"global::Opc\.Ua\.BaseObjectState>\s+Publish\(\s*" + @"global::System\.Func>\s+factory"), "NotifierObjectBuilder must expose the factory-style Publish overload"); Assert.That(fb, Does.Contain( "where TEvent : global::Opc.Ua.BaseEventState"), "Publish 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; 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"), "EventEmittingObjectBuilder must expose Publish 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"), "TestObjectBuilder must not expose Publish — it's not a notifier"); } private static string GetFluentBuilders() { Dictionary 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. 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"), "Accessor extensions must hang off Opc.Ua.IComponentAccessor (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\s+Blue\("), "Method child accessor must return INodeBuilder"); // Variable children (Red) sit under HasComponent in the // TestModel; the generator must emit them as // IVariableBuilder on IComponentAccessor. Assert.That(fb, Does.Match( @"global::Opc\.Ua\.Server\.Fluent\.IVariableBuilder<\w+>\s+Red\("), "Variable HasComponent child accessor must return IVariableBuilder"); } /// /// Regression: the placeholder child Pink_Placeholder (browse name /// <Pink>) got an accessor walking to a child that no /// instance carries, so it could never resolve. /// [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 files = GenerateForTestModel( generateNodeManager: false, omitFluentApi: true); KeyValuePair? fb = files .Where(kv => kv.Key.EndsWith(".FluentBuilders.g.cs", StringComparison.Ordinal)) .Cast?>() .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 files = GenerateForTestModel( generateNodeManager: false, omitFluentApi: false); KeyValuePair 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 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))); } /// /// 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 ns=1;i=2000 whose BrowseName is in a companion namespace /// (2:Device) then resolved ns=Companion;i=2000, a node that does /// not exist. /// [Test] public void TopLevelAccessorUsesTheNodeIdNamespaceNotTheBrowseNameNamespace() { const string companionUri = "http://test.org/UA/Companion/"; Dictionary files = GenerateForDeclarationBackedNodeSet( xml => xml .Replace( "http://test.org/UA/DeclarationBackedMethod/", "http://test.org/UA/DeclarationBackedMethod/" + "" + companionUri + "", StringComparison.Ordinal) .Replace( "BrowseName=\"1:Device\"", "BrowseName=\"2:Device\"", StringComparison.Ordinal) .Replace( "" + "" + "" + "" + companionUri + "" + "" + "" + "" + "" + "CompanionType" + "i=58" + "")); 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 GenerateForDeclarationBackedNodeSet( Func? 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))); } /// /// 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. /// [TestCase("Node")] [TestCase("Builder")] public void EmitChildNamedAfterAWrapperMember_ReportsTheCollision(string childName) { InvalidOperationException ex = Assert.Throws( () => GenerateWrapperWithChildNamed(childName)); Assert.That(ex.Message, Does.Contain(childName)); Assert.That(ex.Message, Does.Contain("already declares")); } /// /// A child whose name does not collide still generates. /// [Test] public void EmitChildWithANonCollidingName_Generates() { string? builders = null; Assert.DoesNotThrow(() => builders = GenerateWrapperWithChildNamed("Setpoint")); Assert.That(builders, Does.Contain("Setpoint")); } /// /// 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. /// [Test] public void EmitWithoutManagerWrappersDoesNotValidateWrapperNames() { Assert.DoesNotThrow(() => GenerateWrappers( "Device", [("Node", NewObject("Node"))], generateManagerWrappers: false)); } /// /// 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). /// [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")); } /// /// Regression: a predefined instance named after a member the typed /// manager builder forwards (e.g. Context) produced two members of /// that name in the typed builder (CS0102). /// [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(); 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())).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(); 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())).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(); } /// /// Regression: a per-type accessor was emitted for an Object child whose /// TypeDefinition is excluded (e.g. a Draft type), returning /// INodeBuilder<DiagnosticsState> for a state class that is /// never generated (CS0246). /// [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(); 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())).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 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(); 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())).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(); } } }