Merge feat/677-aggregates: reverse processed reads SDK-anchored, start-of-data Partial (forward), AggregateConfiguration accept-defaults, IntegralDivisor=1, SDK parity tests (#677)

This commit is contained in:
Joseph Doherty
2026-10-11 11:47:52 -04:00
24 changed files with 1291 additions and 135 deletions
@@ -278,6 +278,94 @@ public sealed class NodeManagerHistoryReadTests : IDisposable
await host.DisposeAsync();
}
/// <summary>
/// #677: a client <c>AggregateConfiguration</c> that differs from the server defaults is refused per node with
/// <c>BadAggregateConfigurationRejected</c> (it used to be silently ignored); the source is never invoked.
/// </summary>
[Theory]
[InlineData(true, (byte)100, (byte)100, false)] // TreatUncertainAsBad
[InlineData(false, (byte)50, (byte)100, false)] // PercentDataBad
[InlineData(false, (byte)100, (byte)80, false)] // PercentDataGood
[InlineData(false, (byte)100, (byte)100, true)] // UseSlopedExtrapolation
[InlineData(false, (byte)0, (byte)0, false)] // an all-zero structure without UseServerCapabilitiesDefaults
public async Task Processed_with_a_non_default_aggregate_configuration_is_rejected(
bool treatUncertainAsBad, byte percentBad, byte percentGood, bool sloped)
{
var (host, server) = await BootAsync();
var nm = server.NodeManager!;
var fake = new RecordingHistorianDataSource { ProcessedResult = Empty() };
nm.HistorianDataSource = fake;
nm.EnsureVariable("eq-1/cfg", parentFolderNodeId: null, displayName: "Cfg", dataType: "Float",
writable: false, historianTagname: "WW.Cfg", realm: AddressSpaceRealm.Uns);
var nodeId = nm.TryGetVariable("eq-1/cfg")!.NodeId;
var (_, errors) = InvokeHistoryRead(server, nm, new ReadProcessedDetails
{
StartTime = DateTime.UtcNow.AddHours(-1), EndTime = DateTime.UtcNow, ProcessingInterval = 10_000.0,
AggregateType = new NodeIdCollection { ObjectIds.AggregateFunction_TimeAverage },
AggregateConfiguration = new AggregateConfiguration
{
UseServerCapabilitiesDefaults = false, TreatUncertainAsBad = treatUncertainAsBad,
PercentDataBad = percentBad, PercentDataGood = percentGood, UseSlopedExtrapolation = sloped,
},
}, nodeId);
errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadAggregateConfigurationRejected);
fake.LastCall.ShouldBeNull();
await host.DisposeAsync();
}
/// <summary>
/// #677: <c>UseServerCapabilitiesDefaults</c> (whatever the other fields say), or a configuration spelling out exactly
/// the server defaults, is served.
/// </summary>
[Theory]
[InlineData(true, true, (byte)0, (byte)0, true)]
[InlineData(false, false, (byte)100, (byte)100, false)]
public async Task Processed_with_the_server_default_aggregate_configuration_is_served(
bool useServerDefaults, bool treatUncertainAsBad, byte percentBad, byte percentGood, bool sloped)
{
var (host, server) = await BootAsync();
var nm = server.NodeManager!;
var fake = new RecordingHistorianDataSource { ProcessedResult = Empty() };
nm.HistorianDataSource = fake;
nm.EnsureVariable("eq-1/cfg-ok", parentFolderNodeId: null, displayName: "CfgOk", dataType: "Float",
writable: false, historianTagname: "WW.CfgOk", realm: AddressSpaceRealm.Uns);
var nodeId = nm.TryGetVariable("eq-1/cfg-ok")!.NodeId;
var (_, errors) = InvokeHistoryRead(server, nm, new ReadProcessedDetails
{
StartTime = DateTime.UtcNow.AddHours(-1), EndTime = DateTime.UtcNow, ProcessingInterval = 10_000.0,
AggregateType = new NodeIdCollection { ObjectIds.AggregateFunction_TimeAverage },
AggregateConfiguration = new AggregateConfiguration
{
UseServerCapabilitiesDefaults = useServerDefaults, TreatUncertainAsBad = treatUncertainAsBad,
PercentDataBad = percentBad, PercentDataGood = percentGood, UseSlopedExtrapolation = sloped,
},
}, nodeId);
StatusCode.IsGood(errors[0].StatusCode).ShouldBeTrue();
fake.LastCall.ShouldBe("Processed");
fake.LastAggregate.ShouldBe(HistoryAggregateType.TimeAverage);
await host.DisposeAsync();
}
/// <summary>#677: the server's declared aggregate defaults are the SDK's own server defaults.</summary>
[Fact]
public void The_server_aggregate_defaults_agree_with_the_SDK_AggregateManager()
{
var sdk = new Opc.Ua.Server.AggregateManager(null!).GetDefaultConfiguration(null);
var ours = ServerAggregateConfiguration.Defaults;
ours.TreatUncertainAsBad.ShouldBe(sdk.TreatUncertainAsBad);
ours.PercentDataBad.ShouldBe(sdk.PercentDataBad);
ours.PercentDataGood.ShouldBe(sdk.PercentDataGood);
ours.UseSlopedExtrapolation.ShouldBe(sdk.UseSlopedExtrapolation);
ServerAggregateConfiguration.IsServable(sdk).ShouldBeTrue();
ServerAggregateConfiguration.IsServable(null).ShouldBeTrue();
}
/// <summary>#285: a Part 13 aggregate beyond the original five (Interpolative) is forwarded to the
/// backend as its own <see cref="HistoryAggregateType"/> member.</summary>
[Fact]