diff --git a/src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI/Commands/AcknowledgeCommand.cs b/src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI/Commands/AcknowledgeCommand.cs new file mode 100644 index 00000000..6fb54813 --- /dev/null +++ b/src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI/Commands/AcknowledgeCommand.cs @@ -0,0 +1,78 @@ +using CliFx.Attributes; +using CliFx.Exceptions; +using CliFx.Infrastructure; +using Opc.Ua; +using ZB.MOM.WW.OtOpcUa.Client.Shared; + +namespace ZB.MOM.WW.OtOpcUa.Client.CLI.Commands; + +[Command("ack", Description = "Acknowledge an active alarm condition (OPC UA Part 9)")] +public class AcknowledgeCommand : CommandBase +{ + /// + /// Creates the acknowledge command used to acknowledge an active OPC UA condition from the terminal. + /// + /// The factory that creates the shared client service for the command run. + public AcknowledgeCommand(IOpcUaClientServiceFactory factory) : base(factory) + { + } + + /// + /// Gets the condition node ID of the alarm event to acknowledge. + /// + [CommandOption("node", 'n', Description = "Condition node ID of the alarm to acknowledge", IsRequired = true)] + public string NodeId { get; init; } = default!; + + /// + /// Gets the event ID (hex-encoded) returned by the server in the alarm notification. + /// + [CommandOption("event-id", 'e', Description = "EventId from the alarm notification (hex-encoded byte array)", IsRequired = true)] + public string EventId { get; init; } = default!; + + /// + /// Gets the operator comment to include with the acknowledgment. + /// + [CommandOption("comment", 'c', Description = "Operator comment for the acknowledgment")] + public string Comment { get; init; } = string.Empty; + + /// + /// Connects to the server and acknowledges the specified alarm condition. + /// + /// The CLI console used for output and cancellation handling. + public override async ValueTask ExecuteAsync(IConsole console) + { + ConfigureLogging(); + + byte[] eventId; + try + { + eventId = Convert.FromHexString(EventId); + } + catch (FormatException ex) + { + throw new CommandException($"Invalid --event-id value: {ex.Message} (expected hex-encoded bytes, e.g. 0A1B2C)"); + } + + IOpcUaClientService? service = null; + try + { + var ct = console.RegisterCancellationHandler(); + (service, _) = await CreateServiceAndConnectAsync(ct); + + var statusCode = await service.AcknowledgeAlarmAsync(NodeId, eventId, Comment, ct); + + if (StatusCode.IsGood(statusCode)) + await console.Output.WriteLineAsync($"Acknowledge successful: {NodeId}"); + else + await console.Output.WriteLineAsync($"Acknowledge failed: {statusCode}"); + } + finally + { + if (service != null) + { + await service.DisconnectAsync(); + service.Dispose(); + } + } + } +} diff --git a/src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI/Commands/ConfirmCommand.cs b/src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI/Commands/ConfirmCommand.cs new file mode 100644 index 00000000..1865503c --- /dev/null +++ b/src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI/Commands/ConfirmCommand.cs @@ -0,0 +1,78 @@ +using CliFx.Attributes; +using CliFx.Exceptions; +using CliFx.Infrastructure; +using Opc.Ua; +using ZB.MOM.WW.OtOpcUa.Client.Shared; + +namespace ZB.MOM.WW.OtOpcUa.Client.CLI.Commands; + +[Command("confirm", Description = "Confirm an acknowledged alarm condition (OPC UA Part 9 two-stage acknowledgment)")] +public class ConfirmCommand : CommandBase +{ + /// + /// Creates the confirm command used to confirm an acknowledged OPC UA condition from the terminal. + /// + /// The factory that creates the shared client service for the command run. + public ConfirmCommand(IOpcUaClientServiceFactory factory) : base(factory) + { + } + + /// + /// Gets the condition node ID of the alarm event to confirm. + /// + [CommandOption("node", 'n', Description = "Condition node ID of the alarm to confirm", IsRequired = true)] + public string NodeId { get; init; } = default!; + + /// + /// Gets the event ID (hex-encoded) returned by the server in the alarm notification. + /// + [CommandOption("event-id", 'e', Description = "EventId from the alarm notification (hex-encoded byte array)", IsRequired = true)] + public string EventId { get; init; } = default!; + + /// + /// Gets the operator comment to include with the confirmation. + /// + [CommandOption("comment", 'c', Description = "Operator comment for the confirmation")] + public string Comment { get; init; } = string.Empty; + + /// + /// Connects to the server and confirms the specified acknowledged alarm condition. + /// + /// The CLI console used for output and cancellation handling. + public override async ValueTask ExecuteAsync(IConsole console) + { + ConfigureLogging(); + + byte[] eventId; + try + { + eventId = Convert.FromHexString(EventId); + } + catch (FormatException ex) + { + throw new CommandException($"Invalid --event-id value: {ex.Message} (expected hex-encoded bytes, e.g. 0A1B2C)"); + } + + IOpcUaClientService? service = null; + try + { + var ct = console.RegisterCancellationHandler(); + (service, _) = await CreateServiceAndConnectAsync(ct); + + var statusCode = await service.ConfirmAlarmAsync(NodeId, eventId, Comment, ct); + + if (StatusCode.IsGood(statusCode)) + await console.Output.WriteLineAsync($"Confirm successful: {NodeId}"); + else + await console.Output.WriteLineAsync($"Confirm failed: {statusCode}"); + } + finally + { + if (service != null) + { + await service.DisconnectAsync(); + service.Dispose(); + } + } + } +} diff --git a/src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI/Commands/ShelveCommand.cs b/src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI/Commands/ShelveCommand.cs new file mode 100644 index 00000000..3b962643 --- /dev/null +++ b/src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI/Commands/ShelveCommand.cs @@ -0,0 +1,77 @@ +using CliFx.Attributes; +using CliFx.Exceptions; +using CliFx.Infrastructure; +using Opc.Ua; +using ZB.MOM.WW.OtOpcUa.Client.Shared; +using ZB.MOM.WW.OtOpcUa.Client.Shared.Models; + +namespace ZB.MOM.WW.OtOpcUa.Client.CLI.Commands; + +[Command("shelve", Description = "Shelve or unshelve an active alarm condition (OPC UA Part 9 ShelvedStateMachine)")] +public class ShelveCommand : CommandBase +{ + /// + /// Creates the shelve command used to shelve or unshelve an OPC UA alarm condition from the terminal. + /// + /// The factory that creates the shared client service for the command run. + public ShelveCommand(IOpcUaClientServiceFactory factory) : base(factory) + { + } + + /// + /// Gets the condition node ID of the alarm to shelve or unshelve. + /// + [CommandOption("node", 'n', Description = "Condition node ID of the alarm to shelve/unshelve", IsRequired = true)] + public string NodeId { get; init; } = default!; + + /// + /// Gets the shelve operation kind: OneShot, Timed, or Unshelve. + /// + [CommandOption("kind", 'k', Description = "Shelve operation: OneShot | Timed | Unshelve", IsRequired = true)] + public string Kind { get; init; } = default!; + + /// + /// Gets the shelving duration in seconds for Timed shelving (ignored for OneShot and Unshelve). + /// + [CommandOption("duration", 'd', Description = "Shelving duration in seconds (required for --kind Timed)")] + public double DurationSeconds { get; init; } + + /// + /// Connects to the server and shelves or unshelves the specified alarm condition. + /// + /// The CLI console used for output and cancellation handling. + public override async ValueTask ExecuteAsync(IConsole console) + { + ConfigureLogging(); + + if (!Enum.TryParse(Kind, ignoreCase: true, out var shelveKind)) + throw new CommandException( + $"Invalid --kind value '{Kind}'. Expected one of: OneShot, Timed, Unshelve."); + + if (shelveKind == ShelveKind.Timed && DurationSeconds <= 0) + throw new CommandException( + "--duration must be greater than 0 when --kind is Timed."); + + IOpcUaClientService? service = null; + try + { + var ct = console.RegisterCancellationHandler(); + (service, _) = await CreateServiceAndConnectAsync(ct); + + var statusCode = await service.ShelveAlarmAsync(NodeId, shelveKind, DurationSeconds, ct); + + if (StatusCode.IsGood(statusCode)) + await console.Output.WriteLineAsync($"Shelve ({shelveKind}) successful: {NodeId}"); + else + await console.Output.WriteLineAsync($"Shelve ({shelveKind}) failed: {statusCode}"); + } + finally + { + if (service != null) + { + await service.DisconnectAsync(); + service.Dispose(); + } + } + } +} diff --git a/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/IOpcUaClientService.cs b/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/IOpcUaClientService.cs index 6990dde4..8d9d5916 100644 --- a/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/IOpcUaClientService.cs +++ b/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/IOpcUaClientService.cs @@ -103,6 +103,36 @@ public interface IOpcUaClientService : IDisposable /// Task AcknowledgeAlarmAsync(string conditionNodeId, byte[] eventId, string comment, CancellationToken ct = default); + /// + /// Confirms an acknowledged condition (Part 9 two-stage acknowledgment) using the event identifier returned by an alarm notification. + /// + /// The condition node associated with the alarm event being confirmed. + /// The event identifier returned by the OPC UA server for the alarm event. + /// The operator confirmation comment to write with the method call. + /// The cancellation token that aborts the confirmation request. + /// + /// on success, or the server's bad + /// (from the underlying ) when the confirm call + /// returns a bad result. Other transport-level failures still surface as exceptions. + /// + Task ConfirmAlarmAsync(string conditionNodeId, byte[] eventId, string comment, CancellationToken ct = default); + + /// + /// Shelves or unshelves an active alarm condition (OPC UA Part 9 ShelvedStateMachine). + /// + /// The condition node associated with the alarm being shelved. + /// The shelve operation: OneShot, Timed, or Unshelve. + /// + /// For Timed shelving: the shelving duration in seconds. Ignored for OneShot and Unshelve. + /// + /// The cancellation token that aborts the shelve request. + /// + /// on success, or the server's bad + /// (from the underlying ) when the shelve call + /// returns a bad result. Other transport-level failures still surface as exceptions. + /// + Task ShelveAlarmAsync(string conditionNodeId, ShelveKind kind, double shelvingTimeSeconds = 0, CancellationToken ct = default); + /// /// Reads raw historical samples for a historized node. /// diff --git a/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Models/ShelveKind.cs b/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Models/ShelveKind.cs new file mode 100644 index 00000000..adc64ecb --- /dev/null +++ b/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Models/ShelveKind.cs @@ -0,0 +1,25 @@ +namespace ZB.MOM.WW.OtOpcUa.Client.Shared.Models; + +/// +/// Shelve operations supported by the OPC UA Part 9 ShelvedStateMachine. +/// +public enum ShelveKind +{ + /// + /// OneShotShelve: suppresses the alarm for one occurrence. The alarm automatically + /// unshelves when it next returns to the inactive state. + /// + OneShot, + + /// + /// TimedShelve: suppresses the alarm for a specified duration (seconds). + /// Requires a positive shelvingTimeSeconds argument. + /// + Timed, + + /// + /// Unshelve: removes an active OneShotShelve or TimedShelve, returning the alarm + /// to its normal active-notification state. + /// + Unshelve +} diff --git a/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/OpcUaClientService.cs b/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/OpcUaClientService.cs index 57a6ac70..45d88aec 100644 --- a/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/OpcUaClientService.cs +++ b/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/OpcUaClientService.cs @@ -376,6 +376,88 @@ public sealed class OpcUaClientService : IOpcUaClientService return StatusCodes.Good; } + /// + public async Task ConfirmAlarmAsync(string conditionNodeId, byte[] eventId, string comment, + CancellationToken ct = default) + { + ThrowIfDisposed(); + ThrowIfNotConnected(); + + // Confirm lives on the same .Condition child node as Acknowledge + var conditionObjId = conditionNodeId.EndsWith(".Condition") + ? NodeId.Parse(conditionNodeId) + : NodeId.Parse(conditionNodeId + ".Condition"); + var confirmMethodId = MethodIds.AcknowledgeableConditionType_Confirm; + + try + { + await _session!.CallMethodAsync( + conditionObjId, + confirmMethodId, + [eventId, new LocalizedText(comment)], + ct); + } + catch (ServiceResultException ex) + { + Logger.Warning(ex, "Failed to confirm alarm on {ConditionId} (status {Status})", + conditionNodeId, ex.StatusCode); + return ex.StatusCode; + } + + Logger.Debug("Confirmed alarm on {ConditionId}", conditionNodeId); + return StatusCodes.Good; + } + + /// + public async Task ShelveAlarmAsync(string conditionNodeId, ShelveKind kind, + double shelvingTimeSeconds = 0, CancellationToken ct = default) + { + ThrowIfDisposed(); + ThrowIfNotConnected(); + + // The shelve methods live on the ShelvingState child of the condition node. + // conditionNodeId is the alarm/condition node; append .ShelvingState to get the state machine node. + var shelvingStateNodeId = NodeId.Parse(conditionNodeId + ".ShelvingState"); + + NodeId methodId; + object[] inputArgs; + + switch (kind) + { + case ShelveKind.OneShot: + methodId = MethodIds.AlarmConditionType_ShelvingState_OneShotShelve; + inputArgs = []; + break; + case ShelveKind.Timed: + if (shelvingTimeSeconds <= 0) + throw new ArgumentOutOfRangeException(nameof(shelvingTimeSeconds), + "Timed shelving requires a positive shelvingTimeSeconds value."); + methodId = MethodIds.AlarmConditionType_ShelvingState_TimedShelve; + inputArgs = [shelvingTimeSeconds]; + break; + case ShelveKind.Unshelve: + methodId = MethodIds.AlarmConditionType_ShelvingState_Unshelve; + inputArgs = []; + break; + default: + throw new ArgumentOutOfRangeException(nameof(kind), kind, "Unknown ShelveKind value."); + } + + try + { + await _session!.CallMethodAsync(shelvingStateNodeId, methodId, inputArgs, ct); + } + catch (ServiceResultException ex) + { + Logger.Warning(ex, "Failed to shelve alarm on {ConditionId} kind={Kind} (status {Status})", + conditionNodeId, kind, ex.StatusCode); + return ex.StatusCode; + } + + Logger.Debug("Shelved alarm on {ConditionId} kind={Kind}", conditionNodeId, kind); + return StatusCodes.Good; + } + /// public async Task> HistoryReadRawAsync( NodeId nodeId, DateTime startTime, DateTime endTime, int maxValues = 1000, CancellationToken ct = default) diff --git a/tests/Client/ZB.MOM.WW.OtOpcUa.Client.CLI.Tests/AcknowledgeCommandTests.cs b/tests/Client/ZB.MOM.WW.OtOpcUa.Client.CLI.Tests/AcknowledgeCommandTests.cs new file mode 100644 index 00000000..e4b72b56 --- /dev/null +++ b/tests/Client/ZB.MOM.WW.OtOpcUa.Client.CLI.Tests/AcknowledgeCommandTests.cs @@ -0,0 +1,106 @@ +using CliFx.Exceptions; +using Opc.Ua; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Client.CLI.Commands; +using ZB.MOM.WW.OtOpcUa.Client.CLI.Tests.Fakes; + +namespace ZB.MOM.WW.OtOpcUa.Client.CLI.Tests; + +public class AcknowledgeCommandTests +{ + private static string HexOf(byte[] bytes) => Convert.ToHexString(bytes); + + /// Verifies that a successful ack prints a success message. + [Fact] + public async Task Execute_Success_PrintsSuccessMessage() + { + var fakeService = new FakeOpcUaClientService + { + AcknowledgeAlarmResult = StatusCodes.Good + }; + var factory = new FakeOpcUaClientServiceFactory(fakeService); + var eventId = new byte[] { 0x01, 0x02, 0x03 }; + var command = new AcknowledgeCommand(factory) + { + Url = "opc.tcp://localhost:4840", + NodeId = "ns=2;s=MyAlarm", + EventId = HexOf(eventId), + Comment = "test comment" + }; + + using var console = TestConsoleHelper.CreateConsole(); + await command.ExecuteAsync(console); + + var output = TestConsoleHelper.GetOutput(console); + output.ShouldContain("Acknowledge successful"); + fakeService.AcknowledgeAlarmCalls.Count.ShouldBe(1); + fakeService.AcknowledgeAlarmCalls[0].ConditionNodeId.ShouldBe("ns=2;s=MyAlarm"); + fakeService.AcknowledgeAlarmCalls[0].EventId.ShouldBe(eventId); + fakeService.AcknowledgeAlarmCalls[0].Comment.ShouldBe("test comment"); + } + + /// Verifies that a bad StatusCode from the service prints a failure message. + [Fact] + public async Task Execute_BadStatus_PrintsFailureMessage() + { + var fakeService = new FakeOpcUaClientService + { + AcknowledgeAlarmResult = StatusCodes.BadEventNotAcknowledgeable + }; + var factory = new FakeOpcUaClientServiceFactory(fakeService); + var command = new AcknowledgeCommand(factory) + { + Url = "opc.tcp://localhost:4840", + NodeId = "ns=2;s=MyAlarm", + EventId = HexOf(new byte[] { 0xAB }), + Comment = "" + }; + + using var console = TestConsoleHelper.CreateConsole(); + await command.ExecuteAsync(console); + + var output = TestConsoleHelper.GetOutput(console); + output.ShouldContain("Acknowledge failed"); + } + + /// Verifies that an invalid hex event-id throws CommandException. + [Fact] + public async Task Execute_InvalidEventId_ThrowsCommandException() + { + var fakeService = new FakeOpcUaClientService(); + var factory = new FakeOpcUaClientServiceFactory(fakeService); + var command = new AcknowledgeCommand(factory) + { + Url = "opc.tcp://localhost:4840", + NodeId = "ns=2;s=MyAlarm", + EventId = "not-hex!", + Comment = "" + }; + + using var console = TestConsoleHelper.CreateConsole(); + var ex = await Should.ThrowAsync(async () => await command.ExecuteAsync(console)); + ex.Message.ShouldContain("event-id", Case.Insensitive); + } + + /// Verifies that the command disconnects in the finally block. + [Fact] + public async Task Execute_DisconnectsInFinally() + { + var fakeService = new FakeOpcUaClientService(); + var factory = new FakeOpcUaClientServiceFactory(fakeService); + var command = new AcknowledgeCommand(factory) + { + Url = "opc.tcp://localhost:4840", + NodeId = "ns=2;s=MyAlarm", + EventId = HexOf(new byte[] { 0x01 }), + Comment = "" + }; + + using var console = TestConsoleHelper.CreateConsole(); + await command.ExecuteAsync(console); + + fakeService.DisconnectCalled.ShouldBeTrue(); + fakeService.DisposeCalled.ShouldBeTrue(); + } +} diff --git a/tests/Client/ZB.MOM.WW.OtOpcUa.Client.CLI.Tests/ConfirmCommandTests.cs b/tests/Client/ZB.MOM.WW.OtOpcUa.Client.CLI.Tests/ConfirmCommandTests.cs new file mode 100644 index 00000000..ced07312 --- /dev/null +++ b/tests/Client/ZB.MOM.WW.OtOpcUa.Client.CLI.Tests/ConfirmCommandTests.cs @@ -0,0 +1,106 @@ +using CliFx.Exceptions; +using Opc.Ua; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Client.CLI.Commands; +using ZB.MOM.WW.OtOpcUa.Client.CLI.Tests.Fakes; + +namespace ZB.MOM.WW.OtOpcUa.Client.CLI.Tests; + +public class ConfirmCommandTests +{ + private static string HexOf(byte[] bytes) => Convert.ToHexString(bytes); + + /// Verifies that a successful confirm prints a success message and passes correct arguments to the service. + [Fact] + public async Task Execute_Success_PrintsSuccessMessage() + { + var fakeService = new FakeOpcUaClientService + { + ConfirmAlarmResult = StatusCodes.Good + }; + var factory = new FakeOpcUaClientServiceFactory(fakeService); + var eventId = new byte[] { 0x0A, 0x1B, 0x2C }; + var command = new ConfirmCommand(factory) + { + Url = "opc.tcp://localhost:4840", + NodeId = "ns=2;s=MyAlarm", + EventId = HexOf(eventId), + Comment = "confirmed by operator" + }; + + using var console = TestConsoleHelper.CreateConsole(); + await command.ExecuteAsync(console); + + var output = TestConsoleHelper.GetOutput(console); + output.ShouldContain("Confirm successful"); + fakeService.ConfirmAlarmCalls.Count.ShouldBe(1); + fakeService.ConfirmAlarmCalls[0].ConditionNodeId.ShouldBe("ns=2;s=MyAlarm"); + fakeService.ConfirmAlarmCalls[0].EventId.ShouldBe(eventId); + fakeService.ConfirmAlarmCalls[0].Comment.ShouldBe("confirmed by operator"); + } + + /// Verifies that a bad StatusCode from the service prints a failure message. + [Fact] + public async Task Execute_BadStatus_PrintsFailureMessage() + { + var fakeService = new FakeOpcUaClientService + { + ConfirmAlarmResult = StatusCodes.BadConditionBranchAlreadyConfirmed + }; + var factory = new FakeOpcUaClientServiceFactory(fakeService); + var command = new ConfirmCommand(factory) + { + Url = "opc.tcp://localhost:4840", + NodeId = "ns=2;s=MyAlarm", + EventId = HexOf(new byte[] { 0xFF }), + Comment = "" + }; + + using var console = TestConsoleHelper.CreateConsole(); + await command.ExecuteAsync(console); + + var output = TestConsoleHelper.GetOutput(console); + output.ShouldContain("Confirm failed"); + } + + /// Verifies that an invalid hex event-id throws CommandException. + [Fact] + public async Task Execute_InvalidEventId_ThrowsCommandException() + { + var fakeService = new FakeOpcUaClientService(); + var factory = new FakeOpcUaClientServiceFactory(fakeService); + var command = new ConfirmCommand(factory) + { + Url = "opc.tcp://localhost:4840", + NodeId = "ns=2;s=MyAlarm", + EventId = "ZZZ-not-hex", + Comment = "" + }; + + using var console = TestConsoleHelper.CreateConsole(); + var ex = await Should.ThrowAsync(async () => await command.ExecuteAsync(console)); + ex.Message.ShouldContain("event-id", Case.Insensitive); + } + + /// Verifies that the command disconnects in the finally block. + [Fact] + public async Task Execute_DisconnectsInFinally() + { + var fakeService = new FakeOpcUaClientService(); + var factory = new FakeOpcUaClientServiceFactory(fakeService); + var command = new ConfirmCommand(factory) + { + Url = "opc.tcp://localhost:4840", + NodeId = "ns=2;s=MyAlarm", + EventId = HexOf(new byte[] { 0x01 }), + Comment = "" + }; + + using var console = TestConsoleHelper.CreateConsole(); + await command.ExecuteAsync(console); + + fakeService.DisconnectCalled.ShouldBeTrue(); + fakeService.DisposeCalled.ShouldBeTrue(); + } +} diff --git a/tests/Client/ZB.MOM.WW.OtOpcUa.Client.CLI.Tests/Fakes/FakeOpcUaClientService.cs b/tests/Client/ZB.MOM.WW.OtOpcUa.Client.CLI.Tests/Fakes/FakeOpcUaClientService.cs index c10745ad..8b213c4c 100644 --- a/tests/Client/ZB.MOM.WW.OtOpcUa.Client.CLI.Tests/Fakes/FakeOpcUaClientService.cs +++ b/tests/Client/ZB.MOM.WW.OtOpcUa.Client.CLI.Tests/Fakes/FakeOpcUaClientService.cs @@ -220,11 +220,46 @@ public sealed class FakeOpcUaClientService : IOpcUaClientService return Task.CompletedTask; } + /// Gets the list of (conditionNodeId, eventId, comment) tuples from AcknowledgeAlarmAsync calls. + public List<(string ConditionNodeId, byte[] EventId, string Comment)> AcknowledgeAlarmCalls { get; } = []; + + /// Gets or sets the status code returned by AcknowledgeAlarmAsync. + public StatusCode AcknowledgeAlarmResult { get; set; } = StatusCodes.Good; + /// public Task AcknowledgeAlarmAsync(string conditionNodeId, byte[] eventId, string comment, CancellationToken ct = default) { - return Task.FromResult(new StatusCode(StatusCodes.Good)); + AcknowledgeAlarmCalls.Add((conditionNodeId, eventId, comment)); + return Task.FromResult(AcknowledgeAlarmResult); + } + + /// Gets the list of (conditionNodeId, eventId, comment) tuples from ConfirmAlarmAsync calls. + public List<(string ConditionNodeId, byte[] EventId, string Comment)> ConfirmAlarmCalls { get; } = []; + + /// Gets or sets the status code returned by ConfirmAlarmAsync. + public StatusCode ConfirmAlarmResult { get; set; } = StatusCodes.Good; + + /// + public Task ConfirmAlarmAsync(string conditionNodeId, byte[] eventId, string comment, + CancellationToken ct = default) + { + ConfirmAlarmCalls.Add((conditionNodeId, eventId, comment)); + return Task.FromResult(ConfirmAlarmResult); + } + + /// Gets the list of (conditionNodeId, kind, durationSeconds) tuples from ShelveAlarmAsync calls. + public List<(string ConditionNodeId, ShelveKind Kind, double DurationSeconds)> ShelveAlarmCalls { get; } = []; + + /// Gets or sets the status code returned by ShelveAlarmAsync. + public StatusCode ShelveAlarmResult { get; set; } = StatusCodes.Good; + + /// + public Task ShelveAlarmAsync(string conditionNodeId, ShelveKind kind, double shelvingTimeSeconds = 0, + CancellationToken ct = default) + { + ShelveAlarmCalls.Add((conditionNodeId, kind, shelvingTimeSeconds)); + return Task.FromResult(ShelveAlarmResult); } /// diff --git a/tests/Client/ZB.MOM.WW.OtOpcUa.Client.CLI.Tests/ShelveCommandTests.cs b/tests/Client/ZB.MOM.WW.OtOpcUa.Client.CLI.Tests/ShelveCommandTests.cs new file mode 100644 index 00000000..8f978827 --- /dev/null +++ b/tests/Client/ZB.MOM.WW.OtOpcUa.Client.CLI.Tests/ShelveCommandTests.cs @@ -0,0 +1,181 @@ +using CliFx.Exceptions; +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 ShelveCommandTests +{ + /// Verifies that OneShot shelving produces the correct kind and zero duration. + [Fact] + public async Task Execute_OneShotKind_PassesOneShotToService() + { + var fakeService = new FakeOpcUaClientService { ShelveAlarmResult = StatusCodes.Good }; + var factory = new FakeOpcUaClientServiceFactory(fakeService); + var command = new ShelveCommand(factory) + { + Url = "opc.tcp://localhost:4840", + NodeId = "ns=2;s=MyAlarm", + Kind = "OneShot", + DurationSeconds = 0 + }; + + using var console = TestConsoleHelper.CreateConsole(); + await command.ExecuteAsync(console); + + fakeService.ShelveAlarmCalls.Count.ShouldBe(1); + fakeService.ShelveAlarmCalls[0].Kind.ShouldBe(ShelveKind.OneShot); + fakeService.ShelveAlarmCalls[0].DurationSeconds.ShouldBe(0); + fakeService.ShelveAlarmCalls[0].ConditionNodeId.ShouldBe("ns=2;s=MyAlarm"); + var output = TestConsoleHelper.GetOutput(console); + output.ShouldContain("successful"); + } + + /// Verifies that Timed shelving passes the correct duration to the service. + [Fact] + public async Task Execute_TimedKind_PassesDurationToService() + { + var fakeService = new FakeOpcUaClientService { ShelveAlarmResult = StatusCodes.Good }; + var factory = new FakeOpcUaClientServiceFactory(fakeService); + var command = new ShelveCommand(factory) + { + Url = "opc.tcp://localhost:4840", + NodeId = "ns=2;s=MyAlarm", + Kind = "Timed", + DurationSeconds = 300 + }; + + using var console = TestConsoleHelper.CreateConsole(); + await command.ExecuteAsync(console); + + fakeService.ShelveAlarmCalls.Count.ShouldBe(1); + fakeService.ShelveAlarmCalls[0].Kind.ShouldBe(ShelveKind.Timed); + fakeService.ShelveAlarmCalls[0].DurationSeconds.ShouldBe(300); + } + + /// Verifies that Unshelve produces the Unshelve kind. + [Fact] + public async Task Execute_UnshelveKind_PassesUnshelveToService() + { + var fakeService = new FakeOpcUaClientService { ShelveAlarmResult = StatusCodes.Good }; + var factory = new FakeOpcUaClientServiceFactory(fakeService); + var command = new ShelveCommand(factory) + { + Url = "opc.tcp://localhost:4840", + NodeId = "ns=2;s=MyAlarm", + Kind = "Unshelve", + DurationSeconds = 0 + }; + + using var console = TestConsoleHelper.CreateConsole(); + await command.ExecuteAsync(console); + + fakeService.ShelveAlarmCalls.Count.ShouldBe(1); + fakeService.ShelveAlarmCalls[0].Kind.ShouldBe(ShelveKind.Unshelve); + } + + /// Verifies that kind parsing is case-insensitive (e.g. "oneshot" works). + [Fact] + public async Task Execute_KindCaseInsensitive_ParsesCorrectly() + { + var fakeService = new FakeOpcUaClientService { ShelveAlarmResult = StatusCodes.Good }; + var factory = new FakeOpcUaClientServiceFactory(fakeService); + var command = new ShelveCommand(factory) + { + Url = "opc.tcp://localhost:4840", + NodeId = "ns=2;s=MyAlarm", + Kind = "oneshot", + DurationSeconds = 0 + }; + + using var console = TestConsoleHelper.CreateConsole(); + await command.ExecuteAsync(console); + + fakeService.ShelveAlarmCalls.Count.ShouldBe(1); + fakeService.ShelveAlarmCalls[0].Kind.ShouldBe(ShelveKind.OneShot); + } + + /// Verifies that an invalid Kind value throws CommandException. + [Fact] + public async Task Execute_InvalidKind_ThrowsCommandException() + { + var fakeService = new FakeOpcUaClientService(); + var factory = new FakeOpcUaClientServiceFactory(fakeService); + var command = new ShelveCommand(factory) + { + Url = "opc.tcp://localhost:4840", + NodeId = "ns=2;s=MyAlarm", + Kind = "SuperShelve", + DurationSeconds = 0 + }; + + using var console = TestConsoleHelper.CreateConsole(); + var ex = await Should.ThrowAsync(async () => await command.ExecuteAsync(console)); + ex.Message.ShouldContain("kind", Case.Insensitive); + } + + /// Verifies that Timed with zero duration throws CommandException (missing --duration). + [Fact] + public async Task Execute_TimedWithZeroDuration_ThrowsCommandException() + { + var fakeService = new FakeOpcUaClientService(); + var factory = new FakeOpcUaClientServiceFactory(fakeService); + var command = new ShelveCommand(factory) + { + Url = "opc.tcp://localhost:4840", + NodeId = "ns=2;s=MyAlarm", + Kind = "Timed", + DurationSeconds = 0 + }; + + using var console = TestConsoleHelper.CreateConsole(); + var ex = await Should.ThrowAsync(async () => await command.ExecuteAsync(console)); + ex.Message.ShouldContain("duration", Case.Insensitive); + } + + /// Verifies that a bad status code from the service prints a failure message. + [Fact] + public async Task Execute_BadStatus_PrintsFailureMessage() + { + var fakeService = new FakeOpcUaClientService { ShelveAlarmResult = StatusCodes.BadNotSupported }; + var factory = new FakeOpcUaClientServiceFactory(fakeService); + var command = new ShelveCommand(factory) + { + Url = "opc.tcp://localhost:4840", + NodeId = "ns=2;s=MyAlarm", + Kind = "OneShot", + DurationSeconds = 0 + }; + + using var console = TestConsoleHelper.CreateConsole(); + await command.ExecuteAsync(console); + + var output = TestConsoleHelper.GetOutput(console); + output.ShouldContain("failed"); + } + + /// Verifies that the command disconnects in the finally block. + [Fact] + public async Task Execute_DisconnectsInFinally() + { + var fakeService = new FakeOpcUaClientService(); + var factory = new FakeOpcUaClientServiceFactory(fakeService); + var command = new ShelveCommand(factory) + { + Url = "opc.tcp://localhost:4840", + NodeId = "ns=2;s=MyAlarm", + Kind = "OneShot", + DurationSeconds = 0 + }; + + using var console = TestConsoleHelper.CreateConsole(); + await command.ExecuteAsync(console); + + fakeService.DisconnectCalled.ShouldBeTrue(); + fakeService.DisposeCalled.ShouldBeTrue(); + } +} diff --git a/tests/Client/ZB.MOM.WW.OtOpcUa.Client.UI.Tests/Fakes/FakeOpcUaClientService.cs b/tests/Client/ZB.MOM.WW.OtOpcUa.Client.UI.Tests/Fakes/FakeOpcUaClientService.cs index 32b7a1a9..e8cbbdbb 100644 --- a/tests/Client/ZB.MOM.WW.OtOpcUa.Client.UI.Tests/Fakes/FakeOpcUaClientService.cs +++ b/tests/Client/ZB.MOM.WW.OtOpcUa.Client.UI.Tests/Fakes/FakeOpcUaClientService.cs @@ -263,6 +263,26 @@ public sealed class FakeOpcUaClientService : IOpcUaClientService return Task.FromResult(AcknowledgeResult); } + /// Gets or sets the status code returned by confirmation operations in UI tests. + public StatusCode ConfirmResult { get; set; } = StatusCodes.Good; + + /// + public Task ConfirmAlarmAsync(string conditionNodeId, byte[] eventId, string comment, + CancellationToken ct = default) + { + return Task.FromResult(ConfirmResult); + } + + /// Gets or sets the status code returned by shelve operations in UI tests. + public StatusCode ShelveResult { get; set; } = StatusCodes.Good; + + /// + public Task ShelveAlarmAsync(string conditionNodeId, ShelveKind kind, double shelvingTimeSeconds = 0, + CancellationToken ct = default) + { + return Task.FromResult(ShelveResult); + } + /// public Task> HistoryReadRawAsync(NodeId nodeId, DateTime startTime, DateTime endTime, int maxValues = 1000, CancellationToken ct = default)