feat(history): full Part 13 aggregate set; unsupported-by-backend aggregates answer BadAggregateNotSupported (#285)

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
Joseph Doherty
2026-10-08 14:24:25 -04:00
parent d898262c4a
commit 55eac5f68b
8 changed files with 180 additions and 10 deletions
@@ -16,10 +16,24 @@ public sealed class AggregateModeMapperTests
public void Maps_each_aggregate(HistoryAggregateType a, RetrievalMode expected)
=> Assert.Equal(expected, AggregateModeMapper.ToRetrievalMode(a));
[Fact] // matrix guard: a new HistoryAggregateType member must fail here
public void Every_aggregate_member_is_mapped()
[Fact] // matrix guard: every member is either mapped or explicitly reported as not supported
public void Every_aggregate_member_is_mapped_or_reports_not_supported()
{
var original = new[]
{
HistoryAggregateType.Average, HistoryAggregateType.Minimum, HistoryAggregateType.Maximum,
HistoryAggregateType.Total, HistoryAggregateType.Count,
};
foreach (var a in Enum.GetValues<HistoryAggregateType>())
_ = AggregateModeMapper.ToRetrievalMode(a); // must not throw for any defined member
{
if (original.Contains(a))
_ = AggregateModeMapper.ToRetrievalMode(a);
else
Assert.Throws<HistoryAggregateNotSupportedException>(() => AggregateModeMapper.ToRetrievalMode(a));
}
}
[Fact]
public void Undefined_value_is_a_programming_error_not_a_not_supported()
=> Assert.Throws<ArgumentOutOfRangeException>(() => AggregateModeMapper.ToRetrievalMode((HistoryAggregateType)9999));
}
@@ -8,14 +8,23 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests;
[Trait("Category", "Unit")]
public sealed class OpcUaClientHistoryTests
{
/// <summary>Every <see cref="HistoryAggregateType"/> member (#285: the full Part 13 set, not just five).</summary>
public static IEnumerable<object[]> AllAggregates() =>
Enum.GetValues<HistoryAggregateType>().Select(a => new object[] { a });
/// <summary>Every member maps to a DISTINCT aggregate NodeId (a copy/paste mapping would collide).</summary>
[Fact]
public void MapAggregateToNodeId_is_injective_over_the_whole_enum()
{
var ids = Enum.GetValues<HistoryAggregateType>().Select(OpcUaClientDriver.MapAggregateToNodeId).ToList();
ids.Distinct().Count().ShouldBe(ids.Count);
Enum.GetValues<HistoryAggregateType>().Length.ShouldBeGreaterThan(5);
}
/// <summary>Verifies MapAggregateToNodeId returns standard Part 13 aggregate for every enum.</summary>
/// <param name="agg">The history aggregate type to test.</param>
[Theory]
[InlineData(HistoryAggregateType.Average)]
[InlineData(HistoryAggregateType.Minimum)]
[InlineData(HistoryAggregateType.Maximum)]
[InlineData(HistoryAggregateType.Total)]
[InlineData(HistoryAggregateType.Count)]
[MemberData(nameof(AllAggregates))]
public void MapAggregateToNodeId_returns_standard_Part13_aggregate_for_every_enum(HistoryAggregateType agg)
{
var nodeId = OpcUaClientDriver.MapAggregateToNodeId(agg);
@@ -268,6 +268,52 @@ public sealed class NodeManagerHistoryReadTests : IDisposable
await host.DisposeAsync();
}
/// <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]
public async Task Processed_extended_aggregate_dispatches_to_the_source()
{
var (host, server) = await BootAsync();
var nm = server.NodeManager!;
var fake = new RecordingHistorianDataSource { ProcessedResult = Empty() };
nm.HistorianDataSource = fake;
nm.EnsureVariable("eq-1/interp", parentFolderNodeId: null, displayName: "I", dataType: "Float",
writable: false, historianTagname: "WW.Interp", realm: AddressSpaceRealm.Uns);
var nodeId = nm.TryGetVariable("eq-1/interp")!.NodeId;
InvokeHistoryRead(server, nm, new ReadProcessedDetails
{
StartTime = DateTime.UtcNow.AddHours(-1), EndTime = DateTime.UtcNow, ProcessingInterval = 10_000.0,
AggregateType = new NodeIdCollection { ObjectIds.AggregateFunction_Interpolative },
}, nodeId);
fake.LastCall.ShouldBe("Processed");
fake.LastAggregate.ShouldBe(HistoryAggregateType.Interpolative);
await host.DisposeAsync();
}
/// <summary>#285: a backend that cannot serve a mapped aggregate answers BadAggregateNotSupported
/// (not the generic BadHistoryOperationUnsupported a plain throw produces).</summary>
[Fact]
public async Task Processed_backend_unsupported_aggregate_yields_BadAggregateNotSupported()
{
var (host, server) = await BootAsync();
var nm = server.NodeManager!;
nm.HistorianDataSource = new RecordingHistorianDataSource { AggregateNotSupported = true };
nm.EnsureVariable("eq-1/ns", parentFolderNodeId: null, displayName: "N", dataType: "Float",
writable: false, historianTagname: "WW.Ns", realm: AddressSpaceRealm.Uns);
var nodeId = nm.TryGetVariable("eq-1/ns")!.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_PercentGood },
}, nodeId);
errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadAggregateNotSupported);
await host.DisposeAsync();
}
/// <summary>AtTime read: the requested timestamps reach ReadAtTimeAsync in order.</summary>
[Fact]
public async Task AtTime_dispatches_requested_timestamps()
@@ -430,6 +476,7 @@ public sealed class NodeManagerHistoryReadTests : IDisposable
private sealed class RecordingHistorianDataSource : IHistorianDataSource
{
public bool ThrowOnRead { get; init; }
public bool AggregateNotSupported { get; init; }
public HistorianRead RawResult { get; set; } = new(Array.Empty<DataValueSnapshot>(), null);
public HistorianRead ProcessedResult { get; set; } = new(Array.Empty<DataValueSnapshot>(), null);
public HistorianRead AtTimeResult { get; set; } = new(Array.Empty<DataValueSnapshot>(), null);
@@ -461,6 +508,7 @@ public sealed class NodeManagerHistoryReadTests : IDisposable
HistoryAggregateType aggregate, CancellationToken cancellationToken)
{
if (ThrowOnRead) throw new InvalidOperationException("backend boom");
if (AggregateNotSupported) throw new HistoryAggregateNotSupportedException(aggregate);
LastCall = "Processed";
LastTagname = fullReference;
LastStart = startUtc;