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 index 3b962643..0c03386d 100644 --- a/src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI/Commands/ShelveCommand.cs +++ b/src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI/Commands/ShelveCommand.cs @@ -31,9 +31,10 @@ public class ShelveCommand : CommandBase public string Kind { get; init; } = default!; /// - /// Gets the shelving duration in seconds for Timed shelving (ignored for OneShot and Unshelve). + /// Gets the shelving duration in seconds for Timed shelving (must be > 0; ignored for OneShot and Unshelve). + /// The value is passed in seconds by the operator and converted to milliseconds for the OPC UA TimedShelve call. /// - [CommandOption("duration", 'd', Description = "Shelving duration in seconds (required for --kind Timed)")] + [CommandOption("duration", 'd', Description = "Shelving duration in seconds (must be > 0; in seconds, converted to milliseconds for the OPC UA call; required for --kind Timed)")] public double DurationSeconds { get; init; } /// @@ -61,9 +62,9 @@ public class ShelveCommand : CommandBase var statusCode = await service.ShelveAlarmAsync(NodeId, shelveKind, DurationSeconds, ct); if (StatusCode.IsGood(statusCode)) - await console.Output.WriteLineAsync($"Shelve ({shelveKind}) successful: {NodeId}"); + await console.Output.WriteLineAsync($"{shelveKind} successful: {NodeId}"); else - await console.Output.WriteLineAsync($"Shelve ({shelveKind}) failed: {statusCode}"); + await console.Output.WriteLineAsync($"{shelveKind} failed: {statusCode}"); } finally { 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 8d9d5916..2eafcbd4 100644 --- a/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/IOpcUaClientService.cs +++ b/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/IOpcUaClientService.cs @@ -123,7 +123,9 @@ public interface IOpcUaClientService : IDisposable /// 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. + /// For Timed shelving: the shelving duration in seconds (must be > 0). + /// This value is converted to milliseconds when passed to the OPC UA TimedShelve method + /// (Duration is milliseconds per OPC UA Part 9). Ignored for OneShot and Unshelve. /// /// The cancellation token that aborts the shelve request. /// 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 45d88aec..28d9870c 100644 --- a/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/OpcUaClientService.cs +++ b/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/OpcUaClientService.cs @@ -417,7 +417,9 @@ public sealed class OpcUaClientService : IOpcUaClientService // 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"); + var shelvingStateNodeId = conditionNodeId.EndsWith(".ShelvingState") + ? NodeId.Parse(conditionNodeId) + : NodeId.Parse(conditionNodeId + ".ShelvingState"); NodeId methodId; object[] inputArgs; @@ -433,7 +435,8 @@ public sealed class OpcUaClientService : IOpcUaClientService throw new ArgumentOutOfRangeException(nameof(shelvingTimeSeconds), "Timed shelving requires a positive shelvingTimeSeconds value."); methodId = MethodIds.AlarmConditionType_ShelvingState_TimedShelve; - inputArgs = [shelvingTimeSeconds]; + // OPC UA Part 9 TimedShelve ShelvingTime input is a Duration (Double) in milliseconds. + inputArgs = [shelvingTimeSeconds * 1000.0]; break; case ShelveKind.Unshelve: methodId = MethodIds.AlarmConditionType_ShelvingState_Unshelve; diff --git a/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/Fakes/FakeSessionAdapter.cs b/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/Fakes/FakeSessionAdapter.cs index d5ea10e6..5abf1068 100644 --- a/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/Fakes/FakeSessionAdapter.cs +++ b/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/Fakes/FakeSessionAdapter.cs @@ -202,11 +202,18 @@ internal sealed class FakeSessionAdapter : ISessionAdapter /// public Exception? CallMethodException { get; set; } + /// + /// Records the input arguments passed to each invocation. + /// Index 0 is the first call, index 1 the second, etc. + /// + public List CallMethodInputArgs { get; } = []; + /// public Task?> CallMethodAsync(NodeId objectId, NodeId methodId, object[] inputArguments, CancellationToken ct = default) { CallMethodCount++; + CallMethodInputArgs.Add(inputArguments); if (CallMethodException != null) throw CallMethodException; return Task.FromResult?>(null); diff --git a/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/OpcUaClientServiceTests.cs b/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/OpcUaClientServiceTests.cs index 0213728b..5f4d3d07 100644 --- a/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/OpcUaClientServiceTests.cs +++ b/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/OpcUaClientServiceTests.cs @@ -1057,6 +1057,123 @@ public class OpcUaClientServiceTests : IDisposable session.CallMethodCount.ShouldBe(1); } + // --- ConfirmAlarm tests --- + + /// + /// Verifies that a successful confirm call returns + /// and reaches the session adapter's CallMethodAsync exactly once. + /// + [Fact] + public async Task ConfirmAlarmAsync_OnSuccess_ReturnsGood() + { + var session = new FakeSessionAdapter(); + _sessionFactory.EnqueueSession(session); + await _service.ConnectAsync(ValidSettings()); + + var result = await _service.ConfirmAlarmAsync("ns=2;s=Cond", new byte[] { 1, 2 }, "confirmed"); + + result.ShouldBe(StatusCodes.Good); + session.CallMethodCount.ShouldBe(1); + } + + /// + /// Verifies that a from the session is captured and returned + /// as a bad rather than propagating to the caller. + /// + [Fact] + public async Task ConfirmAlarmAsync_OnServiceResultException_ReturnsBadStatusCode() + { + var session = new FakeSessionAdapter + { + CallMethodException = new ServiceResultException( + StatusCodes.BadConditionAlreadyEnabled, "already confirmed") + }; + _sessionFactory.EnqueueSession(session); + await _service.ConnectAsync(ValidSettings()); + + var result = await _service.ConfirmAlarmAsync("ns=2;s=Cond", new byte[] { 1, 2 }, "confirmed"); + + StatusCode.IsBad(result).ShouldBeTrue(); + result.Code.ShouldBe(StatusCodes.BadConditionAlreadyEnabled); + } + + // --- ShelveAlarm tests --- + + /// + /// Verifies that OneShot shelving calls the session adapter once with no input arguments. + /// + [Fact] + public async Task ShelveAlarmAsync_OneShot_CallsMethodWithNoArgs() + { + var session = new FakeSessionAdapter(); + _sessionFactory.EnqueueSession(session); + await _service.ConnectAsync(ValidSettings()); + + var result = await _service.ShelveAlarmAsync("ns=2;s=Cond", ShelveKind.OneShot); + + result.ShouldBe(StatusCodes.Good); + session.CallMethodCount.ShouldBe(1); + session.CallMethodInputArgs[0].ShouldBeEmpty(); + } + + /// + /// Verifies that Timed shelving passes the duration converted to milliseconds (seconds × 1000) + /// as the first input argument — regression guard for the Important 1 units bug. + /// + [Fact] + public async Task ShelveAlarmAsync_Timed_PassesDurationInMilliseconds() + { + var session = new FakeSessionAdapter(); + _sessionFactory.EnqueueSession(session); + await _service.ConnectAsync(ValidSettings()); + + const double durationSeconds = 30.0; + var result = await _service.ShelveAlarmAsync("ns=2;s=Cond", ShelveKind.Timed, durationSeconds); + + result.ShouldBe(StatusCodes.Good); + session.CallMethodCount.ShouldBe(1); + session.CallMethodInputArgs[0].Length.ShouldBe(1); + session.CallMethodInputArgs[0][0].ShouldBe(durationSeconds * 1000.0); + } + + /// + /// Verifies that Unshelve calls the session adapter once with no input arguments. + /// + [Fact] + public async Task ShelveAlarmAsync_Unshelve_CallsMethodWithNoArgs() + { + var session = new FakeSessionAdapter(); + _sessionFactory.EnqueueSession(session); + await _service.ConnectAsync(ValidSettings()); + + var result = await _service.ShelveAlarmAsync("ns=2;s=Cond", ShelveKind.Unshelve); + + result.ShouldBe(StatusCodes.Good); + session.CallMethodCount.ShouldBe(1); + session.CallMethodInputArgs[0].ShouldBeEmpty(); + } + + /// + /// Verifies that a from the session is captured and returned + /// as a bad rather than propagating to the caller. + /// + [Fact] + public async Task ShelveAlarmAsync_OnServiceResultException_ReturnsBadStatusCode() + { + var session = new FakeSessionAdapter + { + CallMethodException = new ServiceResultException( + StatusCodes.BadConditionAlreadyShelved, "already shelved") + }; + _sessionFactory.EnqueueSession(session); + await _service.ConnectAsync(ValidSettings()); + + var result = await _service.ShelveAlarmAsync("ns=2;s=Cond", ShelveKind.OneShot); + + StatusCode.IsBad(result).ShouldBeTrue(); + result.Code.ShouldBe(StatusCodes.BadConditionAlreadyShelved); + } + // --- Alarm fallback path (Client.Shared-011) --- ///