Files
UA-.NETStandard/tests/Opc.Ua.Client.Tests/Historian/HistoryClientUnitTests.cs
romanett 9b034f8291 Enable nullable reference types centrally; migrate all unset and annotations-only projects (#4700)
# Description

Nullable reference types are now enabled once, in `common.props`, and
every project in the repository builds warning-clean with them.

**Configuration**
- `<Nullable>enable</Nullable>` lives in `common.props`. All per-project
`<Nullable>` settings are removed: 178 project files (enable,
annotations, and the `disable` in the three Aot test projects).
- 419 per-file `#nullable enable`/`restore` directives and the 4
`#nullable disable` directives are removed.
- Kept on purpose:
- The 5 `<auto-generated>` schema files (`UANodeSet.cs`, `UA Type
Dictionary.cs`, `OPCBinarySchema.cs`, `SecuredApplication.cs`,
`PInvokeHelper.cs`) keep `#nullable enable`. The compiler ignores the
project setting in generated code, so the directive is required there.
- `tests/Opc.Ua.Interop.LegacyPeer` keeps `disable`. It builds against
the 1.5.378 packages and does not import the repository props.
- `docs/DeveloperGuide.md` and `.github/copilot-instructions.md` now
describe the central setting.

**Migration** (the 43 unset and 13 annotations-only projects, roughly
10k diagnostics)
- **Production code: annotated at the declarations.** Covers
`tools/Opc.Ua.SourceGeneration.Core`, `src/Opc.Ua.Lds.Server` and the
LDS sample.
- Members, parameters and returns are nullable where null actually
flows.
- Null-check helpers carry `[NotNullWhen]` (`IsNull`, `IsValidXmlName`,
`IsMethodTypeDesign`).
- `CoreUtils.Format`, `Utils.Format`, `ITemplateWriter.WriteLine` take
`params object?[]`. `Template.AddReplacement` takes `string?` (rendering
already skips null replacements).
- `IRegisteredServerStore.RegisterAsync` returns
`Task<RegistrationEntry?>`: it returns null when a registration is
removed.
- Dereferences backed by an invariant (for example, the validated model)
use `!`, so runtime behaviour is unchanged.
- **Behavioural changes in production code** (small, deliberate):
- `NodeSetToModelDesign` throws `InvalidOperationException` when the
file is not a NodeSet. Before, it threw a NullReferenceException on the
next line.
- `ModelDesignValidator` starts its lookup tables empty instead of null.
A redundant `if (m_nodes == null) return;` guard is gone.
- **Test code: minimal annotations.**
  - `?` on values that can be null, `!` where the test asserts non-null.
  - `= null!` for fixture members assigned in SetUp.
- `TryGetStructure<T>(out T? x)` where the inferred `T?` broke the
`IEncodeable` constraint.
- A few redundant `== null` checks on structs (`NodeId`, `ArrayOf<T>`)
folded into the adjacent `IsNull`/`Count` checks.

**Notes for reviewers**
- The diff is large (~1,500 files) but almost entirely mechanical: `?`,
`!` and directive removal. The non-annotation changes are listed above.
- Known follow-up, not done here:
`IModelDesign.Namespaces/TargetNamespace/NamespaceUris` stay
non-nullable even though the validator returns null before it loads a
dictionary. Making them nullable would ripple through every generator
call site.

## Related Issues

- Fixes #4439

## Checklist

- [ ] I have signed the
[CLA](https://opcfoundation.org/license/cla/ContributorLicenseAgreementv1.0.pdf)
and read the
[CONTRIBUTING](https://github.com/OPCFoundation/UA-.NETStandard/blob/master/CONTRIBUTING.md)
doc.
- [ ] I have added tests that prove my fix is effective or that my
feature works and increased code coverage.
- [x] I have added all necessary documentation.
- [x] I have verified that my changes do not introduce (new) build or
analyzer warnings.
- [ ] I ran **all** tests locally using the **UA.slnx** solution against
at least .net **framework** and .net **10**, and all passed.
- [ ] I fixed **all** failing and flaky tests in the CI pipelines and
**all** CodeQL warnings.
- [ ] I have addressed **all** PR feedback received.

The "no new warnings" box covers net10.0 and net48: full non-incremental
builds of `UA.slnx`, `tools/SourceGeneration.slnx`,
`tools/MigrationAnalyzer.slnx` and the Aot test projects finished with 0
warnings and 0 errors under the repository's warnings-as-errors
settings. net9.0 and net8.0 also had no nullable diagnostics, but they
were not rebuilt after the final fixes. Tests have not been run locally
yet.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 08:48:13 +02:00

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
}
]
};
}
}
}