From 4f55d894a25f907add783b7fc8f8b2187255c801 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 16 Jun 2026 18:23:11 -0400 Subject: [PATCH] feat(client-ui): AlarmsViewModel Shelve/Confirm methods + CanShelve/CanConfirm --- .../ViewModels/AlarmEventViewModel.cs | 6 + .../ViewModels/AlarmsViewModel.cs | 48 ++++ .../AlarmsViewModelTests.cs | 217 ++++++++++++++++++ 3 files changed, 271 insertions(+) diff --git a/src/Client/ZB.MOM.WW.OtOpcUa.Client.UI/ViewModels/AlarmEventViewModel.cs b/src/Client/ZB.MOM.WW.OtOpcUa.Client.UI/ViewModels/AlarmEventViewModel.cs index 3be319a2..0a9362be 100644 --- a/src/Client/ZB.MOM.WW.OtOpcUa.Client.UI/ViewModels/AlarmEventViewModel.cs +++ b/src/Client/ZB.MOM.WW.OtOpcUa.Client.UI/ViewModels/AlarmEventViewModel.cs @@ -96,4 +96,10 @@ public class AlarmEventViewModel : ObservableObject /// Whether this alarm can be acknowledged (active, not yet acked, has EventId). public bool CanAcknowledge => ActiveState && !AckedState && EventId != null && ConditionNodeId != null; + + /// Whether this alarm can be shelved/unshelved (has a ConditionNodeId). + public bool CanShelve => ConditionNodeId != null; + + /// Whether this alarm can be confirmed (already acked, has EventId + ConditionNodeId). + public bool CanConfirm => AckedState && EventId != null && ConditionNodeId != null; } diff --git a/src/Client/ZB.MOM.WW.OtOpcUa.Client.UI/ViewModels/AlarmsViewModel.cs b/src/Client/ZB.MOM.WW.OtOpcUa.Client.UI/ViewModels/AlarmsViewModel.cs index f7033901..50fe5159 100644 --- a/src/Client/ZB.MOM.WW.OtOpcUa.Client.UI/ViewModels/AlarmsViewModel.cs +++ b/src/Client/ZB.MOM.WW.OtOpcUa.Client.UI/ViewModels/AlarmsViewModel.cs @@ -192,6 +192,54 @@ public partial class AlarmsViewModel : ObservableObject } } + /// Shelves / unshelves an alarm and returns (success, message). + /// The alarm event to shelve or unshelve. + /// The shelve operation (OneShot / Timed / Unshelve). + /// The timed-shelve duration in seconds (required for ). + /// A tuple with success flag and message. + public async Task<(bool Success, string Message)> ShelveAlarmAsync( + AlarmEventViewModel alarm, ShelveKind kind, double durationSeconds) + { + if (!IsConnected || alarm.ConditionNodeId == null) + return (false, "Alarm cannot be shelved (not connected or missing ConditionId)."); + if (kind == ShelveKind.Timed && durationSeconds <= 0) + return (false, "Timed shelve requires a positive duration (seconds)."); + + try + { + var result = await _service.ShelveAlarmAsync(alarm.ConditionNodeId, kind, durationSeconds); + if (Opc.Ua.StatusCode.IsGood(result)) + return (true, $"Alarm {kind.ToString().ToLowerInvariant()} succeeded."); + return (false, $"Shelve failed: {Helpers.StatusCodeFormatter.Format(result)}"); + } + catch (Exception ex) + { + return (false, $"Error: {ex.Message}"); + } + } + + /// Confirms an alarm and returns (success, message). + /// The alarm event to confirm. + /// Optional comment for the confirmation. + /// A tuple with success flag and message. + public async Task<(bool Success, string Message)> ConfirmAlarmAsync(AlarmEventViewModel alarm, string comment) + { + if (!IsConnected || alarm.EventId == null || alarm.ConditionNodeId == null) + return (false, "Alarm cannot be confirmed (missing EventId or ConditionId)."); + + try + { + var result = await _service.ConfirmAlarmAsync(alarm.ConditionNodeId, alarm.EventId, comment); + if (Opc.Ua.StatusCode.IsGood(result)) + return (true, "Alarm confirmed successfully."); + return (false, $"Confirm failed: {Helpers.StatusCodeFormatter.Format(result)}"); + } + catch (Exception ex) + { + return (false, $"Error: {ex.Message}"); + } + } + /// /// Returns the monitored node ID for persistence, or null if not subscribed. /// diff --git a/tests/Client/ZB.MOM.WW.OtOpcUa.Client.UI.Tests/AlarmsViewModelTests.cs b/tests/Client/ZB.MOM.WW.OtOpcUa.Client.UI.Tests/AlarmsViewModelTests.cs index d24a1043..503ab5b3 100644 --- a/tests/Client/ZB.MOM.WW.OtOpcUa.Client.UI.Tests/AlarmsViewModelTests.cs +++ b/tests/Client/ZB.MOM.WW.OtOpcUa.Client.UI.Tests/AlarmsViewModelTests.cs @@ -1,3 +1,4 @@ +using Opc.Ua; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Client.Shared.Models; @@ -168,4 +169,220 @@ public class AlarmsViewModelTests _vm.StatusMessage.ShouldNotBeNullOrWhiteSpace(); _vm.StatusMessage.ShouldContain("BadSubscriptionIdInvalid"); } + + /// Builds a representative alarm row for shelve/confirm tests. + /// Whether the alarm is already acknowledged. + /// Whether the row carries an EventId; false models a missing EventId. + /// The condition node id; pass null to model a missing ConditionId. + private static AlarmEventViewModel BuildAlarm( + bool ackedState = false, + bool hasEventId = true, + string? conditionNodeId = "ns=2;s=Cond1") + { + return new AlarmEventViewModel( + "Source1", "HighAlarm", 500, "Temperature high", + true, true, ackedState, DateTime.UtcNow, + hasEventId ? [1, 2, 3] : null, conditionNodeId); + } + + // --- ShelveAlarmAsync --- + + /// Verifies that a OneShot shelve calls the service and reports success. + [Fact] + public async Task ShelveAlarm_Connected_OneShot_Succeeds() + { + _vm.IsConnected = true; + var alarm = BuildAlarm(); + + var (ok, _) = await _vm.ShelveAlarmAsync(alarm, ShelveKind.OneShot, 0); + + ok.ShouldBeTrue(); + _service.ShelveCallCount.ShouldBe(1); + _service.LastShelveCall.ShouldNotBeNull(); + _service.LastShelveCall!.Value.Kind.ShouldBe(ShelveKind.OneShot); + _service.LastShelveCall.Value.ConditionNodeId.ShouldBe(alarm.ConditionNodeId); + } + + /// Verifies that a Timed shelve passes the duration through to the service. + [Fact] + public async Task ShelveAlarm_Connected_Timed_PassesDuration() + { + _vm.IsConnected = true; + var alarm = BuildAlarm(); + + var (ok, _) = await _vm.ShelveAlarmAsync(alarm, ShelveKind.Timed, 300); + + ok.ShouldBeTrue(); + _service.LastShelveCall.ShouldNotBeNull(); + _service.LastShelveCall!.Value.Kind.ShouldBe(ShelveKind.Timed); + _service.LastShelveCall.Value.ShelvingTimeSeconds.ShouldBe(300); + } + + /// Verifies that an Unshelve calls the service and reports success. + [Fact] + public async Task ShelveAlarm_Connected_Unshelve_Succeeds() + { + _vm.IsConnected = true; + var alarm = BuildAlarm(); + + var (ok, _) = await _vm.ShelveAlarmAsync(alarm, ShelveKind.Unshelve, 0); + + ok.ShouldBeTrue(); + _service.LastShelveCall.ShouldNotBeNull(); + _service.LastShelveCall!.Value.Kind.ShouldBe(ShelveKind.Unshelve); + } + + /// Verifies that a Timed shelve with a non-positive duration is rejected without a service call. + [Fact] + public async Task ShelveAlarm_Timed_NonPositiveDuration_Fails_NoServiceCall() + { + _vm.IsConnected = true; + var alarm = BuildAlarm(); + + var (ok, _) = await _vm.ShelveAlarmAsync(alarm, ShelveKind.Timed, 0); + + ok.ShouldBeFalse(); + _service.ShelveCallCount.ShouldBe(0); + } + + /// Verifies that shelving when disconnected is rejected without a service call. + [Fact] + public async Task ShelveAlarm_NotConnected_Fails_NoServiceCall() + { + _vm.IsConnected = false; + var alarm = BuildAlarm(); + + var (ok, _) = await _vm.ShelveAlarmAsync(alarm, ShelveKind.OneShot, 0); + + ok.ShouldBeFalse(); + _service.ShelveCallCount.ShouldBe(0); + } + + /// Verifies that shelving an alarm without a ConditionNodeId is rejected without a service call. + [Fact] + public async Task ShelveAlarm_MissingConditionNodeId_Fails_NoServiceCall() + { + _vm.IsConnected = true; + var alarm = BuildAlarm(conditionNodeId: null); + + var (ok, _) = await _vm.ShelveAlarmAsync(alarm, ShelveKind.OneShot, 0); + + ok.ShouldBeFalse(); + _service.ShelveCallCount.ShouldBe(0); + } + + /// Verifies that a Bad status code from the service surfaces a "Shelve failed" message. + [Fact] + public async Task ShelveAlarm_ServiceBad_Fails_WithMessage() + { + _vm.IsConnected = true; + _service.ShelveResult = StatusCodes.BadNodeIdUnknown; + var alarm = BuildAlarm(); + + var (ok, message) = await _vm.ShelveAlarmAsync(alarm, ShelveKind.OneShot, 0); + + ok.ShouldBeFalse(); + message.ShouldContain("Shelve failed"); + } + + // --- ConfirmAlarmAsync --- + + /// Verifies that confirming an alarm calls the service with the supplied comment. + [Fact] + public async Task ConfirmAlarm_Connected_Succeeds_PassesComment() + { + _vm.IsConnected = true; + var alarm = BuildAlarm(); + + var (ok, _) = await _vm.ConfirmAlarmAsync(alarm, "operator note"); + + ok.ShouldBeTrue(); + _service.ConfirmCallCount.ShouldBe(1); + _service.LastConfirmCall.ShouldNotBeNull(); + _service.LastConfirmCall!.Value.Comment.ShouldBe("operator note"); + } + + /// Verifies that confirming when disconnected is rejected without a service call. + [Fact] + public async Task ConfirmAlarm_NotConnected_Fails_NoServiceCall() + { + _vm.IsConnected = false; + var alarm = BuildAlarm(); + + var (ok, _) = await _vm.ConfirmAlarmAsync(alarm, "note"); + + ok.ShouldBeFalse(); + _service.ConfirmCallCount.ShouldBe(0); + } + + /// Verifies that confirming an alarm without an EventId is rejected without a service call. + [Fact] + public async Task ConfirmAlarm_MissingEventId_Fails_NoServiceCall() + { + _vm.IsConnected = true; + var alarm = BuildAlarm(hasEventId: false); + + var (ok, _) = await _vm.ConfirmAlarmAsync(alarm, "note"); + + ok.ShouldBeFalse(); + _service.ConfirmCallCount.ShouldBe(0); + } + + /// Verifies that a Bad status code from the service surfaces a "Confirm failed" message. + [Fact] + public async Task ConfirmAlarm_ServiceBad_Fails_WithMessage() + { + _vm.IsConnected = true; + _service.ConfirmResult = StatusCodes.BadNodeIdUnknown; + var alarm = BuildAlarm(); + + var (ok, message) = await _vm.ConfirmAlarmAsync(alarm, "note"); + + ok.ShouldBeFalse(); + message.ShouldContain("Confirm failed"); + } + + // --- CanShelve / CanConfirm predicates --- + + /// Verifies CanShelve is true when a ConditionNodeId is present. + [Fact] + public void CanShelve_True_WhenConditionNodeIdPresent() + { + BuildAlarm(conditionNodeId: "ns=2;s=Cond1").CanShelve.ShouldBeTrue(); + } + + /// Verifies CanShelve is false when the ConditionNodeId is missing. + [Fact] + public void CanShelve_False_WhenConditionNodeIdNull() + { + BuildAlarm(conditionNodeId: null).CanShelve.ShouldBeFalse(); + } + + /// Verifies CanConfirm is true when acked with EventId and ConditionNodeId present. + [Fact] + public void CanConfirm_True_WhenAckedWithEventIdAndConditionNodeId() + { + BuildAlarm(ackedState: true).CanConfirm.ShouldBeTrue(); + } + + /// Verifies CanConfirm is false when the alarm has not been acknowledged. + [Fact] + public void CanConfirm_False_WhenNotAcked() + { + BuildAlarm(ackedState: false).CanConfirm.ShouldBeFalse(); + } + + /// Verifies CanConfirm is false when the EventId is missing. + [Fact] + public void CanConfirm_False_WhenEventIdNull() + { + BuildAlarm(ackedState: true, hasEventId: false).CanConfirm.ShouldBeFalse(); + } + + /// Verifies CanConfirm is false when the ConditionNodeId is missing. + [Fact] + public void CanConfirm_False_WhenConditionNodeIdNull() + { + BuildAlarm(ackedState: true, conditionNodeId: null).CanConfirm.ShouldBeFalse(); + } } \ No newline at end of file