Files
UA-.NETStandard/tests/Opc.Ua.Server.Tests/Fluent/NodeAttachBuilderExtensionsTests.cs
romanett 9b034f8291 Enable nullable reference types centrally; migrate all unset and annotations-only projects (#4700)
# 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>
2026-10-08 08:48:13 +02:00

889 lines
35 KiB
C#

/* ========================================================================
* Copyright (c) 2005-2026 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.Linq;
using System.Threading;
using System.Threading.Tasks;
using Moq;
using NUnit.Framework;
using Opc.Ua.Server.Fluent;
namespace Opc.Ua.Server.Tests.Fluent
{
/// <summary>
/// Covers the fluent behavior-attachment surface: type fan-out, subtype chaining,
/// composition of two behaviors on one node, activation order, and the exact-reverse
/// unwind on both teardown and mid-activation failure.
/// </summary>
[TestFixture]
[Category("Fluent")]
public sealed class NodeAttachBuilderExtensionsTests
{
[Test]
public async Task ActivatesChildFirstAndBaseBeforeDerivedAsync()
{
var log = new List<string>();
using var manager = new TestBehaviorManager();
manager.SeedParentAndChild();
INodeManagerBuilder builder = manager.NewBuilder();
builder.AttachToType<BaseObjectState>(
TestBehaviorManager.BaseTypeId,
(node, ctx, ct) => Record(log, $"base:{node.BrowseName.Name}"));
builder.AttachToType<BaseObjectState>(
TestBehaviorManager.DerivedTypeId,
(node, ctx, ct) => Record(log, $"derived:{node.BrowseName.Name}"));
await manager.ActivateAsync().ConfigureAwait(false);
// Child before parent; on each node, the base-type behavior before the
// derived-type one.
Assert.That(
log,
Is.EqualTo(s_activationOrder));
log.Clear();
await manager.DeleteAddressSpaceAsync().ConfigureAwait(false);
Assert.That(
log,
Is.EqualTo(s_teardownOrder),
"teardown must be the exact reverse of activation");
}
[Test]
public async Task FansOutOverEveryInstanceOfTheTypeAsync()
{
var log = new List<string>();
using var manager = new TestBehaviorManager();
manager.SeedSiblings(3);
INodeManagerBuilder builder = manager.NewBuilder();
builder.AttachToType<BaseObjectState>(
TestBehaviorManager.BaseTypeId,
(node, ctx, ct) => Record(log, node.BrowseName.Name!));
await manager.ActivateAsync().ConfigureAwait(false);
Assert.That(log, Has.Count.EqualTo(3));
Assert.That(
log,
Does.Contain("activate Sibling0")
.And.Contain("activate Sibling1")
.And.Contain("activate Sibling2"));
}
[Test]
public void ZeroMatchesThrowsBadConfigurationError()
{
using var manager = new TestBehaviorManager();
manager.SeedParentAndChild();
INodeManagerBuilder builder = manager.NewBuilder();
builder.AttachToType<BaseObjectState>(
TestBehaviorManager.UnmatchedTypeId,
(node, ctx, ct) => new ValueTask<IAsyncDisposable?>((IAsyncDisposable?)null));
ServiceResultException ex = Assert.ThrowsAsync<ServiceResultException>(
async () => await manager.ActivateAsync().ConfigureAwait(false))!;
Assert.That(ex.StatusCode, Is.EqualTo((uint)StatusCodes.BadConfigurationError));
}
[Test]
public async Task ZeroMatchesIsAcceptedWhenAllowedAsync()
{
using var manager = new TestBehaviorManager();
manager.SeedParentAndChild();
INodeManagerBuilder builder = manager.NewBuilder();
builder.AttachToType<BaseObjectState>(
TestBehaviorManager.UnmatchedTypeId,
(node, ctx, ct) => new ValueTask<IAsyncDisposable?>((IAsyncDisposable?)null),
new NodeAttachOptions { AllowZeroMatches = true });
Assert.DoesNotThrowAsync(
async () => await manager.ActivateAsync().ConfigureAwait(false));
await manager.DeleteAddressSpaceAsync().ConfigureAwait(false);
}
[Test]
public async Task ExactTypeRegistrationSkipsSubtypesAsync()
{
var log = new List<string>();
using var manager = new TestBehaviorManager();
manager.SeedParentAndChild();
INodeManagerBuilder builder = manager.NewBuilder();
builder.AttachToType<BaseObjectState>(
TestBehaviorManager.BaseTypeId,
(node, ctx, ct) => Record(log, node.BrowseName.Name!),
new NodeAttachOptions
{
IncludeSubtypes = false,
AllowZeroMatches = true
});
await manager.ActivateAsync().ConfigureAwait(false);
// Both seeded nodes are of the derived type, so an exact-type registration
// on the base type matches nothing.
Assert.That(log, Is.Empty);
}
[Test]
public async Task ManagerScopedBehaviorActivatesLastAndUnwindsFirstAsync()
{
var log = new List<string>();
using var manager = new TestBehaviorManager();
manager.SeedParentAndChild();
INodeManagerBuilder builder = manager.NewBuilder();
builder.AttachToType<BaseObjectState>(
TestBehaviorManager.DerivedTypeId,
(node, ctx, ct) => Record(log, node.BrowseName.Name!));
builder.Attach((ctx, ct) => Record(log, "manager"));
await manager.ActivateAsync().ConfigureAwait(false);
Assert.That(log[^1], Is.EqualTo("activate manager"));
log.Clear();
await manager.DeleteAddressSpaceAsync().ConfigureAwait(false);
Assert.That(log[0], Is.EqualTo("dispose manager"));
}
[Test]
public async Task DecliningAnInstanceRecordsNothingAsync()
{
var log = new List<string>();
using var manager = new TestBehaviorManager();
manager.SeedSiblings(3);
INodeManagerBuilder builder = manager.NewBuilder();
builder.AttachToType<BaseObjectState>(
TestBehaviorManager.BaseTypeId,
(node, ctx, ct) => node.BrowseName.Name == "Sibling1"
? Record(log, node.BrowseName.Name!)
: new ValueTask<IAsyncDisposable?>((IAsyncDisposable?)null));
await manager.ActivateAsync().ConfigureAwait(false);
Assert.That(log, Is.EqualTo(s_activateSibling1));
log.Clear();
await manager.DeleteAddressSpaceAsync().ConfigureAwait(false);
Assert.That(
log,
Is.EqualTo(s_disposeSibling1),
"declined instances must not be disposed");
}
[Test]
public async Task MidActivationFailureUnwindsWhatAlreadyActivatedAsync()
{
var log = new List<string>();
using var manager = new TestBehaviorManager();
manager.SeedParentAndChild();
INodeManagerBuilder builder = manager.NewBuilder();
builder.AttachToType<BaseObjectState>(
TestBehaviorManager.BaseTypeId,
(node, ctx, ct) => Record(log, $"base:{node.BrowseName.Name}"));
builder.AttachToType<BaseObjectState>(
TestBehaviorManager.DerivedTypeId,
(node, ctx, ct) =>
{
// Fails on the parent, after three behaviors are already live.
if (node.BrowseName.Name == "Parent")
{
throw new InvalidOperationException("activation failed");
}
return Record(log, $"derived:{node.BrowseName.Name}");
});
InvalidOperationException ex = Assert.ThrowsAsync<InvalidOperationException>(
async () => await manager.ActivateAsync().ConfigureAwait(false))!;
Assert.That(ex.Message, Is.EqualTo("activation failed"));
Assert.That(
log,
Is.EqualTo(s_rollbackOrder),
"rollback must release exactly what activated, in reverse");
}
[Test]
public async Task LifetimeTokenIsCancelledBeforeReleaseAsync()
{
CancellationToken lifetime = default;
bool cancelledAtRelease = false;
using var manager = new TestBehaviorManager();
manager.SeedSiblings(1);
INodeManagerBuilder builder = manager.NewBuilder();
builder.AttachToType<BaseObjectState>(
TestBehaviorManager.BaseTypeId,
(node, ctx, ct) =>
{
lifetime = ctx.Lifetime;
return new ValueTask<IAsyncDisposable?>(
new CallbackDisposable(() =>
cancelledAtRelease = lifetime.IsCancellationRequested));
});
await manager.ActivateAsync().ConfigureAwait(false);
Assert.That(lifetime.IsCancellationRequested, Is.False);
await manager.DeleteAddressSpaceAsync().ConfigureAwait(false);
Assert.That(
cancelledAtRelease,
Is.True,
"the lifetime token must be tripped before the handle is released");
}
[Test]
public async Task SecondPassUnwindsBeforeTheFirstAsync()
{
var log = new List<string>();
using var manager = new TestBehaviorManager();
manager.SeedSiblings(1);
INodeManagerBuilder first = manager.NewBuilder();
first.Attach((ctx, ct) => Record(log, "first"));
await manager.ActivateAsync().ConfigureAwait(false);
INodeManagerBuilder second = manager.NewBuilder();
second.Attach((ctx, ct) => Record(log, "second"));
await manager.ActivateAsync().ConfigureAwait(false);
Assert.That(
log,
Is.EqualTo(s_activateBothPasses));
log.Clear();
await manager.DeleteAddressSpaceAsync().ConfigureAwait(false);
Assert.That(
log,
Is.EqualTo(s_disposeBothPasses));
}
[Test]
public async Task DrainedRegistrationsDoNotReactivateAsync()
{
var log = new List<string>();
using var manager = new TestBehaviorManager();
manager.SeedSiblings(1);
INodeManagerBuilder builder = manager.NewBuilder();
builder.Attach((ctx, ct) => Record(log, "once"));
await manager.ActivateAsync().ConfigureAwait(false);
await manager.ActivateAsync().ConfigureAwait(false);
Assert.That(log, Is.EqualTo(s_activateOnce));
}
[Test]
public async Task MethodNodesAreNotMatchedByTypeAsync()
{
var log = new List<string>();
using var manager = new TestBehaviorManager();
manager.SeedMethod();
INodeManagerBuilder builder = manager.NewBuilder();
// MethodState aliases MethodDeclarationId onto TypeDefinitionId, so without
// an explicit filter this registration would fire on the method node.
builder.AttachToType<BaseObjectState>(
TestBehaviorManager.DerivedTypeId,
(node, ctx, ct) => Record(log, node.BrowseName.Name!),
new NodeAttachOptions { AllowZeroMatches = true });
await manager.ActivateAsync().ConfigureAwait(false);
Assert.That(log, Is.Empty, "method nodes must not match a type registration");
}
[Test]
public async Task AlarmWiringIsUndoneOnTeardownAsync()
{
using var manager = new TestBehaviorManager();
manager.SeedSiblings(1);
NodeManagerBuilder builder = manager.NewBuilder();
INodeBuilder parent = builder.Node(new NodeId(100u, 1));
var parentObject = (BaseObjectState)parent.Node;
Assert.That(
parentObject.EventNotifier & EventNotifiers.SubscribeToEvents,
Is.Zero,
"precondition: the parent starts without SubscribeToEvents");
NonExclusiveLimitAlarmState alarm = parent
.CreateLimitAlarm(new QualifiedName("Level", 1))
.Alarm;
await manager.ActivateAsync().ConfigureAwait(false);
Assert.Multiple(() =>
{
Assert.That(
alarm.EnabledState?.Id?.Value,
Is.True,
"attaching an alarm enables it");
Assert.That(
parentObject.EventNotifier & EventNotifiers.SubscribeToEvents,
Is.Not.Zero,
"attaching an alarm promotes the parent notifier");
});
await manager.DeleteAddressSpaceAsync().ConfigureAwait(false);
Assert.Multiple(() =>
{
Assert.That(
alarm.EnabledState?.Id?.Value,
Is.False,
"teardown must disable the condition");
Assert.That(
parentObject.EventNotifier & EventNotifiers.SubscribeToEvents,
Is.Zero,
"teardown must clear the notifier bit it set");
});
}
[Test]
public async Task GenericAlarmWiringIsUndoneOnTeardownAsync()
{
using var manager = new TestBehaviorManager();
manager.SeedSiblings(1);
NodeManagerBuilder builder = manager.NewBuilder();
INodeBuilder parent = builder.Node(new NodeId(100u, 1));
var parentObject = (BaseObjectState)parent.Node;
TripAlarmState alarm = parent
.CreateAlarm(new QualifiedName("Trip", 1), node => new TripAlarmState(node))
.Alarm;
await manager.ActivateAsync().ConfigureAwait(false);
Assert.That(alarm.EnabledState?.Id?.Value, Is.True, "attaching an alarm enables it");
await manager.DeleteAddressSpaceAsync().ConfigureAwait(false);
Assert.Multiple(() =>
{
Assert.That(
alarm.EnabledState?.Id?.Value,
Is.False,
"teardown must disable the condition");
Assert.That(
parentObject.EventNotifier & EventNotifiers.SubscribeToEvents,
Is.Zero,
"teardown must clear the notifier bit it set");
});
}
[Test]
public void AlarmWiringIsUnwoundWhenALaterBehaviorFailsActivation()
{
using var manager = new TestBehaviorManager();
manager.SeedSiblings(1);
NodeManagerBuilder builder = manager.NewBuilder();
INodeBuilder parent = builder.Node(new NodeId(100u, 1));
var parentObject = (BaseObjectState)parent.Node;
parent.CreateLimitAlarm(new QualifiedName("Level", 1));
Assert.That(
manager.IsRootNotifier(parentObject.NodeId),
Is.True,
"precondition: attaching the alarm registered its source as a root notifier");
// Manager-scoped behaviors activate after every node behavior, so this one
// fails with the alarm's lease already live and forces it to be rolled back.
builder.Attach(
(_, _) => throw new InvalidOperationException("activation failed"));
InvalidOperationException ex = Assert.ThrowsAsync<InvalidOperationException>(
async () => await manager.ActivateAsync().ConfigureAwait(false))!;
Assert.That(ex.Message, Is.EqualTo("activation failed"));
// Rollback is the only path on which this is observable. A full
// DeleteAddressSpaceAsync would remove the root notifier for every node
// regardless of who registered it, so it cannot distinguish AlarmRelease
// doing its job from the base class covering for it. Here the address space
// survives, and nothing but AlarmRelease can undo either of these.
Assert.Multiple(() =>
{
Assert.That(
manager.IsRootNotifier(parentObject.NodeId),
Is.False,
"rollback must remove the root notifier the alarm registered");
Assert.That(
parentObject.EventNotifier & EventNotifiers.SubscribeToEvents,
Is.Zero,
"rollback must clear the notifier bit the alarm set");
});
}
[Test]
public async Task TeardownDoesNotRedisableAnAlreadyDisabledAlarmAsync()
{
using var manager = new TestBehaviorManager();
manager.SeedSiblings(1);
NodeManagerBuilder builder = manager.NewBuilder();
INodeBuilder parent = builder.Node(new NodeId(100u, 1));
NonExclusiveLimitAlarmState alarm = parent
.CreateLimitAlarm(new QualifiedName("Level", 1))
.Alarm;
await manager.ActivateAsync().ConfigureAwait(false);
// A client may disable a condition at runtime through the Disable method.
// Stand in for that here: what matters is only that the condition is already
// disabled by the time the node manager tears down.
alarm.SetEnableState(manager.SystemContext, enabled: false);
// Retain is the sentinel. UpdateStateAfterDisable clears it unconditionally,
// so setting it now means a second disable transition during teardown would
// be visible afterwards, while a correctly suppressed one leaves it standing.
// The time-based evidence, EnabledState.TransitionTime, cannot be used: both
// disables would stamp DateTime.UtcNow and can land in the same tick.
alarm.Retain!.Value = true;
await manager.DeleteAddressSpaceAsync().ConfigureAwait(false);
Assert.Multiple(() =>
{
Assert.That(
alarm.Retain.Value,
Is.True,
"teardown must not disable a condition that is already disabled: " +
"the Disable method answers BadConditionAlreadyDisabled for exactly " +
"this transition, and SetEnableState makes no such check");
Assert.That(
alarm.EnabledState?.Id?.Value,
Is.False,
"the condition must still end up disabled");
});
}
[Test]
public async Task PublishWiringIsUndoneOnTeardownAsync()
{
using var manager = new TestBehaviorManager();
manager.SeedSiblings(1);
NodeManagerBuilder builder = manager.NewBuilder();
var notifier = (BaseObjectState)builder.Node(new NodeId(100u, 1)).Node;
Assert.That(
notifier.EventNotifier & EventNotifiers.SubscribeToEvents,
Is.Zero,
"precondition: the notifier starts without SubscribeToEvents");
builder.Node<BaseObjectState>(notifier.NodeId)
.Publish<BaseObjectState, BaseEventState>(
(_, _, _) => EmptyEventsAsync(),
new EventPublishOptions { RegisterAsRootNotifier = false });
Assert.That(
notifier.EventNotifier & EventNotifiers.SubscribeToEvents,
Is.Not.Zero,
"registering a Publish source promotes the notifier");
await manager.ActivateAsync().ConfigureAwait(false);
await manager.DeleteAddressSpaceAsync().ConfigureAwait(false);
Assert.That(
notifier.EventNotifier & EventNotifiers.SubscribeToEvents,
Is.Zero,
"teardown must clear the notifier bit registration set");
}
[Test]
public async Task ReleaseFailuresAreAggregatedAndDoNotStopOtherReleasesAsync()
{
var log = new List<string>();
using var manager = new TestBehaviorManager();
manager.SeedSiblings(1);
NodeManagerBuilder builder = manager.NewBuilder();
builder.Attach((ctx, ct) => new ValueTask<IAsyncDisposable?>(
new CallbackDisposable(() => log.Add("first"))));
builder.Attach((ctx, ct) => new ValueTask<IAsyncDisposable?>(
new CallbackDisposable(() => throw new InvalidOperationException("boom A"))));
builder.Attach((ctx, ct) => new ValueTask<IAsyncDisposable?>(
new CallbackDisposable(() => throw new InvalidOperationException("boom B"))));
await manager.ActivateAsync().ConfigureAwait(false);
AggregateException ex = Assert.ThrowsAsync<AggregateException>(
async () => await manager.DeleteAddressSpaceAsync().ConfigureAwait(false))!;
Assert.Multiple(() =>
{
Assert.That(
ex.InnerExceptions.Select(e => e.Message),
Is.EquivalentTo(s_bothReleaseFailures),
"every release failure must surface, not just the first");
Assert.That(
log,
Is.EqualTo(s_firstOnly),
"a failing release must not stop the remaining ones");
});
}
[Test]
public async Task SynchronousDisposeTripsTheLifetimeOfAnActivatedBehaviorAsync()
{
CancellationToken lifetime = default;
var manager = new TestBehaviorManager();
try
{
manager.SeedSiblings(1);
NodeManagerBuilder builder = manager.NewBuilder();
builder.Attach((ctx, ct) =>
{
lifetime = ctx.Lifetime;
return new ValueTask<IAsyncDisposable?>(
new CallbackDisposable(() => { }));
});
await manager.ActivateAsync().ConfigureAwait(false);
Assert.That(lifetime.IsCancellationRequested, Is.False);
// Dispose is signal-only, but it still has to stop background work:
// the awaited release belongs to DeleteAddressSpaceAsync.
manager.Dispose();
Assert.That(
lifetime.IsCancellationRequested,
Is.True,
"synchronous disposal must still trip every behavior's lifetime");
}
finally
{
manager.Dispose();
}
}
[Test]
public void ActivationFailureWithAFailingRollbackReportsBoth()
{
using var manager = new TestBehaviorManager();
manager.SeedSiblings(1);
NodeManagerBuilder builder = manager.NewBuilder();
builder.Attach((ctx, ct) => new ValueTask<IAsyncDisposable?>(
new CallbackDisposable(
() => throw new InvalidOperationException("release failed"))));
builder.Attach((ctx, ct) =>
throw new InvalidOperationException("activation failed"));
AggregateException ex = Assert.ThrowsAsync<AggregateException>(
async () => await manager.ActivateAsync().ConfigureAwait(false))!;
Assert.That(
ex.InnerExceptions.Select(e => e.Message),
Is.EquivalentTo(s_activationAndRollbackFailures),
"a failed rollback must not hide the failure that triggered it");
}
[Test]
public void AttachToTypeRejectsATypeFromAnUnregisteredNamespace()
{
using var manager = new TestBehaviorManager();
manager.SeedSiblings(1);
NodeManagerBuilder builder = manager.NewBuilder();
builder.AttachToType<BaseObjectState>(
new NodeId(5000u, 99),
(node, ctx, ct) => new ValueTask<IAsyncDisposable?>((IAsyncDisposable?)null),
new NodeAttachOptions { AllowZeroMatches = true });
// A namespace index the server does not know cannot be made
// namespace-stable, so the registration cannot be matched at all.
ServiceResultException ex = Assert.ThrowsAsync<ServiceResultException>(
async () => await manager.ActivateAsync().ConfigureAwait(false))!;
Assert.That(
ex.StatusCode,
Is.EqualTo((uint)StatusCodes.BadConfigurationError));
}
[Test]
public void AttachToTypeThrowsWhenTheInstanceIsNotTheExpectedStateType()
{
using var manager = new TestBehaviorManager();
manager.SeedSiblings(1);
NodeManagerBuilder builder = manager.NewBuilder();
builder.AttachToType<PropertyState>(
TestBehaviorManager.DerivedTypeId,
(node, ctx, ct) => new ValueTask<IAsyncDisposable?>((IAsyncDisposable?)null));
// The seeded instances are objects. Failing loudly beats skipping them,
// which would leave a whole set of nodes silently unwired.
ServiceResultException ex = Assert.ThrowsAsync<ServiceResultException>(
async () => await manager.ActivateAsync().ConfigureAwait(false))!;
Assert.Multiple(() =>
{
Assert.That(ex.StatusCode, Is.EqualTo((uint)StatusCodes.BadTypeMismatch));
Assert.That(ex.Message, Does.Contain(nameof(PropertyState)));
Assert.That(ex.Message, Does.Contain(nameof(BaseObjectState)));
});
}
[Test]
public async Task AttachContextResolvesNodesByNodeIdAsync()
{
NodeState? found = null;
NodeState? missing = null;
using var manager = new TestBehaviorManager();
manager.SeedSiblings(1);
NodeManagerBuilder builder = manager.NewBuilder();
builder.Attach((ctx, ct) =>
{
found = ctx.Find(new NodeId(100u, 1));
missing = ctx.Find(new NodeId(4242u, 1));
return new ValueTask<IAsyncDisposable?>((IAsyncDisposable?)null);
});
await manager.ActivateAsync().ConfigureAwait(false);
Assert.Multiple(() =>
{
Assert.That(found?.BrowseName.Name, Is.EqualTo("Sibling0"));
Assert.That(missing, Is.Null);
});
}
[Test]
public void NullArgumentsAreRejected()
{
using var manager = new TestBehaviorManager();
manager.SeedSiblings(1);
NodeManagerBuilder builder = manager.NewBuilder();
Assert.Multiple(() =>
{
Assert.Throws<ArgumentNullException>(
() => builder.AttachToType<BaseObjectState>(
TestBehaviorManager.DerivedTypeId,
null!));
Assert.Throws<ArgumentNullException>(
() => builder.Attach(
(Func<INodeAttachContext, CancellationToken,
ValueTask<IAsyncDisposable?>>)null!));
Assert.Throws<ArgumentNullException>(
() => builder.AttachToType<BaseObjectState>(
NodeId.Null,
(node, ctx, ct) =>
new ValueTask<IAsyncDisposable?>((IAsyncDisposable?)null)));
});
}
private static async IAsyncEnumerable<BaseEventState> EmptyEventsAsync()
{
await Task.CompletedTask.ConfigureAwait(false);
yield break;
}
private static readonly string[] s_firstOnly = ["first"];
private static readonly string[] s_bothReleaseFailures = ["boom A", "boom B"];
private static readonly string[] s_activationAndRollbackFailures =
["activation failed", "release failed"];
private static readonly string[] s_activationOrder =
[
"activate base:Child",
"activate derived:Child",
"activate base:Parent",
"activate derived:Parent"
];
private static readonly string[] s_teardownOrder =
[
"dispose derived:Parent",
"dispose base:Parent",
"dispose derived:Child",
"dispose base:Child"
];
private static readonly string[] s_rollbackOrder =
[
"activate base:Child",
"activate derived:Child",
"activate base:Parent",
"dispose base:Parent",
"dispose derived:Child",
"dispose base:Child"
];
private static readonly string[] s_activateSibling1 = ["activate Sibling1"];
private static readonly string[] s_disposeSibling1 = ["dispose Sibling1"];
private static readonly string[] s_activateBothPasses =
["activate first", "activate second"];
private static readonly string[] s_disposeBothPasses =
["dispose second", "dispose first"];
private static readonly string[] s_activateOnce = ["activate once"];
private static ValueTask<IAsyncDisposable?> Record(List<string> log, string label)
{
log.Add($"activate {label}");
return new ValueTask<IAsyncDisposable?>(
new CallbackDisposable(() => log.Add($"dispose {label}")));
}
private sealed class CallbackDisposable : IAsyncDisposable
{
public CallbackDisposable(Action onDispose)
{
m_onDispose = onDispose;
}
public ValueTask DisposeAsync()
{
m_onDispose();
return default;
}
private readonly Action m_onDispose;
}
/// <summary>
/// A fluent manager whose predefined nodes are seeded directly, so the tests
/// exercise activation rather than the node-loading pipeline.
/// </summary>
private sealed class TestBehaviorManager : FluentNodeManagerBase
{
public static readonly NodeId BaseTypeId = new(5000u);
public static readonly NodeId DerivedTypeId = new(5001u);
public static readonly NodeId UnmatchedTypeId = new(5002u);
public TestBehaviorManager()
: base(CreateMockServer(), TestNamespaceUri)
{
}
public NodeManagerBuilder NewBuilder()
{
return CreateFluentBuilder(1);
}
public ValueTask ActivateAsync()
{
return ActivateNodeBehaviorsAsync(CancellationToken.None);
}
public void SeedParentAndChild()
{
BaseObjectState parent = CreateNode("Parent", 1, DerivedTypeId, null);
BaseObjectState child = CreateNode("Child", 2, DerivedTypeId, parent);
PredefinedNodes.Add(parent.NodeId, parent);
PredefinedNodes.Add(child.NodeId, child);
}
public void SeedMethod()
{
var method = new MethodState(null)
{
NodeId = new NodeId(900u, 1),
BrowseName = new QualifiedName("DoWork", 1),
DisplayName = new LocalizedText("DoWork"),
// Setting the declaration id also sets TypeDefinitionId.
MethodDeclarationId = DerivedTypeId
};
PredefinedNodes.Add(method.NodeId, method);
}
public void SeedSiblings(int count)
{
for (int i = 0; i < count; i++)
{
BaseObjectState node = CreateNode(
$"Sibling{i}",
(uint)(100 + i),
DerivedTypeId,
null);
PredefinedNodes.Add(node.NodeId, node);
}
}
private static BaseObjectState CreateNode(
string name,
uint id,
NodeId typeDefinitionId,
NodeState? parent)
{
return new BaseObjectState(parent)
{
NodeId = new NodeId(id, 1),
BrowseName = new QualifiedName(name, 1),
DisplayName = new LocalizedText(name),
TypeDefinitionId = typeDefinitionId
};
}
private const string TestNamespaceUri = "urn:test:node-attach";
private static IServerInternal CreateMockServer()
{
var ns = new NamespaceTable();
ns.Append(Ua.Namespaces.OpcUa);
ns.Append(TestNamespaceUri);
var typeTree = new TypeTable(ns);
// The supertype has to exist before a subtype can point at it.
typeTree.AddSubtype(BaseTypeId, NodeId.Null);
typeTree.AddSubtype(DerivedTypeId, BaseTypeId);
var mockTelemetry = new Mock<ITelemetryContext>();
var mock = new Mock<IServerInternal>();
mock.SetupGet(m => m.NamespaceUris).Returns(ns);
mock.SetupGet(m => m.TypeTree).Returns(typeTree);
mock.SetupGet(m => m.Telemetry).Returns(mockTelemetry.Object);
IServiceMessageContext msgCtx = ServiceMessageContext.Create(
mockTelemetry.Object);
mock.SetupGet(m => m.MessageContext).Returns(msgCtx);
mock.SetupGet(m => m.DefaultSystemContext).Returns(
new ServerSystemContext(mock.Object));
return mock.Object;
}
}
}
}