Files
element-x-ios/UnitTests/Sources/RoomNotificationSettingsScreenViewModelTests.swift
T
Mauro Romito 58fb7c4fcb Wait on durable state in three flaky test suites
These three suites assert against state that is still being loaded or has
already moved on, so they fail whenever a busy runner stretches the window.
All three passed on CI only because `-retry-tests-on-failure` re-ran them.

- MediaUploadPreviewScreenViewModelTests: the processing failure tests treated
  a one second `deferFailure` window as if it also meant the send had finished.
  Preprocessing the valid files outlasts it, so wait for the interaction to be
  re-enabled instead.
- SecurityAndPrivacyScreenViewModelTests: the screen loads its parent spaces and
  its room directory visibility in two independent tasks, and `isSaveDisabled`
  needs both. Every wait now gates on the visibility having landed too.
- RoomNotificationSettingsScreenViewModelTests: the delete tests waited on a
  transient `deletingCustomSetting` flag that the observation coalesces away
  under load. They now wait on the dismiss action and the alert.
2026-07-29 11:53:17 +02:00

311 lines
17 KiB
Swift

//
// Copyright 2025 Element Creations Ltd.
// Copyright 2022-2025 New Vector Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
// Please see LICENSE files in the repository root for full details.
//
import Combine
@testable import ElementX
import MatrixRustSDK
import Testing
@MainActor
struct RoomNotificationSettingsScreenViewModelTests {
var roomProxyMock: JoinedRoomProxyMock!
var notificationSettingsProxyMock: NotificationSettingsProxyMock!
var cancellables = Set<AnyCancellable>()
init() {
roomProxyMock = JoinedRoomProxyMock(.init(name: "Test"))
notificationSettingsProxyMock = NotificationSettingsProxyMock(with: NotificationSettingsProxyMockConfiguration())
}
@Test
func initialStateDefaultModeEncryptedRoom() async throws {
let roomProxyMock = JoinedRoomProxyMock(.init(name: "Test", isEncrypted: true))
let notificationSettingsProxyMock = NotificationSettingsProxyMock(with: NotificationSettingsProxyMockConfiguration())
notificationSettingsProxyMock.getNotificationSettingsRoomIdIsEncryptedIsOneToOneReturnValue = RoomNotificationSettingsProxyMock(with: .init(mode: .mentionsAndKeywordsOnly, isDefault: true))
let viewModel = RoomNotificationSettingsScreenViewModel(notificationSettingsProxy: notificationSettingsProxyMock,
roomProxy: roomProxyMock,
displayAsUserDefinedRoomSettings: false)
let deferred = deferFulfillment(viewModel.context.observe(\.viewState)) { state in
state.notificationSettingsState.isLoaded
}
notificationSettingsProxyMock.callbacks.send(.settingsDidChange)
try await deferred.fulfill()
#expect(!viewModel.context.allowCustomSetting)
#expect(viewModel.context.viewState.shouldDisplayMentionsOnlyDisclaimer)
#expect(viewModel.context.viewState.description(mode: .mentionsAndKeywordsOnly) != nil)
}
@Test
func initialStateDefaultModeEncryptedRoomWithCanPushEncrypted() async throws {
let roomProxyMock = JoinedRoomProxyMock(.init(name: "Test", isEncrypted: true))
let notificationSettingsProxyMock = NotificationSettingsProxyMock(with: .init(canPushEncryptedEvents: true))
notificationSettingsProxyMock.getNotificationSettingsRoomIdIsEncryptedIsOneToOneReturnValue = RoomNotificationSettingsProxyMock(with: .init(mode: .mentionsAndKeywordsOnly, isDefault: true))
let viewModel = RoomNotificationSettingsScreenViewModel(notificationSettingsProxy: notificationSettingsProxyMock,
roomProxy: roomProxyMock,
displayAsUserDefinedRoomSettings: false)
let deferred = deferFulfillment(viewModel.context.observe(\.viewState)) { state in
state.notificationSettingsState.isLoaded
}
notificationSettingsProxyMock.callbacks.send(.settingsDidChange)
try await deferred.fulfill()
#expect(!viewModel.context.allowCustomSetting)
#expect(!viewModel.context.viewState.shouldDisplayMentionsOnlyDisclaimer)
#expect(viewModel.context.viewState.description(mode: .mentionsAndKeywordsOnly) == nil)
}
@Test
func initialStateDefaultModeUnencryptedRoom() async throws {
let roomProxyMock = JoinedRoomProxyMock(.init(name: "Test", isEncrypted: false))
let notificationSettingsProxyMock = NotificationSettingsProxyMock(with: NotificationSettingsProxyMockConfiguration())
notificationSettingsProxyMock.getNotificationSettingsRoomIdIsEncryptedIsOneToOneReturnValue = RoomNotificationSettingsProxyMock(with: .init(mode: .mentionsAndKeywordsOnly, isDefault: true))
let viewModel = RoomNotificationSettingsScreenViewModel(notificationSettingsProxy: notificationSettingsProxyMock,
roomProxy: roomProxyMock,
displayAsUserDefinedRoomSettings: false)
let deferred = deferFulfillment(viewModel.context.observe(\.viewState)) { state in
state.notificationSettingsState.isLoaded
}
notificationSettingsProxyMock.callbacks.send(.settingsDidChange)
try await deferred.fulfill()
#expect(!viewModel.context.allowCustomSetting)
#expect(!viewModel.context.viewState.shouldDisplayMentionsOnlyDisclaimer)
#expect(viewModel.context.viewState.description(mode: .mentionsAndKeywordsOnly) == nil)
}
@Test
func initialStateCustomMode() async throws {
notificationSettingsProxyMock.getNotificationSettingsRoomIdIsEncryptedIsOneToOneReturnValue = RoomNotificationSettingsProxyMock(with: .init(mode: .mentionsAndKeywordsOnly, isDefault: false))
let viewModel = RoomNotificationSettingsScreenViewModel(notificationSettingsProxy: notificationSettingsProxyMock,
roomProxy: roomProxyMock,
displayAsUserDefinedRoomSettings: false)
let deferred = deferFulfillment(viewModel.context.observe(\.viewState)) { state in
state.notificationSettingsState.isLoaded
}
notificationSettingsProxyMock.callbacks.send(.settingsDidChange)
try await deferred.fulfill()
#expect(viewModel.context.allowCustomSetting)
}
@Test
func initialStateFailure() async throws {
notificationSettingsProxyMock.getNotificationSettingsRoomIdIsEncryptedIsOneToOneThrowableError = NotificationSettingsError.Generic(msg: "error")
let viewModel = RoomNotificationSettingsScreenViewModel(notificationSettingsProxy: notificationSettingsProxyMock,
roomProxy: roomProxyMock,
displayAsUserDefinedRoomSettings: false)
let deferred = deferFulfillment(viewModel.context.observe(\.viewState)) { state in
state.notificationSettingsState.isError
}
notificationSettingsProxyMock.callbacks.send(.settingsDidChange)
try await deferred.fulfill()
let expectedAlertInfo = AlertInfo(id: RoomNotificationSettingsScreenErrorType.loadingSettingsFailed,
title: L10n.commonError,
message: L10n.screenRoomNotificationSettingsErrorLoadingSettings)
#expect(viewModel.context.viewState.bindings.alertInfo?.id == expectedAlertInfo.id)
#expect(viewModel.context.viewState.bindings.alertInfo?.title == expectedAlertInfo.title)
#expect(viewModel.context.viewState.bindings.alertInfo?.message == expectedAlertInfo.message)
}
@Test
func toggleAllCustomSettingOff() async throws {
notificationSettingsProxyMock.getNotificationSettingsRoomIdIsEncryptedIsOneToOneReturnValue = RoomNotificationSettingsProxyMock(with: .init(mode: .mentionsAndKeywordsOnly, isDefault: false))
let viewModel = RoomNotificationSettingsScreenViewModel(notificationSettingsProxy: notificationSettingsProxyMock,
roomProxy: roomProxyMock,
displayAsUserDefinedRoomSettings: false)
let deferred = deferFulfillment(viewModel.context.observe(\.viewState)) { state in
state.notificationSettingsState.isLoaded
}
notificationSettingsProxyMock.callbacks.send(.settingsDidChange)
try await deferred.fulfill()
#expect(viewModel.context.viewState.isRestoringDefaultSetting == false)
let deferredIsRestoringDefaultSettings = deferFulfillment(viewModel.context.observe(\.viewState.isRestoringDefaultSetting),
transitionValues: [true, false])
viewModel.state.bindings.allowCustomSetting = false
viewModel.context.send(viewAction: .changedAllowCustomSettings)
try await deferredIsRestoringDefaultSettings.fulfill()
#expect(notificationSettingsProxyMock.restoreDefaultNotificationModeRoomIdReceivedRoomId == roomProxyMock.id)
#expect(notificationSettingsProxyMock.restoreDefaultNotificationModeRoomIdCallsCount == 1)
}
@Test
func toggleAllCustomSettingOffOn() async throws {
let notificationSettingsProxyMock = NotificationSettingsProxyMock(with: NotificationSettingsProxyMockConfiguration())
notificationSettingsProxyMock.getNotificationSettingsRoomIdIsEncryptedIsOneToOneReturnValue = RoomNotificationSettingsProxyMock(with: .init(mode: .mentionsAndKeywordsOnly, isDefault: true))
let viewModel = RoomNotificationSettingsScreenViewModel(notificationSettingsProxy: notificationSettingsProxyMock,
roomProxy: roomProxyMock,
displayAsUserDefinedRoomSettings: false)
var deferred = deferFulfillment(viewModel.context.observe(\.viewState)) { state in
state.notificationSettingsState.isLoaded
}
notificationSettingsProxyMock.callbacks.send(.settingsDidChange)
try await deferred.fulfill()
deferred = deferFulfillment(viewModel.context.observe(\.viewState)) { state in
state.notificationSettingsState.isLoaded
}
viewModel.state.bindings.allowCustomSetting = true
viewModel.context.send(viewAction: .changedAllowCustomSettings)
await waitForConfirmation { confirmation in
notificationSettingsProxyMock.setNotificationModeRoomIdModeClosure = { id, mode in
#expect(id == roomProxyMock.id)
#expect(mode == .mentionsAndKeywordsOnly)
confirmation()
}
}
try await deferred.fulfill()
}
@Test
func setCustomMode() async throws {
notificationSettingsProxyMock.getNotificationSettingsRoomIdIsEncryptedIsOneToOneReturnValue = RoomNotificationSettingsProxyMock(with: .init(mode: .mentionsAndKeywordsOnly, isDefault: false))
let viewModel = RoomNotificationSettingsScreenViewModel(notificationSettingsProxy: notificationSettingsProxyMock,
roomProxy: roomProxyMock,
displayAsUserDefinedRoomSettings: false)
let deferred = deferFulfillment(viewModel.context.observe(\.viewState)) { state in
state.notificationSettingsState.isLoaded
}
notificationSettingsProxyMock.callbacks.send(.settingsDidChange)
try await deferred.fulfill()
#expect(viewModel.context.viewState.pendingCustomMode == nil)
var deferredMode = deferFulfillment(viewModel.context.observe(\.viewState.pendingCustomMode),
transitionValues: [.allMessages, nil])
viewModel.context.send(viewAction: .setCustomMode(.allMessages))
try await deferredMode.fulfill()
#expect(notificationSettingsProxyMock.setNotificationModeRoomIdModeReceivedArguments?.0 == roomProxyMock.id)
#expect(notificationSettingsProxyMock.setNotificationModeRoomIdModeReceivedArguments?.1 == .allMessages)
#expect(notificationSettingsProxyMock.setNotificationModeRoomIdModeCallsCount == 1)
#expect(viewModel.context.viewState.pendingCustomMode == nil)
deferredMode = deferFulfillment(viewModel.context.observe(\.viewState.pendingCustomMode),
transitionValues: [.mute, nil])
viewModel.context.send(viewAction: .setCustomMode(.mute))
try await deferredMode.fulfill()
#expect(notificationSettingsProxyMock.setNotificationModeRoomIdModeReceivedArguments?.0 == roomProxyMock.id)
#expect(notificationSettingsProxyMock.setNotificationModeRoomIdModeReceivedArguments?.1 == .mute)
#expect(notificationSettingsProxyMock.setNotificationModeRoomIdModeCallsCount == 2)
try await Task.sleep(for: .milliseconds(10)) // Workaround for flaky test
#expect(viewModel.context.viewState.pendingCustomMode == nil)
deferredMode = deferFulfillment(viewModel.context.observe(\.viewState.pendingCustomMode),
transitionValues: [.mentionsAndKeywordsOnly, nil])
viewModel.context.send(viewAction: .setCustomMode(.mentionsAndKeywordsOnly))
try await deferredMode.fulfill()
#expect(notificationSettingsProxyMock.setNotificationModeRoomIdModeReceivedArguments?.0 == roomProxyMock.id)
#expect(notificationSettingsProxyMock.setNotificationModeRoomIdModeReceivedArguments?.1 == .mentionsAndKeywordsOnly)
#expect(notificationSettingsProxyMock.setNotificationModeRoomIdModeCallsCount == 3)
}
@Test
mutating func deleteCustomSettingTapped() async throws {
notificationSettingsProxyMock.getNotificationSettingsRoomIdIsEncryptedIsOneToOneReturnValue = RoomNotificationSettingsProxyMock(with: .init(mode: .mentionsAndKeywordsOnly, isDefault: false))
let viewModel = RoomNotificationSettingsScreenViewModel(notificationSettingsProxy: notificationSettingsProxyMock,
roomProxy: roomProxyMock,
displayAsUserDefinedRoomSettings: true)
let deferred = deferFulfillment(viewModel.context.observe(\.viewState)) { state in
state.notificationSettingsState.isLoaded
}
notificationSettingsProxyMock.callbacks.send(.settingsDidChange)
try await deferred.fulfill()
// The `deletingCustomSetting` flag is only raised for the duration of the call, which is short enough
// that the observation can coalesce it away. Wait on the outcome instead.
let deferredDismiss = deferFulfillment(viewModel.actions) { $0 == .dismiss }
viewModel.context.send(viewAction: .deleteCustomSettingTapped)
// the `dismiss` action must have been sent
try await deferredDismiss.fulfill()
#expect(!viewModel.context.viewState.deletingCustomSetting)
// `restoreDefaultNotificationMode` should have been called
#expect(notificationSettingsProxyMock.restoreDefaultNotificationModeRoomIdCalled)
#expect(notificationSettingsProxyMock.restoreDefaultNotificationModeRoomIdReceivedInvocations == [roomProxyMock.id])
// and no alert is expected
#expect(viewModel.context.alertInfo == nil)
}
@Test
mutating func deleteCustomSettingTappedFailure() async throws {
notificationSettingsProxyMock.getNotificationSettingsRoomIdIsEncryptedIsOneToOneReturnValue = RoomNotificationSettingsProxyMock(with: .init(mode: .mentionsAndKeywordsOnly, isDefault: false))
notificationSettingsProxyMock.restoreDefaultNotificationModeRoomIdThrowableError = NotificationSettingsError.Generic(msg: "error")
let viewModel = RoomNotificationSettingsScreenViewModel(notificationSettingsProxy: notificationSettingsProxyMock,
roomProxy: roomProxyMock,
displayAsUserDefinedRoomSettings: true)
let deferred = deferFulfillment(viewModel.context.observe(\.viewState)) { state in
state.notificationSettingsState.isLoaded
}
notificationSettingsProxyMock.callbacks.send(.settingsDidChange)
try await deferred.fulfill()
var actionSent: RoomNotificationSettingsScreenViewModelAction?
viewModel.actions
.sink { action in
actionSent = action
}
.store(in: &cancellables)
#expect(!viewModel.context.viewState.deletingCustomSetting)
// The `deletingCustomSetting` flag is only raised for the duration of the call, which is short enough
// that the observation can coalesce it away. Wait on the alert instead, it is raised before the flag
// is lowered again.
let deferredAlert = deferFulfillment(viewModel.context.observe(\.viewState.bindings.alertInfo)) { $0?.id == .restoreDefaultFailed }
viewModel.context.send(viewAction: .deleteCustomSettingTapped)
// an alert is expected
try await deferredAlert.fulfill()
#expect(!viewModel.context.viewState.deletingCustomSetting)
// the `dismiss` action must not have been sent
#expect(actionSent == nil)
}
}