mirror of
https://github.com/OPCFoundation/UA-.NETStandard.git
synced 2026-10-29 07:07:24 +08:00
# Description Improve Reference Server coverage for OPC UA Compliance Test Tool scenarios and document the latest CTT findings. ## Reference Server coverage - Historize the configured AccessRights nodes and seed them with the same deterministic mixed-quality history as the other test nodes while preserving their access controls. - Add a historian node that intentionally does not support ServerTimestamp. - Add a SelectionListType String instance whose Selections property is String[] as required by Part 5, with localized descriptions and RestrictToList enforcement. - Add an EUR CurrencyUnit property and verify its structured value and binary encoding. - Add a node with explicit read/write base access bits plus NonatomicRead and NonatomicWrite AccessLevelEx flags. - Ensure the first ByteString array elements meet the CTT minimum-length prerequisite. ## Latest CTT findings - Confirm run-23 A&C residuals are CTT re-entrant Read and branch-unaware Retain checks rather than server regressions. - Confirm NumberOfTransitions includes Uncertain values as required by Part 13. - Document the CTT CurrencyUnit decoder dropping a correctly encoded Exponent. ## Related Issues - Follow-up to #3960 and #4002. ## Checklist - [x] 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. Focused affected tests pass on .NET 10 and .NET Framework 4.8. --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: Marc Schier <marcschier@hotmail.com>
708 lines
29 KiB
C#
708 lines
29 KiB
C#
/* ========================================================================
|
|
* Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved.
|
|
*
|
|
* OPC Foundation MIT License 1.00
|
|
*
|
|
* Permission is hereby granted, free of charge, to any person
|
|
* obtaining a copy of this software and associated documentation
|
|
* files (the "Software"), to deal in the Software without
|
|
* restriction, including without limitation the rights to use,
|
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
* copies of the Software, and to permit persons to whom the
|
|
* Software is furnished to do so, subject to the following
|
|
* conditions:
|
|
*
|
|
* The above copyright notice and this permission notice shall be
|
|
* included in all copies or substantial portions of the Software.
|
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
|
|
* OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
|
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
|
|
* HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
|
* WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
|
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
|
|
* OTHER DEALINGS IN THE SOFTWARE.
|
|
*
|
|
* The complete license agreement can be found here:
|
|
* http://opcfoundation.org/License/MIT/1.00/
|
|
* ======================================================================*/
|
|
|
|
using System;
|
|
using System.Collections.Generic;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using Moq;
|
|
using NUnit.Framework;
|
|
using Opc.Ua.Server.Historian;
|
|
using Opc.Ua.Server.Historian.InMemory;
|
|
using Opc.Ua.Tests;
|
|
|
|
namespace Opc.Ua.Server.Tests
|
|
{
|
|
/// <summary>
|
|
/// Part 11 and Part 13 oracle tests for aggregate scenarios exercised by the CTT aggregate units.
|
|
/// </summary>
|
|
[TestFixture]
|
|
[Category("Aggregators")]
|
|
[Category("Historian")]
|
|
[SetCulture("en-us")]
|
|
[SetUICulture("en-us")]
|
|
[Parallelizable]
|
|
public sealed class AggregateCttRegressionTests
|
|
{
|
|
[TestCase("Minimum", false)]
|
|
[TestCase("Maximum", false)]
|
|
[TestCase("Range", false)]
|
|
[TestCase("TimeAverage", false)]
|
|
[TestCase("Minimum", true)]
|
|
[TestCase("Maximum", true)]
|
|
[TestCase("Range", true)]
|
|
[TestCase("TimeAverage", true)]
|
|
public async Task DirectAndLiveTenIntervalResultsMatchPart13OracleAsync(
|
|
string aggregateName,
|
|
bool reverse)
|
|
{
|
|
NodeId aggregateId = GetAggregateId(aggregateName);
|
|
List<DataValue> rawValues = CreateSharedRawValues();
|
|
DateTimeUtc startTime = reverse ? AtSeconds(100) : s_baseTime;
|
|
DateTimeUtc endTime = reverse ? s_baseTime : AtSeconds(100);
|
|
AggregateConfiguration configuration = CreateConfiguration();
|
|
|
|
List<DataValue> direct = RunDirect(
|
|
aggregateId,
|
|
rawValues,
|
|
startTime,
|
|
endTime,
|
|
10_000,
|
|
configuration);
|
|
|
|
using var harness = new AggregateHarness();
|
|
List<DataValue> live = await harness.ReadProcessedAsync(
|
|
aggregateId,
|
|
rawValues,
|
|
startTime,
|
|
endTime,
|
|
10_000,
|
|
configuration).ConfigureAwait(false);
|
|
|
|
AssertSharedTenIntervalResults(direct, aggregateName, reverse);
|
|
AssertSharedTenIntervalResults(live, aggregateName, reverse);
|
|
}
|
|
|
|
[TestCase("Minimum", 0.0)]
|
|
[TestCase("Maximum", 19.0)]
|
|
[TestCase("Range", 19.0)]
|
|
[TestCase("TimeAverage", 9.75)]
|
|
public async Task DirectAndLiveIntervalEqualToRangeReturnsOneValueAsync(
|
|
string aggregateName,
|
|
double expected)
|
|
{
|
|
NodeId aggregateId = GetAggregateId(aggregateName);
|
|
List<DataValue> rawValues = CreateSharedRawValues();
|
|
AggregateConfiguration configuration = CreateConfiguration();
|
|
DateTimeUtc endTime = AtSeconds(100);
|
|
|
|
List<DataValue> direct = RunDirect(
|
|
aggregateId,
|
|
rawValues,
|
|
s_baseTime,
|
|
endTime,
|
|
100_000,
|
|
configuration);
|
|
|
|
using var harness = new AggregateHarness();
|
|
List<DataValue> live = await harness.ReadProcessedAsync(
|
|
aggregateId,
|
|
rawValues,
|
|
s_baseTime,
|
|
endTime,
|
|
100_000,
|
|
configuration).ConfigureAwait(false);
|
|
|
|
AssertSingleNumericResult(direct, expected, s_baseTime, AggregateBits.Calculated);
|
|
AssertSingleNumericResult(live, expected, s_baseTime, AggregateBits.Calculated);
|
|
}
|
|
|
|
[TestCase("Start", false, 0.0, 0, AggregateBits.Raw)]
|
|
[TestCase("End", false, 5.0, 5, AggregateBits.Raw)]
|
|
[TestCase("StartBound", false, 0.0, 0, AggregateBits.Raw)]
|
|
[TestCase("EndBound", false, 10.0, 0, AggregateBits.Calculated)]
|
|
[TestCase("Start", true, 5.0, 5, AggregateBits.Raw)]
|
|
[TestCase("End", true, 10.0, 10, AggregateBits.Raw)]
|
|
[TestCase("StartBound", true, 10.0, 10, AggregateBits.Raw)]
|
|
[TestCase("EndBound", true, 0.0, 10, AggregateBits.Calculated)]
|
|
public async Task DirectAndLiveStartEndFamiliesMatchPart13OracleAsync(
|
|
string aggregateName,
|
|
bool reverse,
|
|
double expected,
|
|
int expectedTimestampSeconds,
|
|
AggregateBits expectedBits)
|
|
{
|
|
NodeId aggregateId = GetAggregateId(aggregateName);
|
|
List<DataValue> rawValues =
|
|
[
|
|
CreateValue(0, StatusCodes.Good, 0),
|
|
CreateValue(5, StatusCodes.Good, 5),
|
|
CreateValue(10, StatusCodes.Good, 10)
|
|
];
|
|
DateTimeUtc startTime = reverse ? AtSeconds(10) : s_baseTime;
|
|
DateTimeUtc endTime = reverse ? s_baseTime : AtSeconds(10);
|
|
AggregateConfiguration configuration = CreateConfiguration();
|
|
|
|
List<DataValue> direct = RunDirect(
|
|
aggregateId,
|
|
rawValues,
|
|
startTime,
|
|
endTime,
|
|
10_000,
|
|
configuration);
|
|
|
|
using var harness = new AggregateHarness();
|
|
List<DataValue> live = await harness.ReadProcessedAsync(
|
|
aggregateId,
|
|
rawValues,
|
|
startTime,
|
|
endTime,
|
|
10_000,
|
|
configuration).ConfigureAwait(false);
|
|
|
|
DateTimeUtc expectedTimestamp = AtSeconds(expectedTimestampSeconds);
|
|
AssertSingleNumericResult(direct, expected, expectedTimestamp, expectedBits);
|
|
AssertSingleNumericResult(live, expected, expectedTimestamp, expectedBits);
|
|
}
|
|
|
|
[TestCase("PercentGood", false, 50.0)]
|
|
[TestCase("PercentBad", false, 50.0)]
|
|
[TestCase("PercentGood", true, 25.0)]
|
|
[TestCase("PercentBad", true, 75.0)]
|
|
public async Task DirectAndLivePercentAggregatesHonorExplicitUncertainConfigurationAsync(
|
|
string aggregateName,
|
|
bool treatUncertainAsBad,
|
|
double expected)
|
|
{
|
|
NodeId aggregateId = GetAggregateId(aggregateName);
|
|
List<DataValue> rawValues =
|
|
[
|
|
CreateValue(1, StatusCodes.Good, 0),
|
|
CreateValue(1, StatusCodes.Uncertain, 5),
|
|
CreateValue(1, StatusCodes.Bad, 10),
|
|
CreateValue(1, StatusCodes.Bad, 15),
|
|
CreateValue(1, StatusCodes.Good, 20)
|
|
];
|
|
AggregateConfiguration configuration = CreateConfiguration(treatUncertainAsBad);
|
|
DateTimeUtc endTime = AtSeconds(20);
|
|
|
|
List<DataValue> direct = RunDirect(
|
|
aggregateId,
|
|
rawValues,
|
|
s_baseTime,
|
|
endTime,
|
|
20_000,
|
|
configuration);
|
|
|
|
using var harness = new AggregateHarness();
|
|
List<DataValue> live = await harness.ReadProcessedAsync(
|
|
aggregateId,
|
|
rawValues,
|
|
s_baseTime,
|
|
endTime,
|
|
20_000,
|
|
configuration).ConfigureAwait(false);
|
|
|
|
AssertSingleNumericResult(direct, expected, s_baseTime, AggregateBits.Calculated);
|
|
AssertSingleNumericResult(live, expected, s_baseTime, AggregateBits.Calculated);
|
|
}
|
|
|
|
[TestCase("WorstQuality")]
|
|
[TestCase("WorstQuality2")]
|
|
public async Task DirectAndLiveWorstQualitySetMultipleValuesForRepeatedGoodQualityAsync(
|
|
string aggregateName)
|
|
{
|
|
NodeId aggregateId = GetAggregateId(aggregateName);
|
|
List<DataValue> rawValues =
|
|
[
|
|
CreateValue(1, StatusCodes.Good, 0),
|
|
CreateValue(2, StatusCodes.Good, 5),
|
|
CreateValue(3, StatusCodes.Good, 10)
|
|
];
|
|
AggregateConfiguration configuration = CreateConfiguration();
|
|
DateTimeUtc endTime = AtSeconds(10);
|
|
|
|
List<DataValue> direct = RunDirect(
|
|
aggregateId,
|
|
rawValues,
|
|
s_baseTime,
|
|
endTime,
|
|
10_000,
|
|
configuration);
|
|
|
|
using var harness = new AggregateHarness();
|
|
List<DataValue> live = await harness.ReadProcessedAsync(
|
|
aggregateId,
|
|
rawValues,
|
|
s_baseTime,
|
|
endTime,
|
|
10_000,
|
|
configuration).ConfigureAwait(false);
|
|
|
|
AssertWorstQualityResult(direct);
|
|
AssertWorstQualityResult(live);
|
|
}
|
|
|
|
[TestCase("DurationInStateZero", false)]
|
|
[TestCase("DurationInStateNonZero", false)]
|
|
[TestCase("DurationInStateZero", true)]
|
|
[TestCase("DurationInStateNonZero", true)]
|
|
public async Task DirectAndLiveDurationInStateMatchPart13OracleAsync(
|
|
string aggregateName,
|
|
bool reverse)
|
|
{
|
|
NodeId aggregateId = GetAggregateId(aggregateName);
|
|
List<DataValue> rawValues =
|
|
[
|
|
CreateValue(0, StatusCodes.Good, 0),
|
|
CreateValue(1, StatusCodes.Good, 5),
|
|
CreateValue(0, StatusCodes.Good, 10),
|
|
CreateValue(2, StatusCodes.Good, 15),
|
|
CreateValue(0, StatusCodes.Good, 20)
|
|
];
|
|
DateTimeUtc startTime = reverse ? AtSeconds(20) : s_baseTime;
|
|
DateTimeUtc endTime = reverse ? s_baseTime : AtSeconds(20);
|
|
AggregateConfiguration configuration = CreateConfiguration();
|
|
|
|
List<DataValue> direct = RunDirect(
|
|
aggregateId,
|
|
rawValues,
|
|
startTime,
|
|
endTime,
|
|
20_000,
|
|
configuration);
|
|
|
|
using var harness = new AggregateHarness();
|
|
List<DataValue> live = await harness.ReadProcessedAsync(
|
|
aggregateId,
|
|
rawValues,
|
|
startTime,
|
|
endTime,
|
|
20_000,
|
|
configuration).ConfigureAwait(false);
|
|
|
|
DateTimeUtc expectedTimestamp = reverse ? AtSeconds(20) : s_baseTime;
|
|
AssertSingleNumericResult(direct, 10_000, expectedTimestamp, AggregateBits.Calculated);
|
|
AssertSingleNumericResult(live, 10_000, expectedTimestamp, AggregateBits.Calculated);
|
|
}
|
|
|
|
[TestCase(false)]
|
|
[TestCase(true)]
|
|
public async Task DirectAndLiveNumberOfTransitionsMatchesPart13BoundaryRulesAsync(bool reverse)
|
|
{
|
|
List<DataValue> rawValues =
|
|
[
|
|
CreateValue(5, StatusCodes.Good, 0),
|
|
CreateValue(5, StatusCodes.Good, 1),
|
|
CreateValue(6, StatusCodes.Good, 2),
|
|
CreateValue(7, StatusCodes.Good, 3)
|
|
];
|
|
DateTimeUtc startTime = reverse ? AtSeconds(3) : s_baseTime;
|
|
DateTimeUtc endTime = reverse ? s_baseTime : AtSeconds(3);
|
|
AggregateConfiguration configuration = CreateConfiguration();
|
|
|
|
List<DataValue> direct = RunDirect(
|
|
ObjectIds.AggregateFunction_NumberOfTransitions,
|
|
rawValues,
|
|
startTime,
|
|
endTime,
|
|
3_000,
|
|
configuration);
|
|
|
|
using var harness = new AggregateHarness();
|
|
List<DataValue> live = await harness.ReadProcessedAsync(
|
|
ObjectIds.AggregateFunction_NumberOfTransitions,
|
|
rawValues,
|
|
startTime,
|
|
endTime,
|
|
3_000,
|
|
configuration).ConfigureAwait(false);
|
|
|
|
AssertSingleNumericResult(direct, 2, startTime, AggregateBits.Calculated);
|
|
AssertSingleNumericResult(live, 2, startTime, AggregateBits.Calculated);
|
|
}
|
|
|
|
[Test]
|
|
public async Task DirectAndLiveNumberOfTransitionsCountUncertainValuesAsync()
|
|
{
|
|
var rawValues = new List<DataValue>(25);
|
|
for (int index = 0; index <= 24; index++)
|
|
{
|
|
StatusCode status = (index % 10) switch
|
|
{
|
|
7 => StatusCodes.BadDataUnavailable,
|
|
9 => StatusCodes.UncertainSubstituteValue,
|
|
_ => StatusCodes.Good
|
|
};
|
|
rawValues.Add(CreateValue(index, status, index));
|
|
}
|
|
DateTimeUtc endTime = AtSeconds(24);
|
|
AggregateConfiguration configuration = CreateConfiguration(treatUncertainAsBad: true);
|
|
|
|
List<DataValue> direct = RunDirect(
|
|
ObjectIds.AggregateFunction_NumberOfTransitions,
|
|
rawValues,
|
|
s_baseTime,
|
|
endTime,
|
|
24_000,
|
|
configuration);
|
|
|
|
using var harness = new AggregateHarness();
|
|
List<DataValue> live = await harness.ReadProcessedAsync(
|
|
ObjectIds.AggregateFunction_NumberOfTransitions,
|
|
rawValues,
|
|
s_baseTime,
|
|
endTime,
|
|
24_000,
|
|
configuration).ConfigureAwait(false);
|
|
|
|
AssertNumberOfTransitionsWithMixedQuality(direct);
|
|
AssertNumberOfTransitionsWithMixedQuality(live);
|
|
}
|
|
|
|
[Test]
|
|
public async Task LiveProcessedReadWithEqualTimesReturnsBadInvalidArgumentAsync()
|
|
{
|
|
using var harness = new AggregateHarness();
|
|
var result = new HistoryReadResult();
|
|
|
|
ServiceResult error = await harness.DispatchProcessedAsync(
|
|
ObjectIds.AggregateFunction_Average,
|
|
[],
|
|
s_baseTime,
|
|
s_baseTime,
|
|
1,
|
|
CreateConfiguration(),
|
|
result).ConfigureAwait(false);
|
|
|
|
Assert.That(error.StatusCode, Is.EqualTo(StatusCodes.BadInvalidArgument));
|
|
Assert.That(result.StatusCode, Is.EqualTo(StatusCodes.BadInvalidArgument));
|
|
}
|
|
|
|
private static void AssertSharedTenIntervalResults(
|
|
List<DataValue> results,
|
|
string aggregateName,
|
|
bool reverse)
|
|
{
|
|
Assert.That(results, Has.Count.EqualTo(10));
|
|
|
|
for (int index = 0; index < results.Count; index++)
|
|
{
|
|
int interval = reverse ? 9 - index : index;
|
|
double expected = aggregateName switch
|
|
{
|
|
"Minimum" => interval,
|
|
"Maximum" => reverse ? interval + 11 : interval + 10,
|
|
"Range" => reverse ? 11 : 10,
|
|
"TimeAverage" => interval + 5.25,
|
|
_ => throw new ArgumentOutOfRangeException(nameof(aggregateName))
|
|
};
|
|
DateTimeUtc timestamp = reverse
|
|
? AtSeconds(100 - (index * 10))
|
|
: AtSeconds(index * 10);
|
|
// Part 13 §5.4.3.11: Maximum reports Good, Raw when the maximum sample sits on
|
|
// the interval start. In this dataset the maximum is always at the interval
|
|
// start for both forward (earlier bound) and reverse (later bound) reads, so it
|
|
// is Raw in both directions; every other aggregate here is Calculated.
|
|
AggregateBits expectedBits =
|
|
aggregateName == "Maximum"
|
|
? AggregateBits.Raw
|
|
: AggregateBits.Calculated;
|
|
|
|
AssertNumericResult(results[index], expected, timestamp, expectedBits, index);
|
|
}
|
|
}
|
|
|
|
private static void AssertSingleNumericResult(
|
|
List<DataValue> results,
|
|
double expected,
|
|
DateTimeUtc expectedTimestamp,
|
|
AggregateBits expectedBits)
|
|
{
|
|
Assert.That(results, Has.Count.EqualTo(1));
|
|
AssertNumericResult(results[0], expected, expectedTimestamp, expectedBits, 0);
|
|
}
|
|
|
|
private static void AssertNumericResult(
|
|
DataValue result,
|
|
double expected,
|
|
DateTimeUtc expectedTimestamp,
|
|
AggregateBits expectedBits,
|
|
int index)
|
|
{
|
|
Assert.That(
|
|
result.WrappedValue.ConvertToDouble().GetDouble(),
|
|
Is.EqualTo(expected).Within(0.000_001),
|
|
$"value at index {index}");
|
|
Assert.That(result.SourceTimestamp, Is.EqualTo(expectedTimestamp), $"timestamp at index {index}");
|
|
Assert.That(result.StatusCode.CodeBits, Is.EqualTo(StatusCodes.Good), $"status at index {index}");
|
|
Assert.That(result.StatusCode.AggregateBits, Is.EqualTo(expectedBits), $"aggregate bits at index {index}");
|
|
}
|
|
|
|
private static void AssertWorstQualityResult(List<DataValue> results)
|
|
{
|
|
Assert.That(results, Has.Count.EqualTo(1));
|
|
DataValue result = results[0];
|
|
Assert.That(result.WrappedValue.TryGetValue(out StatusCode worstQuality), Is.True);
|
|
Assert.That(worstQuality, Is.EqualTo(StatusCodes.Good));
|
|
Assert.That(result.SourceTimestamp, Is.EqualTo(s_baseTime));
|
|
Assert.That(result.StatusCode.CodeBits, Is.EqualTo(StatusCodes.Good));
|
|
Assert.That(
|
|
result.StatusCode.AggregateBits,
|
|
Is.EqualTo(AggregateBits.Calculated | AggregateBits.MultipleValues));
|
|
}
|
|
|
|
private static void AssertNumberOfTransitionsWithMixedQuality(List<DataValue> results)
|
|
{
|
|
Assert.That(results, Has.Count.EqualTo(1));
|
|
DataValue result = results[0];
|
|
Assert.That(result.WrappedValue.TryGetValue(out int transitions), Is.True);
|
|
Assert.That(transitions, Is.EqualTo(22));
|
|
Assert.That(result.SourceTimestamp, Is.EqualTo(s_baseTime));
|
|
Assert.That(result.StatusCode.CodeBits, Is.EqualTo(StatusCodes.UncertainDataSubNormal));
|
|
Assert.That(result.StatusCode.AggregateBits, Is.EqualTo(AggregateBits.Calculated));
|
|
}
|
|
|
|
private static List<DataValue> RunDirect(
|
|
NodeId aggregateId,
|
|
List<DataValue> rawValues,
|
|
DateTimeUtc startTime,
|
|
DateTimeUtc endTime,
|
|
double processingInterval,
|
|
AggregateConfiguration configuration)
|
|
{
|
|
IAggregateCalculator calculator = Aggregators.CreateStandardCalculator(
|
|
aggregateId,
|
|
startTime,
|
|
endTime,
|
|
processingInterval,
|
|
false,
|
|
configuration,
|
|
NUnitTelemetryContext.Create())!;
|
|
|
|
if (endTime < startTime)
|
|
{
|
|
for (int index = rawValues.Count - 1; index >= 0; index--)
|
|
{
|
|
Assert.That(calculator.QueueRawValue(rawValues[index]), Is.True, $"raw value {index}");
|
|
}
|
|
}
|
|
else
|
|
{
|
|
for (int index = 0; index < rawValues.Count; index++)
|
|
{
|
|
Assert.That(calculator.QueueRawValue(rawValues[index]), Is.True, $"raw value {index}");
|
|
}
|
|
}
|
|
|
|
var results = new List<DataValue>();
|
|
while (calculator.TryGetProcessedValue(true, out DataValue value))
|
|
{
|
|
results.Add(value);
|
|
}
|
|
return results;
|
|
}
|
|
|
|
private static List<DataValue> CreateSharedRawValues()
|
|
{
|
|
var values = new List<DataValue>(21);
|
|
for (int interval = 0; interval < 10; interval++)
|
|
{
|
|
values.Add(CreateValue(interval + 10, StatusCodes.Good, interval * 10));
|
|
values.Add(CreateValue(interval, StatusCodes.Good, (interval * 10) + 5));
|
|
}
|
|
values.Add(CreateValue(20, StatusCodes.Good, 100));
|
|
return values;
|
|
}
|
|
|
|
private static DataValue CreateValue(double value, StatusCode statusCode, int timestampSeconds)
|
|
{
|
|
DateTimeUtc timestamp = AtSeconds(timestampSeconds);
|
|
return new DataValue(Variant.From(value), statusCode, timestamp, timestamp);
|
|
}
|
|
|
|
private static DateTimeUtc AtSeconds(int seconds)
|
|
{
|
|
return s_baseTime.AddMilliseconds(seconds * 1000);
|
|
}
|
|
|
|
private static AggregateConfiguration CreateConfiguration(bool treatUncertainAsBad = false)
|
|
{
|
|
return new AggregateConfiguration
|
|
{
|
|
UseServerCapabilitiesDefaults = false,
|
|
TreatUncertainAsBad = treatUncertainAsBad,
|
|
PercentDataBad = 100,
|
|
PercentDataGood = 100,
|
|
UseSlopedExtrapolation = false
|
|
};
|
|
}
|
|
|
|
private static NodeId GetAggregateId(string aggregateName)
|
|
{
|
|
return aggregateName switch
|
|
{
|
|
"Minimum" => ObjectIds.AggregateFunction_Minimum,
|
|
"Maximum" => ObjectIds.AggregateFunction_Maximum,
|
|
"Range" => ObjectIds.AggregateFunction_Range,
|
|
"TimeAverage" => ObjectIds.AggregateFunction_TimeAverage,
|
|
"Start" => ObjectIds.AggregateFunction_Start,
|
|
"End" => ObjectIds.AggregateFunction_End,
|
|
"StartBound" => ObjectIds.AggregateFunction_StartBound,
|
|
"EndBound" => ObjectIds.AggregateFunction_EndBound,
|
|
"PercentGood" => ObjectIds.AggregateFunction_PercentGood,
|
|
"PercentBad" => ObjectIds.AggregateFunction_PercentBad,
|
|
"WorstQuality" => ObjectIds.AggregateFunction_WorstQuality,
|
|
"WorstQuality2" => ObjectIds.AggregateFunction_WorstQuality2,
|
|
"DurationInStateZero" => ObjectIds.AggregateFunction_DurationInStateZero,
|
|
"DurationInStateNonZero" => ObjectIds.AggregateFunction_DurationInStateNonZero,
|
|
_ => throw new ArgumentOutOfRangeException(nameof(aggregateName))
|
|
};
|
|
}
|
|
|
|
private sealed class AggregateHarness : IDisposable
|
|
{
|
|
public AggregateHarness()
|
|
{
|
|
Provider = new InMemoryHistorianProvider();
|
|
Telemetry = NUnitTelemetryContext.Create();
|
|
|
|
var diagnostics = new Mock<IDiagnosticsNodeManager>();
|
|
var session = new Mock<ISession>();
|
|
MockServer = new Mock<IServerInternal>();
|
|
MockServer.Setup(server => server.NamespaceUris).Returns(new NamespaceTable());
|
|
MockServer.Setup(server => server.ServerUris).Returns(new StringTable());
|
|
MockServer.Setup(server => server.TypeTree).Returns(new TypeTable(new NamespaceTable()));
|
|
MockServer.Setup(server => server.Factory).Returns(EncodeableFactory.Create());
|
|
MockServer.Setup(server => server.Telemetry).Returns(Telemetry);
|
|
MockServer.Setup(server => server.DiagnosticsNodeManager).Returns(diagnostics.Object);
|
|
|
|
AggregateManager = new AggregateManager(MockServer.Object);
|
|
MockServer.Setup(server => server.AggregateManager).Returns(AggregateManager);
|
|
|
|
var operationContext = new OperationContext(
|
|
new RequestHeader(),
|
|
null!,
|
|
RequestType.HistoryRead,
|
|
RequestLifetime.None,
|
|
session.Object);
|
|
SystemContext = new ServerSystemContext(MockServer.Object, operationContext);
|
|
}
|
|
|
|
public AggregateManager AggregateManager { get; }
|
|
public Mock<IServerInternal> MockServer { get; }
|
|
public InMemoryHistorianProvider Provider { get; }
|
|
public ServerSystemContext SystemContext { get; }
|
|
public ITelemetryContext Telemetry { get; }
|
|
|
|
public void Dispose()
|
|
{
|
|
AggregateManager.Dispose();
|
|
Provider.Dispose();
|
|
}
|
|
|
|
public async Task<List<DataValue>> ReadProcessedAsync(
|
|
NodeId aggregateId,
|
|
List<DataValue> rawValues,
|
|
DateTimeUtc startTime,
|
|
DateTimeUtc endTime,
|
|
double processingInterval,
|
|
AggregateConfiguration configuration)
|
|
{
|
|
var result = new HistoryReadResult();
|
|
ServiceResult error = await DispatchProcessedAsync(
|
|
aggregateId,
|
|
rawValues,
|
|
startTime,
|
|
endTime,
|
|
processingInterval,
|
|
configuration,
|
|
result).ConfigureAwait(false);
|
|
|
|
Assert.That(ServiceResult.IsGood(error), Is.True, error.ToString());
|
|
Assert.That(result.StatusCode, Is.EqualTo(StatusCodes.Good));
|
|
Assert.That(result.HistoryData.TryGetValue(out HistoryData historyData), Is.True);
|
|
return [.. historyData!.DataValues];
|
|
}
|
|
|
|
public async Task<ServiceResult> DispatchProcessedAsync(
|
|
NodeId aggregateId,
|
|
List<DataValue> rawValues,
|
|
DateTimeUtc startTime,
|
|
DateTimeUtc endTime,
|
|
double processingInterval,
|
|
AggregateConfiguration configuration,
|
|
HistoryReadResult result)
|
|
{
|
|
QualifiedName aggregateName = Aggregators.GetNameForStandardAggregate(aggregateId);
|
|
await AggregateManager.RegisterFactoryAsync(
|
|
aggregateId,
|
|
aggregateName.Name,
|
|
Aggregators.CreateStandardCalculator,
|
|
CancellationToken.None).ConfigureAwait(false);
|
|
|
|
var nodeId = new NodeId($"ctt-aggregate-{Guid.NewGuid():N}", 1);
|
|
Provider.Register(nodeId);
|
|
if (rawValues.Count > 0)
|
|
{
|
|
IList<StatusCode> insertResults = await Provider.InsertAsync(
|
|
CreateContext(),
|
|
nodeId,
|
|
rawValues,
|
|
CancellationToken.None).ConfigureAwait(false);
|
|
Assert.That(insertResults, Has.Count.EqualTo(rawValues.Count));
|
|
Assert.That(insertResults, Has.All.Matches<StatusCode>(StatusCode.IsGood));
|
|
}
|
|
|
|
var node = new BaseDataVariableState(null)
|
|
{
|
|
NodeId = nodeId,
|
|
BrowseName = new QualifiedName("AggregateVariable"),
|
|
AccessLevel = AccessLevels.HistoryRead,
|
|
Historizing = true
|
|
};
|
|
var details = new ReadProcessedDetails
|
|
{
|
|
StartTime = startTime,
|
|
EndTime = endTime,
|
|
ProcessingInterval = processingInterval,
|
|
AggregateConfiguration = configuration
|
|
};
|
|
var nodeToRead = new HistoryReadValueId
|
|
{
|
|
NodeId = nodeId,
|
|
ContinuationPoint = ByteString.Empty
|
|
};
|
|
|
|
return await HistorianDispatcher.DispatchProcessedReadAsync(
|
|
SystemContext,
|
|
Provider,
|
|
node,
|
|
nodeToRead,
|
|
details,
|
|
aggregateId,
|
|
TimestampsToReturn.Source,
|
|
result,
|
|
CancellationToken.None).ConfigureAwait(false);
|
|
}
|
|
|
|
private HistorianOperationContext CreateContext()
|
|
{
|
|
return new HistorianOperationContext(
|
|
SystemContext,
|
|
SystemContext.OperationContext!,
|
|
null,
|
|
HistoryUpdateType.Insert);
|
|
}
|
|
}
|
|
|
|
private static readonly DateTimeUtc s_baseTime = new(2025, 1, 1, 0, 0, 0);
|
|
}
|
|
}
|