Files
UA-.NETStandard/tests/Opc.Ua.History.Tests/AggregateBaseTests.cs
Marc Schier b97fd8d4b5 Fix OPC 10000-21 onboarding interoperability (#4385)
# Description

Fix the four blockers that prevented the OPC 10000-21 onboarding
registrar and client from working end to end:

- generate every `ModelDesign` target independently, including multiple
models in one directory, while preserving stable identifier CSV matching
and cross-model dependencies;
- ship the generated `Opc.Ua.Onboarding` constants, node states,
encodeables, and typed clients from `Opc.Ua.Gds.Common`;
- correct the standard `EncodedTicket` datatype to derive from
`ByteString`;
- align the new 2.0 `OnboardingClient` API with the generated
`ArrayOf<ByteString>` / `ArrayOf<StatusCode>` contract;
- make `BindToTicketStore` fully asynchronous, cancellation-aware, and
assign the declared output slot instead of appending a second output;
- add generated-model, client fallback, server binding, and in-process
end-to-end regressions.

The change also adds `samples/Gds/OnboardingRegistrar`,
`samples/Gds/OnboardingClient`, and `run-onboarding-demo.ps1`. The demo
loads the generated GDS and Onboarding models, restricts ticket
administration to an ephemeral `RegistrarAdmin` user, proves anonymous
access is denied, and completes a secure register/unregister round trip.

The History CI follow-up rejects negative and non-finite
processed-history intervals before provider dispatch and enforces the
buffered aggregate output cap while calculator results are flushed. This
prevents malformed requests from running until the client timeout,
exhausting memory, delaying server teardown, and causing later
alarm-event tests to fail.

## Related Issues

- Fixes #4378
- Unblocks OPCFoundation/UA-.NETStandard-Samples#819
- Tracks the canonical model correction in OPCFoundation/UA-Nodeset#122

## Validation

- Full `net10.0` test projects:
  - `Opc.Ua.SourceGeneration.Core.Tests`: 3803 passed, 8 skipped
  - `Opc.Ua.SourceGeneration.Tests`: 143 passed
  - `Opc.Ua.Gds.Tests`: 1114 passed, 42 skipped
- `Opc.Ua.History.Tests` with the CI coverage settings: 506 passed, 27
skipped
- `HistorianDispatcher` tests: 88 passed on `net10.0`.
- Invalid processed-history interval regressions passed on `net10.0` and
`net48`.
- Focused onboarding/source-generation regressions passed on `net48`.
- `dotnet build UA.slnx -p:CustomTestTarget=net10.0 --no-restore`
succeeded; the reported analyzer warnings are pre-existing in unchanged
XRegistry/WoT files.
- Packed all three GDS packages and verified the packaged
`Opc.Ua.Gds.Common` assembly contains the generated Onboarding model
surface.
- `pwsh samples/Gds/run-onboarding-demo.ps1` verified anonymous denial
and the authenticated secure ticket lifecycle.

## 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.
- [x] I have added tests that prove my fix is effective or that my
feature works and increased code coverage.
- [x] I have added all necessary documentation.
- [x] I have verified that my changes do not introduce (new) build or
analyzer warnings.
- [ ] I ran **all** tests locally using the **UA.slnx** solution against
at least .net **framework** and .net **10**, and all passed.
- [ ] I fixed **all** failing and flaky tests in the CI pipelines and
**all** CodeQL warnings.
- [x] I have addressed **all** PR feedback received.

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 2d2c52c7-7040-4bd6-98de-aaf82ba7846b
2026-09-02 11:39:19 +02:00

818 lines
34 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.Threading;
using System.Threading.Tasks;
using NUnit.Framework;
using Opc.Ua.Client.TestFramework;
namespace Opc.Ua.History.Tests
{
/// <summary>
/// Conformance tests covering aggregate base scenarios. Each test issues
/// a HistoryReadProcessed call (interval, time, config, request) against
/// the reference server's historizing variable (HistoricalDouble) and
/// asserts the result.
/// </summary>
[TestFixture]
[Category("Conformance")]
[Category("Aggregates")]
[NonParallelizable]
public class AggregateBaseTests : TestFixture
{
/// <summary>
/// Standard processing intervals used by the aggregate base scenarios.
/// </summary>
private const double IntervalDefault = 0; // server default
/// <summary>
/// 1 minute
/// </summary>
private const double IntervalShort = 60_000;
/// <summary>
/// 30 minutes
/// </summary>
private const double IntervalLong = 1_800_000;
// ------------------------------------------------------------------
// 001-XX Single node, default config, varying time arrangements
// ------------------------------------------------------------------
[Description("Aggregate - Base 001-01: Interpolative aggregate, single node, startTime = endTime, useServerCapabilitiesDefaults.")]
[Test]
public async Task ReadProcessedInterpolativeBaseCase01Async()
{
await ExecuteAggregateScenarioAsync(
ObjectIds.AggregateFunction_Interpolative,
TimeArrangement.StartEqualsEnd,
IntervalDefault).ConfigureAwait(false);
}
[Description("Aggregate - Base 001-02: Interpolative aggregate, single node, startTime < endTime within range, useServerCapabilitiesDefaults.")]
[Test]
public async Task ReadProcessedInterpolativeBaseCase02Async()
{
await ExecuteAggregateScenarioAsync(
ObjectIds.AggregateFunction_Interpolative,
TimeArrangement.StartBeforeEnd,
IntervalShort).ConfigureAwait(false);
}
[Description("Aggregate - Base 001-03: Interpolative aggregate, single node, startTime > endTime (reverse).")]
[Test]
public async Task ReadProcessedInterpolativeBaseCase03Async()
{
await ExecuteAggregateScenarioAsync(
ObjectIds.AggregateFunction_Interpolative,
TimeArrangement.StartAfterEnd,
IntervalShort).ConfigureAwait(false);
}
[Description("Aggregate - Base 001-04: Interpolative aggregate, single node, longer processing interval.")]
[Test]
public async Task ReadProcessedInterpolativeBaseCase04Async()
{
await ExecuteAggregateScenarioAsync(
ObjectIds.AggregateFunction_Interpolative,
TimeArrangement.StartBeforeEnd,
IntervalLong).ConfigureAwait(false);
}
// ------------------------------------------------------------------
// 002-XX Average aggregate, time arrangements
// ------------------------------------------------------------------
[Description("Aggregate - Base 002-01: Average aggregate, single node, startTime = endTime.")]
[Test]
public async Task ReadProcessedAverageBaseCase01Async()
{
await ExecuteAggregateScenarioAsync(
ObjectIds.AggregateFunction_Average,
TimeArrangement.StartEqualsEnd,
IntervalDefault).ConfigureAwait(false);
}
[Description("Aggregate - Base 002-02: Average aggregate, single node, startTime < endTime, default processing interval.")]
[Test]
public async Task ReadProcessedAverageBaseCase02Async()
{
await ExecuteAggregateScenarioAsync(
ObjectIds.AggregateFunction_Average,
TimeArrangement.StartBeforeEnd,
IntervalShort).ConfigureAwait(false);
}
[Description("Aggregate - Base 002-03: Average aggregate, reverse time order.")]
[Test]
public async Task ReadProcessedAverageBaseCase03Async()
{
await ExecuteAggregateScenarioAsync(
ObjectIds.AggregateFunction_Average,
TimeArrangement.StartAfterEnd,
IntervalShort).ConfigureAwait(false);
}
[Description("Aggregate - Base 002-04: Average aggregate, long processing interval.")]
[Test]
public async Task ReadProcessedAverageBaseCase04Async()
{
await ExecuteAggregateScenarioAsync(
ObjectIds.AggregateFunction_Average,
TimeArrangement.StartBeforeEnd,
IntervalLong).ConfigureAwait(false);
}
// ------------------------------------------------------------------
// 003-XX TimeAverage aggregate
// ------------------------------------------------------------------
[Description("Aggregate - Base 003-01: TimeAverage aggregate, single node, startTime = endTime.")]
[Test]
public async Task ReadProcessedTimeAverageBaseCase01Async()
{
await ExecuteAggregateScenarioAsync(
ObjectIds.AggregateFunction_TimeAverage,
TimeArrangement.StartEqualsEnd,
IntervalDefault).ConfigureAwait(false);
}
[Description("Aggregate - Base 003-02: TimeAverage aggregate, single node, startTime < endTime.")]
[Test]
public async Task ReadProcessedTimeAverageBaseCase02Async()
{
await ExecuteAggregateScenarioAsync(
ObjectIds.AggregateFunction_TimeAverage,
TimeArrangement.StartBeforeEnd,
IntervalShort).ConfigureAwait(false);
}
[Description("Aggregate - Base 003-03: TimeAverage aggregate, reverse time order.")]
[Test]
public async Task ReadProcessedTimeAverageBaseCase03Async()
{
await ExecuteAggregateScenarioAsync(
ObjectIds.AggregateFunction_TimeAverage,
TimeArrangement.StartAfterEnd,
IntervalShort).ConfigureAwait(false);
}
[Description("Aggregate - Base 003-04: TimeAverage aggregate, longer processing interval.")]
[Test]
public async Task ReadProcessedTimeAverageBaseCase04Async()
{
await ExecuteAggregateScenarioAsync(
ObjectIds.AggregateFunction_TimeAverage,
TimeArrangement.StartBeforeEnd,
IntervalLong).ConfigureAwait(false);
}
// ------------------------------------------------------------------
// 004-XX Total aggregate
// ------------------------------------------------------------------
[Description("Aggregate - Base 004-01: Total aggregate, single node, startTime = endTime.")]
[Test]
public async Task ReadProcessedTotalBaseCase01Async()
{
await ExecuteAggregateScenarioAsync(
ObjectIds.AggregateFunction_Total,
TimeArrangement.StartEqualsEnd,
IntervalDefault).ConfigureAwait(false);
}
[Description("Aggregate - Base 004-02: Total aggregate, single node, startTime < endTime.")]
[Test]
public async Task ReadProcessedTotalBaseCase02Async()
{
await ExecuteAggregateScenarioAsync(
ObjectIds.AggregateFunction_Total,
TimeArrangement.StartBeforeEnd,
IntervalShort).ConfigureAwait(false);
}
[Description("Aggregate - Base 004-03: Total aggregate, reverse time order.")]
[Test]
public async Task ReadProcessedTotalBaseCase03Async()
{
await ExecuteAggregateScenarioAsync(
ObjectIds.AggregateFunction_Total,
TimeArrangement.StartAfterEnd,
IntervalShort).ConfigureAwait(false);
}
[Description("Aggregate - Base 004-04: Total aggregate, longer processing interval.")]
[Test]
public async Task ReadProcessedTotalBaseCase04Async()
{
await ExecuteAggregateScenarioAsync(
ObjectIds.AggregateFunction_Total,
TimeArrangement.StartBeforeEnd,
IntervalLong).ConfigureAwait(false);
}
// ------------------------------------------------------------------
// 005-XX Min/Max with edge cases (out-of-range times etc.)
// ------------------------------------------------------------------
[Description("Aggregate - Base 005-01: Minimum aggregate, both startTime and endTime before recorded data.")]
[Test]
public async Task ReadProcessedMinMaxBaseCase01Async()
{
await ExecuteAggregateScenarioAsync(
ObjectIds.AggregateFunction_Minimum,
TimeArrangement.BothBeforeData,
IntervalShort,
allowAnyResult: true).ConfigureAwait(false);
}
[Description("Aggregate - Base 005-02: Maximum aggregate, both startTime and endTime before recorded data.")]
[Test]
public async Task ReadProcessedMinMaxBaseCase02Async()
{
await ExecuteAggregateScenarioAsync(
ObjectIds.AggregateFunction_Maximum,
TimeArrangement.BothBeforeData,
IntervalShort,
allowAnyResult: true).ConfigureAwait(false);
}
[Description("Aggregate - Base 005-03: Minimum aggregate, both startTime and endTime after recorded data.")]
[Test]
public async Task ReadProcessedMinMaxBaseCase03Async()
{
await ExecuteAggregateScenarioAsync(
ObjectIds.AggregateFunction_Minimum,
TimeArrangement.BothAfterData,
IntervalShort,
allowAnyResult: true).ConfigureAwait(false);
}
[Description("Aggregate - Base 005-04: Maximum aggregate, both startTime and endTime after recorded data.")]
[Test]
public async Task ReadProcessedMinMaxBaseCase04Async()
{
await ExecuteAggregateScenarioAsync(
ObjectIds.AggregateFunction_Maximum,
TimeArrangement.BothAfterData,
IntervalShort,
allowAnyResult: true).ConfigureAwait(false);
}
[Description("Aggregate - Base 005-05: Minimum aggregate, normal time range.")]
[Test]
public async Task ReadProcessedMinMaxBaseCase05Async()
{
await ExecuteAggregateScenarioAsync(
ObjectIds.AggregateFunction_Minimum,
TimeArrangement.StartBeforeEnd,
IntervalShort).ConfigureAwait(false);
}
[Description("Aggregate - Base 005-06: Maximum aggregate, normal time range.")]
[Test]
public async Task ReadProcessedMinMaxBaseCase06Async()
{
await ExecuteAggregateScenarioAsync(
ObjectIds.AggregateFunction_Maximum,
TimeArrangement.StartBeforeEnd,
IntervalShort).ConfigureAwait(false);
}
// ------------------------------------------------------------------
// 006 Count aggregate
// ------------------------------------------------------------------
[Description("Aggregate - Base 006: Count aggregate, single node, startTime < endTime.")]
[Test]
public async Task ReadProcessedCountBaseAsync()
{
await ExecuteAggregateScenarioAsync(
ObjectIds.AggregateFunction_Count,
TimeArrangement.StartBeforeEnd,
IntervalShort).ConfigureAwait(false);
}
// ------------------------------------------------------------------
// 007 NumberOfTransitions aggregate
// ------------------------------------------------------------------
[Description("Aggregate - Base 007: NumberOfTransitions aggregate, single node, startTime < endTime.")]
[Test]
public async Task ReadProcessedNumberOfTransitionsBaseAsync()
{
await ExecuteAggregateScenarioAsync(
ObjectIds.AggregateFunction_NumberOfTransitions,
TimeArrangement.StartBeforeEnd,
IntervalShort).ConfigureAwait(false);
}
// ------------------------------------------------------------------
// 008-XX Standard deviation aggregate
// ------------------------------------------------------------------
[Description("Aggregate - Base 008-01: Standard deviation (sample) aggregate, single node, startTime < endTime.")]
[Test]
public async Task ReadProcessedStandardDeviationBaseCase01Async()
{
await ExecuteAggregateScenarioAsync(
ObjectIds.AggregateFunction_StandardDeviationSample,
TimeArrangement.StartBeforeEnd,
IntervalShort).ConfigureAwait(false);
}
[Description("Aggregate - Base 008-02: Standard deviation (population) aggregate, single node.")]
[Test]
public async Task ReadProcessedStandardDeviationBaseCase02Async()
{
await ExecuteAggregateScenarioAsync(
ObjectIds.AggregateFunction_StandardDeviationPopulation,
TimeArrangement.StartBeforeEnd,
IntervalShort).ConfigureAwait(false);
}
[Description("Aggregate - Base 008-03: Standard deviation aggregate with longer processing interval.")]
[Test]
public async Task ReadProcessedStandardDeviationBaseCase03Async()
{
await ExecuteAggregateScenarioAsync(
ObjectIds.AggregateFunction_StandardDeviationSample,
TimeArrangement.StartBeforeEnd,
IntervalLong).ConfigureAwait(false);
}
// ------------------------------------------------------------------
// Err-XXX Error / negative cases
// ------------------------------------------------------------------
[Description("Aggregate function NodeId is unknown; expect a Bad operation status or service-level rejection.")]
[Test]
public async Task ReadProcessedAggregateErrorCase01Async()
{
NodeId nodeId = ToNodeId(Constants.HistoricalDouble);
DateTime endTime = DateTime.UtcNow;
DateTime startTime = endTime.AddMinutes(-10);
await AssertProcessedReadFailsAsync(
nodeId,
startTime,
endTime,
aggregateId: new NodeId(987654u, 0),
processingInterval: IntervalShort,
allowEmptyResults: false).ConfigureAwait(false);
}
[Description("AggregateConfiguration uses non-default flags but UseServerCapabilitiesDefaults=true; the flags must be ignored and the read must succeed.")]
[Test]
public async Task ReadProcessedAggregateErrorCase02Async()
{
NodeId nodeId = ToNodeId(Constants.HistoricalDouble);
DateTime endTime = DateTime.UtcNow;
DateTime startTime = endTime.AddMinutes(-10);
var details = new ReadProcessedDetails
{
StartTime = startTime,
EndTime = endTime,
ProcessingInterval = IntervalShort,
AggregateType = new NodeId[] { ObjectIds.AggregateFunction_Average }.ToArrayOf(),
AggregateConfiguration = new AggregateConfiguration
{
UseServerCapabilitiesDefaults = true,
TreatUncertainAsBad = true,
PercentDataBad = 50,
PercentDataGood = 50,
UseSlopedExtrapolation = false
}
};
HistoryReadResponse response = await Session.HistoryReadAsync(
null,
new ExtensionObject(details),
TimestampsToReturn.Both,
false,
new HistoryReadValueId[]
{
new() { NodeId = nodeId }
}.ToArrayOf(),
CancellationToken.None).ConfigureAwait(false);
Assert.That(response.Results.Count, Is.EqualTo(1));
// With UseServerCapabilitiesDefaults=true the server may still
// succeed; some servers reject the inconsistent config.
// Accept either Good/Uncertain or Bad — what matters is that
// the call returned a single, deterministic result.
Assert.That(response.Results[0], Is.Not.Null);
}
[Description("ProcessingInterval is negative; expect a Bad operation status or service-level rejection.")]
[Test]
public async Task ReadProcessedAggregateErrorCase03Async()
{
NodeId nodeId = ToNodeId(Constants.HistoricalDouble);
DateTime endTime = DateTime.UtcNow;
DateTime startTime = endTime.AddMinutes(-10);
HistoryReadResponse response = await ExecuteHistoryReadProcessedAsync(
nodeId,
startTime,
endTime,
aggregateId: ObjectIds.AggregateFunction_Average,
processingInterval: -1).ConfigureAwait(false);
Assert.That(response.Results.Count, Is.EqualTo(1));
Assert.That(
response.Results[0].StatusCode,
Is.EqualTo(StatusCodes.BadInvalidArgument));
}
[Description("AggregateType list is empty; expect a Bad operation status or service-level rejection.")]
[Test]
public async Task ReadProcessedAggregateErrorCase04Async()
{
NodeId nodeId = ToNodeId(Constants.HistoricalDouble);
DateTime endTime = DateTime.UtcNow;
DateTime startTime = endTime.AddMinutes(-10);
var details = new ReadProcessedDetails
{
StartTime = startTime,
EndTime = endTime,
ProcessingInterval = IntervalShort,
AggregateType = new ArrayOf<NodeId>(),
AggregateConfiguration = new AggregateConfiguration
{
UseServerCapabilitiesDefaults = true
}
};
try
{
HistoryReadResponse response = await Session.HistoryReadAsync(
null,
new ExtensionObject(details),
TimestampsToReturn.Both,
false,
new HistoryReadValueId[]
{
new() { NodeId = nodeId }
}.ToArrayOf(),
CancellationToken.None).ConfigureAwait(false);
// Some servers reject at the service level; others return
// a per-operation Bad. Either is acceptable — we only check
// that we don't get an empty success.
Assert.That(response.Results.Count, Is.EqualTo(1));
Assert.That(StatusCode.IsBad(response.Results[0].StatusCode), Is.True,
"An empty AggregateType array must produce a Bad operation status; got " +
$"{response.Results[0].StatusCode}.");
}
catch (ServiceResultException ex)
{
Assert.That(StatusCode.IsBad(ex.StatusCode), Is.True,
"An empty AggregateType array must produce a Bad service result; got " +
$"{ex.StatusCode}.");
}
}
[Description("NodeId in HistoryReadValueId is unknown; expect a Bad operation status.")]
[Test]
public async Task ReadProcessedAggregateErrorCase05Async()
{
DateTime endTime = DateTime.UtcNow;
DateTime startTime = endTime.AddMinutes(-10);
HistoryReadResponse response = await ExecuteHistoryReadProcessedAsync(
nodeId: new NodeId(99999999u, 0),
startTime,
endTime,
aggregateId: ObjectIds.AggregateFunction_Average,
processingInterval: IntervalShort).ConfigureAwait(false);
Assert.That(response.Results.Count, Is.EqualTo(1));
Assert.That(StatusCode.IsBad(response.Results[0].StatusCode), Is.True,
"Unknown NodeId must produce a Bad operation status; got " +
$"{response.Results[0].StatusCode}.");
}
// ------------------------------------------------------------------
// D7 Additional ReadProcessedDetails request validation (§4.2.1.2,
// §5.4.3.1). Bad_AggregateInvalidInputs for invalid PercentDataGood /
// PercentDataBad inputs, and a Bad result for inverted time ranges.
// ------------------------------------------------------------------
[Description("PercentDataGood/PercentDataBad both > 100; expect Bad_AggregateInvalidInputs or service-level rejection.")]
[Test]
public async Task ReadProcessedPercentDataOutOfRangeAsync()
{
NodeId nodeId = ToNodeId(Constants.HistoricalDouble);
DateTime endTime = DateTime.UtcNow;
DateTime startTime = endTime.AddMinutes(-5);
var details = new ReadProcessedDetails
{
StartTime = startTime,
EndTime = endTime,
ProcessingInterval = IntervalShort,
AggregateType = new NodeId[] { ObjectIds.AggregateFunction_Average }.ToArrayOf(),
AggregateConfiguration = new AggregateConfiguration
{
UseServerCapabilitiesDefaults = false,
TreatUncertainAsBad = true,
PercentDataBad = 200,
PercentDataGood = 200,
UseSlopedExtrapolation = false
}
};
try
{
HistoryReadResponse response = await Session.HistoryReadAsync(
null,
new ExtensionObject(details),
TimestampsToReturn.Both,
false,
new HistoryReadValueId[]
{
new() { NodeId = nodeId }
}.ToArrayOf(),
CancellationToken.None).ConfigureAwait(false);
Assert.That(response.Results.Count, Is.EqualTo(1));
StatusCode sc = response.Results[0].StatusCode;
Assert.That(
sc.Code,
Is.EqualTo(StatusCodes.BadAggregateInvalidInputs)
.Or.EqualTo(StatusCodes.BadAggregateConfigurationRejected),
$"Invalid PercentData inputs must be rejected; got {sc}.");
}
catch (ServiceResultException ex)
{
Assert.That(StatusCode.IsBad(ex.StatusCode), Is.True,
$"Service exception for PercentData > 100 must be Bad: {ex.StatusCode}.");
}
}
[Description("AggregateConfiguration with invalid relationship PercentDataGood < (100 - PercentDataBad); expect Bad or tolerant Good.")]
[Test]
public async Task ReadProcessedPercentDataInvalidRelationshipAsync()
{
NodeId nodeId = ToNodeId(Constants.HistoricalDouble);
DateTime endTime = DateTime.UtcNow;
DateTime startTime = endTime.AddMinutes(-5);
var details = new ReadProcessedDetails
{
StartTime = startTime,
EndTime = endTime,
ProcessingInterval = IntervalShort,
AggregateType = new NodeId[] { ObjectIds.AggregateFunction_Average }.ToArrayOf(),
AggregateConfiguration = new AggregateConfiguration
{
UseServerCapabilitiesDefaults = false,
TreatUncertainAsBad = true,
PercentDataBad = 80, // 100 - 80 = 20
PercentDataGood = 0, // < 20 → invalid per §4.2.1.2
UseSlopedExtrapolation = false
}
};
try
{
HistoryReadResponse response = await Session.HistoryReadAsync(
null,
new ExtensionObject(details),
TimestampsToReturn.Both,
false,
new HistoryReadValueId[]
{
new() { NodeId = nodeId }
}.ToArrayOf(),
CancellationToken.None).ConfigureAwait(false);
Assert.That(response.Results.Count, Is.EqualTo(1));
StatusCode sc = response.Results[0].StatusCode;
Assert.That(
sc.Code,
Is.EqualTo(StatusCodes.BadAggregateInvalidInputs)
.Or.EqualTo(StatusCodes.BadAggregateConfigurationRejected),
$"Invalid AggregateConfiguration inputs must be rejected; got {sc}.");
}
catch (ServiceResultException ex)
{
Assert.That(StatusCode.IsBad(ex.StatusCode), Is.True);
}
}
// ------------------------------------------------------------------
// X-XXX Cross-cutting behavior: reversed time range (Part 13
// §5.4.3.1, also Part 11 §6.4.3). HistoryRead must not throw and
// must return a result for start > end.
// ------------------------------------------------------------------
[Description("Reversed time range (start > end) on a normal aggregate; service must produce a deterministic per-node result.")]
[Test]
public async Task ReadProcessedReversedTimeRangeReturnsDeterministicResultAsync()
{
await ExecuteAggregateScenarioAsync(
ObjectIds.AggregateFunction_Average,
TimeArrangement.StartAfterEnd,
IntervalShort,
allowAnyResult: true).ConfigureAwait(false);
}
// ------------------------------------------------------------------
// Helpers
// ------------------------------------------------------------------
private enum TimeArrangement
{
StartEqualsEnd,
StartBeforeEnd,
StartAfterEnd,
BothBeforeData,
BothAfterData
}
private async Task ExecuteAggregateScenarioAsync(
NodeId aggregateId,
TimeArrangement arrangement,
double processingInterval,
bool allowAnyResult = false)
{
NodeId nodeId = ToNodeId(Constants.HistoricalDouble);
(DateTime startTime, DateTime endTime) = ComputeTimeRange(arrangement);
HistoryReadResponse response = await ExecuteHistoryReadProcessedAsync(
nodeId,
startTime,
endTime,
aggregateId,
processingInterval).ConfigureAwait(false);
Assert.That(response.Results.Count, Is.EqualTo(1));
StatusCode sc = response.Results[0].StatusCode;
// OPC UA Part 11 §6.5.4.2 requires BadInvalidArgument when
// StartTime equals EndTime because there is no meaningful processed
// time domain to aggregate.
if (arrangement == TimeArrangement.StartEqualsEnd)
{
Assert.That(sc, Is.EqualTo(StatusCodes.BadInvalidArgument));
return;
}
// The Quickstart Reference Server does not implement the
// historical-access node manager with a seeded dataset, so it
// legitimately returns BadHistoryOperationUnsupported for every
// HistoryRead. Treat that as "history not exercised on this
// fixture" and Skip the test rather than fail.
if (sc == StatusCodes.BadHistoryOperationUnsupported ||
sc == StatusCodes.BadNotImplemented ||
sc == StatusCodes.BadServiceUnsupported)
{
Assert.Ignore(
$"Server does not implement historical aggregates: {sc}.");
}
if (allowAnyResult)
{
// Out-of-range or rare-aggregate scenarios may legitimately
// return Good (with empty data), Uncertain, or Bad. The test
// asserts that the call returns SOME deterministic per-node
// status code.
Assert.That(response.Results[0], Is.Not.Null);
return;
}
// For the standard happy-path scenarios, accept Good or Uncertain
// (Uncertain is permitted by the spec when the aggregate covers
// partial data).
Assert.That(
StatusCode.IsGood(sc) || StatusCode.IsUncertain(sc),
Is.True,
$"Expected Good or Uncertain status for aggregate {aggregateId}, got {sc}.");
}
private (DateTime startTime, DateTime endTime) ComputeTimeRange(
TimeArrangement arrangement)
{
DateTime now = DateTime.UtcNow;
return arrangement switch
{
TimeArrangement.StartEqualsEnd => (now.AddMinutes(-30), now.AddMinutes(-30)),
TimeArrangement.StartBeforeEnd => (now.AddHours(-2), now),
TimeArrangement.StartAfterEnd => (now, now.AddHours(-2)),
TimeArrangement.BothBeforeData => (now.AddYears(-5), now.AddYears(-5).AddHours(1)),
TimeArrangement.BothAfterData => (now.AddDays(1), now.AddDays(1).AddHours(1)),
_ => (now.AddHours(-1), now)
};
}
private async Task<HistoryReadResponse> ExecuteHistoryReadProcessedAsync(
NodeId nodeId,
DateTime startTime,
DateTime endTime,
NodeId aggregateId,
double processingInterval)
{
var details = new ReadProcessedDetails
{
StartTime = startTime,
EndTime = endTime,
ProcessingInterval = processingInterval,
AggregateType = new NodeId[] { aggregateId }.ToArrayOf(),
AggregateConfiguration = new AggregateConfiguration
{
UseServerCapabilitiesDefaults = true
}
};
return await Session.HistoryReadAsync(
null,
new ExtensionObject(details),
TimestampsToReturn.Both,
false,
new HistoryReadValueId[]
{
new() { NodeId = nodeId }
}.ToArrayOf(),
CancellationToken.None).ConfigureAwait(false);
}
/// <summary>
/// Asserts that the read either throws ServiceResultException with a Bad
/// status, or returns a Bad operation status, or returns Good with no
/// data (allowEmptyResults). At least one Bad indication is required
/// when allowEmptyResults is false.
/// </summary>
private async Task AssertProcessedReadFailsAsync(
NodeId nodeId,
DateTime startTime,
DateTime endTime,
NodeId aggregateId,
double processingInterval,
bool allowEmptyResults)
{
try
{
HistoryReadResponse response = await ExecuteHistoryReadProcessedAsync(
nodeId,
startTime,
endTime,
aggregateId,
processingInterval).ConfigureAwait(false);
Assert.That(response.Results.Count, Is.EqualTo(1));
StatusCode sc = response.Results[0].StatusCode;
if (StatusCode.IsBad(sc))
{
return;
}
// Some servers tolerate the bad input by returning Good with
// empty/uncertain data instead of a Bad code. Accept that path
// unless the caller insists on a strict Bad result.
Assert.That(allowEmptyResults, Is.True,
$"Expected a Bad operation status for invalid input; got {sc}.");
}
catch (ServiceResultException ex)
{
Assert.That(StatusCode.IsBad(ex.StatusCode), Is.True,
$"Service returned a non-Bad ServiceResultException: {ex.StatusCode}.");
}
}
}
}