using Opc.Ua; 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; public class HistoryReadCommandTests { /// Verifies RawRead execution prints values. [Fact] public async Task Execute_RawRead_PrintsValues() { var time1 = new DateTime(2025, 6, 15, 10, 0, 0, DateTimeKind.Utc); var time2 = new DateTime(2025, 6, 15, 11, 0, 0, DateTimeKind.Utc); var fakeService = new FakeOpcUaClientService { HistoryReadResult = new List { new(new Variant(10.5), StatusCodes.Good, time1, time1), new(new Variant(20.3), StatusCodes.Good, time2, time2) } }; var factory = new FakeOpcUaClientServiceFactory(fakeService); var command = new HistoryReadCommand(factory) { Url = "opc.tcp://localhost:4840", NodeId = "ns=2;s=HistNode", StartTime = "2025-06-15T00:00:00Z", EndTime = "2025-06-15T23:59:59Z" }; using var console = TestConsoleHelper.CreateConsole(); await command.ExecuteAsync(console); var output = TestConsoleHelper.GetOutput(console); output.ShouldContain("History for ns=2;s=HistNode"); output.ShouldContain("Timestamp"); output.ShouldContain("Value"); output.ShouldContain("Status"); output.ShouldContain("2 values returned."); } /// Verifies RawRead execution calls HistoryReadRaw. [Fact] public async Task Execute_RawRead_CallsHistoryReadRaw() { var fakeService = new FakeOpcUaClientService(); var factory = new FakeOpcUaClientServiceFactory(fakeService); var command = new HistoryReadCommand(factory) { Url = "opc.tcp://localhost:4840", NodeId = "ns=2;s=HistNode", MaxValues = 500 }; using var console = TestConsoleHelper.CreateConsole(); await command.ExecuteAsync(console); fakeService.HistoryReadRawCalls.Count.ShouldBe(1); fakeService.HistoryReadRawCalls[0].MaxValues.ShouldBe(500); fakeService.HistoryReadRawCalls[0].NodeId.Identifier.ShouldBe("HistNode"); } /// Verifies AggregateRead execution calls HistoryReadAggregate. [Fact] public async Task Execute_AggregateRead_CallsHistoryReadAggregate() { var fakeService = new FakeOpcUaClientService(); var factory = new FakeOpcUaClientServiceFactory(fakeService); var command = new HistoryReadCommand(factory) { Url = "opc.tcp://localhost:4840", NodeId = "ns=2;s=HistNode", Aggregate = "Average", IntervalMs = 60000 }; using var console = TestConsoleHelper.CreateConsole(); await command.ExecuteAsync(console); fakeService.HistoryReadAggregateCalls.Count.ShouldBe(1); fakeService.HistoryReadAggregateCalls[0].Aggregate.ShouldBe(AggregateType.Average); fakeService.HistoryReadAggregateCalls[0].IntervalMs.ShouldBe(60000); } /// Verifies AggregateRead execution prints aggregate info. [Fact] public async Task Execute_AggregateRead_PrintsAggregateInfo() { var fakeService = new FakeOpcUaClientService(); var factory = new FakeOpcUaClientServiceFactory(fakeService); var command = new HistoryReadCommand(factory) { Url = "opc.tcp://localhost:4840", NodeId = "ns=2;s=HistNode", Aggregate = "Maximum", IntervalMs = 7200000 }; using var console = TestConsoleHelper.CreateConsole(); await command.ExecuteAsync(console); var output = TestConsoleHelper.GetOutput(console); output.ShouldContain("Maximum"); output.ShouldContain("7200000"); } /// Verifies invalid aggregate throws CommandException. [Fact] public async Task Execute_InvalidAggregate_ThrowsCommandException() { var fakeService = new FakeOpcUaClientService(); var factory = new FakeOpcUaClientServiceFactory(fakeService); var command = new HistoryReadCommand(factory) { Url = "opc.tcp://localhost:4840", NodeId = "ns=2;s=HistNode", Aggregate = "InvalidAgg" }; using var console = TestConsoleHelper.CreateConsole(); // Operator-input errors now surface as CliFx CommandException (was ArgumentException) // per Client.CLI-006 so malformed input prints a clean CLI error instead of a stack trace. await Should.ThrowAsync( async () => await command.ExecuteAsync(console)); } /// Verifies disconnect is called in finally block. [Fact] public async Task Execute_DisconnectsInFinally() { var fakeService = new FakeOpcUaClientService(); var factory = new FakeOpcUaClientServiceFactory(fakeService); var command = new HistoryReadCommand(factory) { Url = "opc.tcp://localhost:4840", NodeId = "ns=2;s=HistNode" }; using var console = TestConsoleHelper.CreateConsole(); await command.ExecuteAsync(console); fakeService.DisconnectCalled.ShouldBeTrue(); fakeService.DisposeCalled.ShouldBeTrue(); } /// #668: --modified reads the modification trail (not raw) and prints update type, user and prior value. [Fact] public async Task Execute_ModifiedRead_CallsHistoryReadModified_and_prints_the_trail() { var t = new DateTime(2025, 6, 15, 10, 0, 0, DateTimeKind.Utc); var fakeService = new FakeOpcUaClientService { HistoryReadModifiedResult = [ new HistoryModifiedEntry(new DataValue(Variant.Null, StatusCodes.BadNoData, t, t), t.AddDays(1), HistoryUpdateType.Insert, "editor"), new HistoryModifiedEntry(new DataValue(new Variant(1.5), StatusCodes.Good, t, t), t.AddDays(2), HistoryUpdateType.Replace, "editor"), ], }; var command = new HistoryReadCommand(new FakeOpcUaClientServiceFactory(fakeService)) { Url = "opc.tcp://localhost:4840", NodeId = "ns=2;s=HistNode", Modified = true, MaxValues = 50, }; using var console = TestConsoleHelper.CreateConsole(); await command.ExecuteAsync(console); fakeService.HistoryReadModifiedCalls.ShouldHaveSingleItem().MaxValues.ShouldBe(50); fakeService.HistoryReadRawCalls.ShouldBeEmpty(); var output = TestConsoleHelper.GetOutput(console); output.ShouldContain("Modified history for ns=2;s=HistNode"); output.ShouldContain("Replace"); output.ShouldContain("Insert"); output.ShouldContain("editor"); output.ShouldContain("(none)"); output.ShouldContain("2 modifications returned."); } /// #776: --events reads event history with the default select clauses and prints one line per event. [Fact] public async Task Execute_EventRead_CallsHistoryReadEvents_with_the_default_fields_and_prints_each_event() { var t = new DateTime(2025, 6, 15, 10, 0, 0, DateTimeKind.Utc); var fakeService = new FakeOpcUaClientService { HistoryReadEventsResult = [ new HistoryEventEntry([ new Variant(new byte[] { 0xAB, 0x01 }), new Variant(ObjectTypeIds.LimitAlarmType), new Variant(t), new Variant("Tank1"), new Variant((ushort)700), new Variant(new LocalizedText("high level")), new Variant("temp_hi"), Variant.Null, ]), ], }; var command = new HistoryReadCommand(new FakeOpcUaClientServiceFactory(fakeService)) { Url = "opc.tcp://localhost:4840", NodeId = "ns=2;s=Area/Line/Tank1", Events = true, MaxValues = 25, }; using var console = TestConsoleHelper.CreateConsole(); await command.ExecuteAsync(console); var call = fakeService.HistoryReadEventsCalls.ShouldHaveSingleItem(); call.MaxEvents.ShouldBe(25); call.Filter.SelectClauses.Select(c => c.BrowsePath.ShouldHaveSingleItem().Name) .ShouldBe(["EventId", "EventType", "Time", "SourceName", "Severity", "Message", "ConditionName", "SourceNode"]); call.Filter.SelectClauses.Single(c => c.BrowsePath[0].Name == "ConditionName").TypeDefinitionId .ShouldBe(ObjectTypeIds.ConditionType); call.Filter.WhereClause.Elements.ShouldBeEmpty("no --of-type, no where-clause"); fakeService.HistoryReadRawCalls.ShouldBeEmpty(); var output = TestConsoleHelper.GetOutput(console); output.ShouldContain("Event history for ns=2;s=Area/Line/Tank1"); output.ShouldContain("EventId | EventType | Time | SourceName | Severity | Message | ConditionName | SourceNode"); output.ShouldContain($"AB01 | {ObjectTypeIds.LimitAlarmType} | {t:O} | Tank1 | 700 | high level | temp_hi | -"); output.ShouldContain("1 events returned."); } /// #776: --fields picks the select clauses and --of-type adds an OfType where-clause. [Fact] public async Task Execute_EventRead_with_fields_and_of_type_builds_that_filter() { var fakeService = new FakeOpcUaClientService(); var command = new HistoryReadCommand(new FakeOpcUaClientServiceFactory(fakeService)) { Url = "opc.tcp://localhost:4840", NodeId = "ns=2;s=Area/Line/Tank1", Events = true, Fields = "EventType, Severity", OfType = "i=2955", }; using var console = TestConsoleHelper.CreateConsole(); await command.ExecuteAsync(console); var filter = fakeService.HistoryReadEventsCalls.ShouldHaveSingleItem().Filter; filter.SelectClauses.Select(c => c.BrowsePath[0].Name).ShouldBe(["EventType", "Severity"]); var element = filter.WhereClause.Elements.ShouldHaveSingleItem(); element.FilterOperator.ShouldBe(FilterOperator.OfType); ((LiteralOperand)ExtensionObject.ToEncodeable(element.FilterOperands[0])).Value.Value .ShouldBe(ObjectTypeIds.LimitAlarmType); TestConsoleHelper.GetOutput(console).ShouldContain("0 events returned."); } /// #776: --events cannot be mixed with the other read modes, and --fields/--of-type need --events. [Theory] [InlineData(true, true, null, null)] [InlineData(true, false, "Average", null)] [InlineData(false, false, null, "EventType")] public async Task Execute_EventRead_option_conflicts_throw(bool events, bool modified, string? aggregate, string? fields) { var fakeService = new FakeOpcUaClientService(); var command = new HistoryReadCommand(new FakeOpcUaClientServiceFactory(fakeService)) { Url = "opc.tcp://localhost:4840", NodeId = "ns=2;s=HistNode", Events = events, Modified = modified, Aggregate = aggregate, Fields = fields, }; using var console = TestConsoleHelper.CreateConsole(); await Should.ThrowAsync(async () => await command.ExecuteAsync(console)); fakeService.HistoryReadEventsCalls.ShouldBeEmpty(); } /// --modified cannot be combined with --aggregate. [Fact] public async Task Execute_ModifiedWithAggregate_ThrowsCommandException() { var command = new HistoryReadCommand(new FakeOpcUaClientServiceFactory(new FakeOpcUaClientService())) { Url = "opc.tcp://localhost:4840", NodeId = "ns=2;s=HistNode", Modified = true, Aggregate = "Average", }; using var console = TestConsoleHelper.CreateConsole(); await Should.ThrowAsync(async () => await command.ExecuteAsync(console)); } /// #677.5: the new aggregate names parse (and the aliases). [Theory] [InlineData("TimeAverage", AggregateType.TimeAverage)] [InlineData("timeaverage2", AggregateType.TimeAverage2)] [InlineData("Total", AggregateType.Total)] [InlineData("total2", AggregateType.Total2)] [InlineData("Interpolative", AggregateType.Interpolative)] [InlineData("interp", AggregateType.Interpolative)] public async Task Execute_NewAggregates_Parse(string name, AggregateType expected) { var fakeService = new FakeOpcUaClientService(); var command = new HistoryReadCommand(new FakeOpcUaClientServiceFactory(fakeService)) { Url = "opc.tcp://localhost:4840", NodeId = "ns=2;s=HistNode", Aggregate = name, }; using var console = TestConsoleHelper.CreateConsole(); await command.ExecuteAsync(console); fakeService.HistoryReadAggregateCalls.ShouldHaveSingleItem().Aggregate.ShouldBe(expected); } /// #674.6: a refused read is a one-line CommandException (non-zero exit), not a stack trace. [Fact] public async Task Execute_RefusedRead_ThrowsCommandException_WithOneLineMessage() { var fakeService = new FakeOpcUaClientService { HistoryReadException = new ServiceResultException(StatusCodes.BadHistoryOperationUnsupported, "HistoryRead of ns=2;s=HistNode failed: BadHistoryOperationUnsupported (0x80B40000)."), }; var command = new HistoryReadCommand(new FakeOpcUaClientServiceFactory(fakeService)) { Url = "opc.tcp://localhost:4840", NodeId = "ns=2;s=HistNode", }; using var console = TestConsoleHelper.CreateConsole(); var ex = await Should.ThrowAsync(async () => await command.ExecuteAsync(console)); ex.Message.ShouldContain("BadHistoryOperationUnsupported"); ex.Message.ShouldNotContain("\n"); ex.Message.ShouldNotContain(" at "); ex.ExitCode.ShouldNotBe(0); fakeService.DisconnectCalled.ShouldBeTrue(); } }