mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-01 18:23:03 +00:00
49457f7ae8
* Add a multi-account feature flag The flag lives in Developer options. Switching it on arms the upcoming one-time multi-account announcement, whose seen state defaults to true so fresh installs never see it. * Add an Add account row to Settings behind the multi-account flag The row sits in its own section under the user status, as in the designs. Tapping it is routed up to the settings flow, which ignores it until the add account flow exists. * Remove the Developer options view model tests The Developer options screen is excluded from coverage, so these tests don't add value. * Apply review suggestions to the multi-account flag - Order the multi-account requirements in DeveloperOptionsProtocol to match the screen's sections. - Drop the redundant "Experimental" from the toggle's subtitle.
263 lines
9.6 KiB
Swift
263 lines
9.6 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 Testing
|
|
|
|
@MainActor
|
|
struct SettingsScreenViewModelTests {
|
|
private var clientProxy: ClientProxyMock!
|
|
private var viewModel: SettingsScreenViewModelProtocol!
|
|
private var context: SettingsScreenViewModelType.Context {
|
|
viewModel.context
|
|
}
|
|
|
|
// MARK: - User status
|
|
|
|
@Test
|
|
mutating func statusPickerFlow() {
|
|
// Given a screen where no status has been set.
|
|
setupViewModel()
|
|
#expect(!context.viewState.bindings.isPresentingStatusPicker)
|
|
#expect(!context.viewState.bindings.isShowingCustomStatusField)
|
|
#expect(context.viewState.userStatusRowMode == .pickStatusButton)
|
|
|
|
// When choosing to pick a status.
|
|
context.send(viewAction: .userStatus(.pickStatus))
|
|
|
|
// Then the status picker should be presented.
|
|
#expect(context.viewState.bindings.isPresentingStatusPicker)
|
|
#expect(!context.viewState.bindings.isShowingCustomStatusField)
|
|
#expect(context.viewState.userStatusRowMode == .pickStatusButton)
|
|
|
|
// When choosing to enter a custom status.
|
|
context.send(viewAction: .userStatus(.customStatus))
|
|
|
|
// Then the picker should be dismissed and the custom status field shown.
|
|
#expect(!context.viewState.bindings.isPresentingStatusPicker)
|
|
#expect(context.viewState.bindings.isShowingCustomStatusField)
|
|
#expect(context.viewState.userStatusRowMode == .customStatusInput(emoji: "😄"))
|
|
|
|
// When cancelling.
|
|
context.send(viewAction: .userStatus(.cancel))
|
|
|
|
// Then all status editing should be dismissed.
|
|
#expect(!context.viewState.bindings.isPresentingStatusPicker)
|
|
#expect(!context.viewState.bindings.isShowingCustomStatusField)
|
|
#expect(context.viewState.userStatusRowMode == .pickStatusButton)
|
|
}
|
|
|
|
@Test
|
|
mutating func persistingStatus() async throws {
|
|
// Given a screen where no status has been set or removed yet.
|
|
setupViewModel()
|
|
#expect(clientProxy.setUserStatusCallsCount == 0)
|
|
#expect(clientProxy.clearUserStatusCallsCount == 0)
|
|
|
|
// When setting a status.
|
|
let status = UserStatus.Raw(text: "Away", emoji: "🌴")
|
|
let (setStream, setContinuation) = AsyncStream<UserStatus.Raw>.makeStream()
|
|
clientProxy.setUserStatusClosure = {
|
|
setContinuation.yield($0)
|
|
return .success(())
|
|
}
|
|
let statusSet = deferFulfillment(setStream) { _ in true }
|
|
context.send(viewAction: .userStatus(.set(status)))
|
|
|
|
// Then only the set endpoint should be called, with the chosen status.
|
|
#expect(try await statusSet.fulfill() == status)
|
|
#expect(clientProxy.setUserStatusCallsCount == 1)
|
|
#expect(clientProxy.clearUserStatusCallsCount == 0)
|
|
|
|
// When clearing the status.
|
|
let (removeStream, removeContinuation) = AsyncStream<Void>.makeStream()
|
|
clientProxy.clearUserStatusClosure = {
|
|
removeContinuation.yield()
|
|
return .success(())
|
|
}
|
|
let statusRemoved = deferFulfillment(removeStream) { _ in true }
|
|
context.send(viewAction: .userStatus(.clear))
|
|
|
|
// Then the clear user status endpoint should be called.
|
|
try await statusRemoved.fulfill()
|
|
#expect(clientProxy.clearUserStatusCallsCount == 1)
|
|
#expect(clientProxy.setUserStatusCallsCount == 1)
|
|
}
|
|
|
|
@Test
|
|
mutating func clearCallStatus() async throws {
|
|
// Given a screen where the user has a call status set.
|
|
let callStatus = UserStatus.mockCall
|
|
setupViewModel(status: callStatus)
|
|
#expect(!clientProxy.clearUserStatusCalled)
|
|
#expect(!clientProxy.setUserStatusCalled)
|
|
|
|
// When clearing the status.
|
|
let (removeStream, removeContinuation) = AsyncStream<Void>.makeStream()
|
|
clientProxy.clearUserStatusClosure = {
|
|
removeContinuation.yield()
|
|
return .success(())
|
|
}
|
|
let statusRemoved = deferFulfillment(removeStream) { _ in true }
|
|
context.send(viewAction: .userStatus(.clear))
|
|
|
|
// Then the clear user status endpoint should be called.
|
|
try await statusRemoved.fulfill()
|
|
#expect(clientProxy.clearUserStatusCallsCount == 1)
|
|
#expect(!clientProxy.setUserStatusCalled)
|
|
}
|
|
|
|
@Test
|
|
mutating func showsExistingStatus() throws {
|
|
// Given a screen where the user has a status set.
|
|
let status = UserStatus.mockHoliday
|
|
setupViewModel(status: status)
|
|
|
|
// Then that status should be shown.
|
|
let displayedStatus = try #require(status.displayed)
|
|
#expect(context.viewState.userStatusRowMode == .showingStatus(displayedStatus))
|
|
}
|
|
|
|
@Test
|
|
mutating func selectingCustomStatusEmoji() async throws {
|
|
// Given a custom status field showing the default emoji.
|
|
setupViewModel()
|
|
context.send(viewAction: .userStatus(.customStatus))
|
|
#expect(context.viewState.userStatusRowMode == .customStatusInput(emoji: "😄"))
|
|
|
|
// When selecting an emoji from the picker.
|
|
try await selectCustomStatusEmoji("🎉")
|
|
|
|
// Then the custom status field should show the selected emoji.
|
|
#expect(context.viewState.userStatusRowMode == .customStatusInput(emoji: "🎉"))
|
|
|
|
// When cancelling and returning to the custom status field.
|
|
context.send(viewAction: .userStatus(.cancel))
|
|
context.send(viewAction: .userStatus(.customStatus))
|
|
|
|
// Then the emoji should have been reset to the default.
|
|
#expect(context.viewState.userStatusRowMode == .customStatusInput(emoji: "😄"))
|
|
}
|
|
|
|
@Test
|
|
mutating func reportBug() async throws {
|
|
setupViewModel()
|
|
|
|
let deferred = deferFulfillment(viewModel.actions) { $0.isReportBug }
|
|
context.send(viewAction: .reportBug)
|
|
try await deferred.fulfill()
|
|
}
|
|
|
|
@Test
|
|
mutating func analytics() async throws {
|
|
setupViewModel()
|
|
|
|
let deferred = deferFulfillment(viewModel.actions) { $0.isAnalytics }
|
|
context.send(viewAction: .analytics)
|
|
try await deferred.fulfill()
|
|
}
|
|
|
|
@Test
|
|
mutating func logout() async throws {
|
|
setupViewModel()
|
|
|
|
let deferred = deferFulfillment(viewModel.actions) { $0.isLogout }
|
|
context.send(viewAction: .logout)
|
|
try await deferred.fulfill()
|
|
}
|
|
|
|
// MARK: - Add account
|
|
|
|
@Test
|
|
mutating func addAccountButtonFollowsTheFeatureFlag() async throws {
|
|
let userSettings = UserSettings.volatile()
|
|
setupViewModel(userSettings: userSettings)
|
|
#expect(!context.viewState.showAddAccountButton)
|
|
|
|
var deferred = deferFulfillment(context.observe(\.viewState.showAddAccountButton)) { $0 }
|
|
userSettings.multiAccountEnabled = true
|
|
try await deferred.fulfill()
|
|
|
|
deferred = deferFulfillment(context.observe(\.viewState.showAddAccountButton)) { !$0 }
|
|
userSettings.multiAccountEnabled = false
|
|
try await deferred.fulfill()
|
|
}
|
|
|
|
@Test
|
|
mutating func addAccount() async throws {
|
|
setupViewModel()
|
|
|
|
let deferred = deferFulfillment(viewModel.actions) { $0.isAddAccount }
|
|
context.send(viewAction: .addAccount)
|
|
try await deferred.fulfill()
|
|
}
|
|
|
|
// MARK: - Helpers
|
|
|
|
private mutating func setupViewModel(status: UserStatus = .init(), userSettings: UserSettings = .volatile()) {
|
|
clientProxy = ClientProxyMock(.init(userID: "", status: status))
|
|
viewModel = SettingsScreenViewModel(userSession: UserSessionMock(.init(clientProxy: clientProxy)),
|
|
userSettings: userSettings,
|
|
isBugReportServiceEnabled: true,
|
|
isInSecondaryWindow: false,
|
|
userIndicatorController: UserIndicatorControllerMock())
|
|
}
|
|
|
|
/// Shows the emoji picker and selects the provided emoji through its continuation.
|
|
private func selectCustomStatusEmoji(_ emoji: String) async throws {
|
|
let pickerPresented = deferFulfillment(viewModel.actions) { $0.isUserStatusEmojiPicker }
|
|
context.send(viewAction: .userStatus(.pickCustomEmoji))
|
|
guard case let .userStatusEmojiPicker(continuation) = try await pickerPresented.fulfill() else {
|
|
Issue.record("Expected the emoji picker to be presented.")
|
|
return
|
|
}
|
|
|
|
let emojiUpdated = deferFulfillment(context.observe(\.viewState.bindings.customStatusEmoji)) { $0 == Character(emoji) }
|
|
continuation.yield(emoji)
|
|
try await emojiUpdated.fulfill()
|
|
}
|
|
}
|
|
|
|
private extension SettingsScreenViewModelAction {
|
|
var isUserStatusEmojiPicker: Bool {
|
|
switch self {
|
|
case .userStatusEmojiPicker: true
|
|
default: false
|
|
}
|
|
}
|
|
|
|
var isReportBug: Bool {
|
|
switch self {
|
|
case .reportBug: true
|
|
default: false
|
|
}
|
|
}
|
|
|
|
var isAnalytics: Bool {
|
|
switch self {
|
|
case .analytics: true
|
|
default: false
|
|
}
|
|
}
|
|
|
|
var isLogout: Bool {
|
|
switch self {
|
|
case .logout: true
|
|
default: false
|
|
}
|
|
}
|
|
|
|
var isAddAccount: Bool {
|
|
switch self {
|
|
case .addAccount: true
|
|
default: false
|
|
}
|
|
}
|
|
}
|