feat(client-ui): AlarmsViewModel Shelve/Confirm methods + CanShelve/CanConfirm

This commit is contained in:
Joseph Doherty
2026-06-16 18:23:11 -04:00
parent 8980adceb3
commit 4f55d894a2
3 changed files with 271 additions and 0 deletions
@@ -96,4 +96,10 @@ public class AlarmEventViewModel : ObservableObject
/// <summary>Whether this alarm can be acknowledged (active, not yet acked, has EventId).</summary>
public bool CanAcknowledge => ActiveState && !AckedState && EventId != null && ConditionNodeId != null;
/// <summary>Whether this alarm can be shelved/unshelved (has a ConditionNodeId).</summary>
public bool CanShelve => ConditionNodeId != null;
/// <summary>Whether this alarm can be confirmed (already acked, has EventId + ConditionNodeId).</summary>
public bool CanConfirm => AckedState && EventId != null && ConditionNodeId != null;
}
@@ -192,6 +192,54 @@ public partial class AlarmsViewModel : ObservableObject
}
}
/// <summary>Shelves / unshelves an alarm and returns (success, message).</summary>
/// <param name="alarm">The alarm event to shelve or unshelve.</param>
/// <param name="kind">The shelve operation (OneShot / Timed / Unshelve).</param>
/// <param name="durationSeconds">The timed-shelve duration in seconds (required for <see cref="ShelveKind.Timed"/>).</param>
/// <returns>A tuple with success flag and message.</returns>
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}");
}
}
/// <summary>Confirms an alarm and returns (success, message).</summary>
/// <param name="alarm">The alarm event to confirm.</param>
/// <param name="comment">Optional comment for the confirmation.</param>
/// <returns>A tuple with success flag and message.</returns>
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}");
}
}
/// <summary>
/// Returns the monitored node ID for persistence, or null if not subscribed.
/// </summary>
@@ -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");
}
/// <summary>Builds a representative alarm row for shelve/confirm tests.</summary>
/// <param name="ackedState">Whether the alarm is already acknowledged.</param>
/// <param name="hasEventId">Whether the row carries an EventId; <c>false</c> models a missing EventId.</param>
/// <param name="conditionNodeId">The condition node id; pass <c>null</c> to model a missing ConditionId.</param>
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 ---
/// <summary>Verifies that a OneShot shelve calls the service and reports success.</summary>
[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);
}
/// <summary>Verifies that a Timed shelve passes the duration through to the service.</summary>
[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);
}
/// <summary>Verifies that an Unshelve calls the service and reports success.</summary>
[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);
}
/// <summary>Verifies that a Timed shelve with a non-positive duration is rejected without a service call.</summary>
[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);
}
/// <summary>Verifies that shelving when disconnected is rejected without a service call.</summary>
[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);
}
/// <summary>Verifies that shelving an alarm without a ConditionNodeId is rejected without a service call.</summary>
[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);
}
/// <summary>Verifies that a Bad status code from the service surfaces a "Shelve failed" message.</summary>
[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 ---
/// <summary>Verifies that confirming an alarm calls the service with the supplied comment.</summary>
[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");
}
/// <summary>Verifies that confirming when disconnected is rejected without a service call.</summary>
[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);
}
/// <summary>Verifies that confirming an alarm without an EventId is rejected without a service call.</summary>
[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);
}
/// <summary>Verifies that a Bad status code from the service surfaces a "Confirm failed" message.</summary>
[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 ---
/// <summary>Verifies CanShelve is true when a ConditionNodeId is present.</summary>
[Fact]
public void CanShelve_True_WhenConditionNodeIdPresent()
{
BuildAlarm(conditionNodeId: "ns=2;s=Cond1").CanShelve.ShouldBeTrue();
}
/// <summary>Verifies CanShelve is false when the ConditionNodeId is missing.</summary>
[Fact]
public void CanShelve_False_WhenConditionNodeIdNull()
{
BuildAlarm(conditionNodeId: null).CanShelve.ShouldBeFalse();
}
/// <summary>Verifies CanConfirm is true when acked with EventId and ConditionNodeId present.</summary>
[Fact]
public void CanConfirm_True_WhenAckedWithEventIdAndConditionNodeId()
{
BuildAlarm(ackedState: true).CanConfirm.ShouldBeTrue();
}
/// <summary>Verifies CanConfirm is false when the alarm has not been acknowledged.</summary>
[Fact]
public void CanConfirm_False_WhenNotAcked()
{
BuildAlarm(ackedState: false).CanConfirm.ShouldBeFalse();
}
/// <summary>Verifies CanConfirm is false when the EventId is missing.</summary>
[Fact]
public void CanConfirm_False_WhenEventIdNull()
{
BuildAlarm(ackedState: true, hasEventId: false).CanConfirm.ShouldBeFalse();
}
/// <summary>Verifies CanConfirm is false when the ConditionNodeId is missing.</summary>
[Fact]
public void CanConfirm_False_WhenConditionNodeIdNull()
{
BuildAlarm(ackedState: true, conditionNodeId: null).CanConfirm.ShouldBeFalse();
}
}