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."); } /// --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(); } }