mirror of
https://github.com/OPCFoundation/UA-.NETStandard.git
synced 2026-10-16 21:02:51 +08:00
b3c1e60f46
# 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.
656 lines
32 KiB
C#
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);
|
|
}
|
|
}
|