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); } // --- Alarm update and non-retain remove paths (Client.UI-012 / Client.UI-013) --- /// /// Regression test for Client.UI-013 / Client.UI-012 — a second event for the same /// source+condition must update the existing row in place rather than adding a duplicate. /// [Fact] public void AlarmEvent_ExistingAlarm_UpdatesInPlace() { var first = new AlarmEventArgs( "Source1", "HighAlarm", 500, "Temperature high", retain: true, activeState: true, ackedState: false, time: DateTime.UtcNow); _service.RaiseAlarmEvent(first); var updated = new AlarmEventArgs( "Source1", "HighAlarm", 750, "Temperature very high", retain: true, activeState: true, ackedState: false, time: DateTime.UtcNow); _service.RaiseAlarmEvent(updated); _vm.AlarmEvents.Count.ShouldBe(1); _vm.AlarmEvents[0].Severity.ShouldBe((ushort)750); _vm.AlarmEvents[0].Message.ShouldBe("Temperature very high"); } /// /// Regression test for Client.UI-013 — when an existing retained alarm becomes /// non-retained the row must be removed from the collection with exactly ONE collection /// mutation (not a Replace + Remove pair). /// [Fact] public void AlarmEvent_ExistingAlarmBecomesNonRetained_IsRemovedCleanly() { // Seed a retained alarm. var first = new AlarmEventArgs( "Source1", "HighAlarm", 500, "Active", retain: true, activeState: true, ackedState: false, time: DateTime.UtcNow); _service.RaiseAlarmEvent(first); _vm.AlarmEvents.Count.ShouldBe(1); // Track collection-change notifications. var changeCount = 0; _vm.AlarmEvents.CollectionChanged += (_, _) => changeCount++; // Now the alarm is cleared (Retain = false). var cleared = new AlarmEventArgs( "Source1", "HighAlarm", 500, "Cleared", retain: false, activeState: false, ackedState: false, time: DateTime.UtcNow); _service.RaiseAlarmEvent(cleared); // Row must be removed. _vm.AlarmEvents.ShouldBeEmpty(); // Exactly one collection-change notification (Remove), not two (Replace then Remove). changeCount.ShouldBe(1); } /// /// Verifies that a non-retained alarm that has no existing row in the collection /// is silently dropped (not added). /// [Fact] public void AlarmEvent_NonRetained_WithNoExistingRow_IsNotAdded() { var nonRetained = new AlarmEventArgs( "Source1", "HighAlarm", 500, "Already cleared", retain: false, activeState: false, ackedState: false, time: DateTime.UtcNow); _service.RaiseAlarmEvent(nonRetained); _vm.AlarmEvents.ShouldBeEmpty(); } /// /// Verifies that ActiveAlarmCount is updated correctly when an alarm is acknowledged /// via an event update (AckedState changes from false to true). /// [Fact] public void AlarmEvent_AcknowledgedUpdate_DecrementsActiveAlarmCount() { // Seed an unacknowledged active alarm. var active = new AlarmEventArgs( "Source1", "HighAlarm", 500, "Active", retain: true, activeState: true, ackedState: false, time: DateTime.UtcNow); _service.RaiseAlarmEvent(active); _vm.ActiveAlarmCount.ShouldBe(1); // Acknowledge the alarm. var acked = new AlarmEventArgs( "Source1", "HighAlarm", 500, "Acked", retain: true, activeState: true, ackedState: true, time: DateTime.UtcNow); _service.RaiseAlarmEvent(acked); _vm.AlarmEvents.Count.ShouldBe(1); _vm.AlarmEvents[0].AckedState.ShouldBeTrue(); _vm.ActiveAlarmCount.ShouldBe(0); } /// /// 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(); } }