using Opc.Ua; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Client.Shared.Models; using ZB.MOM.WW.OtOpcUa.Client.UI.Services; using ZB.MOM.WW.OtOpcUa.Client.UI.Tests.Fakes; using ZB.MOM.WW.OtOpcUa.Client.UI.ViewModels; namespace ZB.MOM.WW.OtOpcUa.Client.UI.Tests; public class AlarmsViewModelTests { private readonly FakeOpcUaClientService _service; private readonly AlarmsViewModel _vm; /// Initializes a new test instance. public AlarmsViewModelTests() { _service = new FakeOpcUaClientService(); var dispatcher = new SynchronousUiDispatcher(); _vm = new AlarmsViewModel(_service, dispatcher); } /// Verifies that SubscribeCommand cannot execute when disconnected. [Fact] public void SubscribeCommand_CannotExecute_WhenDisconnected() { _vm.IsConnected = false; _vm.SubscribeCommand.CanExecute(null).ShouldBeFalse(); } /// Verifies that SubscribeCommand cannot execute when already subscribed. [Fact] public void SubscribeCommand_CannotExecute_WhenAlreadySubscribed() { _vm.IsConnected = true; _vm.IsSubscribed = true; _vm.SubscribeCommand.CanExecute(null).ShouldBeFalse(); } /// Verifies that SubscribeCommand can execute when connected and not subscribed. [Fact] public void SubscribeCommand_CanExecute_WhenConnectedAndNotSubscribed() { _vm.IsConnected = true; _vm.IsSubscribed = false; _vm.SubscribeCommand.CanExecute(null).ShouldBeTrue(); } /// Verifies that SubscribeCommand sets IsSubscribed flag. [Fact] public async Task SubscribeCommand_SetsIsSubscribed() { _vm.IsConnected = true; await _vm.SubscribeCommand.ExecuteAsync(null); _vm.IsSubscribed.ShouldBeTrue(); _service.SubscribeAlarmsCallCount.ShouldBe(1); } /// Verifies that UnsubscribeCommand cannot execute when not subscribed. [Fact] public void UnsubscribeCommand_CannotExecute_WhenNotSubscribed() { _vm.IsConnected = true; _vm.IsSubscribed = false; _vm.UnsubscribeCommand.CanExecute(null).ShouldBeFalse(); } /// Verifies that UnsubscribeCommand clears IsSubscribed flag. [Fact] public async Task UnsubscribeCommand_ClearsIsSubscribed() { _vm.IsConnected = true; await _vm.SubscribeCommand.ExecuteAsync(null); await _vm.UnsubscribeCommand.ExecuteAsync(null); _vm.IsSubscribed.ShouldBeFalse(); _service.UnsubscribeAlarmsCallCount.ShouldBe(1); } /// Verifies that RefreshCommand calls the service. [Fact] public async Task RefreshCommand_CallsService() { _vm.IsConnected = true; _vm.IsSubscribed = true; await _vm.RefreshCommand.ExecuteAsync(null); _service.RequestConditionRefreshCallCount.ShouldBe(1); } /// Verifies that RefreshCommand cannot execute when not subscribed. [Fact] public void RefreshCommand_CannotExecute_WhenNotSubscribed() { _vm.IsConnected = true; _vm.IsSubscribed = false; _vm.RefreshCommand.CanExecute(null).ShouldBeFalse(); } /// Verifies that alarm events are added to the collection. [Fact] public void AlarmEvent_AddsToCollection() { var alarm = new AlarmEventArgs( "Source1", "HighAlarm", 500, "Temperature high", true, true, false, DateTime.UtcNow); _service.RaiseAlarmEvent(alarm); _vm.AlarmEvents.Count.ShouldBe(1); _vm.AlarmEvents[0].SourceName.ShouldBe("Source1"); _vm.AlarmEvents[0].ConditionName.ShouldBe("HighAlarm"); _vm.AlarmEvents[0].Severity.ShouldBe((ushort)500); _vm.AlarmEvents[0].Message.ShouldBe("Temperature high"); } /// Verifies that Clear resets the view model state. [Fact] public void Clear_ResetsState() { _vm.IsSubscribed = true; _vm.AlarmEvents.Add(new AlarmEventViewModel("Src", "Cond", 100, "Msg", true, true, false, DateTime.UtcNow)); _vm.Clear(); _vm.AlarmEvents.ShouldBeEmpty(); _vm.IsSubscribed.ShouldBeFalse(); } /// Verifies that Teardown unregisters the event handler. [Fact] public void Teardown_UnhooksEventHandler() { _vm.Teardown(); var alarm = new AlarmEventArgs( "Source1", "HighAlarm", 500, "Test", true, true, false, DateTime.UtcNow); _service.RaiseAlarmEvent(alarm); _vm.AlarmEvents.ShouldBeEmpty(); } /// Verifies that the default polling interval is 1000ms. [Fact] public void DefaultInterval_Is1000() { _vm.Interval.ShouldBe(1000); } /// /// Regression test for Client.UI-006 — when SubscribeAlarmsAsync throws, the failure must be /// surfaced to the operator via the view model's StatusMessage rather than silently swallowed. /// [Fact] public async Task Subscribe_OnFailure_SurfacesStatusMessage() { _vm.IsConnected = true; _service.SubscribeAlarmsException = new Exception("Server returned BadSubscriptionIdInvalid"); await _vm.SubscribeCommand.ExecuteAsync(null); _vm.IsSubscribed.ShouldBeFalse(); _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"); } /// Verifies that confirming an alarm without a ConditionNodeId is rejected without a service call. [Fact] public async Task ConfirmAlarm_MissingConditionNodeId_Fails_NoServiceCall() { _vm.IsConnected = true; var alarm = BuildAlarm(conditionNodeId: null); var (ok, _) = await _vm.ConfirmAlarmAsync(alarm, "note"); ok.ShouldBeFalse(); _service.ConfirmCallCount.ShouldBe(0); } /// Verifies that a transport exception from ShelveAlarmAsync is caught and surfaced as an error message. [Fact] public async Task ShelveAlarm_ServiceThrows_ReturnsError() { _vm.IsConnected = true; _service.ShelveException = new InvalidOperationException("transport error"); var alarm = BuildAlarm(); var (ok, message) = await _vm.ShelveAlarmAsync(alarm, ShelveKind.OneShot, 0); ok.ShouldBeFalse(); message.ShouldContain("Error"); } /// Verifies that a transport exception from ConfirmAlarmAsync is caught and surfaced as an error message. [Fact] public async Task ConfirmAlarm_ServiceThrows_ReturnsError() { _vm.IsConnected = true; _service.ConfirmException = new InvalidOperationException("transport error"); var alarm = BuildAlarm(); var (ok, message) = await _vm.ConfirmAlarmAsync(alarm, "note"); ok.ShouldBeFalse(); message.ShouldContain("Error"); } // --- 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(); } }