Files
UA-.NETStandard/tests/Opc.Ua.AMB.Tests/AmbBoundedClientTests.Values.cs
Marc Schier b3c1e60f46 AMB: add bounded strict reads and configurable sample hosting (#4803)
# Description

Extract the AMB layer from #4462 onto the client/server predecessor
#4802, then address the four review findings in
`6428686ae45724510bd6bd00619f4992f3d36e46`.

The initial extraction (`95b0caa1ac30d1fdacaf7a780f19c5f97e6fd02d`)
contained exactly 19 paths: 12 AMB client files, four AMB test files,
two sample entry points and `docs/AMB.md`. Its code/tests matched the
frozen source
`5b03e9afa492cdb36049ee52ae844f630f89d8db...390c4a7a51b71da40caec2d22ef74518bbe670ce`;
only the two agreed UaLens-only documentation passages were omitted. The
review follow-up intentionally changes that extracted implementation and
adds the related factory/registration tests and package documentation.
No UaLens, Machinery, Pumps, Positioning, native-rendering, build or CI
changes are introduced by this layer.

The AMB client provides opt-in bounded traversal, strict response/type
validation and original `AssetReadValue` evidence while preserving
tolerant defaults. The samples expose validated host, port, PKI root,
certificate auto-accept and bounded-runtime options, with unchanged
standalone defaults.

The review corrections are:

- Accept header-validated empty BrowseNext release responses required by
Part 4 §5.9.3.2, as well as the stack's single-result response. Invalid
cleanup results still preserve the original failure.
- Keep event observation tolerant of denied/limited maintenance-class
discovery without caching incomplete classes. Subsequent strict reads
retry discovery and still report errors.
- Accept typed-null ExtensionObjects only in the nullable
structure-field checks, preserving original evidence without weakening
scalar validation.
- Pass optional immutable limits through `AddAssetManagementClient`, the
registered factory/delegate and directly constructed factories. Preserve
existing overloads and tolerant behavior when options are omitted;
reject invalid limits before opening a session.

### Review regression evidence

| Requirement | Evidence |
| --- | --- |
| Accept empty cleanup responses without masking cancellation or limits
| `OneBeyondReferenceLimitReleasesAcquiredContinuationAsync` and
`CancellationWhileAwaitingNextReleasesTheSubmittedContinuationAsync`,
each with empty and single-result responses; malformed responses remain
covered |
| Preserve observation fallback and do not cache incomplete classes |
`ObservationFallsBackWithoutCachingIncompleteClassesAsync`: both
observation APIs, denied browsing and exhausted budget, correct
filter/event evidence, and a subsequent strict rediscovery |
| Preserve typed-null structure evidence without weakening scalar checks
| `ContextAndEntryReadsKeepSourceAndNullStructureEvidenceAsync`,
`MaintenanceTypedNullStructuresKeepOriginalEvidenceAsync`,
`ScalarHealthRejectsTypedNullStructuresAsync`, plus
wrong-non-null-structure rejection tests |
| Apply configured hosted limits while retaining tolerant defaults |
`HostedClientOptionsReachClientsAsync`: registered factory, registered
delegate and direct factory, singleton and builder registration, actual
one-reference limit and read evidence;
`InvalidHostedLimitsAreRejectedBeforeOpeningASession` |

### Validation

All commands below ran in this layer's worktree with the existing pinned
.NET SDK 10.0.401. No SDK installation, global.json change,
registry/path-policy change, test suppression or analyzer suppression
was made. The short owned NUnit directory `D:\a61r` and its generated
certificates/state were removed afterward.

**Latest full AMB suite: Passed: 285, Failed: 0, Skipped: 0 on each
framework.** Both builds emitted no compiler/analyzer diagnostics. TRX
files confirm the regression cases executed on both TFMs.

```powershell
& 'D:\git\UA-.NETStandard_master\TestResults\ualens-528-deep-repro\sdk\dotnet.exe' test 'tests\Opc.Ua.AMB.Tests\Opc.Ua.AMB.Tests.csproj' --no-restore --configuration Release --framework net10.0 -p:CustomTestTarget=net10.0 --nologo --verbosity minimal --logger 'trx;LogFileName=amb-feedback-net10.trx' --results-directory 'C:\Users\mschier\.copilot\session-state\61d7ed73-c89e-406a-a049-1d8ba9681308\files\TestResults\feedback-net10' -- 'NUnit.WorkDirectory=D:\a61r'
& 'D:\git\UA-.NETStandard_master\TestResults\ualens-528-deep-repro\sdk\dotnet.exe' test 'tests\Opc.Ua.AMB.Tests\Opc.Ua.AMB.Tests.csproj' --configuration Release --framework net48 -p:CustomTestTarget=net48 --nologo --verbosity minimal --logger 'trx;LogFileName=amb-feedback-net48.trx' --results-directory 'C:\Users\mschier\.copilot\session-state\61d7ed73-c89e-406a-a049-1d8ba9681308\files\TestResults\feedback-net48' -- 'NUnit.WorkDirectory=D:\a61r'
```

**Focused regression run: Passed: 127, Failed: 0, Skipped: 0.** Before
the production fixes, the focused repro executed 112 cases with 16
failures reproducing all four reported defects. Additional
registration/validation cases were included in the final run.

```powershell
& 'D:\git\UA-.NETStandard_master\TestResults\ualens-528-deep-repro\sdk\dotnet.exe' test 'tests\Opc.Ua.AMB.Tests\Opc.Ua.AMB.Tests.csproj' --no-restore --configuration Release --framework net10.0 -p:CustomTestTarget=net10.0 --nologo --verbosity minimal --filter 'FullyQualifiedName~AmbBoundedClientTests|FullyQualifiedName~AmbClientTests|FullyQualifiedName~HostedClientOptionsReachClientsAsync' --logger 'trx;LogFileName=amb-feedback-focused.trx' --results-directory 'C:\Users\mschier\.copilot\session-state\61d7ed73-c89e-406a-a049-1d8ba9681308\files\TestResults\feedback-focused' -- 'NUnit.WorkDirectory=D:\a61r'
```

**Whitespace and newly introduced style findings: passed.** `git diff
--check` and the following commands exited 0:

```powershell
& 'D:\git\UA-.NETStandard_master\TestResults\ualens-528-deep-repro\sdk\dotnet.exe' format whitespace --folder --verify-no-changes --verbosity quiet --include 'src\Opc.Ua.AMB.Client\AmbClient.Bounds.cs' 'src\Opc.Ua.AMB.Client\AmbClient.Health.cs' 'src\Opc.Ua.AMB.Client\AmbClient.Structure.cs' 'src\Opc.Ua.AMB.Client\AmbClient.Values.cs' 'src\Opc.Ua.AMB.Client\AmbClientFactory.cs' 'src\Opc.Ua.AMB.Client\Hosting\OpcUaAmbClientBuilderExtensions.cs' 'tests\Opc.Ua.AMB.Tests\AmbBoundedClientTests.cs' 'tests\Opc.Ua.AMB.Tests\AmbBoundedClientTests.Traversal.cs' 'tests\Opc.Ua.AMB.Tests\AmbBoundedClientTests.Values.cs' 'tests\Opc.Ua.AMB.Tests\AmbClientServerE2eTests.cs' 'tests\Opc.Ua.AMB.Tests\AmbClientTests.cs'
$env:CustomTestTarget = 'net10.0'
$env:Configuration = 'Release'
& 'D:\git\UA-.NETStandard_master\TestResults\ualens-528-deep-repro\sdk\dotnet.exe' format style 'tests\Opc.Ua.AMB.Tests\Opc.Ua.AMB.Tests.csproj' --no-restore --verify-no-changes --severity info --diagnostics IDE0039 --verbosity quiet --include 'tests\Opc.Ua.AMB.Tests\AmbClientServerE2eTests.cs' 'tests\Opc.Ua.AMB.Tests\AmbClientTests.cs'
```

The broader formatter check was **not clean**. It reported pre-existing
informational suggestions and design-time `NUnit1003` diagnostics for
generated browse-name constants, including pre-existing test cases.
Those diagnostics do not occur in the actual builds, which compile and
execute those cases successfully. Two new local-function suggestions
were corrected and rechecked above; no unrelated baseline cleanup or
suppression was included.

```powershell
$env:CustomTestTarget = 'net10.0'
& 'D:\git\UA-.NETStandard_master\TestResults\ualens-528-deep-repro\sdk\dotnet.exe' format 'tests\Opc.Ua.AMB.Tests\Opc.Ua.AMB.Tests.csproj' --no-restore --verify-no-changes --severity info --verbosity quiet --include 'src\Opc.Ua.AMB.Client\AmbClient.Bounds.cs' 'src\Opc.Ua.AMB.Client\AmbClient.Health.cs' 'src\Opc.Ua.AMB.Client\AmbClient.Structure.cs' 'src\Opc.Ua.AMB.Client\AmbClient.Values.cs' 'src\Opc.Ua.AMB.Client\AmbClientFactory.cs' 'src\Opc.Ua.AMB.Client\Hosting\OpcUaAmbClientBuilderExtensions.cs' 'tests\Opc.Ua.AMB.Tests\AmbBoundedClientTests.cs' 'tests\Opc.Ua.AMB.Tests\AmbBoundedClientTests.Traversal.cs' 'tests\Opc.Ua.AMB.Tests\AmbBoundedClientTests.Values.cs' 'tests\Opc.Ua.AMB.Tests\AmbClientServerE2eTests.cs' 'tests\Opc.Ua.AMB.Tests\AmbClientTests.cs'
```

<details>
<summary>Initial extraction validation and environment history</summary>

The original extraction passed all 263 AMB tests on both TFMs. Its
initial net48 run had one unchanged maintenance-test server-start
timeout; shortening only NUnit.WorkDirectory resolved it, consistent
with certificate-store path lengths. This follow-up used a short
directory from the start and all 285 cases passed on both frameworks.

A no-restore test invocation during the follow-up initially executed no
tests; it was not counted as passing evidence. A no-restore build
identified `NETSDK1005` (missing net10.0 target assets after switching
TFMs). The normal test invocation restored the target assets. Initial
regression-test compilation issues were corrected before capturing the
16 failing behavioral cases.

Both samples were built during extraction on both TFMs; each command
below passed with 0 warnings and 0 errors. They were not rebuilt during
this review follow-up, which does not modify the samples.

```powershell
& 'D:\git\UA-.NETStandard_master\TestResults\ualens-528-deep-repro\sdk\dotnet.exe' build 'samples\AMB\AmbClient\AmbClient.csproj' --configuration Release --framework net10.0 -p:CustomTestTarget=net10.0 --nologo --verbosity quiet
& 'D:\git\UA-.NETStandard_master\TestResults\ualens-528-deep-repro\sdk\dotnet.exe' build 'samples\AMB\AmbServer\AmbServer.csproj' --configuration Release --framework net10.0 -p:CustomTestTarget=net10.0 --nologo --verbosity quiet
& 'D:\git\UA-.NETStandard_master\TestResults\ualens-528-deep-repro\sdk\dotnet.exe' build 'samples\AMB\AmbClient\AmbClient.csproj' --configuration Release --framework net48 -p:CustomTestTarget=net48 --nologo --verbosity quiet
& 'D:\git\UA-.NETStandard_master\TestResults\ualens-528-deep-repro\sdk\dotnet.exe' build 'samples\AMB\AmbServer\AmbServer.csproj' --configuration Release --framework net48 -p:CustomTestTarget=net48 --nologo --verbosity quiet
```

</details>

This remains a draft: the complete `UA.slnx` net48/net10.0 matrix,
coverage, CI/CodeQL and CLA status are not established by this focused
validation. No coverage increase or complete-solution pass is claimed.
The feedback commit is based on the original AMB extraction; integration
of later lower-layer updates is coordinated separately, without
rewriting this branch or native stack 4807.

## Related Issues

- References #4461, the existing tracking issue; this PR does not close
it.
- Extracted from #4462 at frozen head
`390c4a7a51b71da40caec2d22ef74518bbe670ce`; the source PR is not closed
by this PR.
- Immediate predecessor: #4802 (`marcschier-extract-client-and-server`),
above core/types #4801. The original extraction parent was
`86ff10d3994f132f7343a929abdae209bc2e69b7`.

This is an isolated extraction and review correction of the existing
tracked implementation, not a claim of new architectural sign-off.

## Checklist

_Put an `x` in the boxes that apply. You can complete these step by step
after opening the PR._

- [ ] 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.
- [x] I have addressed **all** PR feedback received.
2026-10-15 21:08:24 +02:00

656 lines
32 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.AMB.Client;
using Opc.Ua.Client;
namespace Opc.Ua.AMB.Tests
{
public sealed partial class AmbBoundedClientTests
{
[TestCase(false)]
[TestCase(true)]
public async Task LinksPreserveUriAccessAndOptionalMarkerEvidenceAsync(bool hasMarker)
{
Mock<ISession> session = CreateSession();
SetupLinks(session, 1, hasMarker);
DataValue uri = TimedValue(Variant.From("https://example.invalid/manual"), StatusCodes.GoodClamped);
DataValue marker = TimedValue(Variant.From(false), StatusCodes.Good);
SetupRead(session, (node, attribute) => attribute == Attributes.UserAccessLevel
? new DataValue(Variant.From((byte)(AccessLevels.CurrentRead | AccessLevels.CurrentWrite)))
: node == s_marker ? marker : uri);
ArrayOf<DocumentationLinkRecord> links = await CreateClient(session)
.ReadDocumentationLinksAsync(s_asset).ConfigureAwait(false);
Assert.Multiple(() =>
{
Assert.That(links.Count, Is.EqualTo(1));
Assert.That(links[0].NodeId, Is.EqualTo(new NodeId(100u, 4)));
Assert.That(links[0].Uri, Is.EqualTo("https://example.invalid/manual"));
Assert.That(links[0].IsWritable, Is.True);
Assert.That(links[0].IsUserLink, Is.False);
Assert.That(links[0].Values.Count, Is.EqualTo(3));
Assert.That(links[0].Values[0].Value, Is.EqualTo(uri));
Assert.That(links[0].Values[1].AttributeId, Is.EqualTo(Attributes.UserAccessLevel));
Assert.That(links[0].Values[2].IsAbsent, Is.EqualTo(!hasMarker));
if (hasMarker)
{
Assert.That(links[0].Values[2].SourceNodeId, Is.EqualTo(s_marker));
Assert.That(links[0].Values[2].Value, Is.EqualTo(marker));
}
else
{
Assert.That(links[0].Values[2].Value.StatusCode, Is.EqualTo(StatusCodes.BadNoMatch));
}
});
VerifyNoMutationOrDisposal(session);
}
[TestCase("uri")]
[TestCase("access")]
[TestCase("marker")]
public async Task WrongLinkTypesDoNotBecomeEmptyUriOrFalsePermissionsAsync(string field)
{
Mock<ISession> session = CreateSession();
SetupLinks(session, 1, true);
SetupRead(session, (node, attribute) =>
{
string current = attribute == Attributes.UserAccessLevel
? "access" : node == s_marker ? "marker" : "uri";
if (current == field)
{
return TimedValue(Variant.From(42), StatusCodes.Good);
}
return current switch
{
"access" => new DataValue(Variant.From((byte)AccessLevels.CurrentRead)),
"marker" => new DataValue(Variant.From(false)),
_ => new DataValue(Variant.From("https://example.invalid/manual"))
};
});
AmbValueReadException failure = await FailureAsync<AmbValueReadException>(
() => CreateClient(session).ReadDocumentationLinksAsync(s_asset).AsTask()).ConfigureAwait(false);
Assert.That(failure.StatusCode, Is.EqualTo(StatusCodes.BadTypeMismatch));
Assert.That(failure.ReadValue!.Value.WrappedValue, Is.EqualTo(Variant.From(42)));
Assert.That(failure.ReadValue.Value.StatusCode, Is.EqualTo(StatusCodes.Good));
Assert.That(failure.ReadValue.IsAbsent, Is.False);
}
[TestCaseSource(nameof(NonGoodStatuses))]
public async Task NonGoodLinkAccessDoesNotBecomeReadOnlyAsync(StatusCode status)
{
Mock<ISession> session = CreateSession();
SetupLinks(session, 1, false);
DataValue denied = TimedValue(Variant.From((byte)AccessLevels.CurrentWrite), status);
SetupRead(session, (_, attribute) => attribute == Attributes.UserAccessLevel
? denied : new DataValue(Variant.From("https://example.invalid/manual")));
AmbValueReadException failure = await FailureAsync<AmbValueReadException>(
() => CreateClient(session).ReadDocumentationLinksAsync(s_asset).AsTask()).ConfigureAwait(false);
Assert.That(failure.StatusCode, Is.EqualTo(status));
Assert.That(failure.ReadValue!.AttributeId, Is.EqualTo(Attributes.UserAccessLevel));
Assert.That(failure.ReadValue.Value, Is.EqualTo(denied));
}
[Test]
public async Task ReadRequestsAreBatchedBeforeSendingAsync()
{
Mock<ISession> session = CreateSession();
SetupLinks(session, 65, false);
SetupRead(session, (_, attribute) => attribute == Attributes.UserAccessLevel
? new DataValue(Variant.From((byte)AccessLevels.CurrentRead))
: new DataValue(Variant.From("https://example.invalid/manual")));
ArrayOf<DocumentationLinkRecord> links = await CreateClient(session, 65)
.ReadDocumentationLinksAsync(s_asset).ConfigureAwait(false);
Assert.That(links.Count, Is.EqualTo(65));
session.Verify(s => s.ReadAsync(
It.IsAny<RequestHeader?>(), 0, TimestampsToReturn.Both,
It.Is<ArrayOf<ReadValueId>>(values => values.Count == 64), It.IsAny<CancellationToken>()),
Times.Exactly(2));
session.Verify(s => s.ReadAsync(
It.IsAny<RequestHeader?>(), 0, TimestampsToReturn.Both,
It.Is<ArrayOf<ReadValueId>>(values => values.Count == 1), It.IsAny<CancellationToken>()),
Times.Exactly(2));
}
[Test]
public async Task MarkerBrowsesShareTheDocumentationReferenceBudgetAsync()
{
Mock<ISession> session = CreateSession();
SetupLinks(session, 1, true);
SetupRead(session, (_, attribute) => attribute == Attributes.UserAccessLevel
? new DataValue(Variant.From((byte)AccessLevels.CurrentRead))
: new DataValue(Variant.From("https://example.invalid/manual")));
await AssertStatusAsync(
() => CreateClient(session, 1).ReadDocumentationLinksAsync(s_asset).AsTask(),
StatusCodes.BadEncodingLimitsExceeded).ConfigureAwait(false);
}
[Test]
public async Task HealthAndMaintenanceKeepCurrentFieldEvidenceAndNullDateSemanticsAsync()
{
Mock<ISession> session = CreateSession();
SetupConditions(session);
SetupRead(session, ConditionValue);
AmbClient client = CreateClient(session);
ArrayOf<AssetAlarmRecord> alarms = await client.ReadHealthAlarmsAsync(s_asset).ConfigureAwait(false);
ArrayOf<MaintenanceActivityRecord> activities = await client.ReadMaintenanceActivitiesAsync(s_asset)
.ConfigureAwait(false);
Assert.Multiple(() =>
{
Assert.That(alarms.Count, Is.EqualTo(1));
Assert.That(alarms[0].ConditionId, Is.EqualTo(s_alarm));
Assert.That(alarms[0].IsActive, Is.False);
Assert.That(activities.Count, Is.EqualTo(1));
Assert.That(activities[0].ConditionId, Is.EqualTo(s_maintenance));
Assert.That(activities[0].State, Is.EqualTo(MaintenanceStateKind.Planned));
Assert.That(activities[0].PlannedDate.IsNull, Is.True);
AssetReadValue active = alarms[0].Values.ToArray()!.Single(value =>
value.BrowsePath[0].Name == Ua.BrowseNames.ActiveState);
Assert.That(active.SourceNodeId, Is.EqualTo(new NodeId("alarm/ActiveState", 4)));
Assert.That(active.IsAbsent, Is.False);
Assert.That(active.Value, Is.EqualTo(ConditionValue(active.SourceNodeId, Attributes.Value)));
AssetReadValue planned = activities[0].Values.ToArray()!.Single(value =>
value.BrowsePath[0].Name == BrowseNames.PlannedDate);
Assert.That(planned.IsAbsent, Is.False, "a null date has a real source");
Assert.That(planned.Value.WrappedValue.TryGetValue(out DateTimeUtc date), Is.True);
Assert.That(date.IsNull, Is.True);
});
VerifyNoMutationOrDisposal(session);
}
[TestCase(Ua.BrowseNames.ActiveState)]
[TestCase(Ua.BrowseNames.ConditionClassId)]
public async Task WrongConditionFieldsCannotBecomeFalseOrMisclassifiedAsync(string field)
{
Mock<ISession> session = CreateSession();
SetupConditions(session);
SetupRead(session, (node, attribute) =>
{
node.TryGetValue(out string? name);
return name == "alarm/" + field
? new DataValue(Variant.From("wrong type"))
: ConditionValue(node, attribute);
});
AmbValueReadException failure = await FailureAsync<AmbValueReadException>(
() => CreateClient(session).ReadHealthAlarmsAsync(s_asset).AsTask()).ConfigureAwait(false);
Assert.That(failure.StatusCode, Is.EqualTo(StatusCodes.BadTypeMismatch));
Assert.That(failure.ReadValue!.SourceNodeId, Is.EqualTo(new NodeId("alarm/" + field, 4)));
}
[TestCase(false)]
[TestCase(true)]
public async Task ContextAndEntryReadsKeepSourceAndNullStructureEvidenceAsync(bool typedNull)
{
Mock<ISession> session = CreateSession();
SetupTranslate(session, paths =>
{
var results = new BrowsePathResult[paths.Count];
for (int ii = 0; ii < paths.Count; ii++)
{
results[ii] = Resolved(new NodeId(paths[ii].RelativePath.Elements[0].TargetName.Name!, 4));
}
return results;
});
SetupBrowse(session, request => request.NodeId == new NodeId(AmbBrowseNames.Requirements, 4)
? new BrowseResult { References = [Reference("voltage")] }
: new BrowseResult());
DataValue voltage = TimedValue(Variant.From(24.0), StatusCodes.GoodClamped);
TimeZoneDataType? missingLocalTime = null;
DataValue localTime = TimedValue(
typedNull ? Variant.FromStructure(missingLocalTime!) : Variant.Null, StatusCodes.GoodClamped);
SetupRead(session, (node, _) => node == new NodeId("voltage", 4)
? voltage
: node == new NodeId(Ua.BrowseNames.LocalTime, 4)
? localTime
: new DataValue(Variant.From(string.Empty)));
AmbClient client = CreateClient(session);
AssetContextRecord context = await client.ReadContextAsync(s_asset).ConfigureAwait(false);
ArrayOf<AssetEntryRecord> entries = await client.ReadEntriesAsync(s_asset, AssetEntryFolder.Requirements)
.ConfigureAwait(false);
Assert.That(context.LocalTime, Is.Null);
Assert.That(context.Values[3].IsAbsent, Is.False);
Assert.That(context.Values[3].SourceNodeId, Is.EqualTo(new NodeId(Ua.BrowseNames.LocalTime, 4)));
Assert.That(context.Values[3].Value, Is.EqualTo(localTime));
Assert.That(context.Values[3].Value.WrappedValue.IsNull, Is.EqualTo(!typedNull));
Assert.That(entries.Count, Is.EqualTo(1));
Assert.That(entries[0].ReadValue!.SourceNodeId, Is.EqualTo(new NodeId("voltage", 4)));
Assert.That(entries[0].ReadValue!.Value, Is.EqualTo(voltage));
}
[TestCase(BrowseNames.MaintenanceSupplier)]
[TestCase(BrowseNames.QualificationOfPersonnel)]
public async Task MaintenanceTypedNullStructuresKeepOriginalEvidenceAsync(string field)
{
Mock<ISession> session = CreateSession();
SetupConditions(session);
var source = new NodeId("maintenance/" + field, 4);
NameNodeIdDataType? missingName = null;
DataValue original = TimedValue(Variant.FromStructure(missingName!), StatusCodes.GoodClamped);
SetupRead(session, (node, attribute) => node == source ? original : ConditionValue(node, attribute));
ArrayOf<MaintenanceActivityRecord> activities = await CreateClient(session)
.ReadMaintenanceActivitiesAsync(s_asset).ConfigureAwait(false);
Assert.That(activities.Count, Is.EqualTo(1));
Assert.That(activities[0].MaintenanceSupplier, Is.Null);
Assert.That(activities[0].QualificationOfPersonnel, Is.Null);
AssetReadValue evidence = activities[0].Values.ToArray()!.Single(value => value.SourceNodeId == source);
Assert.That(evidence.IsAbsent, Is.False);
Assert.That(evidence.Value, Is.EqualTo(original));
Assert.That(evidence.BrowsePath[0], Is.EqualTo(new QualifiedName(field, 2)));
Assert.That(evidence.Value.WrappedValue.IsNull, Is.False);
Assert.That(evidence.Value.WrappedValue.TryGetValue(out ExtensionObject structure), Is.True);
Assert.That(structure.IsNull, Is.True);
}
[TestCase(BrowseNames.MaintenanceSupplier)]
[TestCase(BrowseNames.QualificationOfPersonnel)]
public async Task MaintenanceRejectsNonNullStructuresOfTheWrongTypeAsync(string field)
{
Mock<ISession> session = CreateSession();
SetupConditions(session);
var source = new NodeId("maintenance/" + field, 4);
DataValue original = TimedValue(Variant.FromStructure(new TimeZoneDataType()), StatusCodes.Good);
SetupRead(session, (node, attribute) => node == source ? original : ConditionValue(node, attribute));
AmbValueReadException failure = await FailureAsync<AmbValueReadException>(
() => CreateClient(session).ReadMaintenanceActivitiesAsync(s_asset).AsTask()).ConfigureAwait(false);
Assert.That(failure.StatusCode, Is.EqualTo(StatusCodes.BadTypeMismatch));
Assert.That(failure.ReadValue!.SourceNodeId, Is.EqualTo(source));
Assert.That(failure.ReadValue.Value, Is.EqualTo(original));
}
[Test]
public async Task ContextRejectsNonNullStructuresOfTheWrongTypeAsync()
{
Mock<ISession> session = CreateSession();
var source = new NodeId(Ua.BrowseNames.LocalTime, 4);
SetupTranslate(session, _ => [Missing(), Missing(), Missing(), Resolved(source)]);
DataValue original = TimedValue(Variant.FromStructure(new NameNodeIdDataType()), StatusCodes.Good);
SetupRead(session, (_, _) => original);
AmbValueReadException failure = await FailureAsync<AmbValueReadException>(
() => CreateClient(session).ReadContextAsync(s_asset).AsTask()).ConfigureAwait(false);
Assert.That(failure.StatusCode, Is.EqualTo(StatusCodes.BadTypeMismatch));
Assert.That(failure.ReadValue!.SourceNodeId, Is.EqualTo(source));
Assert.That(failure.ReadValue.Value, Is.EqualTo(original));
}
[Test]
public async Task ScalarHealthRejectsTypedNullStructuresAsync()
{
Mock<ISession> session = CreateSession();
var source = new NodeId("Health", 4);
SetupTranslate(session, _ => [Resolved(source)]);
TimeZoneDataType? missingLocalTime = null;
DataValue original = TimedValue(Variant.FromStructure(missingLocalTime!), StatusCodes.Good);
SetupRead(session, (_, _) => original);
AmbValueReadException failure = await FailureAsync<AmbValueReadException>(
() => CreateClient(session).ReadDeviceHealthValueAsync(s_asset).AsTask()).ConfigureAwait(false);
Assert.That(failure.StatusCode, Is.EqualTo(StatusCodes.BadTypeMismatch));
Assert.That(failure.ReadValue!.SourceNodeId, Is.EqualTo(source));
Assert.That(failure.ReadValue.Value, Is.EqualTo(original));
}
[Test]
public async Task TypedAssetIdWriteResolvesOnlyItsTargetAndWritesOnceAsync()
{
Mock<ISession> session = CreateSession();
var target = new NodeId("asset-id", 4);
int resolutions = 0;
SetupTranslate(session, paths =>
{
Assert.That(paths.Count, Is.EqualTo(1));
ArrayOf<RelativePathElement> path = paths[0].RelativePath.Elements;
Assert.That(path[path.Count - 1].TargetName,
Is.EqualTo(new QualifiedName(Opc.Ua.Di.BrowseNames.AssetId, 3)));
return [++resolutions == 1 ? Missing() : Resolved(target)];
});
session.Setup(s => s.WriteAsync(
It.IsAny<RequestHeader?>(), It.IsAny<ArrayOf<WriteValue>>(), It.IsAny<CancellationToken>()))
.Returns((RequestHeader? _, ArrayOf<WriteValue> writes, CancellationToken _) =>
{
Assert.That(writes.Count, Is.EqualTo(1));
Assert.That(writes[0].NodeId, Is.EqualTo(target));
Assert.That(writes[0].Value.WrappedValue, Is.EqualTo(Variant.From("P-42")));
return new ValueTask<WriteResponse>(new WriteResponse
{
ResponseHeader = new ResponseHeader(),
Results = [StatusCodes.Good]
});
});
await CreateClient(session).WriteAssetIdAsync(s_asset, "P-42").ConfigureAwait(false);
Assert.That(resolutions, Is.EqualTo(2));
VerifyOneWrite(session);
VerifyNoRead(session);
}
[TestCase(0)]
[TestCase(2)]
public async Task TypedWritesRejectWrongCardinalityWithoutReplayAsync(int count)
{
Mock<ISession> session = CreateSession();
session.Setup(s => s.WriteAsync(
It.IsAny<RequestHeader?>(), It.IsAny<ArrayOf<WriteValue>>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new WriteResponse
{
ResponseHeader = new ResponseHeader(),
Results = new StatusCode[count]
});
await AssertStatusAsync(
() => CreateClient(session).WriteDocumentationLinkAsync(s_asset, "https://example.invalid/").AsTask(),
StatusCodes.BadDecodingError).ConfigureAwait(false);
VerifyOneWrite(session);
}
[TestCaseSource(nameof(NonGoodWriteStatuses))]
public async Task TypedWritesRejectNonGoodServiceAndOperationStatusesAsync(StatusCode status, bool service)
{
Mock<ISession> session = CreateSession();
session.Setup(s => s.WriteAsync(
It.IsAny<RequestHeader?>(), It.IsAny<ArrayOf<WriteValue>>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new WriteResponse
{
ResponseHeader = new ResponseHeader { ServiceResult = service ? status : StatusCodes.Good },
Results = [service ? StatusCodes.Good : status]
});
await AssertStatusAsync(
() => CreateClient(session).WriteDocumentationLinkAsync(s_asset, "https://example.invalid/").AsTask(),
status).ConfigureAwait(false);
VerifyOneWrite(session);
}
[Test]
public async Task CancelledWritesAreNeverSentAsync()
{
Mock<ISession> session = CreateSession();
using var cancellation = new CancellationTokenSource();
cancellation.Cancel();
await Assert.ThatAsync(
async () => await CreateClient(session).WriteDocumentationLinkAsync(
s_asset, "https://example.invalid/", cancellation.Token).ConfigureAwait(false),
Throws.TypeOf<OperationCanceledException>()).ConfigureAwait(false);
VerifyNoMutationOrDisposal(session);
}
[Test]
public async Task TypedLinkMethodsResolveInstanceAndCallOnlyOnceAsync()
{
Mock<ISession> session = CreateSession();
SetupDocumentationMethodResolution(session);
var link = new NodeId("new-link", 4);
int adds = 0;
int removes = 0;
SetupCall(session, request =>
{
Assert.That(request.ObjectId, Is.EqualTo(s_addIn));
if (request.MethodId == new NodeId(BrowseNames.AddLink, 4))
{
adds++;
Assert.That(request.InputArguments.Count, Is.EqualTo(4));
Assert.That(request.InputArguments[0], Is.EqualTo(Variant.From("https://example.invalid/manual")));
Assert.That(request.InputArguments[1], Is.EqualTo(Variant.From(new QualifiedName("Manual", 4))));
return new CallMethodResult { OutputArguments = [Variant.From(link)] };
}
removes++;
Assert.That(request.MethodId, Is.EqualTo(new NodeId(BrowseNames.RemoveLink, 4)));
Assert.That(request.InputArguments.Count, Is.EqualTo(1));
Assert.That(request.InputArguments[0], Is.EqualTo(Variant.From(link)));
return new CallMethodResult();
});
AmbClient client = CreateClient(session);
NodeId added = await client.AddDocumentationLinkAsync(
s_asset, "https://example.invalid/manual", new QualifiedName("Manual", 4)).ConfigureAwait(false);
await client.RemoveDocumentationLinkAsync(s_asset, added).ConfigureAwait(false);
Assert.That(added, Is.EqualTo(link));
Assert.That(adds, Is.EqualTo(1));
Assert.That(removes, Is.EqualTo(1));
session.Verify(s => s.Dispose(), Times.Never);
}
[Test]
public async Task UnknownNonIdempotentOutcomeIsNotReplayedAsync()
{
Mock<ISession> session = CreateSession();
SetupDocumentationMethodResolution(session);
session.Setup(s => s.CallAsync(
It.IsAny<RequestHeader?>(), It.IsAny<ArrayOf<CallMethodRequest>>(), It.IsAny<CancellationToken>()))
.ThrowsAsync(new ServiceResultException(StatusCodes.BadTimeout));
await AssertStatusAsync(
() => CreateClient(session).AddDocumentationLinkAsync(
s_asset, "https://example.invalid/manual", new QualifiedName("Manual", 4)).AsTask(),
StatusCodes.BadTimeout).ConfigureAwait(false);
VerifyOneCall(session);
}
[TestCase(0)]
[TestCase(2)]
public async Task TypedCallsRejectWrongCardinalityWithoutReplayAsync(int count)
{
Mock<ISession> session = CreateSession();
SetupDocumentationMethodResolution(session);
var results = new CallMethodResult[count];
for (int ii = 0; ii < count; ii++)
{
results[ii] = new CallMethodResult();
}
session.Setup(s => s.CallAsync(
It.IsAny<RequestHeader?>(), It.IsAny<ArrayOf<CallMethodRequest>>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new CallResponse
{
ResponseHeader = new ResponseHeader(),
Results = results.ToArrayOf()
});
await AssertStatusAsync(
() => CreateClient(session).RemoveDocumentationLinkAsync(s_asset, s_asset).AsTask(),
StatusCodes.BadDecodingError).ConfigureAwait(false);
VerifyOneCall(session);
}
[TestCaseSource(nameof(NonGoodWriteStatuses))]
public async Task TypedCallsRejectNonGoodServiceAndOperationStatusesAsync(StatusCode status, bool service)
{
Mock<ISession> session = CreateSession();
SetupDocumentationMethodResolution(session);
session.Setup(s => s.CallAsync(
It.IsAny<RequestHeader?>(), It.IsAny<ArrayOf<CallMethodRequest>>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new CallResponse
{
ResponseHeader = new ResponseHeader { ServiceResult = service ? status : StatusCodes.Good },
Results = [new CallMethodResult { StatusCode = service ? StatusCodes.Good : status }]
});
await AssertStatusAsync(
() => CreateClient(session).RemoveDocumentationLinkAsync(s_asset, s_asset).AsTask(), status)
.ConfigureAwait(false);
VerifyOneCall(session);
}
private static IEnumerable<TestCaseData> NonGoodWriteStatuses()
{
yield return new TestCaseData(StatusCodes.BadUserAccessDenied, false);
yield return new TestCaseData(StatusCodes.BadUserAccessDenied, true);
yield return new TestCaseData(StatusCodes.Uncertain, false);
yield return new TestCaseData(StatusCodes.Uncertain, true);
}
private static void SetupDocumentationMethodResolution(Mock<ISession> session)
{
SetupTranslate(session, paths =>
{
Assert.That(paths.Count, Is.EqualTo(1));
QualifiedName name = paths[0].RelativePath.Elements[0].TargetName;
Assert.That(name.NamespaceIndex, Is.EqualTo(2));
return [Resolved(name.Name == BrowseNames.DocumentationLinks ? s_addIn : new NodeId(name.Name!, 4))];
});
}
private static void SetupLinks(Mock<ISession> session, int count, bool hasMarker)
{
SetupTranslate(session, _ => [Resolved(s_addIn)]);
SetupBrowse(session, request =>
{
if (request.NodeId == s_addIn)
{
var references = new ReferenceDescription[count];
for (int ii = 0; ii < count; ii++)
{
references[ii] = new ReferenceDescription
{
NodeId = new NodeId(100u + (uint)ii, 4),
BrowseName = new QualifiedName("Link" + ii, 4),
NodeClass = NodeClass.Variable
};
}
return new BrowseResult { References = references.ToArrayOf() };
}
return hasMarker ? new BrowseResult
{
References =
[
new ReferenceDescription
{
NodeId = s_marker,
BrowseName = new QualifiedName(DocumentationLinkProperties.UserLink, 4),
NodeClass = NodeClass.Variable
}
]
} : new BrowseResult();
});
}
private static void SetupConditions(Mock<ISession> session)
{
var folder = new NodeId("conditions", 4);
SetupBrowse(session, request => request.NodeId == folder
? new BrowseResult { References = [Reference(s_alarm), Reference(s_maintenance)] }
: new BrowseResult());
SetupTranslate(session, paths =>
{
if (paths[0].StartingNode == s_asset)
{
return [Resolved(folder)];
}
paths[0].StartingNode.TryGetValue(out string? owner);
var results = new BrowsePathResult[paths.Count];
for (int ii = 0; ii < paths.Count; ii++)
{
results[ii] = Resolved(new NodeId(
owner + "/" + paths[ii].RelativePath.Elements[0].TargetName.Name, 4));
}
return results;
});
}
private static DataValue ConditionValue(NodeId node, uint attribute)
{
Assert.That(attribute, Is.EqualTo(Attributes.Value));
node.TryGetValue(out string? name);
bool maintenance = name!.StartsWith("maintenance/", StringComparison.Ordinal);
string field = name.Substring(maintenance ? "maintenance/".Length : "alarm/".Length);
Variant value = field switch
{
Ua.BrowseNames.ConditionClassId => Variant.From(maintenance
? Ua.ObjectTypeIds.MaintenanceConditionClassType : Ua.ObjectTypeIds.BaseConditionClassType),
Ua.BrowseNames.EventType => Variant.From(Ua.ObjectTypeIds.AlarmConditionType),
Ua.BrowseNames.SourceNode => Variant.From(s_asset),
Ua.BrowseNames.ActiveState or Ua.BrowseNames.Retain => Variant.From(false),
Ua.BrowseNames.AckedState => Variant.From(true),
Ua.BrowseNames.ConditionName => Variant.From(maintenance ? "Scheduled" : "Alarm"),
Ua.BrowseNames.Time => Variant.From(
new DateTimeUtc(new DateTime(2026, 10, 10, 0, 0, 0, DateTimeKind.Utc))),
BrowseNames.MaintenanceState => maintenance
? Variant.From(new NodeId(MaintenanceEventStateMachineTypeIds.StateIds.Planned, 2)) : Variant.Null,
BrowseNames.PlannedDate => Variant.From(default(DateTimeUtc)),
_ => Variant.Null
};
return TimedValue(value, StatusCodes.Good);
}
private static void VerifyOneWrite(Mock<ISession> session)
{
session.Verify(s => s.WriteAsync(
It.IsAny<RequestHeader?>(), It.IsAny<ArrayOf<WriteValue>>(), It.IsAny<CancellationToken>()),
Times.Once);
session.Verify(s => s.Dispose(), Times.Never);
}
private static void VerifyOneCall(Mock<ISession> session)
{
session.Verify(s => s.CallAsync(
It.IsAny<RequestHeader?>(), It.IsAny<ArrayOf<CallMethodRequest>>(), It.IsAny<CancellationToken>()),
Times.Once);
session.Verify(s => s.Dispose(), Times.Never);
}
private static readonly NodeId s_addIn = new("documentation", 4);
private static readonly NodeId s_marker = new("user-link-marker", 4);
private static readonly NodeId s_alarm = new("alarm", 4);
private static readonly NodeId s_maintenance = new("maintenance", 4);
}
}