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 BrowseResult = ZB.MOM.WW.OtOpcUa.Client.Shared.Models.BrowseResult; namespace ZB.MOM.WW.OtOpcUa.Client.CLI.Tests; /// /// Regression tests for SubscribeCommand summary bucketing, --duration, --quiet, --summary-file, /// and recursive collection. Covers Client.CLI-002, -009, and -010. /// public class SubscribeCommandSummaryTests { [Fact] public async Task Summary_NodeWithNoUpdate_IsCountedAsNeverNotAsNeverWentBad() { // Client.CLI-002: A node that received no updates at all is "never received an update", // NOT a "suspect that never went bad". var fakeService = new FakeOpcUaClientService(); var factory = new FakeOpcUaClientServiceFactory(fakeService); var command = new SubscribeCommand(factory) { Url = "opc.tcp://localhost:4840", NodeId = "ns=2;s=SilentNode", DurationSeconds = 1 }; using var console = TestConsoleHelper.CreateConsole(); await command.ExecuteAsync(console); var output = TestConsoleHelper.GetOutput(console); output.ShouldContain("No update received at all: 1"); output.ShouldContain("NEVER went bad (suspect): 0"); // The "suspect" detail header should not appear when the suspect list is empty. output.ShouldNotContain("--- Nodes that NEVER received a bad-quality update (suspect) ---"); // The "never received update" detail header should appear. output.ShouldContain("--- Nodes that never received an update at all ---"); } [Fact] public async Task Summary_NodeReceivedOnlyGoodValues_IsCountedAsNeverWentBad() { var fakeService = new FakeOpcUaClientService(); var factory = new FakeOpcUaClientServiceFactory(fakeService); var command = new SubscribeCommand(factory) { Url = "opc.tcp://localhost:4840", NodeId = "ns=2;s=GoodNode", // Use --duration so the command auto-exits. DurationSeconds = 2 }; using var console = TestConsoleHelper.CreateConsole(); var runTask = Task.Run(async () => await command.ExecuteAsync(console)); // Wait until the subscription is registered by the fake. await WaitForAsync(() => fakeService.SubscribeCalls.Count == 1); fakeService.RaiseDataChanged( "ns=2;s=GoodNode", new DataValue(new Variant(42), StatusCodes.Good, DateTime.UtcNow, DateTime.UtcNow)); await runTask; var output = TestConsoleHelper.GetOutput(console); output.ShouldContain("NEVER went bad (suspect): 1"); output.ShouldContain("Last status GOOD: 1"); output.ShouldContain("No update received at all: 0"); } [Fact] public async Task Summary_NodeReceivedBadValue_IsCountedAsEverWentBad() { var fakeService = new FakeOpcUaClientService(); var factory = new FakeOpcUaClientServiceFactory(fakeService); var command = new SubscribeCommand(factory) { Url = "opc.tcp://localhost:4840", NodeId = "ns=2;s=BadNode", DurationSeconds = 2 }; using var console = TestConsoleHelper.CreateConsole(); var runTask = Task.Run(async () => await command.ExecuteAsync(console)); await WaitForAsync(() => fakeService.SubscribeCalls.Count == 1); fakeService.RaiseDataChanged( "ns=2;s=BadNode", new DataValue(Variant.Null, StatusCodes.BadDeviceFailure, DateTime.UtcNow, DateTime.UtcNow)); await runTask; var output = TestConsoleHelper.GetOutput(console); output.ShouldContain("Ever went BAD during window: 1"); output.ShouldContain("NEVER went bad (suspect): 0"); } [Fact] public async Task Duration_ZeroOrPositive_AutoExits() { // --duration > 0 should make the command exit on its own without needing cancellation. var fakeService = new FakeOpcUaClientService(); var factory = new FakeOpcUaClientServiceFactory(fakeService); var command = new SubscribeCommand(factory) { Url = "opc.tcp://localhost:4840", NodeId = "ns=2;s=AutoExit", DurationSeconds = 1 }; using var console = TestConsoleHelper.CreateConsole(); var sw = System.Diagnostics.Stopwatch.StartNew(); await command.ExecuteAsync(console); sw.Stop(); sw.Elapsed.ShouldBeGreaterThanOrEqualTo(TimeSpan.FromMilliseconds(900)); sw.Elapsed.ShouldBeLessThan(TimeSpan.FromSeconds(10)); var output = TestConsoleHelper.GetOutput(console); output.ShouldContain("==================== SUMMARY ===================="); } [Fact] public async Task Quiet_SuppressesPerUpdateOutputButPrintsSummary() { var fakeService = new FakeOpcUaClientService(); var factory = new FakeOpcUaClientServiceFactory(fakeService); var command = new SubscribeCommand(factory) { Url = "opc.tcp://localhost:4840", NodeId = "ns=2;s=Quiet", Quiet = true, DurationSeconds = 2 }; using var console = TestConsoleHelper.CreateConsole(); var runTask = Task.Run(async () => await command.ExecuteAsync(console)); await WaitForAsync(() => fakeService.SubscribeCalls.Count == 1); fakeService.RaiseDataChanged( "ns=2;s=Quiet", new DataValue(new Variant(1.0), StatusCodes.Good, DateTime.UtcNow, DateTime.UtcNow)); await runTask; var output = TestConsoleHelper.GetOutput(console); // No per-update "value = ..." line should appear because --quiet was set. output.ShouldNotContain(" = 1 ("); // Summary section is still printed. output.ShouldContain("==================== SUMMARY ===================="); } [Fact] public async Task SummaryFile_WritesSummaryToDisk() { var fakeService = new FakeOpcUaClientService(); var factory = new FakeOpcUaClientServiceFactory(fakeService); var tempFile = Path.Combine(Path.GetTempPath(), $"otopcua-cli-summary-{Guid.NewGuid():N}.txt"); try { var command = new SubscribeCommand(factory) { Url = "opc.tcp://localhost:4840", NodeId = "ns=2;s=SummaryFileNode", DurationSeconds = 1, SummaryFile = tempFile }; using var console = TestConsoleHelper.CreateConsole(); await command.ExecuteAsync(console); File.Exists(tempFile).ShouldBeTrue(); var contents = await File.ReadAllTextAsync(tempFile); contents.ShouldContain("SUMMARY"); contents.ShouldContain("Total subscribed: 1"); } finally { if (File.Exists(tempFile)) File.Delete(tempFile); } } [Fact] public async Task Recursive_BrowsesSubtreeAndSubscribesEveryVariable() { // Root has a Folder child and a top-level Variable; the Folder contains a second Variable. // Each node id is distinct so the ToDictionary(t => t.nodeId.ToString()) collapse // doesn't trip a duplicate-key error. var fakeService = new FakeOpcUaClientService(); fakeService.BrowseResultsByParent["ns=2;s=Root"] = new List { new("ns=2;s=Folder", "Folder", "Object", true), new("ns=2;s=Tag1", "Tag1", "Variable", false) }; fakeService.BrowseResultsByParent["ns=2;s=Folder"] = new List { new("ns=2;s=Tag2", "Tag2", "Variable", false) }; var factory = new FakeOpcUaClientServiceFactory(fakeService); var command = new SubscribeCommand(factory) { Url = "opc.tcp://localhost:4840", NodeId = "ns=2;s=Root", Recursive = true, MaxDepth = 3, DurationSeconds = 1 }; using var console = TestConsoleHelper.CreateConsole(); await command.ExecuteAsync(console); // Both Variables (depth 1 + depth 2) should have been subscribed. fakeService.SubscribeCalls.Count.ShouldBeGreaterThanOrEqualTo(2); fakeService.SubscribeCalls.ShouldContain(c => c.NodeId.Identifier.ToString() == "Tag1"); fakeService.SubscribeCalls.ShouldContain(c => c.NodeId.Identifier.ToString() == "Tag2"); var output = TestConsoleHelper.GetOutput(console); output.ShouldContain("Browsing subtree of ns=2;s=Root"); } [Fact] public async Task SubscribeFailure_PrintsFailedMessage_DoesNotCrash() { var fakeService = new FakeOpcUaClientService { SubscribeException = new InvalidOperationException("forced failure") }; var factory = new FakeOpcUaClientServiceFactory(fakeService); var command = new SubscribeCommand(factory) { Url = "opc.tcp://localhost:4840", NodeId = "ns=2;s=FailNode", DurationSeconds = 1 }; using var console = TestConsoleHelper.CreateConsole(); await command.ExecuteAsync(console); var output = TestConsoleHelper.GetOutput(console); output.ShouldContain("FAILED to subscribe"); output.ShouldContain("forced failure"); // The summary block is still printed. output.ShouldContain("==================== SUMMARY ===================="); } private static async Task WaitForAsync(Func predicate, int timeoutMs = 5000) { var deadline = DateTime.UtcNow.AddMilliseconds(timeoutMs); while (!predicate() && DateTime.UtcNow < deadline) await Task.Delay(25); if (!predicate()) throw new TimeoutException("Condition not met within timeout."); } }