Files
Marc SchierandCopilot e73e71184d [WotCon] Add OPC UA WoT Connectivity 1.1 with the Types, source generation, Server and xRegistry work it builds on (#4128)
## Summary

Adds OPC UA WoT Connectivity 1.1 to the stack, together with the Types,
source generation, Server and xRegistry work it is built on. This branch
is the merge point for the fourteen-PR stack that replaced integration
PR #4093; every constituent PR was reviewed and approved separately and
is listed below.

414 files changed, +108,936 / -3,199. Roughly 46k added lines of product
code, 57k of tests, plus samples, tools and documentation.

## Types

- Complete `ExtensionObject` raw-body decoding for binary, XML and JSON
bodies by resolving the concrete type through the message context's
encodeable factory, and simplify the `Variant` structure helpers by
delegating to it (#4128).
- Add `IAtomicFileReplace` as an optional `IFileSystem` capability so
existing external implementations keep working, with atomic publish for
`LocalFileSystem` via `File.Replace`/`File.Move` and for
`VirtualFileSystem` by re-keying the in-memory entry (#4128).
- Add lossless conversion between WoT documents and NodeSet2
(`Opc.Ua.Wot.WotNodeSetConverter`). A byte-exact `uav:nodeSet` envelope
is preserved when requested, the structured `uav:nodes` projection is
used when the readable vocabulary is incomplete, and NodeSet2 is
otherwise synthesized from readable WoT terms. Unmapped JSON members
survive a round trip as pointer-addressed residue (#4131).

## Source generation

- Improve the NodeSet to ModelDesign conversion and the generated node
state (#4132).
- Generate OPC UA models directly from WoT Thing Description files
(#4134).

## Server

- Add live NodeManager shadow and immediate reload. A reload
materializes a new generation beside the active one and switches
atomically; the superseded generation is retired gracefully so existing
MonitoredItems keep being served until they drain, or immediately when
the caller asks for it. Includes the request admission and drain
machinery that lets an orderly shutdown wait for admitted requests
instead of tearing down underneath them (#4147).
- Make subscription transfer between sessions transactional, so a failed
transfer leaves neither session holding a partially moved subscription
(#4150).

## xRegistry

- Allow clients to use explicit registry roots rather than assuming a
well-known location (#4130).

## WoT Connectivity

- Add the WoT Connectivity 1.1 information model: a registry-first
revision layered on the abstract xRegistry base model, incorporating the
complete published OPC 10100-1 v1.02 surface as deprecated nodes in the
same namespace so existing 1.02 clients keep working (#4139).
- Add the protocol binding abstractions and planners that compile WoT
forms into executable plans (#4140).
- Add the HTTP, Modbus, OPC UA and MQTT binding executors (#4144).
- Add the registry and its stores, including document versioning,
validation, dependency resolution and refresh (#4146).
- Add the registry client (#4142).
- Materialize WoT Thing Descriptions into the server address space,
deriving types from Thing Models and instances from Thing Descriptions
(#4154).
- Restore the WoT asset registry bridge so the deprecated 1.02 asset
surface is backed by the registry (#4171).
- Add the WoT Connectivity samples and complete the documentation
(#4156), and restore the remaining coverage and docs (#4172).

## New projects

`src/Opc.Ua.WotCon.Bindings`, `src/Opc.Ua.WotCon.Bindings.Mqtt`,
`tests/Opc.Ua.WotCon.Bindings.Tests`,
`tests/Opc.Ua.WotCon.Samples.Tests`, and the `samples/WotCon`
AggregationServer, AggregationClient and FlatTagServer samples.

## Constituent pull requests

Merged into this branch top-down, each independently approved:

| PR | Title |
| --- | --- |
| #4128 | [Types] Complete ExtensionObject decoding and add an atomic
file replace capability |
| #4131 | [Types] Add lossless conversion between WoT documents and
NodeSet2 |
| #4132 | [SourceGeneration] Improve NodeSet to ModelDesign conversion
and generated node state |
| #4134 | [SourceGeneration] Generate OPC UA models from WoT Thing
Description files |
| #4147 | [Server] Add live NodeManager shadow and immediate reload |
| #4150 | [Server] Make subscription transfer between sessions
transactional |
| #4130 | [XRegistry] Allow clients to use explicit registry roots |
| #4139 | [WotCon] Add the WoT Connectivity 1.1 information model |
| #4140 | [WotCon] Add the WoT protocol binding abstractions and
planners |
| #4144 | [WotCon] Add the HTTP, Modbus, OPC UA and MQTT binding
executors |
| #4146 | [WotCon] Add the WoT Connectivity registry and its stores |
| #4142 | [WotCon] Add the WoT Connectivity registry client |
| #4154 | [WotCon] Materialize WoT Thing Descriptions into the server
address space |
| #4156 | [WotCon] Add the WoT Connectivity samples and complete the
documentation |
| #4171 | Restore WoT asset registry bridge |
| #4172 | Restore WoT coverage and docs |

Supersedes #4093.

## Follow-up

The WoT Connectivity and WoT Binding drafts have moved to 1.1-draft2
since this work was authored. Aligning with that revision - the removed
group vocabulary, the new projection/View construct, the remaining model
vocabulary terms and the two new portable-identity validation rules - is
tracked separately and will follow in its own pull request.

## Validation

Built for every target framework with zero warnings and zero errors, and
validated at the tip of the stack with `Opc.Ua.WotCon.Tests` at 999
passed / 0 failed and `Opc.Ua.Server.Tests` at 4024 passed / 0 failed.

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 9e6a5abf-3299-4cd1-9855-010fedbf0ad8
2026-08-05 10:14:15 +02:00

717 lines
34 KiB
C#

/* ========================================================================
* Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved.
*
* OPC Foundation MIT License 1.00
*
* Permission is hereby granted, free of charge, to any person
* obtaining a copy of this software and associated documentation
* files (the "Software"), to deal in the Software without
* restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the
* Software is furnished to do so, subject to the following
* conditions:
*
* The above copyright notice and this permission notice shall be
* included in all copies or substantial portions of the Software.
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
* OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
* HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
* WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
* OTHER DEALINGS IN THE SOFTWARE.
*
* The complete license agreement can be found here:
* http://opcfoundation.org/License/MIT/1.00/
* ======================================================================*/
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.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 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, Pink_Placeholder) 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>");
}
[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)));
}
private static Dictionary<string, string> GenerateForDeclarationBackedNodeSet()
{
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);
var nodesets = new NodesetFileCollection(
[
(path, new NodesetFileOptions
{
ModelUri = namespaceUri,
Name = "DeclarationBackedMethod",
Prefix = "DeclarationBackedMethod"
})
],
[],
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)));
}
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();
}
}
}