diff --git a/src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI/Commands/HistoryReadCommand.cs b/src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI/Commands/HistoryReadCommand.cs index b19e20fb..64d5d665 100644 --- a/src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI/Commands/HistoryReadCommand.cs +++ b/src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI/Commands/HistoryReadCommand.cs @@ -47,7 +47,7 @@ public class HistoryReadCommand : CommandBase /// /// Gets the optional aggregate name used when the operator wants processed history instead of raw samples. /// - [CommandOption("aggregate", Description = "Aggregate function: Average, Minimum, Maximum, Count, Start, End, StandardDeviation, TimeAverage, TimeAverage2, Total, Total2, Interpolative")] + [CommandOption("aggregate", Description = "Aggregate function: Average, Minimum, Maximum, Count, Start, End, TimeAverage, TimeAverage2, Total, Total2, Interpolative, Range, Delta, MinimumActualTime, MaximumActualTime, PercentGood, PercentBad")] public string? Aggregate { get; init; } /// @@ -199,24 +199,33 @@ public class HistoryReadCommand : CommandBase } } + private static readonly string SupportedAggregates = string.Join(", ", Enum.GetNames()); + private static AggregateType ParseAggregateType(string name) { - return name.Trim().ToLowerInvariant() switch + var trimmed = name.Trim(); + var alias = trimmed.ToLowerInvariant() switch { - "average" or "avg" => AggregateType.Average, - "minimum" or "min" => AggregateType.Minimum, - "maximum" or "max" => AggregateType.Maximum, - "count" => AggregateType.Count, - "start" or "first" => AggregateType.Start, - "end" or "last" => AggregateType.End, - "standarddeviation" or "stddev" or "stdev" => AggregateType.StandardDeviation, - "timeaverage" or "timeavg" => AggregateType.TimeAverage, - "timeaverage2" or "timeavg2" => AggregateType.TimeAverage2, - "total" => AggregateType.Total, - "total2" => AggregateType.Total2, - "interpolative" or "interp" => AggregateType.Interpolative, - _ => throw new ArgumentException( - $"Unknown aggregate: '{name}'. Supported: Average, Minimum, Maximum, Count, Start, End, StandardDeviation, TimeAverage, TimeAverage2, Total, Total2, Interpolative") + "avg" => AggregateType.Average, + "min" => AggregateType.Minimum, + "max" => AggregateType.Maximum, + "first" => AggregateType.Start, + "last" => AggregateType.End, + "timeavg" => AggregateType.TimeAverage, + "timeavg2" => AggregateType.TimeAverage2, + "interp" => AggregateType.Interpolative, + "mintime" => AggregateType.MinimumActualTime, + "maxtime" => AggregateType.MaximumActualTime, + _ => (AggregateType?)null }; + if (alias is { } a) return a; + + // Every enum name (the served set; a parity test pins it), case-insensitive. Numeric strings are refused: + // Enum.TryParse would accept "6" or "99" as an undefined value. + if (!int.TryParse(trimmed, out _) && + Enum.TryParse(trimmed, ignoreCase: true, out var parsed) && Enum.IsDefined(parsed)) + return parsed; + + throw new ArgumentException($"Unknown aggregate: '{name}'. Supported: {SupportedAggregates}"); } } diff --git a/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Helpers/AggregateTypeMapper.cs b/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Helpers/AggregateTypeMapper.cs index 44462199..23b70ede 100644 --- a/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Helpers/AggregateTypeMapper.cs +++ b/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Helpers/AggregateTypeMapper.cs @@ -23,12 +23,17 @@ public static class AggregateTypeMapper AggregateType.Count => ObjectIds.AggregateFunction_Count, AggregateType.Start => ObjectIds.AggregateFunction_Start, AggregateType.End => ObjectIds.AggregateFunction_End, - AggregateType.StandardDeviation => ObjectIds.AggregateFunction_StandardDeviationPopulation, AggregateType.TimeAverage => ObjectIds.AggregateFunction_TimeAverage, AggregateType.TimeAverage2 => ObjectIds.AggregateFunction_TimeAverage2, AggregateType.Total => ObjectIds.AggregateFunction_Total, AggregateType.Total2 => ObjectIds.AggregateFunction_Total2, AggregateType.Interpolative => ObjectIds.AggregateFunction_Interpolative, + AggregateType.Range => ObjectIds.AggregateFunction_Range, + AggregateType.Delta => ObjectIds.AggregateFunction_Delta, + AggregateType.MinimumActualTime => ObjectIds.AggregateFunction_MinimumActualTime, + AggregateType.MaximumActualTime => ObjectIds.AggregateFunction_MaximumActualTime, + AggregateType.PercentGood => ObjectIds.AggregateFunction_PercentGood, + AggregateType.PercentBad => ObjectIds.AggregateFunction_PercentBad, _ => throw new ArgumentOutOfRangeException(nameof(aggregate), aggregate, "Unknown AggregateType value.") }; } diff --git a/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Models/AggregateType.cs b/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Models/AggregateType.cs index b7f48ab7..17dddc51 100644 --- a/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Models/AggregateType.cs +++ b/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Models/AggregateType.cs @@ -1,43 +1,64 @@ namespace ZB.MOM.WW.OtOpcUa.Client.Shared.Models; /// -/// Aggregate functions for processed history reads. +/// Aggregate functions for processed history reads — exactly the seventeen the server's SQL historian backends +/// serve (AggregateFamilies.Served; a parity test pins it). The Gateway backend serves a subset and refuses +/// the rest with BadAggregateNotSupported. /// +/// +/// Values are explicit so the pre-existing members keep their numbers. 6 (the former StandardDeviation, +/// which no backend serves) is retired; do not reuse it. +/// public enum AggregateType { /// Average of values in the interval. - Average, + Average = 0, /// Minimum value in the interval. - Minimum, + Minimum = 1, /// Maximum value in the interval. - Maximum, + Maximum = 2, /// Count of values in the interval. - Count, + Count = 3, /// First value in the interval. - Start, + Start = 4, /// Last value in the interval. - End, - - /// Population standard deviation of values in the interval. - StandardDeviation, + End = 5, /// Time-weighted average, interpolated bounds (Part 13 TimeAverage). - TimeAverage, + TimeAverage = 7, /// Time-weighted average, stepped bounds (Part 13 TimeAverage2). - TimeAverage2, + TimeAverage2 = 8, /// Time-weighted integral over the interval (Part 13 Total). - Total, + Total = 9, /// Time-weighted integral, stepped bounds (Part 13 Total2). - Total2, + Total2 = 10, /// Interpolated value at the interval start (Part 13 Interpolative). - Interpolative -} \ No newline at end of file + Interpolative = 11, + + /// Maximum minus minimum in the interval (Part 13 Range). + Range = 12, + + /// Last minus first value in the interval (Part 13 Delta). + Delta = 13, + + /// Minimum value with its own timestamp (Part 13 MinimumActualTime). + MinimumActualTime = 14, + + /// Maximum value with its own timestamp (Part 13 MaximumActualTime). + MaximumActualTime = 15, + + /// Percentage of the interval with Good data (Part 13 PercentGood). + PercentGood = 16, + + /// Percentage of the interval with Bad data (Part 13 PercentBad). + PercentBad = 17 +} diff --git a/src/Client/ZB.MOM.WW.OtOpcUa.Client.UI/ViewModels/HistoryViewModel.cs b/src/Client/ZB.MOM.WW.OtOpcUa.Client.UI/ViewModels/HistoryViewModel.cs index aab55811..b883be03 100644 --- a/src/Client/ZB.MOM.WW.OtOpcUa.Client.UI/ViewModels/HistoryViewModel.cs +++ b/src/Client/ZB.MOM.WW.OtOpcUa.Client.UI/ViewModels/HistoryViewModel.cs @@ -43,23 +43,9 @@ public partial class HistoryViewModel : ObservableObject _dispatcher = dispatcher; } - /// Available aggregate types (null means "Raw"). + /// Available aggregate types (null means "Raw"): every client aggregate, which is the served set. public IReadOnlyList AggregateTypes { get; } = - [ - null, - AggregateType.Average, - AggregateType.Minimum, - AggregateType.Maximum, - AggregateType.Count, - AggregateType.Start, - AggregateType.End, - AggregateType.StandardDeviation, - AggregateType.TimeAverage, - AggregateType.TimeAverage2, - AggregateType.Total, - AggregateType.Total2, - AggregateType.Interpolative - ]; + [null, .. Enum.GetValues().Select(a => (AggregateType?)a)]; /// Gets a value indicating whether an aggregate read is selected. public bool IsAggregateRead => SelectedAggregateType != null; diff --git a/tests/Client/ZB.MOM.WW.OtOpcUa.Client.CLI.Tests/HistoryReadAggregateParsingTests.cs b/tests/Client/ZB.MOM.WW.OtOpcUa.Client.CLI.Tests/HistoryReadAggregateParsingTests.cs new file mode 100644 index 00000000..8d150ae2 --- /dev/null +++ b/tests/Client/ZB.MOM.WW.OtOpcUa.Client.CLI.Tests/HistoryReadAggregateParsingTests.cs @@ -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; + +/// +/// historyread --aggregate 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. +/// +public class HistoryReadAggregateParsingTests +{ + /// Every client aggregate, by its name. + /// Each value. + public static TheoryData AllAggregates() => new(Enum.GetValues()); + + /// Every aggregate name (in any case) reaches the service as that aggregate. + /// The aggregate. + [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); + } + + /// The short aliases still parse. + /// The alias. + /// The aggregate it names. + [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); + } + + /// + /// StandardDeviation is no longer offered (no backend serves it): it is refused before connecting, naming the + /// supported set. + /// + [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(async () => await command.ExecuteAsync(console)); + + ex.Message.ShouldContain("PercentGood"); + fakeService.HistoryReadAggregateCalls.ShouldBeEmpty(); + } +} diff --git a/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/Helpers/AggregateTypeMapperTests.cs b/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/Helpers/AggregateTypeMapperTests.cs index 663ab473..267baf02 100644 --- a/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/Helpers/AggregateTypeMapperTests.cs +++ b/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/Helpers/AggregateTypeMapperTests.cs @@ -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); } - /// Verifies that StandardDeviation aggregate type maps to the correct OPC UA node ID. - [Fact] - public void ToNodeId_StandardDeviation_MapsCorrectly() - { - AggregateTypeMapper.ToNodeId(AggregateType.StandardDeviation) - .ShouldBe(ObjectIds.AggregateFunction_StandardDeviationPopulation); - } - /// Verifies that invalid aggregate type value throws ArgumentOutOfRangeException. [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); } -} \ No newline at end of file + + /// + /// The client offers exactly the aggregates the server's SQL backends serve (): + /// nothing the server always refuses (the old StandardDeviation), nothing served left unreachable + /// (Range, Delta, Min/MaxActualTime, PercentGood/Bad). + /// + [Fact] + public void Client_aggregates_are_exactly_the_served_set() + { + Enum.GetNames().Order() + .ShouldBe(AggregateFamilies.Served.Select(a => a.ToString()).Order()); + } + + /// Each aggregate maps to the Part 13 ObjectId of the same name. + /// The aggregate. + [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)!); + } + + /// Every client aggregate. + /// Each value. + public static TheoryData AllAggregates() => new(Enum.GetValues()); +} diff --git a/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/Models/ModelConstructionTests.cs b/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/Models/ModelConstructionTests.cs index 3d3a32a4..a9f01b51 100644 --- a/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/Models/ModelConstructionTests.cs +++ b/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/Models/ModelConstructionTests.cs @@ -132,10 +132,10 @@ public class ModelConstructionTests ((int)ConnectionState.Reconnecting).ShouldBe(3); } - /// Verifies that AggregateType enum has the expected number of values. + /// AggregateType holds the seventeen served aggregates (the parity test pins which). [Fact] public void AggregateType_Enum_HasExpectedValues() { - Enum.GetValues().Length.ShouldBe(12); + Enum.GetValues().Length.ShouldBe(17); } } \ No newline at end of file diff --git a/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests.csproj b/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests.csproj index 903b39f9..e588e7af 100644 --- a/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests.csproj +++ b/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests.csproj @@ -21,6 +21,8 @@ + + diff --git a/tests/Client/ZB.MOM.WW.OtOpcUa.Client.UI.Tests/HistoryViewModelTests.cs b/tests/Client/ZB.MOM.WW.OtOpcUa.Client.UI.Tests/HistoryViewModelTests.cs index e056f78b..a333e699 100644 --- a/tests/Client/ZB.MOM.WW.OtOpcUa.Client.UI.Tests/HistoryViewModelTests.cs +++ b/tests/Client/ZB.MOM.WW.OtOpcUa.Client.UI.Tests/HistoryViewModelTests.cs @@ -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().Length + 1); // null + every aggregate + } + + /// The picker offers every client aggregate (the served set), including Range/Delta/PercentGood/... + [Fact] + public void AggregateTypes_offers_every_aggregate() + { + _vm.AggregateTypes.Where(a => a != null).Select(a => a!.Value) + .ShouldBe(Enum.GetValues(), ignoreOrder: true); + _vm.AggregateTypes.ShouldContain(AggregateType.PercentGood); } /// Verifies that the Clear method resets the view model state.