/* ======================================================================== * 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.Text; using Microsoft.Extensions.Logging; using NUnit.Framework; 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 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 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\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\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(__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, Pink_Placeholder) 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"); } [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))); } } }