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:
@@ -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();
|
||||
}
|
||||
}
|
||||
+29
-10
@@ -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);
|
||||
}
|
||||
}
|
||||
+2
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user