mirror of
https://github.com/OPCFoundation/UA-.NETStandard.git
synced 2026-10-21 09:06:37 +08:00
9b034f8291
# 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>
1731 lines
76 KiB
C#
1731 lines
76 KiB
C#
/* ========================================================================
|
|
* Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved.
|
|
*
|
|
* OPC Foundation MIT License 1.00
|
|
*
|
|
* Permission is hereby granted, free of charge, to any person
|
|
* obtaining a copy of this software and associated documentation
|
|
* files (the "Software"), to deal in the Software without
|
|
* restriction, including without limitation the rights to use,
|
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
* copies of the Software, and to permit persons to whom the
|
|
* Software is furnished to do so, subject to the following
|
|
* conditions:
|
|
*
|
|
* The above copyright notice and this permission notice shall be
|
|
* included in all copies or substantial portions of the Software.
|
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
|
|
* OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
|
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
|
|
* HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
|
* WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
|
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
|
|
* OTHER DEALINGS IN THE SOFTWARE.
|
|
*
|
|
* The complete license agreement can be found here:
|
|
* http://opcfoundation.org/License/MIT/1.00/
|
|
* ======================================================================*/
|
|
|
|
using System;
|
|
using System.Collections.Generic;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using Moq;
|
|
using NUnit.Framework;
|
|
using Opc.Ua.Client.Historian;
|
|
|
|
namespace Opc.Ua.Client.Tests.Historian
|
|
{
|
|
/// <summary>
|
|
/// Unit tests for <see cref="HistoryClient"/> that use a mocked
|
|
/// <see cref="ISession"/> to exercise error paths that are difficult
|
|
/// to trigger over the wire.
|
|
/// </summary>
|
|
[TestFixture]
|
|
[Category("Historian")]
|
|
public class HistoryClientUnitTests
|
|
{
|
|
/// <summary>
|
|
/// Verifies that constructing a history client with a null session throws ArgumentNullException.
|
|
/// </summary>
|
|
[Test]
|
|
public void ConstructorWithNullSessionThrowsArgumentNullException()
|
|
{
|
|
Assert.Throws<ArgumentNullException>(() => new HistoryClient(null!));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that a bad raw-history result is surfaced as a ServiceResultException with the server status.
|
|
/// </summary>
|
|
[Test]
|
|
public Task ReadRawThrowsServiceResultExceptionOnBadStatusAsync()
|
|
{
|
|
var mockSession = new Mock<ISession>();
|
|
mockSession
|
|
.Setup(s => s.HistoryReadAsync(
|
|
It.IsAny<RequestHeader>(),
|
|
It.IsAny<ExtensionObject>(),
|
|
It.IsAny<TimestampsToReturn>(),
|
|
It.IsAny<bool>(),
|
|
It.IsAny<ArrayOf<HistoryReadValueId>>(),
|
|
It.IsAny<CancellationToken>()))
|
|
.Returns(new ValueTask<HistoryReadResponse>(new HistoryReadResponse
|
|
{
|
|
Results = new HistoryReadResult[]
|
|
{
|
|
new()
|
|
{
|
|
StatusCode = StatusCodes.BadHistoryOperationInvalid
|
|
}
|
|
}.ToArrayOf()
|
|
}));
|
|
|
|
var client = new HistoryClient(mockSession.Object);
|
|
var nodeId = new NodeId("TestNode", 2);
|
|
DateTime start = DateTime.UtcNow.AddHours(-1);
|
|
DateTime end = DateTime.UtcNow;
|
|
|
|
ServiceResultException ex = Assert.ThrowsAsync<ServiceResultException>(async () =>
|
|
{
|
|
await foreach (DataValue v in client.ReadRawAsync(nodeId, start, end).ConfigureAwait(false))
|
|
{
|
|
// should not reach here
|
|
}
|
|
});
|
|
|
|
Assert.That(ex.StatusCode, Is.EqualTo(StatusCodes.BadHistoryOperationInvalid));
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that an empty raw-history result collection produces BadUnexpectedError.
|
|
/// </summary>
|
|
[Test]
|
|
public void ReadRawWithEmptyResultsThrowsBadUnexpectedError()
|
|
{
|
|
var mockSession = new Mock<ISession>();
|
|
mockSession
|
|
.Setup(s => s.HistoryReadAsync(
|
|
It.IsAny<RequestHeader>(),
|
|
It.IsAny<ExtensionObject>(),
|
|
It.IsAny<TimestampsToReturn>(),
|
|
It.IsAny<bool>(),
|
|
It.IsAny<ArrayOf<HistoryReadValueId>>(),
|
|
It.IsAny<CancellationToken>()))
|
|
.Returns(new ValueTask<HistoryReadResponse>(new HistoryReadResponse
|
|
{
|
|
Results = Array.Empty<HistoryReadResult>().ToArrayOf()
|
|
}));
|
|
|
|
var client = new HistoryClient(mockSession.Object);
|
|
ServiceResultException exception =
|
|
Assert.ThrowsAsync<ServiceResultException>(
|
|
async () => await DrainAsync(
|
|
client.ReadRawAsync(
|
|
new NodeId("TestNode", 2),
|
|
DateTime.UtcNow.AddHours(-1),
|
|
DateTime.UtcNow)).ConfigureAwait(false))!;
|
|
|
|
Assert.That(
|
|
exception.StatusCode,
|
|
Is.EqualTo(StatusCodes.BadUnexpectedError));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that raw-history requests preserve multidimensional index ranges.
|
|
/// </summary>
|
|
[Test]
|
|
public async Task ReadRawPreservesMultidimensionalIndexRangeAsync()
|
|
{
|
|
string? capturedIndexRange = null;
|
|
var mockSession = new Mock<ISession>();
|
|
mockSession
|
|
.Setup(s => s.HistoryReadAsync(
|
|
It.IsAny<RequestHeader>(),
|
|
It.IsAny<ExtensionObject>(),
|
|
It.IsAny<TimestampsToReturn>(),
|
|
It.IsAny<bool>(),
|
|
It.IsAny<ArrayOf<HistoryReadValueId>>(),
|
|
It.IsAny<CancellationToken>()))
|
|
.Callback<RequestHeader, ExtensionObject, TimestampsToReturn,
|
|
bool, ArrayOf<HistoryReadValueId>, CancellationToken>(
|
|
(_, _, _, _, nodes, _) =>
|
|
capturedIndexRange = nodes[0].IndexRange)
|
|
.Returns(new ValueTask<HistoryReadResponse>(
|
|
new HistoryReadResponse
|
|
{
|
|
Results =
|
|
[
|
|
new HistoryReadResult
|
|
{
|
|
StatusCode = StatusCodes.Good,
|
|
HistoryData = new ExtensionObject(
|
|
new HistoryData())
|
|
}
|
|
]
|
|
}));
|
|
var client = new HistoryClient(mockSession.Object);
|
|
|
|
await DrainAsync(client.ReadRawAsync(
|
|
new NodeId("TestNode", 2),
|
|
DateTime.UtcNow.AddHours(-1),
|
|
DateTime.UtcNow,
|
|
nodeOptions: new HistoryReadNodeOptions
|
|
{
|
|
IndexRange = NumericRange.Parse("1:2,0:3")
|
|
})).ConfigureAwait(false);
|
|
|
|
Assert.That(capturedIndexRange, Is.EqualTo("1:2,0:3"));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that advancing empty raw-history pages stop at the configured page limit.
|
|
/// </summary>
|
|
[Test]
|
|
public Task ReadRawWithAdvancingEmptyPagesStopsAtConfiguredPageLimitAsync()
|
|
{
|
|
int callCount = 0;
|
|
ByteString released = ByteString.Empty;
|
|
var mockSession = new Mock<ISession>();
|
|
mockSession
|
|
.Setup(s => s.HistoryReadAsync(
|
|
It.IsAny<RequestHeader>(),
|
|
It.IsAny<ExtensionObject>(),
|
|
It.IsAny<TimestampsToReturn>(),
|
|
It.IsAny<bool>(),
|
|
It.IsAny<ArrayOf<HistoryReadValueId>>(),
|
|
It.IsAny<CancellationToken>()))
|
|
.Returns<RequestHeader, ExtensionObject, TimestampsToReturn, bool,
|
|
ArrayOf<HistoryReadValueId>, CancellationToken>(
|
|
(_, _, _, release, nodes, _) =>
|
|
{
|
|
if (release)
|
|
{
|
|
released = nodes[0].ContinuationPoint;
|
|
return new ValueTask<HistoryReadResponse>(
|
|
new HistoryReadResponse
|
|
{
|
|
Results =
|
|
[
|
|
new HistoryReadResult
|
|
{
|
|
StatusCode = StatusCodes.Good
|
|
}
|
|
]
|
|
});
|
|
}
|
|
callCount++;
|
|
return new ValueTask<HistoryReadResponse>(new HistoryReadResponse
|
|
{
|
|
Results = new HistoryReadResult[]
|
|
{
|
|
new()
|
|
{
|
|
StatusCode = StatusCodes.Good,
|
|
ContinuationPoint = ByteString.From(
|
|
[(byte)callCount])
|
|
}
|
|
}.ToArrayOf()
|
|
});
|
|
});
|
|
|
|
var client = new HistoryClient(
|
|
mockSession.Object,
|
|
new HistoryClientOptions
|
|
{
|
|
MaxPagesPerRead = 4
|
|
});
|
|
|
|
ServiceResultException ex = Assert.ThrowsAsync<ServiceResultException>(async () =>
|
|
{
|
|
await foreach (DataValue v in client.ReadRawAsync(
|
|
new NodeId("TestNode", 2),
|
|
DateTime.UtcNow.AddHours(-1),
|
|
DateTime.UtcNow).ConfigureAwait(false))
|
|
{
|
|
// should never yield
|
|
}
|
|
});
|
|
|
|
Assert.That(ex.StatusCode, Is.EqualTo(StatusCodes.BadTimeout));
|
|
Assert.That(callCount, Is.EqualTo(4));
|
|
Assert.That(released, Is.EqualTo(ByteString.From([4])));
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that a history update with an empty result collection throws ServiceResultException.
|
|
/// </summary>
|
|
[Test]
|
|
public void PerformUpdateWithEmptyResultsThrowsServiceResultException()
|
|
{
|
|
var mockSession = new Mock<ISession>();
|
|
mockSession
|
|
.Setup(s => s.HistoryUpdateAsync(
|
|
It.IsAny<RequestHeader>(),
|
|
It.IsAny<ArrayOf<ExtensionObject>>(),
|
|
It.IsAny<CancellationToken>()))
|
|
.Returns(new ValueTask<HistoryUpdateResponse>(new HistoryUpdateResponse
|
|
{
|
|
Results = Array.Empty<HistoryUpdateResult>().ToArrayOf()
|
|
}));
|
|
|
|
var client = new HistoryClient(mockSession.Object);
|
|
DateTime now = DateTime.UtcNow;
|
|
|
|
ServiceResultException ex = Assert.ThrowsAsync<ServiceResultException>(async () =>
|
|
await client.InsertAsync(
|
|
new NodeId("TestNode", 2),
|
|
[
|
|
new DataValue(
|
|
new Variant(1.0),
|
|
StatusCodes.Good,
|
|
sourceTimestamp: now,
|
|
serverTimestamp: now)
|
|
]).ConfigureAwait(false));
|
|
|
|
Assert.That(ex.StatusCode, Is.EqualTo(StatusCodes.BadUnexpectedError),
|
|
"When HistoryUpdate returns a result count that does not match the request, " +
|
|
"InsertAsync should surface a BadUnexpectedError.");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that at-time reads return values and send the requested timestamps in the service details.
|
|
/// </summary>
|
|
[Test]
|
|
public async Task ReadAtTimeAsyncReturnsValuesAndCapturesDetailsAsync()
|
|
{
|
|
ExtensionObject? capturedDetails = null;
|
|
var mockSession = new Mock<ISession>();
|
|
mockSession
|
|
.Setup(s => s.HistoryReadAsync(
|
|
It.IsAny<RequestHeader>(),
|
|
It.IsAny<ExtensionObject>(),
|
|
It.IsAny<TimestampsToReturn>(),
|
|
It.IsAny<bool>(),
|
|
It.IsAny<ArrayOf<HistoryReadValueId>>(),
|
|
It.IsAny<CancellationToken>()))
|
|
.Callback<RequestHeader, ExtensionObject, TimestampsToReturn, bool, ArrayOf<HistoryReadValueId>, CancellationToken>(
|
|
(_, details, _, _, _, _) => capturedDetails = details)
|
|
.Returns(new ValueTask<HistoryReadResponse>(new HistoryReadResponse
|
|
{
|
|
Results = [new HistoryReadResult
|
|
{
|
|
StatusCode = StatusCodes.Good,
|
|
ContinuationPoint = ByteString.Empty,
|
|
HistoryData = new ExtensionObject(
|
|
new HistoryData
|
|
{
|
|
DataValues =
|
|
[
|
|
new DataValue(Variant.From(1)),
|
|
new DataValue(Variant.From(2))
|
|
]
|
|
})
|
|
}]
|
|
}));
|
|
|
|
var client = new HistoryClient(mockSession.Object);
|
|
var values = new List<DataValue>();
|
|
|
|
await foreach (DataValue value in client.ReadAtTimeAsync(
|
|
new NodeId("TestNode", 2),
|
|
[DateTime.UtcNow.AddHours(-1), DateTime.UtcNow],
|
|
useSimpleBounds: true).ConfigureAwait(false))
|
|
{
|
|
values.Add(value);
|
|
}
|
|
|
|
Assert.That(values, Has.Count.EqualTo(2));
|
|
Assert.That(capturedDetails, Is.Not.Null);
|
|
ExtensionObject detailsObject = capturedDetails ?? throw new AssertionException("No ReadAtTimeDetails captured.");
|
|
Assert.That(detailsObject.TryGetValue(out ReadAtTimeDetails? details), Is.True);
|
|
Assert.That(details!.UseSimpleBounds, Is.True);
|
|
Assert.That(details.ReqTimes.Count, Is.EqualTo(2));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that at-time reads reject a response whose value count does not match the requested timestamps.
|
|
/// </summary>
|
|
[Test]
|
|
public void ReadAtTimeAsyncRejectsMismatchedValueCount()
|
|
{
|
|
var mockSession = new Mock<ISession>();
|
|
mockSession
|
|
.Setup(s => s.HistoryReadAsync(
|
|
It.IsAny<RequestHeader>(),
|
|
It.IsAny<ExtensionObject>(),
|
|
It.IsAny<TimestampsToReturn>(),
|
|
It.IsAny<bool>(),
|
|
It.IsAny<ArrayOf<HistoryReadValueId>>(),
|
|
It.IsAny<CancellationToken>()))
|
|
.Returns(new ValueTask<HistoryReadResponse>(
|
|
new HistoryReadResponse
|
|
{
|
|
Results =
|
|
[
|
|
new HistoryReadResult
|
|
{
|
|
StatusCode = StatusCodes.Good,
|
|
HistoryData = new ExtensionObject(
|
|
new HistoryData
|
|
{
|
|
DataValues =
|
|
[
|
|
new DataValue(
|
|
Variant.From(1))
|
|
]
|
|
})
|
|
}
|
|
]
|
|
}));
|
|
var client = new HistoryClient(mockSession.Object);
|
|
|
|
ServiceResultException exception =
|
|
Assert.ThrowsAsync<ServiceResultException>(
|
|
async () =>
|
|
{
|
|
await foreach (DataValue _ in
|
|
client.ReadAtTimeAsync(
|
|
new NodeId("TestNode", 2),
|
|
[
|
|
DateTime.UtcNow.AddMinutes(-1),
|
|
DateTime.UtcNow
|
|
]).ConfigureAwait(false))
|
|
{
|
|
}
|
|
})!;
|
|
|
|
Assert.That(
|
|
exception.StatusCode,
|
|
Is.EqualTo(StatusCodes.BadDecodingError));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that an empty processed-history read uses the default aggregate configuration and yields no values.
|
|
/// </summary>
|
|
[Test]
|
|
public async Task ReadProcessedAsyncReturnsNoValuesAndUsesDefaultConfigurationAsync()
|
|
{
|
|
ExtensionObject? capturedDetails = null;
|
|
var mockSession = new Mock<ISession>();
|
|
mockSession
|
|
.Setup(s => s.HistoryReadAsync(
|
|
It.IsAny<RequestHeader>(),
|
|
It.IsAny<ExtensionObject>(),
|
|
It.IsAny<TimestampsToReturn>(),
|
|
It.IsAny<bool>(),
|
|
It.IsAny<ArrayOf<HistoryReadValueId>>(),
|
|
It.IsAny<CancellationToken>()))
|
|
.Callback<RequestHeader, ExtensionObject, TimestampsToReturn, bool, ArrayOf<HistoryReadValueId>, CancellationToken>(
|
|
(_, details, _, _, _, _) => capturedDetails = details)
|
|
.Returns(new ValueTask<HistoryReadResponse>(new HistoryReadResponse
|
|
{
|
|
Results = [new HistoryReadResult
|
|
{
|
|
StatusCode = StatusCodes.Good,
|
|
ContinuationPoint = ByteString.Empty
|
|
}]
|
|
}));
|
|
|
|
var client = new HistoryClient(mockSession.Object);
|
|
var values = new List<DataValue>();
|
|
|
|
await foreach (DataValue value in client.ReadProcessedAsync(
|
|
new NodeId("TestNode", 2),
|
|
new NodeId("Aggregate", 2),
|
|
DateTime.UtcNow.AddHours(-2),
|
|
DateTime.UtcNow,
|
|
2.5).ConfigureAwait(false))
|
|
{
|
|
values.Add(value);
|
|
}
|
|
|
|
Assert.That(values, Is.Empty);
|
|
Assert.That(capturedDetails, Is.Not.Null);
|
|
ExtensionObject detailsObject = capturedDetails ?? throw new AssertionException("No ReadProcessedDetails captured.");
|
|
Assert.That(detailsObject.TryGetValue(out ReadProcessedDetails? details), Is.True);
|
|
Assert.That(details!.AggregateType.Count, Is.EqualTo(1));
|
|
Assert.That(details.AggregateType[0], Is.EqualTo(new NodeId("Aggregate", 2)));
|
|
Assert.That(details.AggregateConfiguration.UseServerCapabilitiesDefaults, Is.True);
|
|
Assert.That(details.ProcessingInterval, Is.EqualTo(2.5));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that reading annotations fails when the annotation property cannot be resolved.
|
|
/// </summary>
|
|
[Test]
|
|
public void ReadAnnotationsAsyncThrowsWhenPropertyIsMissing()
|
|
{
|
|
var mockSession = new Mock<ISession>();
|
|
mockSession
|
|
.Setup(s => s.TranslateBrowsePathsToNodeIdsAsync(
|
|
It.IsAny<RequestHeader>(),
|
|
It.IsAny<ArrayOf<BrowsePath>>(),
|
|
It.IsAny<CancellationToken>()))
|
|
.Returns(new ValueTask<TranslateBrowsePathsToNodeIdsResponse>(
|
|
new TranslateBrowsePathsToNodeIdsResponse
|
|
{
|
|
ResponseHeader = new ResponseHeader(),
|
|
Results =
|
|
[
|
|
new BrowsePathResult
|
|
{
|
|
StatusCode = StatusCodes.BadNoMatch
|
|
}
|
|
],
|
|
DiagnosticInfos = []
|
|
}));
|
|
|
|
var client = new HistoryClient(mockSession.Object);
|
|
ServiceResultException exception =
|
|
Assert.ThrowsAsync<ServiceResultException>(
|
|
async () => await DrainAsync(
|
|
client.ReadAnnotationsAsync(
|
|
new NodeId("TestNode", 2),
|
|
DateTime.UtcNow.AddHours(-1),
|
|
DateTime.UtcNow)).ConfigureAwait(false))!;
|
|
|
|
Assert.That(
|
|
exception.StatusCode,
|
|
Is.EqualTo(StatusCodes.BadHistoryOperationUnsupported));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that writing an annotation returns BadNodeIdUnknown when the annotation property is missing.
|
|
/// </summary>
|
|
[Test]
|
|
public async Task WriteAnnotationAsyncReturnsBadNodeIdUnknownWhenPropertyIsMissingAsync()
|
|
{
|
|
var mockSession = new Mock<ISession>();
|
|
mockSession
|
|
.Setup(s => s.TranslateBrowsePathsToNodeIdsAsync(
|
|
It.IsAny<RequestHeader>(),
|
|
It.IsAny<ArrayOf<BrowsePath>>(),
|
|
It.IsAny<CancellationToken>()))
|
|
.Returns(new ValueTask<TranslateBrowsePathsToNodeIdsResponse>(
|
|
new TranslateBrowsePathsToNodeIdsResponse
|
|
{
|
|
ResponseHeader = new ResponseHeader(),
|
|
Results =
|
|
[
|
|
new BrowsePathResult
|
|
{
|
|
StatusCode = StatusCodes.BadNoMatch
|
|
}
|
|
],
|
|
DiagnosticInfos = []
|
|
}));
|
|
|
|
var client = new HistoryClient(mockSession.Object);
|
|
|
|
StatusCode result = await client.WriteAnnotationAsync(
|
|
new NodeId("TestNode", 2),
|
|
DateTime.UtcNow,
|
|
"note").ConfigureAwait(false);
|
|
|
|
Assert.That(result, Is.EqualTo(StatusCodes.BadNodeIdUnknown));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that deleting an annotation returns BadNodeIdUnknown when the annotation property is missing.
|
|
/// </summary>
|
|
[Test]
|
|
public async Task DeleteAnnotationAsyncReturnsBadNodeIdUnknownWhenPropertyIsMissingAsync()
|
|
{
|
|
var mockSession = new Mock<ISession>();
|
|
mockSession
|
|
.Setup(s => s.TranslateBrowsePathsToNodeIdsAsync(
|
|
It.IsAny<RequestHeader>(),
|
|
It.IsAny<ArrayOf<BrowsePath>>(),
|
|
It.IsAny<CancellationToken>()))
|
|
.Returns(new ValueTask<TranslateBrowsePathsToNodeIdsResponse>(
|
|
new TranslateBrowsePathsToNodeIdsResponse
|
|
{
|
|
ResponseHeader = new ResponseHeader(),
|
|
Results =
|
|
[
|
|
new BrowsePathResult
|
|
{
|
|
StatusCode = StatusCodes.BadNoMatch
|
|
}
|
|
],
|
|
DiagnosticInfos = []
|
|
}));
|
|
|
|
var client = new HistoryClient(mockSession.Object);
|
|
|
|
StatusCode result = await client.DeleteAnnotationAsync(
|
|
new NodeId("TestNode", 2),
|
|
DateTime.UtcNow).ConfigureAwait(false);
|
|
|
|
Assert.That(result, Is.EqualTo(StatusCodes.BadNodeIdUnknown));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that server capabilities use readable values and defaults for bad entries.
|
|
/// </summary>
|
|
[Test]
|
|
public async Task GetServerCapabilitiesAsyncReadsValuesAndUsesDefaultsForBadEntriesAsync()
|
|
{
|
|
var mockSession = new Mock<ISession>();
|
|
mockSession
|
|
.Setup(s => s.ReadAsync(
|
|
It.IsAny<RequestHeader>(),
|
|
It.IsAny<double>(),
|
|
It.IsAny<TimestampsToReturn>(),
|
|
It.IsAny<ArrayOf<ReadValueId>>(),
|
|
It.IsAny<CancellationToken>()))
|
|
.Returns(new ValueTask<ReadResponse>(new ReadResponse
|
|
{
|
|
ResponseHeader = new ResponseHeader(),
|
|
Results =
|
|
[
|
|
DataValue.FromStatusCode(StatusCodes.BadUnexpectedError),
|
|
new DataValue(new Variant(true)),
|
|
new DataValue(new Variant(123u)),
|
|
DataValue.FromStatusCode(StatusCodes.BadUnexpectedError),
|
|
new DataValue(new Variant(true)),
|
|
new DataValue(new Variant(false)),
|
|
DataValue.FromStatusCode(StatusCodes.BadUnexpectedError),
|
|
new DataValue(new Variant(true)),
|
|
new DataValue(new Variant(false)),
|
|
new DataValue(new Variant(true)),
|
|
new DataValue(new Variant(true)),
|
|
new DataValue(new Variant(true)),
|
|
new DataValue(new Variant(false)),
|
|
new DataValue(new Variant(true)),
|
|
new DataValue(new Variant(false))
|
|
],
|
|
DiagnosticInfos = []
|
|
}));
|
|
|
|
var client = new HistoryClient(mockSession.Object);
|
|
|
|
HistoryServerCapabilitiesInfo capabilities = await client.GetServerCapabilitiesAsync().ConfigureAwait(false);
|
|
|
|
Assert.That(capabilities.AccessHistoryData, Is.False);
|
|
Assert.That(capabilities.AccessHistoryEvents, Is.True);
|
|
Assert.That(capabilities.MaxReturnDataValues, Is.EqualTo(123u));
|
|
Assert.That(capabilities.MaxReturnEventValues, Is.Zero);
|
|
Assert.That(capabilities.InsertData, Is.True);
|
|
Assert.That(capabilities.ReplaceData, Is.False);
|
|
Assert.That(capabilities.UpdateData, Is.False);
|
|
Assert.That(capabilities.DeleteRaw, Is.True);
|
|
Assert.That(capabilities.DeleteAtTime, Is.False);
|
|
Assert.That(capabilities.InsertAnnotation, Is.True);
|
|
Assert.That(capabilities.ServerTimestampSupported, Is.True);
|
|
Assert.That(capabilities.InsertEvent, Is.True);
|
|
Assert.That(capabilities.ReplaceEvent, Is.False);
|
|
Assert.That(capabilities.UpdateEvent, Is.True);
|
|
Assert.That(capabilities.DeleteEvent, Is.False);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that conformance information includes only historical-access claims.
|
|
/// </summary>
|
|
[Test]
|
|
public async Task GetConformanceInfoAsyncFiltersHistoricalClaimsAsync()
|
|
{
|
|
var mockSession = new Mock<ISession>();
|
|
mockSession
|
|
.Setup(s => s.ReadAsync(
|
|
It.IsAny<RequestHeader>(),
|
|
It.IsAny<double>(),
|
|
It.IsAny<TimestampsToReturn>(),
|
|
It.IsAny<ArrayOf<ReadValueId>>(),
|
|
It.IsAny<CancellationToken>()))
|
|
.Returns(new ValueTask<ReadResponse>(new ReadResponse
|
|
{
|
|
ResponseHeader = new ResponseHeader(),
|
|
Results =
|
|
[
|
|
new DataValue(Variant.From(
|
|
[
|
|
"http://opcfoundation.org/UA-Profile/Server/HistoricalRawData2022",
|
|
"http://opcfoundation.org/UA-Profile/Server/Standard"
|
|
])),
|
|
new DataValue(Variant.From(
|
|
[
|
|
new QualifiedName("Historical Access Read Raw"),
|
|
new QualifiedName("Attribute Read"),
|
|
new QualifiedName("Aggregate Average")
|
|
]))
|
|
],
|
|
DiagnosticInfos = []
|
|
}));
|
|
var client = new HistoryClient(mockSession.Object);
|
|
|
|
HistoricalConformanceInfo info =
|
|
await client.GetConformanceInfoAsync().ConfigureAwait(false);
|
|
|
|
Assert.That(info.ServerProfiles, Has.Count.EqualTo(1));
|
|
Assert.That(
|
|
info.ServerProfiles[0],
|
|
Does.Contain("HistoricalRawData2022"));
|
|
Assert.That(info.ConformanceUnits, Has.Count.EqualTo(2));
|
|
Assert.That(
|
|
info.ConformanceUnits[0].Name,
|
|
Is.EqualTo("Historical Access Read Raw"));
|
|
Assert.That(
|
|
info.ConformanceUnits[1].Name,
|
|
Is.EqualTo("Aggregate Average"));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that a null ServerProfileArray element is skipped instead of
|
|
/// throwing a NullReferenceException.
|
|
/// </summary>
|
|
[Test]
|
|
public async Task GetConformanceInfoAsyncSkipsNullProfileElementAsync()
|
|
{
|
|
var mockSession = new Mock<ISession>();
|
|
mockSession
|
|
.Setup(s => s.ReadAsync(
|
|
It.IsAny<RequestHeader>(),
|
|
It.IsAny<double>(),
|
|
It.IsAny<TimestampsToReturn>(),
|
|
It.IsAny<ArrayOf<ReadValueId>>(),
|
|
It.IsAny<CancellationToken>()))
|
|
.Returns(new ValueTask<ReadResponse>(new ReadResponse
|
|
{
|
|
ResponseHeader = new ResponseHeader(),
|
|
Results =
|
|
[
|
|
new DataValue(Variant.From(
|
|
[
|
|
(string)null!,
|
|
"http://opcfoundation.org/UA-Profile/Server/HistoricalRawData2022"
|
|
])),
|
|
new DataValue(Variant.From(
|
|
[
|
|
new QualifiedName("Attribute Read")
|
|
]))
|
|
],
|
|
DiagnosticInfos = []
|
|
}));
|
|
var client = new HistoryClient(mockSession.Object);
|
|
|
|
HistoricalConformanceInfo info =
|
|
await client.GetConformanceInfoAsync().ConfigureAwait(false);
|
|
|
|
Assert.That(info.ServerProfiles, Has.Count.EqualTo(1));
|
|
Assert.That(
|
|
info.ServerProfiles[0],
|
|
Does.Contain("HistoricalRawData2022"));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that modified-history reads yield the returned data and request modified-history details.
|
|
/// </summary>
|
|
[Test]
|
|
public async Task ReadModifiedAsyncYieldsHistoryDataAndUsesModifiedDetailsAsync()
|
|
{
|
|
ExtensionObject? capturedDetails = null;
|
|
var mockSession = new Mock<ISession>();
|
|
mockSession
|
|
.Setup(s => s.HistoryReadAsync(
|
|
It.IsAny<RequestHeader>(),
|
|
It.IsAny<ExtensionObject>(),
|
|
It.IsAny<TimestampsToReturn>(),
|
|
It.IsAny<bool>(),
|
|
It.IsAny<ArrayOf<HistoryReadValueId>>(),
|
|
It.IsAny<CancellationToken>()))
|
|
.Callback<RequestHeader, ExtensionObject, TimestampsToReturn, bool, ArrayOf<HistoryReadValueId>, CancellationToken>(
|
|
(_, details, _, _, _, _) => capturedDetails = details)
|
|
.Returns(new ValueTask<HistoryReadResponse>(new HistoryReadResponse
|
|
{
|
|
Results = [new HistoryReadResult
|
|
{
|
|
StatusCode = StatusCodes.Good,
|
|
ContinuationPoint = ByteString.Empty,
|
|
HistoryData = new ExtensionObject(new HistoryModifiedData
|
|
{
|
|
DataValues =
|
|
[
|
|
new DataValue(new Variant(42), StatusCodes.Good, DateTime.UtcNow)
|
|
],
|
|
ModificationInfos =
|
|
[
|
|
new ModificationInfo
|
|
{
|
|
ModificationTime = DateTime.UtcNow,
|
|
UpdateType = HistoryUpdateType.Replace,
|
|
UserName = "operator"
|
|
}
|
|
]
|
|
})
|
|
}]
|
|
}));
|
|
|
|
var client = new HistoryClient(mockSession.Object);
|
|
var values = new List<ModifiedHistoryValue>();
|
|
|
|
await foreach (ModifiedHistoryValue value in client.ReadModifiedAsync(
|
|
new NodeId("TestNode", 2),
|
|
DateTime.UtcNow.AddHours(-1),
|
|
DateTime.UtcNow,
|
|
maxValuesPerNode: 10u,
|
|
timestampsToReturn: TimestampsToReturn.Both).ConfigureAwait(false))
|
|
{
|
|
values.Add(value);
|
|
}
|
|
|
|
Assert.That(values, Has.Count.EqualTo(1));
|
|
Assert.That(values[0].Value.WrappedValue.TryGetValue(out int number), Is.True);
|
|
Assert.That(number, Is.EqualTo(42));
|
|
Assert.That(values[0].Info.UpdateType, Is.EqualTo(HistoryUpdateType.Replace));
|
|
Assert.That(values[0].Info.UserName, Is.EqualTo("operator"));
|
|
Assert.That(capturedDetails, Is.Not.Null);
|
|
ExtensionObject detailsObject = capturedDetails ?? throw new AssertionException("No ReadModified details captured.");
|
|
Assert.That(detailsObject.TryGetValue(out ReadRawModifiedDetails? details), Is.True);
|
|
Assert.That(details!.IsReadModified, Is.True);
|
|
Assert.That(details.NumValuesPerNode, Is.EqualTo(10u));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that disposing a raw-history enumerator releases its outstanding continuation point.
|
|
/// </summary>
|
|
[Test]
|
|
public async Task ReadRawAsyncReleasesContinuationPointWhenEnumeratorIsDisposedAsync()
|
|
{
|
|
var releaseCalls = new List<HistoryReadValueId>();
|
|
var mockSession = new Mock<ISession>();
|
|
mockSession
|
|
.Setup(s => s.HistoryReadAsync(
|
|
It.IsAny<RequestHeader>(),
|
|
It.IsAny<ExtensionObject>(),
|
|
It.IsAny<TimestampsToReturn>(),
|
|
It.IsAny<bool>(),
|
|
It.IsAny<ArrayOf<HistoryReadValueId>>(),
|
|
It.IsAny<CancellationToken>()))
|
|
.Returns<RequestHeader, ExtensionObject, TimestampsToReturn, bool, ArrayOf<HistoryReadValueId>, CancellationToken>(
|
|
(_, _, _, releaseContinuationPoints, nodesToRead, _) =>
|
|
{
|
|
if (releaseContinuationPoints)
|
|
{
|
|
releaseCalls.Add(nodesToRead[0]);
|
|
return new ValueTask<HistoryReadResponse>(new HistoryReadResponse
|
|
{
|
|
Results = [new HistoryReadResult { StatusCode = StatusCodes.Good }]
|
|
});
|
|
}
|
|
|
|
return new ValueTask<HistoryReadResponse>(new HistoryReadResponse
|
|
{
|
|
Results = [new HistoryReadResult
|
|
{
|
|
StatusCode = StatusCodes.Good,
|
|
ContinuationPoint = (ByteString)"z"u8.ToArray(),
|
|
HistoryData = new ExtensionObject(new HistoryData
|
|
{
|
|
DataValues =
|
|
[
|
|
new DataValue(new Variant("first"), StatusCodes.Good, DateTime.UtcNow)
|
|
]
|
|
})
|
|
}]
|
|
});
|
|
});
|
|
|
|
var client = new HistoryClient(mockSession.Object);
|
|
IAsyncEnumerator<DataValue> enumerator = client
|
|
.ReadRawAsync(new NodeId("TestNode", 2), DateTime.UtcNow.AddHours(-1), DateTime.UtcNow)
|
|
.GetAsyncEnumerator();
|
|
|
|
try
|
|
{
|
|
Assert.That(await enumerator.MoveNextAsync().ConfigureAwait(false), Is.True);
|
|
Assert.That(enumerator.Current.WrappedValue.TryGetValue(out string value), Is.True);
|
|
Assert.That(value, Is.EqualTo("first"));
|
|
}
|
|
finally
|
|
{
|
|
await enumerator.DisposeAsync().ConfigureAwait(false);
|
|
}
|
|
|
|
Assert.That(releaseCalls, Has.Count.EqualTo(1));
|
|
Assert.That(releaseCalls[0].ContinuationPoint, Is.EqualTo((ByteString)"z"u8.ToArray()));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that a disposed session during continuation release does not escape enumerator disposal.
|
|
/// </summary>
|
|
[Test]
|
|
public async Task ReleaseObjectDisposedDoesNotEscapeEnumeratorDisposalAsync()
|
|
{
|
|
var mockSession = new Mock<ISession>();
|
|
mockSession
|
|
.Setup(s => s.HistoryReadAsync(
|
|
It.IsAny<RequestHeader>(),
|
|
It.IsAny<ExtensionObject>(),
|
|
It.IsAny<TimestampsToReturn>(),
|
|
It.IsAny<bool>(),
|
|
It.IsAny<ArrayOf<HistoryReadValueId>>(),
|
|
It.IsAny<CancellationToken>()))
|
|
.Returns<RequestHeader, ExtensionObject, TimestampsToReturn,
|
|
bool, ArrayOf<HistoryReadValueId>, CancellationToken>(
|
|
(_, _, _, release, _, _) =>
|
|
{
|
|
if (release)
|
|
{
|
|
throw new ObjectDisposedException("session");
|
|
}
|
|
return new ValueTask<HistoryReadResponse>(
|
|
new HistoryReadResponse
|
|
{
|
|
Results =
|
|
[
|
|
new HistoryReadResult
|
|
{
|
|
StatusCode = StatusCodes.Good,
|
|
ContinuationPoint =
|
|
(ByteString)"z"u8.ToArray(),
|
|
HistoryData = new ExtensionObject(
|
|
new HistoryData
|
|
{
|
|
DataValues =
|
|
[
|
|
new DataValue(
|
|
new Variant("first"),
|
|
StatusCodes.Good,
|
|
DateTime.UtcNow)
|
|
]
|
|
})
|
|
}
|
|
]
|
|
});
|
|
});
|
|
var client = new HistoryClient(mockSession.Object);
|
|
IAsyncEnumerator<DataValue> enumerator = client.ReadRawAsync(
|
|
new NodeId("TestNode", 2),
|
|
DateTime.UtcNow.AddHours(-1),
|
|
DateTime.UtcNow).GetAsyncEnumerator();
|
|
|
|
Assert.That(
|
|
await enumerator.MoveNextAsync().ConfigureAwait(false),
|
|
Is.True);
|
|
Assert.That(
|
|
async () => await enumerator.DisposeAsync()
|
|
.ConfigureAwait(false),
|
|
Throws.Nothing);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that history deletion methods return the server's per-operation statuses.
|
|
/// </summary>
|
|
[Test]
|
|
public async Task DeleteMethodsReturnOperationStatusesAsync()
|
|
{
|
|
ExtensionObject? deleteRawDetails = null;
|
|
ExtensionObject? deleteAtTimeDetails = null;
|
|
int callCount = 0;
|
|
var mockSession = new Mock<ISession>();
|
|
mockSession
|
|
.Setup(s => s.HistoryUpdateAsync(
|
|
It.IsAny<RequestHeader>(),
|
|
It.IsAny<ArrayOf<ExtensionObject>>(),
|
|
It.IsAny<CancellationToken>()))
|
|
.Returns<RequestHeader, ArrayOf<ExtensionObject>, CancellationToken>((_, details, _) =>
|
|
{
|
|
callCount++;
|
|
if (callCount == 1)
|
|
{
|
|
deleteRawDetails = details[0];
|
|
return new ValueTask<HistoryUpdateResponse>(new HistoryUpdateResponse
|
|
{
|
|
Results = [new HistoryUpdateResult { StatusCode = StatusCodes.Good }]
|
|
});
|
|
}
|
|
|
|
deleteAtTimeDetails = details[0];
|
|
return new ValueTask<HistoryUpdateResponse>(new HistoryUpdateResponse
|
|
{
|
|
Results = [new HistoryUpdateResult
|
|
{
|
|
StatusCode = StatusCodes.Good,
|
|
OperationResults =
|
|
[
|
|
StatusCodes.Good,
|
|
StatusCodes.BadNoData
|
|
]
|
|
}]
|
|
});
|
|
});
|
|
|
|
var client = new HistoryClient(mockSession.Object);
|
|
|
|
StatusCode deleteRaw = await client.DeleteRawAsync(
|
|
new NodeId("TestNode", 2),
|
|
DateTime.UtcNow.AddHours(-2),
|
|
DateTime.UtcNow,
|
|
isDeleteModified: true).ConfigureAwait(false);
|
|
ArrayOf<StatusCode> deleteAtTime = await client.DeleteAtTimeAsync(
|
|
new NodeId("TestNode", 2),
|
|
[DateTime.UtcNow.AddMinutes(-10), DateTime.UtcNow]).ConfigureAwait(false);
|
|
|
|
Assert.That(deleteRaw, Is.EqualTo(StatusCodes.Good));
|
|
Assert.That(deleteRawDetails, Is.Not.Null);
|
|
ExtensionObject rawDetailsObject = deleteRawDetails ?? throw new AssertionException("No raw delete details.");
|
|
Assert.That(rawDetailsObject.TryGetValue(out DeleteRawModifiedDetails? rawDetails), Is.True);
|
|
Assert.That(rawDetails!.IsDeleteModified, Is.True);
|
|
Assert.That(deleteAtTime, Has.Count.EqualTo(2));
|
|
Assert.That(deleteAtTime[0], Is.EqualTo(StatusCodes.Good));
|
|
Assert.That(deleteAtTime[1], Is.EqualTo(StatusCodes.BadNoData));
|
|
Assert.That(deleteAtTimeDetails, Is.Not.Null);
|
|
ExtensionObject atTimeDetailsObject = deleteAtTimeDetails ?? throw new AssertionException("No at-time delete details.");
|
|
Assert.That(atTimeDetailsObject.TryGetValue(out DeleteAtTimeDetails? atTimeDetails), Is.True);
|
|
Assert.That(atTimeDetails!.ReqTimes.Count, Is.EqualTo(2));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that annotation reads resolve the annotation property and yield its decoded values.
|
|
/// </summary>
|
|
[Test]
|
|
public async Task ReadAnnotationsAsyncYieldsResolvedAnnotationValuesAsync()
|
|
{
|
|
Mock<ISession> mockSession = CreateSessionWithNamespaceTable();
|
|
SetupBrowsePathResults(mockSession, new NodeId("Annotations", 2));
|
|
var expected = new Annotation
|
|
{
|
|
Message = "operator note",
|
|
UserName = "user",
|
|
AnnotationTime = DateTime.UtcNow
|
|
};
|
|
mockSession
|
|
.Setup(s => s.HistoryReadAsync(
|
|
It.IsAny<RequestHeader>(),
|
|
It.IsAny<ExtensionObject>(),
|
|
It.IsAny<TimestampsToReturn>(),
|
|
It.IsAny<bool>(),
|
|
It.IsAny<ArrayOf<HistoryReadValueId>>(),
|
|
It.IsAny<CancellationToken>()))
|
|
.Returns(new ValueTask<HistoryReadResponse>(new HistoryReadResponse
|
|
{
|
|
Results = [new HistoryReadResult
|
|
{
|
|
StatusCode = StatusCodes.Good,
|
|
ContinuationPoint = ByteString.Empty,
|
|
HistoryData = new ExtensionObject(new HistoryData
|
|
{
|
|
DataValues =
|
|
[
|
|
new DataValue(new Variant(new ExtensionObject(expected)))
|
|
]
|
|
})
|
|
}]
|
|
}));
|
|
|
|
var client = new HistoryClient(mockSession.Object);
|
|
var annotations = new List<Annotation>();
|
|
|
|
await foreach (Annotation annotation in client.ReadAnnotationsAsync(
|
|
new NodeId("TestNode", 2),
|
|
DateTime.UtcNow.AddHours(-1),
|
|
DateTime.UtcNow).ConfigureAwait(false))
|
|
{
|
|
annotations.Add(annotation);
|
|
}
|
|
|
|
Assert.That(annotations, Has.Count.EqualTo(1));
|
|
Assert.That(annotations[0].Message, Is.EqualTo("operator note"));
|
|
Assert.That(annotations[0].UserName, Is.EqualTo("user"));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that annotation reads reject malformed annotation values.
|
|
/// </summary>
|
|
[Test]
|
|
public void ReadAnnotationsAsyncRejectsMalformedValues()
|
|
{
|
|
Mock<ISession> mockSession = CreateSessionWithNamespaceTable();
|
|
SetupBrowsePathResults(
|
|
mockSession,
|
|
new NodeId("Annotations", 2));
|
|
mockSession
|
|
.Setup(s => s.HistoryReadAsync(
|
|
It.IsAny<RequestHeader>(),
|
|
It.IsAny<ExtensionObject>(),
|
|
It.IsAny<TimestampsToReturn>(),
|
|
It.IsAny<bool>(),
|
|
It.IsAny<ArrayOf<HistoryReadValueId>>(),
|
|
It.IsAny<CancellationToken>()))
|
|
.Returns(new ValueTask<HistoryReadResponse>(
|
|
new HistoryReadResponse
|
|
{
|
|
Results =
|
|
[
|
|
new HistoryReadResult
|
|
{
|
|
StatusCode = StatusCodes.Good,
|
|
HistoryData = new ExtensionObject(
|
|
new HistoryData
|
|
{
|
|
DataValues =
|
|
[
|
|
new DataValue(
|
|
Variant.From(123))
|
|
]
|
|
})
|
|
}
|
|
]
|
|
}));
|
|
var client = new HistoryClient(mockSession.Object);
|
|
|
|
ServiceResultException exception =
|
|
Assert.ThrowsAsync<ServiceResultException>(
|
|
async () =>
|
|
{
|
|
await foreach (Annotation _ in
|
|
client.ReadAnnotationsAsync(
|
|
new NodeId("TestNode", 2),
|
|
DateTime.UtcNow.AddHours(-1),
|
|
DateTime.UtcNow).ConfigureAwait(false))
|
|
{
|
|
}
|
|
})!;
|
|
|
|
Assert.That(
|
|
exception.StatusCode,
|
|
Is.EqualTo(StatusCodes.BadDecodingError));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that annotation writes target the resolved property and return the operation result.
|
|
/// </summary>
|
|
[Test]
|
|
public async Task WriteAnnotationAsyncUsesResolvedPropertyAndReturnsOperationResultAsync()
|
|
{
|
|
ExtensionObject? capturedDetails = null;
|
|
Mock<ISession> mockSession = CreateSessionWithNamespaceTable();
|
|
SetupBrowsePathResults(mockSession, new NodeId("Annotations", 2));
|
|
mockSession
|
|
.Setup(s => s.HistoryUpdateAsync(
|
|
It.IsAny<RequestHeader>(),
|
|
It.IsAny<ArrayOf<ExtensionObject>>(),
|
|
It.IsAny<CancellationToken>()))
|
|
.Callback<RequestHeader, ArrayOf<ExtensionObject>, CancellationToken>(
|
|
(_, details, _) => capturedDetails = details[0])
|
|
.Returns(new ValueTask<HistoryUpdateResponse>(new HistoryUpdateResponse
|
|
{
|
|
Results = [new HistoryUpdateResult
|
|
{
|
|
StatusCode = StatusCodes.Good,
|
|
OperationResults = [StatusCodes.BadNoData]
|
|
}]
|
|
}));
|
|
|
|
var client = new HistoryClient(mockSession.Object);
|
|
|
|
StatusCode result = await client.WriteAnnotationAsync(
|
|
new NodeId("TestNode", 2),
|
|
DateTime.UtcNow,
|
|
"note",
|
|
"operator",
|
|
PerformUpdateType.Replace).ConfigureAwait(false);
|
|
|
|
Assert.That(result, Is.EqualTo(StatusCodes.BadNoData));
|
|
Assert.That(capturedDetails, Is.Not.Null);
|
|
ExtensionObject detailsObject = capturedDetails ?? throw new AssertionException("No update details.");
|
|
Assert.That(detailsObject.TryGetValue(out UpdateStructureDataDetails? details), Is.True);
|
|
Assert.That(details!.NodeId, Is.EqualTo(new NodeId("Annotations", 2)));
|
|
Assert.That(details.PerformInsertReplace, Is.EqualTo(PerformUpdateType.Replace));
|
|
Assert.That(details.UpdateValues.Count, Is.EqualTo(1));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that historical configuration reads resolve and decode the configuration's child properties.
|
|
/// </summary>
|
|
[Test]
|
|
public async Task GetConfigurationAsyncReadsResolvedPropertyValuesAsync()
|
|
{
|
|
DateTime startOfArchive = DateTime.UtcNow.AddDays(-10);
|
|
DateTime startOfOnline = DateTime.UtcNow.AddDays(-1);
|
|
var nodes = new Queue<NodeId>(
|
|
[
|
|
new NodeId("HAConfiguration", 2),
|
|
new NodeId("Stepped", 2),
|
|
new NodeId("Definition", 2),
|
|
new NodeId("MaxTimeInterval", 2),
|
|
new NodeId("MinTimeInterval", 2),
|
|
new NodeId("ExceptionDeviation", 2),
|
|
new NodeId("ExceptionDeviationFormat", 2),
|
|
new NodeId("StartOfArchive", 2),
|
|
new NodeId("StartOfOnlineArchive", 2),
|
|
new NodeId("ServerTimestampSupported", 2),
|
|
new NodeId("MaxTimeStoredValues", 2),
|
|
new NodeId("MaxCountStoredValues", 2),
|
|
new NodeId("AggregateConfiguration", 2),
|
|
new NodeId("PercentDataGood", 2),
|
|
new NodeId("PercentDataBad", 2),
|
|
new NodeId("TreatUncertainAsBad", 2),
|
|
new NodeId("UseSlopedExtrapolation", 2)
|
|
]);
|
|
Mock<ISession> mockSession = CreateSessionWithNamespaceTable();
|
|
SetupBrowsePathResults(mockSession, nodes);
|
|
int readCall = 0;
|
|
mockSession
|
|
.Setup(s => s.ReadAsync(
|
|
It.IsAny<RequestHeader>(),
|
|
It.IsAny<double>(),
|
|
It.IsAny<TimestampsToReturn>(),
|
|
It.IsAny<ArrayOf<ReadValueId>>(),
|
|
It.IsAny<CancellationToken>()))
|
|
.Returns(() =>
|
|
{
|
|
DataValue[] results = readCall++ == 0
|
|
?
|
|
[
|
|
new DataValue(new Variant(true)),
|
|
new DataValue(new Variant("configured")),
|
|
new DataValue(new Variant(1000d)),
|
|
new DataValue(new Variant(1d)),
|
|
new DataValue(new Variant(0.5d)),
|
|
new DataValue(
|
|
Variant.From(
|
|
ExceptionDeviationFormat.PercentOfValue)),
|
|
new DataValue(new Variant((DateTimeUtc)startOfArchive)),
|
|
new DataValue(new Variant((DateTimeUtc)startOfOnline)),
|
|
new DataValue(new Variant(true)),
|
|
new DataValue(new Variant(86_400_000d)),
|
|
new DataValue(new Variant(1000u))
|
|
]
|
|
:
|
|
[
|
|
new DataValue(new Variant((byte)100)),
|
|
new DataValue(new Variant((byte)100)),
|
|
new DataValue(new Variant(true)),
|
|
new DataValue(new Variant(false))
|
|
];
|
|
return new ValueTask<ReadResponse>(new ReadResponse
|
|
{
|
|
ResponseHeader = new ResponseHeader(),
|
|
Results = results,
|
|
DiagnosticInfos = []
|
|
});
|
|
});
|
|
|
|
var client = new HistoryClient(mockSession.Object);
|
|
|
|
HistoricalDataConfigurationInfo configuration = await client.GetConfigurationAsync(
|
|
new NodeId("TestNode", 2)).ConfigureAwait(false);
|
|
|
|
Assert.That(configuration.HasConfiguration, Is.True);
|
|
Assert.That(configuration.Stepped, Is.True);
|
|
Assert.That(configuration.Definition, Is.EqualTo("configured"));
|
|
Assert.That(configuration.MaxTimeInterval, Is.EqualTo(1000d));
|
|
Assert.That(configuration.MinTimeInterval, Is.EqualTo(1d));
|
|
Assert.That(configuration.ExceptionDeviation, Is.EqualTo(0.5d));
|
|
Assert.That(
|
|
configuration.ExceptionDeviationFormat,
|
|
Is.EqualTo(ExceptionDeviationFormat.PercentOfValue));
|
|
Assert.That(configuration.StartOfArchive, Is.EqualTo(startOfArchive));
|
|
Assert.That(configuration.StartOfOnlineArchive, Is.EqualTo(startOfOnline));
|
|
Assert.That(configuration.ServerTimestampSupported, Is.True);
|
|
Assert.That(configuration.MaxTimeStoredValues, Is.EqualTo(86_400_000d));
|
|
Assert.That(configuration.MaxCountStoredValues, Is.EqualTo(1000u));
|
|
Assert.That(configuration.AggregateConfiguration, Is.Not.Null);
|
|
Assert.That(configuration.AggregateConfiguration!.PercentDataGood, Is.EqualTo((byte)100));
|
|
Assert.That(configuration.AggregateConfiguration.PercentDataBad, Is.EqualTo((byte)100));
|
|
Assert.That(configuration.AggregateConfiguration.TreatUncertainAsBad, Is.True);
|
|
Assert.That(configuration.AggregateConfiguration.UseSlopedExtrapolation, Is.False);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that an at-time read with a null timestamp collection throws ArgumentNullException.
|
|
/// </summary>
|
|
[Test]
|
|
public void ReadAtTimeAsyncWithNullTimesThrowsArgumentNullException()
|
|
{
|
|
var client = new HistoryClient(new Mock<ISession>().Object);
|
|
|
|
Assert.That(
|
|
() => client.ReadAtTimeAsync(
|
|
new NodeId("TestNode", 2),
|
|
ArrayOf<DateTime>.Null),
|
|
Throws.ArgumentNullException.With.Property(nameof(ArgumentNullException.ParamName)).EqualTo("times"));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that history replacement and update return their per-operation results.
|
|
/// </summary>
|
|
[Test]
|
|
public async Task ReplaceAndUpdateAsyncReturnOperationResultsAsync()
|
|
{
|
|
var capturedUpdates = new List<UpdateDataDetails>();
|
|
var mockSession = new Mock<ISession>();
|
|
mockSession
|
|
.Setup(s => s.HistoryUpdateAsync(
|
|
It.IsAny<RequestHeader>(),
|
|
It.IsAny<ArrayOf<ExtensionObject>>(),
|
|
It.IsAny<CancellationToken>()))
|
|
.Returns<RequestHeader, ArrayOf<ExtensionObject>, CancellationToken>((_, details, _) =>
|
|
{
|
|
Assert.That(details[0].TryGetValue(out UpdateDataDetails? update), Is.True);
|
|
capturedUpdates.Add(update!);
|
|
return new ValueTask<HistoryUpdateResponse>(new HistoryUpdateResponse
|
|
{
|
|
Results = [new HistoryUpdateResult
|
|
{
|
|
StatusCode = StatusCodes.Good,
|
|
OperationResults = [StatusCodes.GoodClamped]
|
|
}]
|
|
});
|
|
});
|
|
var client = new HistoryClient(mockSession.Object);
|
|
var values = new List<DataValue>
|
|
{
|
|
new(new Variant(1), StatusCodes.Good, DateTime.UtcNow)
|
|
};
|
|
|
|
ArrayOf<StatusCode> replace = await client.ReplaceAsync(
|
|
new NodeId("TestNode", 2),
|
|
values).ConfigureAwait(false);
|
|
ArrayOf<StatusCode> update = await client.UpdateAsync(
|
|
new NodeId("TestNode", 2),
|
|
values).ConfigureAwait(false);
|
|
|
|
Assert.That(replace, Has.Count.EqualTo(1));
|
|
Assert.That(update, Has.Count.EqualTo(1));
|
|
Assert.That(replace[0], Is.EqualTo(StatusCodes.GoodClamped));
|
|
Assert.That(update[0], Is.EqualTo(StatusCodes.GoodClamped));
|
|
Assert.That(capturedUpdates, Has.Count.EqualTo(2));
|
|
Assert.That(capturedUpdates[0].PerformInsertReplace, Is.EqualTo(PerformUpdateType.Replace));
|
|
Assert.That(capturedUpdates[1].PerformInsertReplace, Is.EqualTo(PerformUpdateType.Update));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that raw-history deletion throws ServiceResultException when the server returns no results.
|
|
/// </summary>
|
|
[Test]
|
|
public void DeleteRawAsyncThrowsServiceResultExceptionForEmptyResults()
|
|
{
|
|
var mockSession = new Mock<ISession>();
|
|
mockSession
|
|
.Setup(s => s.HistoryUpdateAsync(
|
|
It.IsAny<RequestHeader>(),
|
|
It.IsAny<ArrayOf<ExtensionObject>>(),
|
|
It.IsAny<CancellationToken>()))
|
|
.Returns(new ValueTask<HistoryUpdateResponse>(new HistoryUpdateResponse
|
|
{
|
|
Results = []
|
|
}));
|
|
var client = new HistoryClient(mockSession.Object);
|
|
|
|
ServiceResultException ex = Assert.ThrowsAsync<ServiceResultException>(async () =>
|
|
await client.DeleteRawAsync(
|
|
new NodeId("TestNode", 2),
|
|
DateTime.UtcNow.AddHours(-1),
|
|
DateTime.UtcNow).ConfigureAwait(false));
|
|
|
|
Assert.That(ex.StatusCode, Is.EqualTo(StatusCodes.BadUnexpectedError));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that annotation writes fall back to the result status but reject an empty result collection.
|
|
/// </summary>
|
|
[Test]
|
|
public async Task WriteAnnotationAsyncFallsBackToStatusThenThrowsOnEmptyResultsAsync()
|
|
{
|
|
Mock<ISession> mockSession = CreateSessionWithNamespaceTable();
|
|
SetupBrowsePathResults(mockSession, new NodeId("Annotations", 2));
|
|
int callCount = 0;
|
|
mockSession
|
|
.Setup(s => s.HistoryUpdateAsync(
|
|
It.IsAny<RequestHeader>(),
|
|
It.IsAny<ArrayOf<ExtensionObject>>(),
|
|
It.IsAny<CancellationToken>()))
|
|
.Returns(() =>
|
|
{
|
|
callCount++;
|
|
return new ValueTask<HistoryUpdateResponse>(new HistoryUpdateResponse
|
|
{
|
|
Results = callCount == 1
|
|
? [new HistoryUpdateResult { StatusCode = StatusCodes.BadNoData }]
|
|
: []
|
|
});
|
|
});
|
|
var client = new HistoryClient(mockSession.Object);
|
|
|
|
StatusCode statusFallback = await client.WriteAnnotationAsync(
|
|
new NodeId("TestNode", 2),
|
|
DateTime.UtcNow,
|
|
"note").ConfigureAwait(false);
|
|
|
|
Assert.That(statusFallback, Is.EqualTo(StatusCodes.BadNoData));
|
|
|
|
ServiceResultException ex = Assert.ThrowsAsync<ServiceResultException>(async () =>
|
|
await client.WriteAnnotationAsync(
|
|
new NodeId("TestNode", 2),
|
|
DateTime.UtcNow,
|
|
"note").ConfigureAwait(false));
|
|
|
|
Assert.That(ex.StatusCode, Is.EqualTo(StatusCodes.BadUnexpectedError));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that a missing historical configuration node produces an empty configuration.
|
|
/// </summary>
|
|
[Test]
|
|
public async Task GetConfigurationAsyncReturnsEmptyWhenConfigurationNodeIsMissingAsync()
|
|
{
|
|
Mock<ISession> mockSession = CreateSessionWithNamespaceTable();
|
|
SetupBrowsePathResults(mockSession, NodeId.Null);
|
|
var client = new HistoryClient(mockSession.Object);
|
|
|
|
HistoricalDataConfigurationInfo configuration = await client.GetConfigurationAsync(
|
|
new NodeId("TestNode", 2)).ConfigureAwait(false);
|
|
|
|
Assert.That(configuration.HasConfiguration, Is.False);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that missing historical configuration properties are represented by null values.
|
|
/// </summary>
|
|
[Test]
|
|
public async Task GetConfigurationAsyncUsesNullsWhenChildPropertiesAreMissingAsync()
|
|
{
|
|
var nodes = new Queue<NodeId>(
|
|
[
|
|
new NodeId("HAConfiguration", 2),
|
|
NodeId.Null,
|
|
NodeId.Null,
|
|
NodeId.Null,
|
|
NodeId.Null,
|
|
NodeId.Null,
|
|
NodeId.Null,
|
|
NodeId.Null,
|
|
NodeId.Null,
|
|
NodeId.Null,
|
|
NodeId.Null,
|
|
NodeId.Null,
|
|
NodeId.Null
|
|
]);
|
|
Mock<ISession> mockSession = CreateSessionWithNamespaceTable();
|
|
SetupBrowsePathResults(mockSession, nodes);
|
|
mockSession
|
|
.Setup(s => s.ReadAsync(
|
|
It.IsAny<RequestHeader>(),
|
|
It.IsAny<double>(),
|
|
It.IsAny<TimestampsToReturn>(),
|
|
It.IsAny<ArrayOf<ReadValueId>>(),
|
|
It.IsAny<CancellationToken>()))
|
|
.Returns<RequestHeader, double, TimestampsToReturn,
|
|
ArrayOf<ReadValueId>, CancellationToken>(
|
|
(_, _, _, requests, _) =>
|
|
{
|
|
var results = new DataValue[requests.Count];
|
|
for (int i = 0; i < results.Length; i++)
|
|
{
|
|
results[i] = DataValue.FromStatusCode(
|
|
StatusCodes.BadNoData);
|
|
}
|
|
return new ValueTask<ReadResponse>(
|
|
new ReadResponse
|
|
{
|
|
ResponseHeader = new ResponseHeader(),
|
|
Results = results,
|
|
DiagnosticInfos = []
|
|
});
|
|
});
|
|
var client = new HistoryClient(mockSession.Object);
|
|
|
|
HistoricalDataConfigurationInfo configuration = await client.GetConfigurationAsync(
|
|
new NodeId("TestNode", 2)).ConfigureAwait(false);
|
|
|
|
Assert.That(configuration.HasConfiguration, Is.True);
|
|
Assert.That(configuration.Stepped, Is.Null);
|
|
Assert.That(configuration.Definition, Is.Null);
|
|
Assert.That(configuration.MaxTimeInterval, Is.Null);
|
|
Assert.That(configuration.MinTimeInterval, Is.Null);
|
|
Assert.That(configuration.ExceptionDeviation, Is.Null);
|
|
Assert.That(configuration.ExceptionDeviationFormat, Is.Null);
|
|
Assert.That(configuration.StartOfArchive, Is.Null);
|
|
Assert.That(configuration.StartOfOnlineArchive, Is.Null);
|
|
Assert.That(configuration.ServerTimestampSupported, Is.Null);
|
|
Assert.That(configuration.MaxTimeStoredValues, Is.Null);
|
|
Assert.That(configuration.MaxCountStoredValues, Is.Null);
|
|
Assert.That(configuration.AggregateConfiguration, Is.Null);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that disposing an at-time history enumerator releases its outstanding continuation point.
|
|
/// </summary>
|
|
[Test]
|
|
public async Task ReadAtTimeAsyncReleasesContinuationPointWhenEnumeratorIsDisposedAsync()
|
|
{
|
|
var releaseCalls = new List<HistoryReadValueId>();
|
|
var mockSession = new Mock<ISession>();
|
|
mockSession
|
|
.Setup(s => s.HistoryReadAsync(
|
|
It.IsAny<RequestHeader>(),
|
|
It.IsAny<ExtensionObject>(),
|
|
It.IsAny<TimestampsToReturn>(),
|
|
It.IsAny<bool>(),
|
|
It.IsAny<ArrayOf<HistoryReadValueId>>(),
|
|
It.IsAny<CancellationToken>()))
|
|
.Returns<RequestHeader, ExtensionObject, TimestampsToReturn, bool, ArrayOf<HistoryReadValueId>, CancellationToken>(
|
|
(_, _, _, releaseContinuationPoints, nodesToRead, _) =>
|
|
{
|
|
if (releaseContinuationPoints)
|
|
{
|
|
releaseCalls.Add(nodesToRead[0]);
|
|
return new ValueTask<HistoryReadResponse>(new HistoryReadResponse
|
|
{
|
|
Results = [new HistoryReadResult { StatusCode = StatusCodes.Good }]
|
|
});
|
|
}
|
|
|
|
return new ValueTask<HistoryReadResponse>(new HistoryReadResponse
|
|
{
|
|
Results = [new HistoryReadResult
|
|
{
|
|
StatusCode = StatusCodes.Good,
|
|
ContinuationPoint = (ByteString)"\""u8.ToArray(),
|
|
HistoryData = new ExtensionObject(new HistoryData
|
|
{
|
|
DataValues =
|
|
[
|
|
new DataValue(new Variant(1234), StatusCodes.Good, DateTime.UtcNow)
|
|
]
|
|
})
|
|
}]
|
|
});
|
|
});
|
|
var client = new HistoryClient(mockSession.Object);
|
|
IAsyncEnumerator<DataValue> enumerator = client
|
|
.ReadAtTimeAsync(new NodeId("TestNode", 2), [DateTime.UtcNow])
|
|
.GetAsyncEnumerator();
|
|
|
|
try
|
|
{
|
|
Assert.That(await enumerator.MoveNextAsync().ConfigureAwait(false), Is.True);
|
|
}
|
|
finally
|
|
{
|
|
await enumerator.DisposeAsync().ConfigureAwait(false);
|
|
}
|
|
|
|
Assert.That(releaseCalls, Has.Count.EqualTo(1));
|
|
Assert.That(releaseCalls[0].ContinuationPoint, Is.EqualTo((ByteString)"\""u8.ToArray()));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that at-time history paging stops at the configured page limit.
|
|
/// </summary>
|
|
[Test]
|
|
public void ReadAtTimeAsyncStopsAtConfiguredPageLimit()
|
|
{
|
|
int readCalls = 0;
|
|
ByteString released = ByteString.Empty;
|
|
var mockSession = new Mock<ISession>();
|
|
mockSession
|
|
.Setup(s => s.HistoryReadAsync(
|
|
It.IsAny<RequestHeader>(),
|
|
It.IsAny<ExtensionObject>(),
|
|
It.IsAny<TimestampsToReturn>(),
|
|
It.IsAny<bool>(),
|
|
It.IsAny<ArrayOf<HistoryReadValueId>>(),
|
|
It.IsAny<CancellationToken>()))
|
|
.Returns<RequestHeader, ExtensionObject, TimestampsToReturn, bool,
|
|
ArrayOf<HistoryReadValueId>, CancellationToken>(
|
|
(_, _, _, release, nodes, _) =>
|
|
{
|
|
if (release)
|
|
{
|
|
released = nodes[0].ContinuationPoint;
|
|
return new ValueTask<HistoryReadResponse>(
|
|
new HistoryReadResponse
|
|
{
|
|
Results =
|
|
[
|
|
new HistoryReadResult
|
|
{
|
|
StatusCode = StatusCodes.Good
|
|
}
|
|
]
|
|
});
|
|
}
|
|
readCalls++;
|
|
return new ValueTask<HistoryReadResponse>(
|
|
new HistoryReadResponse
|
|
{
|
|
Results =
|
|
[
|
|
new HistoryReadResult
|
|
{
|
|
StatusCode = StatusCodes.Good,
|
|
ContinuationPoint = ByteString.From(
|
|
[(byte)readCalls])
|
|
}
|
|
]
|
|
});
|
|
});
|
|
var client = new HistoryClient(
|
|
mockSession.Object,
|
|
new HistoryClientOptions
|
|
{
|
|
MaxPagesPerRead = 4
|
|
});
|
|
|
|
ServiceResultException ex = Assert.ThrowsAsync<ServiceResultException>(async () =>
|
|
{
|
|
await foreach (DataValue value in client.ReadAtTimeAsync(
|
|
new NodeId("TestNode", 2),
|
|
[DateTime.UtcNow]).ConfigureAwait(false))
|
|
{
|
|
Assert.Fail($"Unexpected value {value}.");
|
|
}
|
|
})!;
|
|
|
|
Assert.That(ex.StatusCode, Is.EqualTo(StatusCodes.BadTimeout));
|
|
Assert.That(readCalls, Is.EqualTo(4));
|
|
Assert.That(released, Is.EqualTo(ByteString.From([4])));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that failed historical configuration property reads use default values.
|
|
/// </summary>
|
|
[Test]
|
|
public async Task GetConfigurationAsyncUsesDefaultValuesForBadPropertyReadsAsync()
|
|
{
|
|
var nodes = new Queue<NodeId>(
|
|
[
|
|
new NodeId("HAConfiguration", 2),
|
|
new NodeId("Stepped", 2),
|
|
new NodeId("Definition", 2),
|
|
new NodeId("MaxTimeInterval", 2),
|
|
new NodeId("MinTimeInterval", 2),
|
|
new NodeId("ExceptionDeviation", 2),
|
|
new NodeId("ExceptionDeviationFormat", 2),
|
|
new NodeId("StartOfArchive", 2),
|
|
new NodeId("StartOfOnlineArchive", 2),
|
|
new NodeId("ServerTimestampSupported", 2),
|
|
new NodeId("MaxTimeStoredValues", 2),
|
|
new NodeId("MaxCountStoredValues", 2),
|
|
new NodeId("AggregateConfiguration", 2),
|
|
new NodeId("PercentDataGood", 2),
|
|
new NodeId("PercentDataBad", 2),
|
|
new NodeId("TreatUncertainAsBad", 2),
|
|
new NodeId("UseSlopedExtrapolation", 2)
|
|
]);
|
|
Mock<ISession> mockSession = CreateSessionWithNamespaceTable();
|
|
SetupBrowsePathResults(mockSession, nodes);
|
|
mockSession
|
|
.Setup(s => s.ReadAsync(
|
|
It.IsAny<RequestHeader>(),
|
|
It.IsAny<double>(),
|
|
It.IsAny<TimestampsToReturn>(),
|
|
It.IsAny<ArrayOf<ReadValueId>>(),
|
|
It.IsAny<CancellationToken>()))
|
|
.Returns<RequestHeader, double, TimestampsToReturn,
|
|
ArrayOf<ReadValueId>, CancellationToken>(
|
|
(_, _, _, requests, _) =>
|
|
{
|
|
var results = new DataValue[requests.Count];
|
|
for (int i = 0; i < results.Length; i++)
|
|
{
|
|
results[i] = DataValue.FromStatusCode(
|
|
StatusCodes.BadNoData);
|
|
}
|
|
return new ValueTask<ReadResponse>(
|
|
new ReadResponse
|
|
{
|
|
ResponseHeader = new ResponseHeader(),
|
|
Results = results,
|
|
DiagnosticInfos = []
|
|
});
|
|
});
|
|
var client = new HistoryClient(mockSession.Object);
|
|
|
|
HistoricalDataConfigurationInfo configuration = await client.GetConfigurationAsync(
|
|
new NodeId("TestNode", 2)).ConfigureAwait(false);
|
|
|
|
Assert.That(configuration.HasConfiguration, Is.True);
|
|
Assert.That(configuration.Stepped, Is.False);
|
|
Assert.That(configuration.Definition, Is.Null);
|
|
Assert.That(configuration.MaxTimeInterval, Is.Zero);
|
|
Assert.That(configuration.MinTimeInterval, Is.Zero);
|
|
Assert.That(configuration.ExceptionDeviation, Is.Zero);
|
|
Assert.That(
|
|
configuration.ExceptionDeviationFormat,
|
|
Is.EqualTo(default(ExceptionDeviationFormat)));
|
|
Assert.That(configuration.StartOfArchive, Is.EqualTo(DateTimeUtc.MinValue.ToDateTime()));
|
|
Assert.That(configuration.StartOfOnlineArchive, Is.EqualTo(DateTimeUtc.MinValue.ToDateTime()));
|
|
Assert.That(configuration.ServerTimestampSupported, Is.False);
|
|
Assert.That(configuration.MaxTimeStoredValues, Is.Zero);
|
|
Assert.That(configuration.MaxCountStoredValues, Is.Zero);
|
|
Assert.That(configuration.AggregateConfiguration, Is.Not.Null);
|
|
Assert.That(configuration.AggregateConfiguration!.PercentDataGood, Is.Zero);
|
|
Assert.That(configuration.AggregateConfiguration.PercentDataBad, Is.Zero);
|
|
Assert.That(configuration.AggregateConfiguration.TreatUncertainAsBad, Is.False);
|
|
Assert.That(configuration.AggregateConfiguration.UseSlopedExtrapolation, Is.False);
|
|
}
|
|
|
|
private static async Task DrainAsync<T>(IAsyncEnumerable<T> values)
|
|
{
|
|
await foreach (T _ in values.ConfigureAwait(false))
|
|
{
|
|
}
|
|
}
|
|
|
|
private static Mock<ISession> CreateSessionWithNamespaceTable()
|
|
{
|
|
var mockSession = new Mock<ISession>();
|
|
var namespaceTable = new NamespaceTable();
|
|
namespaceTable.Append("urn:test");
|
|
mockSession.SetupGet(s => s.NamespaceUris).Returns(namespaceTable);
|
|
return mockSession;
|
|
}
|
|
|
|
private static void SetupBrowsePathResults(Mock<ISession> mockSession, NodeId nodeId)
|
|
{
|
|
mockSession
|
|
.Setup(s => s.TranslateBrowsePathsToNodeIdsAsync(
|
|
It.IsAny<RequestHeader>(),
|
|
It.IsAny<ArrayOf<BrowsePath>>(),
|
|
It.IsAny<CancellationToken>()))
|
|
.Returns(new ValueTask<TranslateBrowsePathsToNodeIdsResponse>(
|
|
new TranslateBrowsePathsToNodeIdsResponse
|
|
{
|
|
ResponseHeader = new ResponseHeader(),
|
|
Results = [CreateBrowsePathResult(nodeId)],
|
|
DiagnosticInfos = []
|
|
}));
|
|
}
|
|
|
|
private static void SetupBrowsePathResults(Mock<ISession> mockSession, Queue<NodeId> nodeIds)
|
|
{
|
|
mockSession
|
|
.Setup(s => s.TranslateBrowsePathsToNodeIdsAsync(
|
|
It.IsAny<RequestHeader>(),
|
|
It.IsAny<ArrayOf<BrowsePath>>(),
|
|
It.IsAny<CancellationToken>()))
|
|
.Returns(() => new ValueTask<TranslateBrowsePathsToNodeIdsResponse>(
|
|
new TranslateBrowsePathsToNodeIdsResponse
|
|
{
|
|
ResponseHeader = new ResponseHeader(),
|
|
Results = [CreateBrowsePathResult(nodeIds.Dequeue())],
|
|
DiagnosticInfos = []
|
|
}));
|
|
}
|
|
|
|
private static BrowsePathResult CreateBrowsePathResult(NodeId nodeId)
|
|
{
|
|
return new BrowsePathResult
|
|
{
|
|
StatusCode = StatusCodes.Good,
|
|
Targets =
|
|
[
|
|
new BrowsePathTarget
|
|
{
|
|
TargetId = nodeId,
|
|
RemainingPathIndex = uint.MaxValue
|
|
}
|
|
]
|
|
};
|
|
}
|
|
}
|
|
}
|