fix(client): offer exactly the served aggregates in the CLI and UI

R08 2.3: AggregateType drops StandardDeviation (no backend serves it; always
BadAggregateNotSupported) and gains Range, Delta, MinimumActualTime,
MaximumActualTime, PercentGood and PercentBad. A parity test pins the enum to
AggregateFamilies.Served and each member to its same-named Part 13 ObjectId.
The CLI parses every enum name (plus aliases) and the UI picker lists them all.
Existing members keep their numeric values; 6 is retired.

Refs #747
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
This commit is contained in:
Joseph Doherty
2026-10-22 16:24:18 -04:00
parent 57ae69a05e
commit a74a18e49a
9 changed files with 200 additions and 62 deletions
@@ -0,0 +1,87 @@
using CliFx.Exceptions;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Client.CLI.Commands;
using ZB.MOM.WW.OtOpcUa.Client.CLI.Tests.Fakes;
using ZB.MOM.WW.OtOpcUa.Client.Shared.Models;
namespace ZB.MOM.WW.OtOpcUa.Client.CLI.Tests;
/// <summary>
/// <c>historyread --aggregate</c> accepts every aggregate the client offers (which is exactly the served set), so the
/// gate recipes in docs/Historian.md that need Range / Delta / PercentGood / ... can run from this CLI.
/// </summary>
public class HistoryReadAggregateParsingTests
{
/// <summary>Every client aggregate, by its name.</summary>
/// <returns>Each <see cref="AggregateType"/> value.</returns>
public static TheoryData<AggregateType> AllAggregates() => new(Enum.GetValues<AggregateType>());
/// <summary>Every aggregate name (in any case) reaches the service as that aggregate.</summary>
/// <param name="aggregate">The aggregate.</param>
[Theory]
[MemberData(nameof(AllAggregates))]
public async Task Every_aggregate_name_parses(AggregateType aggregate)
{
var fakeService = new FakeOpcUaClientService();
var command = new HistoryReadCommand(new FakeOpcUaClientServiceFactory(fakeService))
{
Url = "opc.tcp://localhost:4840",
NodeId = "ns=2;s=HistNode",
Aggregate = aggregate.ToString().ToUpperInvariant()
};
using var console = TestConsoleHelper.CreateConsole();
await command.ExecuteAsync(console);
fakeService.HistoryReadAggregateCalls.ShouldHaveSingleItem().Aggregate.ShouldBe(aggregate);
}
/// <summary>The short aliases still parse.</summary>
/// <param name="alias">The alias.</param>
/// <param name="expected">The aggregate it names.</param>
[Theory]
[InlineData("avg", AggregateType.Average)]
[InlineData("min", AggregateType.Minimum)]
[InlineData("last", AggregateType.End)]
[InlineData("interp", AggregateType.Interpolative)]
[InlineData("mintime", AggregateType.MinimumActualTime)]
[InlineData("maxtime", AggregateType.MaximumActualTime)]
public async Task Aliases_parse(string alias, AggregateType expected)
{
var fakeService = new FakeOpcUaClientService();
var command = new HistoryReadCommand(new FakeOpcUaClientServiceFactory(fakeService))
{
Url = "opc.tcp://localhost:4840",
NodeId = "ns=2;s=HistNode",
Aggregate = alias
};
using var console = TestConsoleHelper.CreateConsole();
await command.ExecuteAsync(console);
fakeService.HistoryReadAggregateCalls.ShouldHaveSingleItem().Aggregate.ShouldBe(expected);
}
/// <summary>
/// StandardDeviation is no longer offered (no backend serves it): it is refused before connecting, naming the
/// supported set.
/// </summary>
[Fact]
public async Task StandardDeviation_is_refused_before_connecting()
{
var fakeService = new FakeOpcUaClientService();
var command = new HistoryReadCommand(new FakeOpcUaClientServiceFactory(fakeService))
{
Url = "opc.tcp://localhost:4840",
NodeId = "ns=2;s=HistNode",
Aggregate = "stddev"
};
using var console = TestConsoleHelper.CreateConsole();
var ex = await Should.ThrowAsync<CommandException>(async () => await command.ExecuteAsync(console));
ex.Message.ShouldContain("PercentGood");
fakeService.HistoryReadAggregateCalls.ShouldBeEmpty();
}
}
@@ -3,6 +3,7 @@ using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Client.Shared.Helpers;
using ZB.MOM.WW.OtOpcUa.Client.Shared.Models;
using ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common;
namespace ZB.MOM.WW.OtOpcUa.Client.Shared.Tests.Helpers;
@@ -17,7 +18,6 @@ public class AggregateTypeMapperTests
[InlineData(AggregateType.Count)]
[InlineData(AggregateType.Start)]
[InlineData(AggregateType.End)]
[InlineData(AggregateType.StandardDeviation)]
[InlineData(AggregateType.TimeAverage)]
[InlineData(AggregateType.TimeAverage2)]
[InlineData(AggregateType.Total)]
@@ -72,14 +72,6 @@ public class AggregateTypeMapperTests
AggregateTypeMapper.ToNodeId(AggregateType.End).ShouldBe(ObjectIds.AggregateFunction_End);
}
/// <summary>Verifies that StandardDeviation aggregate type maps to the correct OPC UA node ID.</summary>
[Fact]
public void ToNodeId_StandardDeviation_MapsCorrectly()
{
AggregateTypeMapper.ToNodeId(AggregateType.StandardDeviation)
.ShouldBe(ObjectIds.AggregateFunction_StandardDeviationPopulation);
}
/// <summary>Verifies that invalid aggregate type value throws ArgumentOutOfRangeException.</summary>
[Fact]
public void ToNodeId_InvalidValue_Throws()
@@ -98,4 +90,31 @@ public class AggregateTypeMapperTests
AggregateTypeMapper.ToNodeId(AggregateType.Total2).ShouldBe(ObjectIds.AggregateFunction_Total2);
AggregateTypeMapper.ToNodeId(AggregateType.Interpolative).ShouldBe(ObjectIds.AggregateFunction_Interpolative);
}
}
/// <summary>
/// The client offers exactly the aggregates the server's SQL backends serve (<see cref="AggregateFamilies.Served"/>):
/// nothing the server always refuses (the old <c>StandardDeviation</c>), nothing served left unreachable
/// (Range, Delta, Min/MaxActualTime, PercentGood/Bad).
/// </summary>
[Fact]
public void Client_aggregates_are_exactly_the_served_set()
{
Enum.GetNames<AggregateType>().Order()
.ShouldBe(AggregateFamilies.Served.Select(a => a.ToString()).Order());
}
/// <summary>Each aggregate maps to the Part 13 ObjectId of the same name.</summary>
/// <param name="aggregate">The aggregate.</param>
[Theory]
[MemberData(nameof(AllAggregates))]
public void ToNodeId_maps_to_the_same_named_AggregateFunction(AggregateType aggregate)
{
var field = typeof(ObjectIds).GetField("AggregateFunction_" + aggregate);
field.ShouldNotBeNull();
AggregateTypeMapper.ToNodeId(aggregate).ShouldBe((NodeId)field.GetValue(null)!);
}
/// <summary>Every client aggregate.</summary>
/// <returns>Each <see cref="AggregateType"/> value.</returns>
public static TheoryData<AggregateType> AllAggregates() => new(Enum.GetValues<AggregateType>());
}
@@ -132,10 +132,10 @@ public class ModelConstructionTests
((int)ConnectionState.Reconnecting).ShouldBe(3);
}
/// <summary>Verifies that AggregateType enum has the expected number of values.</summary>
/// <summary>AggregateType holds the seventeen served aggregates (the parity test pins which).</summary>
[Fact]
public void AggregateType_Enum_HasExpectedValues()
{
Enum.GetValues<AggregateType>().Length.ShouldBe(12);
Enum.GetValues<AggregateType>().Length.ShouldBe(17);
}
}
@@ -21,6 +21,8 @@
<ItemGroup>
<ProjectReference Include="..\..\..\src\Client\ZB.MOM.WW.OtOpcUa.Client.Shared\ZB.MOM.WW.OtOpcUa.Client.Shared.csproj"/>
<!-- Test-only: the served-aggregate parity oracle (AggregateFamilies.Served). -->
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common\ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common.csproj"/>
</ItemGroup>
</Project>
@@ -141,7 +141,16 @@ public class HistoryViewModelTests
public void AggregateTypes_ContainsNullForRaw()
{
_vm.AggregateTypes.ShouldContain((AggregateType?)null);
_vm.AggregateTypes.Count.ShouldBe(13); // null + 12 enum values
_vm.AggregateTypes.Count.ShouldBe(Enum.GetValues<AggregateType>().Length + 1); // null + every aggregate
}
/// <summary>The picker offers every client aggregate (the served set), including Range/Delta/PercentGood/...</summary>
[Fact]
public void AggregateTypes_offers_every_aggregate()
{
_vm.AggregateTypes.Where(a => a != null).Select(a => a!.Value)
.ShouldBe(Enum.GetValues<AggregateType>(), ignoreOrder: true);
_vm.AggregateTypes.ShouldContain(AggregateType.PercentGood);
}
/// <summary>Verifies that the Clear method resets the view model state.</summary>