Finish migration of UnitTests target from XCTestCase to Swift Testing (#5129)

* Initial plan

* Migrate 3 test files from XCTest to Swift Testing

- MediaUploadPreviewScreenViewModelTests: @MainActor @Suite struct with init(),
  BundleFinder class for Bundle(for:), mutating test/setup functions,
  [self] capture replacing [weak self] in closures
- NotificationManagerTests: @MainActor @Suite final class with init()/deinit,
  expectation/fulfillment(of:) replaced with confirmation(...), test_ prefix stripped
- NotificationSettingsScreenViewModelTests: @MainActor @Suite struct with
  init() throws, non-optional stored properties, test prefix stripped

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Migrate 3 XCTest files to Swift Testing

- NotificationSettingsEditScreenViewModelTests: @MainActor @Suite struct with init() throws, mutating test methods
- TimelineViewModelTests: @MainActor @Suite final class with init() async throws + deinit
- AttributedStringBuilderTests: @Suite struct with init() async throws

All XCT assertions replaced with #expect/#require/Issue.record

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Migrate 4 test files from XCTest to Swift Testing

- TimelineMediaPreviewViewModelTests: @Suite struct, mutating @Test funcs,
  testLoadingItem renamed to loadingItem (called internally by other tests)
- ServerConfirmationScreenViewModelTests: @Suite final class with init()/deinit
- CompletionSuggestionServiceTests: @Suite struct with init()
- RoomFlowCoordinatorTests: @Suite final class with deinit

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Migrate 4 test files from XCTest to Swift Testing

- VoiceMessageRecorderTests: @Suite struct with init() async throws,
  added BundleFinder class for Bundle lookup, migrated all assertions
- SpaceScreenViewModelTests: @Suite struct, private mutating setupViewModel,
  all test funcs mutating, XCTestExpectation → confirmation
- RoomNotificationSettingsScreenViewModelTests: @Suite struct with
  init() throws, cancellable tests marked mutating
- JoinRoomScreenViewModelTests: @Suite final class with init()/deinit,
  XCTestExpectation → confirmation

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Migrate 6 test files from XCTestCase to Swift Testing

Co-authored-by: pixlwave <6060466+pixlwave@users.noreply.github.com>

* Fix trailing blank line in RoomPollsHistoryScreenViewModelTests

Co-authored-by: pixlwave <6060466+pixlwave@users.noreply.github.com>

* Migrate 3 test files from XCTest to Swift Testing

- MediaUploadingPreprocessorTests: @Suite final class with init()/deinit,
  removed executionTimeAllowance, XCTAssertEqual(accuracy:) → abs(Double)
- SecurityAndPrivacyScreenViewModelTests: @MainActor @Suite final class,
  5 expectation+fulfillment → await confirmation(...)
- CreateRoomViewModelTests: @MainActor @Suite final class,
  4 expectation+fulfillment → await confirmation(...)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Migrate RoomScreenViewModelTests and RoomDetailsScreenViewModelTests to Swift Testing

- Replace XCTest with Testing framework
- RoomScreenViewModelTests: final class with init() async throws + deinit
- RoomDetailsScreenViewModelTests: struct with init() and mutating funcs
- Convert XCT assertions to #expect / Issue.record
- Convert XCTestExpectation patterns to confirmation { confirm in }
- Strip 'test' prefix from all test function names

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Migrate ComposerToolbarViewModelTests from XCTest to Swift Testing

- Replace import XCTest with import Testing
- Convert XCTestCase class to @MainActor @Suite final class
- Replace setUp()/tearDown() with init()/deinit
- Strip 'test' prefix from all 41 test method names and add @Test
- Replace XCTAssert* with #expect()/#require()
- Replace try XCTUnwrap() with try #require()
- Convert expectation+wait patterns to deferFulfillment with PassthroughSubject
- Convert isInverted expectation to boolean flag checked after await
- Use deferFulfillment on $viewState for state-transition tests

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Address comments with Copilot.

* Fix the failing tests.

* Fixed flaky tests (#5137)

resolved flaky tests

* Tweaks and fixes.

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: pixlwave <6060466+pixlwave@users.noreply.github.com>
Co-authored-by: Doug <douglase@element.io>
Co-authored-by: Mauro <34335419+Velin92@users.noreply.github.com>
This commit is contained in:
Copilot
2026-02-24 12:20:01 +00:00
committed by GitHub
parent acd0d2c6b2
commit 4834f453ef
38 changed files with 3460 additions and 3304 deletions

View File

@@ -26,9 +26,12 @@ extension SessionVerificationControllerProxyMock {
mock.acknowledgeVerificationRequestDetailsReturnValue = .success(())
mock.requestDeviceVerificationClosure = { [unowned mock] in
mock.requestDeviceVerificationClosure = { [weak mock] in
Task.detached {
guard let mock else { return }
try await Task.sleep(for: requestDelay)
mock.actions.send(.acceptedVerificationRequest)
if otherDeviceStartsSasVerification {
@@ -42,8 +45,10 @@ extension SessionVerificationControllerProxyMock {
return .success(())
}
mock.startSasVerificationClosure = { [unowned mock] in
mock.startSasVerificationClosure = { [weak mock] in
Task.detached {
guard let mock else { return }
try await Task.sleep(for: requestDelay)
mock.actions.send(.startedSasVerification)
@@ -56,8 +61,10 @@ extension SessionVerificationControllerProxyMock {
return .success(())
}
mock.approveVerificationClosure = { [unowned mock] in
mock.approveVerificationClosure = { [weak mock] in
Task.detached {
guard let mock else { return }
try await Task.sleep(for: requestDelay)
mock.actions.send(.finished)
}
@@ -65,8 +72,10 @@ extension SessionVerificationControllerProxyMock {
return .success(())
}
mock.declineVerificationClosure = { [unowned mock] in
mock.declineVerificationClosure = { [weak mock] in
Task.detached {
guard let mock else { return }
try await Task.sleep(for: requestDelay)
mock.actions.send(.cancelled)
}
@@ -74,8 +83,10 @@ extension SessionVerificationControllerProxyMock {
return .success(())
}
mock.cancelVerificationClosure = { [unowned mock] in
mock.cancelVerificationClosure = { [weak mock] in
Task.detached {
guard let mock else { return }
try await Task.sleep(for: requestDelay)
mock.actions.send(.cancelled)
}

View File

@@ -9,7 +9,7 @@
import Combine
import SwiftUI
typealias RoomNotificationSettingsScreenViewModelType = StateStoreViewModel<RoomNotificationSettingsScreenViewState, RoomNotificationSettingsScreenViewAction>
typealias RoomNotificationSettingsScreenViewModelType = StateStoreViewModelV2<RoomNotificationSettingsScreenViewState, RoomNotificationSettingsScreenViewAction>
class RoomNotificationSettingsScreenViewModel: RoomNotificationSettingsScreenViewModelType, RoomNotificationSettingsScreenViewModelProtocol {
private let actionsSubject: PassthroughSubject<RoomNotificationSettingsScreenViewModelAction, Never> = .init()
@@ -115,7 +115,9 @@ class RoomNotificationSettingsScreenViewModel: RoomNotificationSettingsScreenVie
} catch {
displayError(.restoreDefaultFailed)
}
state.isRestoringDefaultSetting = false
await MainActor.run {
state.isRestoringDefaultSetting = false
}
}
}
@@ -134,7 +136,9 @@ class RoomNotificationSettingsScreenViewModel: RoomNotificationSettingsScreenVie
} catch {
displayError(.setModeFailed)
}
state.pendingCustomMode = nil
await MainActor.run {
state.pendingCustomMode = nil
}
}
}

View File

@@ -10,7 +10,7 @@ import Compound
import SwiftUI
struct RoomNotificationSettingsCustomSectionView: View {
@ObservedObject var context: RoomNotificationSettingsScreenViewModel.Context
@Bindable var context: RoomNotificationSettingsScreenViewModel.Context
var body: some View {
Section {

View File

@@ -10,7 +10,7 @@ import Compound
import SwiftUI
struct RoomNotificationSettingsScreen: View {
@ObservedObject var context: RoomNotificationSettingsScreenViewModel.Context
@Bindable var context: RoomNotificationSettingsScreenViewModel.Context
var body: some View {
Form {

View File

@@ -10,7 +10,7 @@ import Compound
import SwiftUI
struct RoomNotificationSettingsUserDefinedScreen: View {
@ObservedObject var context: RoomNotificationSettingsScreenViewModel.Context
@Bindable var context: RoomNotificationSettingsScreenViewModel.Context
var body: some View {
Form {

View File

@@ -9,7 +9,7 @@
import Foundation
import MatrixRustSDK
enum RoomNotificationModeProxy: String, CaseIterable {
enum RoomNotificationModeProxy: String, CaseIterable, Equatable {
case allMessages
case mentionsAndKeywordsOnly
case mute

View File

@@ -20,7 +20,7 @@ enum SessionVerificationControllerProxyError: Error {
case failedCancellingVerification
}
enum SessionVerificationControllerProxyAction {
enum SessionVerificationControllerProxyAction: Equatable {
case receivedVerificationRequest(details: SessionVerificationRequestDetails)
case acceptedVerificationRequest
case startedSasVerification
@@ -30,7 +30,7 @@ enum SessionVerificationControllerProxyAction {
case failed
}
struct SessionVerificationRequestDetails {
struct SessionVerificationRequestDetails: Equatable {
let senderProfile: UserProfileProxy
let flowID: String
let deviceID: String

File diff suppressed because it is too large Load Diff

View File

@@ -54,6 +54,7 @@ final class BugReportServiceTests {
}
@Test
@MainActor
func initialStateWithRealService() {
let urlPublisher: CurrentValueSubject<RageshakeConfiguration, Never> = .init(.url("https://example.com/submit"))
let service = BugReportService(rageshakeURLPublisher: urlPublisher.asCurrentValuePublisher(),

View File

@@ -8,10 +8,12 @@
import Combine
@testable import ElementX
import XCTest
import Foundation
import Testing
@Suite
@MainActor
class ChatsTabFlowCoordinatorTests: XCTestCase {
struct ChatsTabFlowCoordinatorTests {
var clientProxy: ClientProxyMock!
var timelineControllerFactory: TimelineControllerFactoryMock!
var chatsTabFlowCoordinator: ChatsTabFlowCoordinator!
@@ -22,15 +24,14 @@ class ChatsTabFlowCoordinatorTests: XCTestCase {
var cancellables = Set<AnyCancellable>()
var detailCoordinator: CoordinatorProtocol? {
splitCoordinator?.detailCoordinator
splitCoordinator.detailCoordinator
}
var detailNavigationStack: NavigationStackCoordinator? {
detailCoordinator as? NavigationStackCoordinator
}
override func setUp() async throws {
cancellables.removeAll()
init() async throws {
clientProxy = ClientProxyMock(.init(userID: "hi@bob", roomSummaryProvider: RoomSummaryProviderMock(.init(state: .loaded(.mockRooms)))))
timelineControllerFactory = TimelineControllerFactoryMock(.init())
@@ -60,224 +61,234 @@ class ChatsTabFlowCoordinatorTests: XCTestCase {
try await deferred.fulfill()
}
func testRoomPresentation() async throws {
@Test
mutating func roomPresentation() async throws {
try await process(route: .room(roomID: "1", via: []), expectedState: .roomList(detailState: .room(roomID: "1")))
XCTAssertTrue(detailNavigationStack?.rootCoordinator is RoomScreenCoordinator)
XCTAssertNotNil(detailCoordinator)
#expect(detailNavigationStack?.rootCoordinator is RoomScreenCoordinator)
#expect(detailCoordinator != nil)
try await process(route: .roomList, expectedState: .roomList(detailState: nil))
XCTAssertNil(detailNavigationStack?.rootCoordinator)
XCTAssertNil(detailCoordinator)
#expect(detailNavigationStack?.rootCoordinator == nil)
#expect(detailCoordinator == nil)
try await process(route: .room(roomID: "1", via: []), expectedState: .roomList(detailState: .room(roomID: "1")))
XCTAssertTrue(detailNavigationStack?.rootCoordinator is RoomScreenCoordinator)
XCTAssertNotNil(detailCoordinator)
#expect(detailNavigationStack?.rootCoordinator is RoomScreenCoordinator)
#expect(detailCoordinator != nil)
try await process(route: .room(roomID: "2", via: []), expectedState: .roomList(detailState: .room(roomID: "2")))
XCTAssertTrue(detailNavigationStack?.rootCoordinator is RoomScreenCoordinator)
XCTAssertNotNil(detailCoordinator)
#expect(detailNavigationStack?.rootCoordinator is RoomScreenCoordinator)
#expect(detailCoordinator != nil)
try await process(route: .roomList, expectedState: .roomList(detailState: nil))
XCTAssertNil(detailNavigationStack?.rootCoordinator)
XCTAssertNil(detailCoordinator)
#expect(detailNavigationStack?.rootCoordinator == nil)
#expect(detailCoordinator == nil)
XCTAssertEqual(notificationManager.removeDeliveredMessageNotificationsForReceivedInvocations, ["1", "1", "2"])
#expect(notificationManager.removeDeliveredMessageNotificationsForReceivedInvocations == ["1", "1", "2"])
}
func testRoomAliasPresentation() async throws {
@Test
mutating func roomAliasPresentation() async throws {
clientProxy.resolveRoomAliasReturnValue = .success(.init(roomId: "1", servers: []))
try await process(route: .roomAlias("#alias:matrix.org"), expectedState: .roomList(detailState: .room(roomID: "1")))
XCTAssertTrue(detailNavigationStack?.rootCoordinator is RoomScreenCoordinator)
XCTAssertNotNil(detailCoordinator)
#expect(detailNavigationStack?.rootCoordinator is RoomScreenCoordinator)
#expect(detailCoordinator != nil)
try await process(route: .roomList, expectedState: .roomList(detailState: nil))
XCTAssertNil(detailNavigationStack?.rootCoordinator)
XCTAssertNil(detailCoordinator)
#expect(detailNavigationStack?.rootCoordinator == nil)
#expect(detailCoordinator == nil)
try await process(route: .room(roomID: "1", via: []), expectedState: .roomList(detailState: .room(roomID: "1")))
XCTAssertTrue(detailNavigationStack?.rootCoordinator is RoomScreenCoordinator)
XCTAssertNotNil(detailCoordinator)
#expect(detailNavigationStack?.rootCoordinator is RoomScreenCoordinator)
#expect(detailCoordinator != nil)
clientProxy.resolveRoomAliasReturnValue = .success(.init(roomId: "2", servers: []))
try await process(route: .room(roomID: "2", via: []), expectedState: .roomList(detailState: .room(roomID: "2")))
XCTAssertTrue(detailNavigationStack?.rootCoordinator is RoomScreenCoordinator)
XCTAssertNotNil(detailCoordinator)
#expect(detailNavigationStack?.rootCoordinator is RoomScreenCoordinator)
#expect(detailCoordinator != nil)
try await process(route: .roomList, expectedState: .roomList(detailState: nil))
XCTAssertNil(detailNavigationStack?.rootCoordinator)
XCTAssertNil(detailCoordinator)
#expect(detailNavigationStack?.rootCoordinator == nil)
#expect(detailCoordinator == nil)
XCTAssertEqual(notificationManager.removeDeliveredMessageNotificationsForReceivedInvocations, ["1", "1", "2"])
#expect(notificationManager.removeDeliveredMessageNotificationsForReceivedInvocations == ["1", "1", "2"])
}
func testRoomDetailsPresentation() async throws {
@Test
mutating func roomDetailsPresentation() async throws {
try await process(route: .roomDetails(roomID: "1"), expectedState: .roomList(detailState: .room(roomID: "1")))
XCTAssertTrue(detailNavigationStack?.rootCoordinator is RoomDetailsScreenCoordinator)
XCTAssertNotNil(detailCoordinator)
#expect(detailNavigationStack?.rootCoordinator is RoomDetailsScreenCoordinator)
#expect(detailCoordinator != nil)
try await process(route: .roomList, expectedState: .roomList(detailState: nil))
XCTAssertNil(detailNavigationStack?.rootCoordinator)
XCTAssertNil(detailCoordinator)
#expect(detailNavigationStack?.rootCoordinator == nil)
#expect(detailCoordinator == nil)
}
func testStackUnwinding() async throws {
@Test
mutating func stackUnwinding() async throws {
try await process(route: .roomDetails(roomID: "1"), expectedState: .roomList(detailState: .room(roomID: "1")))
XCTAssertTrue(detailNavigationStack?.rootCoordinator is RoomDetailsScreenCoordinator)
XCTAssertNotNil(detailCoordinator)
#expect(detailNavigationStack?.rootCoordinator is RoomDetailsScreenCoordinator)
#expect(detailCoordinator != nil)
try await process(route: .room(roomID: "2", via: []), expectedState: .roomList(detailState: .room(roomID: "2")))
XCTAssertTrue(detailNavigationStack?.rootCoordinator is RoomScreenCoordinator)
XCTAssertNotNil(detailCoordinator)
#expect(detailNavigationStack?.rootCoordinator is RoomScreenCoordinator)
#expect(detailCoordinator != nil)
}
func testNoOp() async throws {
@Test
mutating func noOp() async throws {
try await process(route: .roomDetails(roomID: "1"), expectedState: .roomList(detailState: .room(roomID: "1")))
XCTAssertTrue(detailNavigationStack?.rootCoordinator is RoomDetailsScreenCoordinator)
XCTAssertNotNil(detailCoordinator)
#expect(detailNavigationStack?.rootCoordinator is RoomDetailsScreenCoordinator)
#expect(detailCoordinator != nil)
let unexpectedFulfillment = deferFailure(stateMachineFactory.chatsTabFlowStatePublisher, timeout: 1) { _ in true }
let unexpectedFulfillment = deferFailure(stateMachineFactory.chatsTabFlowStatePublisher, timeout: .seconds(1)) { _ in true }
chatsTabFlowCoordinator.handleAppRoute(.roomDetails(roomID: "1"), animated: true)
try await unexpectedFulfillment.fulfill()
XCTAssertTrue(detailNavigationStack?.rootCoordinator is RoomDetailsScreenCoordinator)
XCTAssertNotNil(detailCoordinator)
#expect(detailNavigationStack?.rootCoordinator is RoomDetailsScreenCoordinator)
#expect(detailCoordinator != nil)
}
func testSwitchToDifferentDetails() async throws {
@Test
mutating func switchToDifferentDetails() async throws {
try await process(route: .roomDetails(roomID: "1"), expectedState: .roomList(detailState: .room(roomID: "1")))
XCTAssertTrue(detailNavigationStack?.rootCoordinator is RoomDetailsScreenCoordinator)
XCTAssertNotNil(detailCoordinator)
#expect(detailNavigationStack?.rootCoordinator is RoomDetailsScreenCoordinator)
#expect(detailCoordinator != nil)
try await process(route: .roomDetails(roomID: "2"), expectedState: .roomList(detailState: .room(roomID: "2")))
XCTAssertTrue(detailNavigationStack?.rootCoordinator is RoomDetailsScreenCoordinator)
XCTAssertNotNil(detailCoordinator)
#expect(detailNavigationStack?.rootCoordinator is RoomDetailsScreenCoordinator)
#expect(detailCoordinator != nil)
}
func testPushDetails() async throws {
@Test
mutating func pushDetails() async throws {
try await process(route: .room(roomID: "1", via: []), expectedState: .roomList(detailState: .room(roomID: "1")))
XCTAssertTrue(detailNavigationStack?.rootCoordinator is RoomScreenCoordinator)
XCTAssertNotNil(detailCoordinator)
#expect(detailNavigationStack?.rootCoordinator is RoomScreenCoordinator)
#expect(detailCoordinator != nil)
let unexpectedFulfillment = deferFailure(stateMachineFactory.chatsTabFlowStatePublisher, timeout: 1) { _ in true }
let unexpectedFulfillment = deferFailure(stateMachineFactory.chatsTabFlowStatePublisher, timeout: .seconds(1)) { _ in true }
chatsTabFlowCoordinator.handleAppRoute(.roomDetails(roomID: "1"), animated: true)
try await unexpectedFulfillment.fulfill()
XCTAssertTrue(detailNavigationStack?.rootCoordinator is RoomScreenCoordinator)
XCTAssertEqual(detailNavigationStack?.stackCoordinators.count, 1)
XCTAssertTrue(detailNavigationStack?.stackCoordinators.first is RoomDetailsScreenCoordinator)
XCTAssertNotNil(detailCoordinator)
#expect(detailNavigationStack?.rootCoordinator is RoomScreenCoordinator)
#expect(detailNavigationStack?.stackCoordinators.count == 1)
#expect(detailNavigationStack?.stackCoordinators.first is RoomDetailsScreenCoordinator)
#expect(detailCoordinator != nil)
}
func testReplaceDetailsWithTimeline() async throws {
@Test
mutating func replaceDetailsWithTimeline() async throws {
try await process(route: .roomDetails(roomID: "1"), expectedState: .roomList(detailState: .room(roomID: "1")))
XCTAssertTrue(detailNavigationStack?.rootCoordinator is RoomDetailsScreenCoordinator)
XCTAssertNotNil(detailCoordinator)
#expect(detailNavigationStack?.rootCoordinator is RoomDetailsScreenCoordinator)
#expect(detailCoordinator != nil)
try await process(route: .room(roomID: "1", via: []), expectedState: .roomList(detailState: .room(roomID: "1")))
XCTAssertTrue(detailNavigationStack?.rootCoordinator is RoomScreenCoordinator)
XCTAssertNotNil(detailCoordinator)
#expect(detailNavigationStack?.rootCoordinator is RoomScreenCoordinator)
#expect(detailCoordinator != nil)
}
func testUserProfileClearsStack() async throws {
@Test
mutating func userProfileClearsStack() async throws {
try await process(route: .roomDetails(roomID: "1"), expectedState: .roomList(detailState: .room(roomID: "1")))
XCTAssertTrue(detailNavigationStack?.rootCoordinator is RoomDetailsScreenCoordinator)
XCTAssertNotNil(detailCoordinator)
XCTAssertNil(splitCoordinator?.sheetCoordinator)
#expect(detailNavigationStack?.rootCoordinator is RoomDetailsScreenCoordinator)
#expect(detailCoordinator != nil)
#expect(splitCoordinator.sheetCoordinator == nil)
try await process(route: .userProfile(userID: "alice"), expectedState: .userProfileScreen)
XCTAssertNil(detailNavigationStack?.rootCoordinator)
guard let sheetStackCoordinator = splitCoordinator?.sheetCoordinator as? NavigationStackCoordinator else {
XCTFail("There should be a navigation stack presented as a sheet.")
return
}
XCTAssertTrue(sheetStackCoordinator.rootCoordinator is UserProfileScreenCoordinator)
#expect(detailNavigationStack?.rootCoordinator == nil)
let sheetStackCoordinator = try #require(splitCoordinator.sheetCoordinator as? NavigationStackCoordinator, "There should be a navigation stack presented as a sheet.")
#expect(sheetStackCoordinator.rootCoordinator is UserProfileScreenCoordinator)
}
func testRoomClearsStack() async throws {
@Test
mutating func roomClearsStack() async throws {
try await process(route: .room(roomID: "1", via: []), expectedState: .roomList(detailState: .room(roomID: "1")))
XCTAssertTrue(detailNavigationStack?.rootCoordinator is RoomScreenCoordinator)
XCTAssertEqual(detailNavigationStack?.stackCoordinators.count, 0)
XCTAssertNotNil(detailCoordinator)
#expect(detailNavigationStack?.rootCoordinator is RoomScreenCoordinator)
#expect(detailNavigationStack?.stackCoordinators.count == 0)
#expect(detailCoordinator != nil)
chatsTabFlowCoordinator.handleAppRoute(.childRoom(roomID: "2", via: []), animated: true)
try await Task.sleep(for: .milliseconds(100))
XCTAssertTrue(detailNavigationStack?.rootCoordinator is RoomScreenCoordinator)
XCTAssertEqual(detailNavigationStack?.stackCoordinators.count, 1)
XCTAssertTrue(detailNavigationStack?.stackCoordinators.first is RoomScreenCoordinator)
XCTAssertNotNil(detailCoordinator)
#expect(detailNavigationStack?.rootCoordinator is RoomScreenCoordinator)
#expect(detailNavigationStack?.stackCoordinators.count == 1)
#expect(detailNavigationStack?.stackCoordinators.first is RoomScreenCoordinator)
#expect(detailCoordinator != nil)
try await process(route: .room(roomID: "3", via: []), expectedState: .roomList(detailState: .room(roomID: "3")))
XCTAssertTrue(detailNavigationStack?.rootCoordinator is RoomScreenCoordinator)
XCTAssertEqual(detailNavigationStack?.stackCoordinators.count, 0)
XCTAssertNotNil(detailCoordinator)
#expect(detailNavigationStack?.rootCoordinator is RoomScreenCoordinator)
#expect(detailNavigationStack?.stackCoordinators.count == 0)
#expect(detailCoordinator != nil)
}
func testEventRoutes() async throws {
@Test
mutating func eventRoutes() async throws {
// A regular event route should set its room as the root of the stack and focus on the event.
try await process(route: .event(eventID: "1", roomID: "1", via: []), expectedState: .roomList(detailState: .room(roomID: "1")))
XCTAssertTrue(detailNavigationStack?.rootCoordinator is RoomScreenCoordinator)
XCTAssertEqual(detailNavigationStack?.stackCoordinators.count, 0)
XCTAssertNotNil(detailCoordinator)
XCTAssertEqual(timelineControllerFactory.buildTimelineControllerRoomProxyInitialFocussedEventIDTimelineItemFactoryMediaProviderCallsCount, 1)
XCTAssertEqual(timelineControllerFactory.buildTimelineControllerRoomProxyInitialFocussedEventIDTimelineItemFactoryMediaProviderReceivedArguments?.initialFocussedEventID, "1")
#expect(detailNavigationStack?.rootCoordinator is RoomScreenCoordinator)
#expect(detailNavigationStack?.stackCoordinators.count == 0)
#expect(detailCoordinator != nil)
#expect(timelineControllerFactory.buildTimelineControllerRoomProxyInitialFocussedEventIDTimelineItemFactoryMediaProviderCallsCount == 1)
#expect(timelineControllerFactory.buildTimelineControllerRoomProxyInitialFocussedEventIDTimelineItemFactoryMediaProviderReceivedArguments?.initialFocussedEventID == "1")
// A child event route should push a new room screen onto the stack and focus on the event.
chatsTabFlowCoordinator.handleAppRoute(.childEvent(eventID: "2", roomID: "2", via: []), animated: true)
try await Task.sleep(for: .milliseconds(100))
XCTAssertTrue(detailNavigationStack?.rootCoordinator is RoomScreenCoordinator)
XCTAssertEqual(detailNavigationStack?.stackCoordinators.count, 1)
XCTAssertTrue(detailNavigationStack?.stackCoordinators.first is RoomScreenCoordinator)
XCTAssertNotNil(detailCoordinator)
XCTAssertEqual(timelineControllerFactory.buildTimelineControllerRoomProxyInitialFocussedEventIDTimelineItemFactoryMediaProviderCallsCount, 2)
XCTAssertEqual(timelineControllerFactory.buildTimelineControllerRoomProxyInitialFocussedEventIDTimelineItemFactoryMediaProviderReceivedArguments?.initialFocussedEventID, "2")
#expect(detailNavigationStack?.rootCoordinator is RoomScreenCoordinator)
#expect(detailNavigationStack?.stackCoordinators.count == 1)
#expect(detailNavigationStack?.stackCoordinators.first is RoomScreenCoordinator)
#expect(detailCoordinator != nil)
#expect(timelineControllerFactory.buildTimelineControllerRoomProxyInitialFocussedEventIDTimelineItemFactoryMediaProviderCallsCount == 2)
#expect(timelineControllerFactory.buildTimelineControllerRoomProxyInitialFocussedEventIDTimelineItemFactoryMediaProviderReceivedArguments?.initialFocussedEventID == "2")
// A subsequent regular event route should clear the stack and set the new room as the root of the stack.
try await process(route: .event(eventID: "3", roomID: "3", via: []), expectedState: .roomList(detailState: .room(roomID: "3")))
XCTAssertTrue(detailNavigationStack?.rootCoordinator is RoomScreenCoordinator)
XCTAssertEqual(detailNavigationStack?.stackCoordinators.count, 0)
XCTAssertNotNil(detailCoordinator)
XCTAssertEqual(timelineControllerFactory.buildTimelineControllerRoomProxyInitialFocussedEventIDTimelineItemFactoryMediaProviderCallsCount, 3)
XCTAssertEqual(timelineControllerFactory.buildTimelineControllerRoomProxyInitialFocussedEventIDTimelineItemFactoryMediaProviderReceivedArguments?.initialFocussedEventID, "3")
#expect(detailNavigationStack?.rootCoordinator is RoomScreenCoordinator)
#expect(detailNavigationStack?.stackCoordinators.count == 0)
#expect(detailCoordinator != nil)
#expect(timelineControllerFactory.buildTimelineControllerRoomProxyInitialFocussedEventIDTimelineItemFactoryMediaProviderCallsCount == 3)
#expect(timelineControllerFactory.buildTimelineControllerRoomProxyInitialFocussedEventIDTimelineItemFactoryMediaProviderReceivedArguments?.initialFocussedEventID == "3")
// A regular event route for the same room should set a new instance of the room as the root of the stack.
try await process(route: .event(eventID: "4", roomID: "3", via: []), expectedState: .roomList(detailState: .room(roomID: "3")))
XCTAssertTrue(detailNavigationStack?.rootCoordinator is RoomScreenCoordinator)
XCTAssertEqual(detailNavigationStack?.stackCoordinators.count, 0)
XCTAssertNotNil(detailCoordinator)
XCTAssertEqual(timelineControllerFactory.buildTimelineControllerRoomProxyInitialFocussedEventIDTimelineItemFactoryMediaProviderCallsCount, 4)
XCTAssertEqual(timelineControllerFactory.buildTimelineControllerRoomProxyInitialFocussedEventIDTimelineItemFactoryMediaProviderReceivedArguments?.initialFocussedEventID, "4",
"A new timeline should be created for the same room ID, so that the screen isn't stale while loading.")
#expect(detailNavigationStack?.rootCoordinator is RoomScreenCoordinator)
#expect(detailNavigationStack?.stackCoordinators.count == 0)
#expect(detailCoordinator != nil)
#expect(timelineControllerFactory.buildTimelineControllerRoomProxyInitialFocussedEventIDTimelineItemFactoryMediaProviderCallsCount == 4)
#expect(timelineControllerFactory.buildTimelineControllerRoomProxyInitialFocussedEventIDTimelineItemFactoryMediaProviderReceivedArguments?.initialFocussedEventID == "4",
"A new timeline should be created for the same room ID, so that the screen isn't stale while loading.")
}
func testShareMediaRouteWithRoom() async throws {
@Test
mutating func shareMediaRouteWithRoom() async throws {
try await process(route: .event(eventID: "1", roomID: "1", via: []), expectedState: .roomList(detailState: .room(roomID: "1")))
XCTAssertTrue(detailNavigationStack?.rootCoordinator is RoomScreenCoordinator)
#expect(detailNavigationStack?.rootCoordinator is RoomScreenCoordinator)
let sharePayload: ShareExtensionPayload = .mediaFiles(roomID: "2", mediaFiles: [.init(url: .picturesDirectory, suggestedName: nil)])
try await process(route: .share(sharePayload),
expectedState: .roomList(detailState: .room(roomID: "2")))
XCTAssertTrue(detailNavigationStack?.rootCoordinator is RoomScreenCoordinator)
XCTAssertTrue((splitCoordinator?.sheetCoordinator as? NavigationStackCoordinator)?.rootCoordinator is MediaUploadPreviewScreenCoordinator)
#expect(detailNavigationStack?.rootCoordinator is RoomScreenCoordinator)
#expect((splitCoordinator.sheetCoordinator as? NavigationStackCoordinator)?.rootCoordinator is MediaUploadPreviewScreenCoordinator)
}
func testShareTextRouteWithRoom() async throws {
@Test
mutating func shareTextRouteWithRoom() async throws {
try await process(route: .event(eventID: "1", roomID: "1", via: []), expectedState: .roomList(detailState: .room(roomID: "1")))
XCTAssertTrue(detailNavigationStack?.rootCoordinator is RoomScreenCoordinator)
#expect(detailNavigationStack?.rootCoordinator is RoomScreenCoordinator)
let sharePayload: ShareExtensionPayload = .text(roomID: "2", text: "Important text")
try await process(route: .share(sharePayload),
expectedState: .roomList(detailState: .room(roomID: "2")))
XCTAssertTrue(detailNavigationStack?.rootCoordinator is RoomScreenCoordinator)
XCTAssertNil(splitCoordinator?.sheetCoordinator, "The media upload sheet shouldn't be shown when sharing text.")
#expect(detailNavigationStack?.rootCoordinator is RoomScreenCoordinator)
#expect(splitCoordinator.sheetCoordinator == nil, "The media upload sheet shouldn't be shown when sharing text.")
}
// MARK: - Private
private func process(route: AppRoute, expectedState: ChatsTabFlowCoordinatorStateMachine.State) async throws {
private mutating func process(route: AppRoute, expectedState: ChatsTabFlowCoordinatorStateMachine.State) async throws {
// Sometimes the state machine's state changes before the coordinators have updated the stack.
let delayedPublisher = stateMachineFactory.chatsTabFlowStatePublisher.delay(for: .milliseconds(100), scheduler: DispatchQueue.main)

View File

@@ -8,17 +8,13 @@
import Combine
@testable import ElementX
import XCTest
import Testing
@Suite
@MainActor
final class CompletionSuggestionServiceTests: XCTestCase {
private var cancellables = Set<AnyCancellable>()
override func setUp() {
cancellables.removeAll()
}
func testUserSuggestions() async throws {
struct CompletionSuggestionServiceTests {
@Test
func userSuggestions() async throws {
let alice: RoomMemberProxyMock = .mockAlice
let members: [RoomMemberProxyMock] = [alice, .mockBob, .mockCharlie, .mockMe]
let roomProxyMock = JoinedRoomProxyMock(.init(id: "roomID", name: "test", members: members))
@@ -57,7 +53,8 @@ final class CompletionSuggestionServiceTests: XCTestCase {
try await deferred.fulfill()
}
func testUserSuggestionsIncludingAllUsers() async throws {
@Test
func userSuggestionsIncludingAllUsers() async throws {
let alice: RoomMemberProxyMock = .mockAlice
let members: [RoomMemberProxyMock] = [alice, .mockBob, .mockCharlie, .mockMe]
let roomProxyMock = JoinedRoomProxyMock(.init(id: "roomID",
@@ -88,7 +85,8 @@ final class CompletionSuggestionServiceTests: XCTestCase {
try await deferred.fulfill()
}
func testUserSuggestionsWithEmptyText() async throws {
@Test
func userSuggestionsWithEmptyText() async throws {
let alice: RoomMemberProxyMock = .mockAlice
let bob: RoomMemberProxyMock = .mockBob
let members: [RoomMemberProxyMock] = [alice, bob, .mockMe]
@@ -124,7 +122,8 @@ final class CompletionSuggestionServiceTests: XCTestCase {
try await deferred.fulfill()
}
func testUserSuggestionInDifferentMessagePositions() async throws {
@Test
func userSuggestionInDifferentMessagePositions() async throws {
let alice: RoomMemberProxyMock = .mockAlice
let members: [RoomMemberProxyMock] = [alice, .mockBob, .mockCharlie, .mockMe]
let roomProxyMock = JoinedRoomProxyMock(.init(name: "test", members: members))
@@ -151,7 +150,8 @@ final class CompletionSuggestionServiceTests: XCTestCase {
try await deferred.fulfill()
}
func testUserSuggestionWithMultipleMentionSymbol() async throws {
@Test
func userSuggestionWithMultipleMentionSymbol() async throws {
let alice: RoomMemberProxyMock = .mockAlice
let bob: RoomMemberProxyMock = .mockBob
let members: [RoomMemberProxyMock] = [alice, bob, .mockCharlie, .mockMe]
@@ -179,7 +179,8 @@ final class CompletionSuggestionServiceTests: XCTestCase {
try await deffered.fulfill()
}
func testRoomSuggestions() async throws {
@Test
func roomSuggestions() async throws {
let alice: RoomMemberProxyMock = .mockAlice
// We keep the users in the tests since they should not appear in the suggestions when using the room trigger
let members: [RoomMemberProxyMock] = [alice, .mockBob, .mockCharlie, .mockMe]
@@ -252,7 +253,8 @@ final class CompletionSuggestionServiceTests: XCTestCase {
try await deferred.fulfill()
}
func testRoomSuggestionInDifferentMessagePositions() async throws {
@Test
func roomSuggestionInDifferentMessagePositions() async throws {
let alice: RoomMemberProxyMock = .mockAlice
// We keep the users in the tests since they should not appear in the suggestions when using the room trigger
let members: [RoomMemberProxyMock] = [alice, .mockBob, .mockCharlie, .mockMe]
@@ -301,7 +303,8 @@ final class CompletionSuggestionServiceTests: XCTestCase {
try await deferred.fulfill()
}
func testRoomSuggestionWithMultipleMentionSymbol() async throws {
@Test
func roomSuggestionWithMultipleMentionSymbol() async throws {
let alice: RoomMemberProxyMock = .mockAlice
// We keep the users in the tests since they should not appear in the suggestions when using the room trigger
let members: [RoomMemberProxyMock] = [alice, .mockBob, .mockCharlie, .mockMe]
@@ -351,7 +354,8 @@ final class CompletionSuggestionServiceTests: XCTestCase {
try await deffered.fulfill()
}
func testSuggestionsWithMultipleDifferentTriggers() async throws {
@Test
func suggestionsWithMultipleDifferentTriggers() async throws {
let alice: RoomMemberProxyMock = .mockAlice
// We keep the users in the tests since they should not appear in the suggestions when using the room trigger
let members: [RoomMemberProxyMock] = [alice, .mockBob, .mockCharlie, .mockMe]
@@ -380,7 +384,8 @@ final class CompletionSuggestionServiceTests: XCTestCase {
try await deffered.fulfill()
}
func testSuggestionsContainingNonAlphanumericCharacters() async throws {
@Test
func suggestionsContainingNonAlphanumericCharacters() async throws {
let alice: RoomMemberProxyMock = .mockAlice
// We keep the users in the tests since they should not appear in the suggestions when using the room trigger
let members: [RoomMemberProxyMock] = [alice, .mockBob, .mockCharlie, .mockMe]

View File

@@ -8,89 +8,86 @@
import Combine
@testable import ElementX
import Foundation
import MatrixRustSDK
import Testing
import WysiwygComposer
import XCTest
@Suite
@MainActor
class ComposerToolbarViewModelTests: XCTestCase {
final class ComposerToolbarViewModelTests {
private var appSettings: AppSettings!
private var wysiwygViewModel: WysiwygComposerViewModel!
private var viewModel: ComposerToolbarViewModel!
private var completionSuggestionServiceMock: CompletionSuggestionServiceMock!
private var draftServiceMock: ComposerDraftServiceMock!
override func setUp() {
init() {
AppSettings.resetAllSettings()
appSettings = AppSettings()
ServiceLocator.shared.register(appSettings: appSettings)
setUpViewModel()
}
override func tearDown() {
deinit {
AppSettings.resetAllSettings()
}
func testComposerFocus() {
@Test
func composerFocus() {
viewModel.process(timelineAction: .setMode(mode: .edit(originalEventOrTransactionID: .eventID("mock"), type: .default)))
XCTAssertTrue(viewModel.state.bindings.composerFocused)
#expect(viewModel.state.bindings.composerFocused)
viewModel.process(timelineAction: .removeFocus)
XCTAssertFalse(viewModel.state.bindings.composerFocused)
#expect(!viewModel.state.bindings.composerFocused)
}
func testComposerMode() {
@Test
func composerMode() {
let mode: ComposerMode = .edit(originalEventOrTransactionID: .eventID("mock"), type: .default)
viewModel.process(timelineAction: .setMode(mode: mode))
XCTAssertEqual(viewModel.state.composerMode, mode)
#expect(viewModel.state.composerMode == mode)
viewModel.process(timelineAction: .clear)
XCTAssertEqual(viewModel.state.composerMode, .default)
#expect(viewModel.state.composerMode == .default)
}
func testComposerModeIsPublished() {
@Test
func composerModeIsPublished() async throws {
let mode: ComposerMode = .edit(originalEventOrTransactionID: .eventID("mock"), type: .default)
let expectation = expectation(description: "Composer mode is published")
let cancellable = viewModel
.context
.$viewState
.map(\.composerMode)
.removeDuplicates()
.dropFirst()
.sink { composerMode in
XCTAssertEqual(composerMode, mode)
expectation.fulfill()
}
let deferred = deferFulfillment(viewModel.context.$viewState.map(\.composerMode).removeDuplicates().dropFirst()) { $0 == mode }
viewModel.process(timelineAction: .setMode(mode: mode))
wait(for: [expectation], timeout: 2.0)
cancellable.cancel()
try await deferred.fulfill()
}
func testHandleKeyCommand() {
XCTAssertTrue(viewModel.context.viewState.keyCommands.count == 1)
@Test
func handleKeyCommand() {
#expect(viewModel.context.viewState.keyCommands.count == 1)
}
func testComposerFocusAfterEnablingRTE() {
@Test
func composerFocusAfterEnablingRTE() {
viewModel.process(viewAction: .enableTextFormatting)
XCTAssertTrue(viewModel.state.bindings.composerFocused)
#expect(viewModel.state.bindings.composerFocused)
}
func testRTEEnabledAfterSendingMessage() {
@Test
func rteEnabledAfterSendingMessage() {
viewModel.process(viewAction: .enableTextFormatting)
XCTAssertTrue(viewModel.state.bindings.composerFocused)
#expect(viewModel.state.bindings.composerFocused)
viewModel.state.composerEmpty = false
viewModel.process(viewAction: .sendMessage)
XCTAssertTrue(viewModel.state.bindings.composerFormattingEnabled)
#expect(viewModel.state.bindings.composerFormattingEnabled)
}
func testAlertIsShownAfterLinkAction() {
XCTAssertNil(viewModel.state.bindings.alertInfo)
@Test
func alertIsShownAfterLinkAction() {
#expect(viewModel.state.bindings.alertInfo == nil)
viewModel.process(viewAction: .enableTextFormatting)
viewModel.process(viewAction: .composerAction(action: .link))
XCTAssertNotNil(viewModel.state.bindings.alertInfo)
#expect(viewModel.state.bindings.alertInfo != nil)
}
func testSuggestions() {
@Test
func suggestions() {
let suggestions: [SuggestionItem] = [.init(suggestionType: .user(.init(id: "@user_mention_1:matrix.org", displayName: "User 1", avatarURL: nil)), range: .init(), rawSuggestionText: ""),
.init(suggestionType: .user(.init(id: "@user_mention_2:matrix.org", displayName: "User 2", avatarURL: nil)), range: .init(), rawSuggestionText: "")]
let mockCompletionSuggestionService = CompletionSuggestionServiceMock(configuration: .init(suggestions: suggestions))
@@ -104,31 +101,34 @@ class ComposerToolbarViewModelTests: XCTestCase {
analyticsService: ServiceLocator.shared.analytics,
composerDraftService: draftServiceMock)
XCTAssertEqual(viewModel.state.suggestions, suggestions)
#expect(viewModel.state.suggestions == suggestions)
}
func testSuggestionTrigger() async throws {
@Test
func suggestionTrigger() async throws {
let deferred = deferFulfillment(wysiwygViewModel.$attributedContent) { $0.plainText == "#room-alias-test" }
wysiwygViewModel.setMarkdownContent("@user-test")
wysiwygViewModel.setMarkdownContent("#room-alias-test")
try await deferred.fulfill()
// The first one is nil because when initialised the view model is empty
XCTAssertEqual(completionSuggestionServiceMock.setSuggestionTriggerReceivedInvocations, [nil,
.init(type: .user, text: "user-test", range: .init(location: 0, length: 10)),
.init(type: .room, text: "room-alias-test",
range: .init(location: 0, length: 16))])
#expect(completionSuggestionServiceMock.setSuggestionTriggerReceivedInvocations == [nil,
.init(type: .user, text: "user-test", range: .init(location: 0, length: 10)),
.init(type: .room, text: "room-alias-test",
range: .init(location: 0, length: 16))])
}
func testSelectedUserSuggestion() {
@Test
func selectedUserSuggestion() {
let suggestion = SuggestionItem(suggestionType: .user(.init(id: "@test:matrix.org", displayName: "Test", avatarURL: nil)), range: .init(), rawSuggestionText: "")
viewModel.context.send(viewAction: .selectedSuggestion(suggestion))
// The display name can be used for HTML injection in the rich text editor and it's useless anyway as the clients don't use it when resolving display names
XCTAssertEqual(wysiwygViewModel.content.html, "<a href=\"https://matrix.to/#/@test:matrix.org\">@test:matrix.org</a> ")
#expect(wysiwygViewModel.content.html == "<a href=\"https://matrix.to/#/@test:matrix.org\">@test:matrix.org</a> ")
}
func testSelectedRoomSuggestion() {
@Test
func selectedRoomSuggestion() {
let suggestion = SuggestionItem(suggestionType: .room(.init(id: "!room:matrix.org",
canonicalAlias: "#room-alias:matrix.org",
name: "Room",
@@ -140,19 +140,21 @@ class ComposerToolbarViewModelTests: XCTestCase {
// The display name can be used for HTML injection in the rich text editor and it's useless anyway as the clients don't use it when resolving display names
XCTAssertEqual(wysiwygViewModel.content.html, "<a href=\"https://matrix.to/#/%23room-alias:matrix.org\">#room-alias:matrix.org</a> ")
#expect(wysiwygViewModel.content.html == "<a href=\"https://matrix.to/#/%23room-alias:matrix.org\">#room-alias:matrix.org</a> ")
}
func testAllUsersSuggestion() throws {
@Test
func allUsersSuggestion() throws {
let suggestion = SuggestionItem(suggestionType: .allUsers(.room(id: "", name: nil, avatarURL: nil)), range: .init(), rawSuggestionText: "")
viewModel.context.send(viewAction: .selectedSuggestion(suggestion))
var string = "@room"
try string.unicodeScalars.append(XCTUnwrap(UnicodeScalar(String.nbsp)))
XCTAssertEqual(wysiwygViewModel.content.html, string)
try string.unicodeScalars.append(#require(UnicodeScalar(String.nbsp)))
#expect(wysiwygViewModel.content.html == string)
}
func testUserMentionPillInRTE() async {
@Test
func userMentionPillInRTE() async {
viewModel.context.send(viewAction: .composerAppeared)
await Task.yield()
let userID = "@test:matrix.org"
@@ -160,10 +162,11 @@ class ComposerToolbarViewModelTests: XCTestCase {
viewModel.context.send(viewAction: .selectedSuggestion(suggestion))
let attachment = wysiwygViewModel.textView.attributedText.attribute(.attachment, at: 0, effectiveRange: nil) as? PillTextAttachment
XCTAssertEqual(attachment?.pillData?.type, .user(userID: userID))
#expect(attachment?.pillData?.type == .user(userID: userID))
}
func testRoomMentionPillInRTE() async {
@Test
func roomMentionPillInRTE() async {
viewModel.context.send(viewAction: .composerAppeared)
await Task.yield()
let roomAlias = "#test:matrix.org"
@@ -171,20 +174,22 @@ class ComposerToolbarViewModelTests: XCTestCase {
viewModel.context.send(viewAction: .selectedSuggestion(suggestion))
let attachment = wysiwygViewModel.textView.attributedText.attribute(.attachment, at: 0, effectiveRange: nil) as? PillTextAttachment
XCTAssertEqual(attachment?.pillData?.type, .roomAlias(roomAlias))
#expect(attachment?.pillData?.type == .roomAlias(roomAlias))
}
func testAllUsersMentionPillInRTE() async {
@Test
func allUsersMentionPillInRTE() async {
viewModel.context.send(viewAction: .composerAppeared)
await Task.yield()
let suggestion = SuggestionItem(suggestionType: .allUsers(.room(id: "", name: nil, avatarURL: nil)), range: .init(), rawSuggestionText: "")
viewModel.context.send(viewAction: .selectedSuggestion(suggestion))
let attachment = wysiwygViewModel.textView.attributedText.attribute(.attachment, at: 0, effectiveRange: nil) as? PillTextAttachment
XCTAssertEqual(attachment?.pillData?.type, .allUsers)
#expect(attachment?.pillData?.type == .allUsers)
}
func testIntentionalMentions() async throws {
@Test
func intentionalMentions() async throws {
wysiwygViewModel.setHtmlContent("""
<p>Hello @room \
and especially hello to <a href=\"https://matrix.to/#/@test:matrix.org\">Test</a></p>
@@ -205,77 +210,81 @@ class ComposerToolbarViewModelTests: XCTestCase {
// MARK: - Draft
func testSaveDraftPlainText() async {
let expectation = expectation(description: "Wait for draft to be saved")
draftServiceMock.saveDraftClosure = { draft in
XCTAssertEqual(draft.plainText, "Hello world!")
XCTAssertNil(draft.htmlText)
XCTAssertEqual(draft.draftType, .newMessage)
defer { expectation.fulfill() }
return .success(())
}
@Test
func saveDraftPlainText() async throws {
viewModel.context.composerFormattingEnabled = false
viewModel.context.plainComposerText = .init(string: "Hello world!")
viewModel.saveDraft()
await fulfillment(of: [expectation], timeout: 10)
XCTAssertEqual(draftServiceMock.saveDraftCallsCount, 1)
XCTAssertFalse(draftServiceMock.clearDraftCalled)
XCTAssertFalse(draftServiceMock.loadDraftCalled)
}
func testSaveDraftFormattedText() async {
let expectation = expectation(description: "Wait for draft to be saved")
draftServiceMock.saveDraftClosure = { draft in
XCTAssertEqual(draft.plainText, "__Hello__ world!")
XCTAssertEqual(draft.htmlText, "<strong>Hello</strong> world!")
XCTAssertEqual(draft.draftType, .newMessage)
defer { expectation.fulfill() }
return .success(())
var capturedDraft: ComposerDraftProxy?
await waitForConfirmation("Save draft") { confirmation in
draftServiceMock.saveDraftClosure = { draft in
capturedDraft = draft
confirmation()
return .success(())
}
viewModel.saveDraft()
}
let draft = try #require(capturedDraft)
#expect(draft.plainText == "Hello world!")
#expect(draft.htmlText == nil)
#expect(draft.draftType == .newMessage)
#expect(draftServiceMock.saveDraftCallsCount == 1)
#expect(!draftServiceMock.clearDraftCalled)
#expect(!draftServiceMock.loadDraftCalled)
}
@Test
func saveDraftFormattedText() async throws {
viewModel.context.composerFormattingEnabled = true
wysiwygViewModel.setHtmlContent("<strong>Hello</strong> world!")
viewModel.saveDraft()
await fulfillment(of: [expectation], timeout: 10)
XCTAssertEqual(draftServiceMock.saveDraftCallsCount, 1)
XCTAssertFalse(draftServiceMock.clearDraftCalled)
XCTAssertFalse(draftServiceMock.loadDraftCalled)
}
func testSaveDraftEdit() async {
let expectation = expectation(description: "Wait for draft to be saved")
draftServiceMock.saveDraftClosure = { draft in
XCTAssertEqual(draft.plainText, "Hello world!")
XCTAssertNil(draft.htmlText)
XCTAssertEqual(draft.draftType, .edit(eventID: "testID"))
defer { expectation.fulfill() }
return .success(())
var capturedDraft: ComposerDraftProxy?
await waitForConfirmation("Save draft") { confirmation in
draftServiceMock.saveDraftClosure = { draft in
capturedDraft = draft
confirmation()
return .success(())
}
viewModel.saveDraft()
}
let draft = try #require(capturedDraft)
#expect(draft.plainText == "__Hello__ world!")
#expect(draft.htmlText == "<strong>Hello</strong> world!")
#expect(draft.draftType == .newMessage)
#expect(draftServiceMock.saveDraftCallsCount == 1)
#expect(!draftServiceMock.clearDraftCalled)
#expect(!draftServiceMock.loadDraftCalled)
}
@Test
func saveDraftEdit() async throws {
viewModel.context.composerFormattingEnabled = false
viewModel.process(timelineAction: .setMode(mode: .edit(originalEventOrTransactionID: .eventID("testID"), type: .default)))
viewModel.context.plainComposerText = .init(string: "Hello world!")
viewModel.saveDraft()
await fulfillment(of: [expectation], timeout: 10)
XCTAssertEqual(draftServiceMock.saveDraftCallsCount, 1)
XCTAssertFalse(draftServiceMock.clearDraftCalled)
XCTAssertFalse(draftServiceMock.loadDraftCalled)
}
func testSaveDraftReply() async {
let expectation = expectation(description: "Wait for draft to be saved")
draftServiceMock.saveDraftClosure = { draft in
XCTAssertEqual(draft.plainText, "Hello world!")
XCTAssertNil(draft.htmlText)
XCTAssertEqual(draft.draftType, .reply(eventID: "testID"))
defer { expectation.fulfill() }
return .success(())
var capturedDraft: ComposerDraftProxy?
await waitForConfirmation("Save draft") { confirmation in
draftServiceMock.saveDraftClosure = { draft in
capturedDraft = draft
confirmation()
return .success(())
}
viewModel.saveDraft()
}
let draft = try #require(capturedDraft)
#expect(draft.plainText == "Hello world!")
#expect(draft.htmlText == nil)
#expect(draft.draftType == .edit(eventID: "testID"))
#expect(draftServiceMock.saveDraftCallsCount == 1)
#expect(!draftServiceMock.clearDraftCalled)
#expect(!draftServiceMock.loadDraftCalled)
}
@Test
func saveDraftReply() async throws {
viewModel.context.composerFormattingEnabled = false
viewModel.process(timelineAction: .setMode(mode: .reply(eventID: "testID",
replyDetails: .loaded(sender: .init(id: ""),
@@ -283,143 +292,161 @@ class ComposerToolbarViewModelTests: XCTestCase {
eventContent: .message(.text(.init(body: "reply text")))),
isThread: false)))
viewModel.context.plainComposerText = .init(string: "Hello world!")
viewModel.saveDraft()
await fulfillment(of: [expectation], timeout: 10)
XCTAssertEqual(draftServiceMock.saveDraftCallsCount, 1)
XCTAssertFalse(draftServiceMock.clearDraftCalled)
XCTAssertFalse(draftServiceMock.loadDraftCalled)
}
func testSaveDraftWhenEmptyReply() async {
let expectation = expectation(description: "Wait for draft to be saved")
draftServiceMock.saveDraftClosure = { draft in
XCTAssertEqual(draft.plainText, "")
XCTAssertNil(draft.htmlText)
XCTAssertEqual(draft.draftType, .reply(eventID: "testID"))
defer { expectation.fulfill() }
return .success(())
var capturedDraft: ComposerDraftProxy?
await waitForConfirmation("Save draft") { confirmation in
draftServiceMock.saveDraftClosure = { draft in
capturedDraft = draft
confirmation()
return .success(())
}
viewModel.saveDraft()
}
let draft = try #require(capturedDraft)
#expect(draft.plainText == "Hello world!")
#expect(draft.htmlText == nil)
#expect(draft.draftType == .reply(eventID: "testID"))
#expect(draftServiceMock.saveDraftCallsCount == 1)
#expect(!draftServiceMock.clearDraftCalled)
#expect(!draftServiceMock.loadDraftCalled)
}
@Test
func saveDraftWhenEmptyReply() async throws {
viewModel.context.composerFormattingEnabled = false
viewModel.process(timelineAction: .setMode(mode: .reply(eventID: "testID",
replyDetails: .loaded(sender: .init(id: ""),
eventID: "testID",
eventContent: .message(.text(.init(body: "reply text")))),
isThread: false)))
viewModel.saveDraft()
await fulfillment(of: [expectation], timeout: 10)
XCTAssertEqual(draftServiceMock.saveDraftCallsCount, 1)
XCTAssertFalse(draftServiceMock.clearDraftCalled)
XCTAssertFalse(draftServiceMock.loadDraftCalled)
}
func testClearDraftWhenEmptyNormalMessage() async {
let expectation = expectation(description: "Wait for draft to be cleared")
draftServiceMock.clearDraftClosure = {
defer { expectation.fulfill() }
return .success(())
var capturedDraft: ComposerDraftProxy?
await waitForConfirmation("Save draft") { confirmation in
draftServiceMock.saveDraftClosure = { draft in
capturedDraft = draft
confirmation()
return .success(())
}
viewModel.saveDraft()
}
let draft = try #require(capturedDraft)
#expect(draft.plainText == "")
#expect(draft.htmlText == nil)
#expect(draft.draftType == .reply(eventID: "testID"))
#expect(draftServiceMock.saveDraftCallsCount == 1)
#expect(!draftServiceMock.clearDraftCalled)
#expect(!draftServiceMock.loadDraftCalled)
}
@Test
func clearDraftWhenEmptyNormalMessage() async {
viewModel.context.composerFormattingEnabled = false
viewModel.saveDraft()
await fulfillment(of: [expectation], timeout: 10)
XCTAssertFalse(draftServiceMock.saveDraftCalled)
XCTAssertEqual(draftServiceMock.clearDraftCallsCount, 1)
XCTAssertFalse(draftServiceMock.loadDraftCalled)
}
func testClearDraftForNonTextMode() async {
let expectation = expectation(description: "Wait for draft to be cleared")
draftServiceMock.clearDraftClosure = {
defer { expectation.fulfill() }
return .success(())
await waitForConfirmation("Clear draft") { confirmation in
draftServiceMock.clearDraftClosure = {
confirmation()
return .success(())
}
viewModel.saveDraft()
}
#expect(!draftServiceMock.saveDraftCalled)
#expect(draftServiceMock.clearDraftCallsCount == 1)
#expect(!draftServiceMock.loadDraftCalled)
}
@Test
func clearDraftForNonTextMode() async {
viewModel.context.composerFormattingEnabled = false
let waveformData: [Float] = Array(repeating: 1.0, count: 1000)
viewModel.context.plainComposerText = .init(string: "Hello world!")
viewModel.process(timelineAction: .setMode(mode: .previewVoiceMessage(state: AudioPlayerState(id: .recorderPreview, title: "", duration: 10.0),
waveform: .data(waveformData),
isUploading: false)))
viewModel.saveDraft()
await fulfillment(of: [expectation], timeout: 10)
XCTAssertFalse(draftServiceMock.saveDraftCalled)
XCTAssertEqual(draftServiceMock.clearDraftCallsCount, 1)
XCTAssertFalse(draftServiceMock.loadDraftCalled)
await waitForConfirmation("Clear draft") { confirmation in
draftServiceMock.clearDraftClosure = {
confirmation()
return .success(())
}
viewModel.saveDraft()
}
#expect(!draftServiceMock.saveDraftCalled)
#expect(draftServiceMock.clearDraftCallsCount == 1)
#expect(!draftServiceMock.loadDraftCalled)
}
func testNothingToRestore() async {
@Test
func nothingToRestore() async {
viewModel.context.composerFormattingEnabled = false
let expectation = expectation(description: "Wait for draft to be restored")
draftServiceMock.loadDraftClosure = {
defer { expectation.fulfill() }
return .success(nil)
.success(nil)
}
await viewModel.loadDraft()
await fulfillment(of: [expectation], timeout: 10)
XCTAssertFalse(viewModel.context.composerFormattingEnabled)
XCTAssertTrue(viewModel.state.composerEmpty)
XCTAssertEqual(viewModel.state.composerMode, .default)
#expect(!viewModel.context.composerFormattingEnabled)
#expect(viewModel.state.composerEmpty)
#expect(viewModel.state.composerMode == .default)
}
func testRestoreNormalPlainTextMessage() async {
@Test
func restoreNormalPlainTextMessage() async {
viewModel.context.composerFormattingEnabled = false
let expectation = expectation(description: "Wait for draft to be restored")
draftServiceMock.loadDraftClosure = {
defer { expectation.fulfill() }
return .success(.init(plainText: "Hello world!",
htmlText: nil,
draftType: .newMessage))
.success(.init(plainText: "Hello world!",
htmlText: nil,
draftType: .newMessage))
}
await viewModel.loadDraft()
await fulfillment(of: [expectation], timeout: 10)
XCTAssertFalse(viewModel.context.composerFormattingEnabled)
XCTAssertEqual(viewModel.state.composerMode, .default)
XCTAssertEqual(viewModel.context.plainComposerText, NSAttributedString(string: "Hello world!"))
#expect(!viewModel.context.composerFormattingEnabled)
#expect(viewModel.state.composerMode == .default)
#expect(viewModel.context.plainComposerText == NSAttributedString(string: "Hello world!"))
}
func testRestoreNormalFormattedTextMessage() async {
@Test
func restoreNormalFormattedTextMessage() async throws {
viewModel.context.composerFormattingEnabled = false
try await confirmation { confirmation in
draftServiceMock.loadDraftClosure = {
defer { confirmation() }
return .success(.init(plainText: "__Hello__ world!",
htmlText: "<strong>Hello</strong> world!",
draftType: .newMessage))
}
let deferred = deferFulfillment(wysiwygViewModel.$isContentEmpty) { !$0 }
await viewModel.loadDraft()
try await deferred.fulfill()
}
#expect(viewModel.context.composerFormattingEnabled)
#expect(viewModel.state.composerMode == .default)
#expect(wysiwygViewModel.content.html == "<strong>Hello</strong> world!")
#expect(wysiwygViewModel.content.markdown == "__Hello__ world!")
}
@Test
func restoreEdit() async {
viewModel.context.composerFormattingEnabled = false
let expectation = expectation(description: "Wait for draft to be restored")
draftServiceMock.loadDraftClosure = {
defer { expectation.fulfill() }
return .success(.init(plainText: "__Hello__ world!",
htmlText: "<strong>Hello</strong> world!",
draftType: .newMessage))
.success(.init(plainText: "Hello world!",
htmlText: nil,
draftType: .edit(eventID: "testID")))
}
await viewModel.loadDraft()
await fulfillment(of: [expectation], timeout: 10)
XCTAssertTrue(viewModel.context.composerFormattingEnabled)
XCTAssertEqual(viewModel.state.composerMode, .default)
XCTAssertEqual(wysiwygViewModel.content.html, "<strong>Hello</strong> world!")
XCTAssertEqual(wysiwygViewModel.content.markdown, "__Hello__ world!")
#expect(!viewModel.context.composerFormattingEnabled)
#expect(viewModel.state.composerMode == .edit(originalEventOrTransactionID: .eventID("testID"), type: .default))
#expect(viewModel.context.plainComposerText == NSAttributedString(string: "Hello world!"))
}
func testRestoreEdit() async {
viewModel.context.composerFormattingEnabled = false
let expectation = expectation(description: "Wait for draft to be restored")
draftServiceMock.loadDraftClosure = {
defer { expectation.fulfill() }
return .success(.init(plainText: "Hello world!",
htmlText: nil,
draftType: .edit(eventID: "testID")))
}
await viewModel.loadDraft()
await fulfillment(of: [expectation], timeout: 10)
XCTAssertFalse(viewModel.context.composerFormattingEnabled)
XCTAssertEqual(viewModel.state.composerMode, .edit(originalEventOrTransactionID: .eventID("testID"), type: .default))
XCTAssertEqual(viewModel.context.plainComposerText, NSAttributedString(string: "Hello world!"))
}
func testRestoreReply() async {
@Test
func restoreReply() async throws {
let testEventID = "testID"
let text = "Hello world!"
let loadedReply = TimelineItemReplyDetails.loaded(sender: .init(id: "userID",
@@ -428,39 +455,38 @@ class ComposerToolbarViewModelTests: XCTestCase {
eventContent: .message(.text(.init(body: "Reply text"))))
viewModel.context.composerFormattingEnabled = false
let draftExpectation = expectation(description: "Wait for draft to be restored")
draftServiceMock.loadDraftClosure = {
defer { draftExpectation.fulfill() }
return .success(.init(plainText: text,
htmlText: nil,
draftType: .reply(eventID: testEventID)))
.success(.init(plainText: text,
htmlText: nil,
draftType: .reply(eventID: testEventID)))
}
let loadReplyExpectation = expectation(description: "Wait for reply to be loaded")
let deferredReplyLoaded = deferFulfillment(viewModel.context.$viewState) {
$0.composerMode == .reply(eventID: testEventID, replyDetails: loadedReply, isThread: true)
}
draftServiceMock.getReplyEventIDClosure = { eventID in
defer { loadReplyExpectation.fulfill() }
XCTAssertEqual(eventID, testEventID)
#expect(eventID == testEventID)
try? await Task.sleep(for: .seconds(1))
return .success(.init(details: loadedReply,
isThreaded: true))
}
await viewModel.loadDraft()
await fulfillment(of: [draftExpectation], timeout: 10)
XCTAssertFalse(viewModel.context.composerFormattingEnabled)
#expect(!viewModel.context.composerFormattingEnabled)
// Testing the loading state first
XCTAssertEqual(viewModel.state.composerMode, .reply(eventID: testEventID,
replyDetails: .loading(eventID: testEventID),
isThread: false))
XCTAssertEqual(viewModel.context.plainComposerText, NSAttributedString(string: text))
#expect(viewModel.state.composerMode == .reply(eventID: testEventID,
replyDetails: .loading(eventID: testEventID),
isThread: false))
#expect(viewModel.context.plainComposerText == NSAttributedString(string: text))
await fulfillment(of: [loadReplyExpectation], timeout: 10)
XCTAssertEqual(viewModel.state.composerMode, .reply(eventID: testEventID,
replyDetails: loadedReply,
isThread: true))
try await deferredReplyLoaded.fulfill()
#expect(viewModel.state.composerMode == .reply(eventID: testEventID,
replyDetails: loadedReply,
isThread: true))
}
func testRestoreReplyAndCancelReplyMode() async {
@Test
func restoreReplyAndCancelReplyMode() async throws {
let testEventID = "testID"
let text = "Hello world!"
let loadedReply = TimelineItemReplyDetails.loaded(sender: .init(id: "userID", displayName: "Username"),
@@ -468,103 +494,105 @@ class ComposerToolbarViewModelTests: XCTestCase {
eventContent: .message(.text(.init(body: "Reply text"))))
viewModel.context.composerFormattingEnabled = false
let draftExpectation = expectation(description: "Wait for draft to be restored")
draftServiceMock.loadDraftClosure = {
defer { draftExpectation.fulfill() }
return .success(.init(plainText: text,
htmlText: nil,
draftType: .reply(eventID: testEventID)))
.success(.init(plainText: text,
htmlText: nil,
draftType: .reply(eventID: testEventID)))
}
let loadReplyExpectation = expectation(description: "Wait for reply to be loaded")
let replyLoadedSubject = PassthroughSubject<Void, Never>()
let deferredReplyLoaded = deferFulfillment(replyLoadedSubject) { _ in true }
draftServiceMock.getReplyEventIDClosure = { eventID in
defer { loadReplyExpectation.fulfill() }
XCTAssertEqual(eventID, testEventID)
defer { replyLoadedSubject.send(()) }
#expect(eventID == testEventID)
try? await Task.sleep(for: .seconds(1))
return .success(.init(details: loadedReply,
isThreaded: true))
}
await viewModel.loadDraft()
await fulfillment(of: [draftExpectation], timeout: 10)
XCTAssertFalse(viewModel.context.composerFormattingEnabled)
#expect(!viewModel.context.composerFormattingEnabled)
// Testing the loading state first
XCTAssertEqual(viewModel.state.composerMode, .reply(eventID: testEventID,
replyDetails: .loading(eventID: testEventID),
isThread: false))
XCTAssertEqual(viewModel.context.plainComposerText, NSAttributedString(string: text))
#expect(viewModel.state.composerMode == .reply(eventID: testEventID,
replyDetails: .loading(eventID: testEventID),
isThread: false))
#expect(viewModel.context.plainComposerText == NSAttributedString(string: text))
// Now we change the state to cancel the reply mode update
viewModel.process(viewAction: .cancelReply)
await fulfillment(of: [loadReplyExpectation], timeout: 10)
XCTAssertEqual(viewModel.state.composerMode, .default)
try await deferredReplyLoaded.fulfill()
#expect(viewModel.state.composerMode == .default)
}
func testSaveVolatileDraftWhenEditing() {
@Test
func saveVolatileDraftWhenEditing() {
viewModel.context.composerFormattingEnabled = false
viewModel.context.plainComposerText = .init(string: "Hello world!")
viewModel.process(timelineAction: .setMode(mode: .edit(originalEventOrTransactionID: .eventID(UUID().uuidString), type: .default)))
let draft = draftServiceMock.saveVolatileDraftReceivedDraft
XCTAssertNotNil(draft)
XCTAssertEqual(draft?.plainText, "Hello world!")
XCTAssertNil(draft?.htmlText)
XCTAssertEqual(draft?.draftType, .newMessage)
#expect(draft != nil)
#expect(draft?.plainText == "Hello world!")
#expect(draft?.htmlText == nil)
#expect(draft?.draftType == .newMessage)
}
func testRestoreVolatileDraftWhenCancellingEdit() async {
let expectation = expectation(description: "Wait for draft to be restored")
draftServiceMock.loadVolatileDraftClosure = {
defer { expectation.fulfill() }
return .init(plainText: "Hello world",
htmlText: nil,
draftType: .newMessage)
@Test
func restoreVolatileDraftWhenCancellingEdit() async {
await waitForConfirmation("Volatile draft loaded") { confirmation in
draftServiceMock.loadVolatileDraftClosure = {
defer { confirmation() }
return .init(plainText: "Hello world",
htmlText: nil,
draftType: .newMessage)
}
DispatchQueue.main.async {
self.viewModel.process(viewAction: .cancelEdit)
}
}
viewModel.process(viewAction: .cancelEdit)
await fulfillment(of: [expectation])
XCTAssertEqual(viewModel.context.plainComposerText, NSAttributedString(string: "Hello world"))
#expect(viewModel.context.plainComposerText == NSAttributedString(string: "Hello world"))
}
func testRestoreVolatileDraftWhenClearing() async {
let expectation1 = expectation(description: "Wait for draft to be restored")
draftServiceMock.loadVolatileDraftClosure = {
defer { expectation1.fulfill() }
return .init(plainText: "Hello world",
htmlText: nil,
draftType: .newMessage)
@Test
func restoreVolatileDraftWhenClearing() async {
await waitForConfirmation("Volatile draft loaded and cleared", expectedCount: 2) { confirmation in
draftServiceMock.loadVolatileDraftClosure = {
defer { confirmation() }
return .init(plainText: "Hello world",
htmlText: nil,
draftType: .newMessage)
}
draftServiceMock.clearVolatileDraftClosure = {
confirmation()
}
DispatchQueue.main.async {
self.viewModel.process(timelineAction: .clear)
}
}
let expectation2 = expectation(description: "The draft should also be cleared after being loaded")
draftServiceMock.clearVolatileDraftClosure = {
expectation2.fulfill()
}
viewModel.process(timelineAction: .clear)
await fulfillment(of: [expectation1, expectation2])
XCTAssertEqual(viewModel.context.plainComposerText, NSAttributedString(string: "Hello world"))
#expect(viewModel.context.plainComposerText == NSAttributedString(string: "Hello world"))
}
func testRestoreVolatileDraftDoubleClear() async {
let expectation1 = expectation(description: "Wait for draft to be restored")
draftServiceMock.loadVolatileDraftClosure = {
defer { expectation1.fulfill() }
return .init(plainText: "Hello world",
htmlText: nil,
draftType: .newMessage)
@Test
func restoreVolatileDraftDoubleClear() async {
await waitForConfirmation("Volatile draft loaded and cleared", expectedCount: 2) { confirmation in
draftServiceMock.loadVolatileDraftClosure = {
defer { confirmation() }
return .init(plainText: "Hello world",
htmlText: nil,
draftType: .newMessage)
}
draftServiceMock.clearVolatileDraftClosure = {
confirmation()
}
DispatchQueue.main.async {
self.viewModel.process(timelineAction: .clear)
}
}
let expectation2 = expectation(description: "The draft should also be cleared after being loaded")
draftServiceMock.clearVolatileDraftClosure = {
expectation2.fulfill()
}
viewModel.process(timelineAction: .clear)
await fulfillment(of: [expectation1, expectation2])
XCTAssertEqual(viewModel.context.plainComposerText, NSAttributedString(string: "Hello world"))
#expect(viewModel.context.plainComposerText == NSAttributedString(string: "Hello world"))
}
func testRestoreUserMentionInPlainText() async throws {
@Test
func restoreUserMentionInPlainText() async throws {
viewModel.context.composerFormattingEnabled = false
let text = "Hello [TestName](https://matrix.to/#/@test:matrix.org)!"
viewModel.process(timelineAction: .setText(plainText: text, htmlText: nil))
@@ -584,7 +612,8 @@ class ComposerToolbarViewModelTests: XCTestCase {
try await deferred.fulfill()
}
func testRestoreAllUsersMentionInPlainText() async throws {
@Test
func restoreAllUsersMentionInPlainText() async throws {
viewModel.context.composerFormattingEnabled = false
let text = "Hello @room"
viewModel.process(timelineAction: .setText(plainText: text, htmlText: nil))
@@ -603,7 +632,8 @@ class ComposerToolbarViewModelTests: XCTestCase {
try await deferred.fulfill()
}
func testRestoreMixedMentionsInPlainText() async throws {
@Test
func restoreMixedMentionsInPlainText() async throws {
viewModel.context.composerFormattingEnabled = false
let text = "Hello [User1](https://matrix.to/#/@user1:matrix.org), [User2](https://matrix.to/#/@user2:matrix.org) and @room"
viewModel.process(timelineAction: .setText(plainText: text, htmlText: nil))
@@ -623,7 +653,8 @@ class ComposerToolbarViewModelTests: XCTestCase {
try await deferred.fulfill()
}
func testRestoreAmbiguousMention() async throws {
@Test
func restoreAmbiguousMention() async throws {
viewModel.context.composerFormattingEnabled = false
let text = "Hello [User1](https://matrix.to/#/@roomuser:matrix.org)"
viewModel.process(timelineAction: .setText(plainText: text, htmlText: nil))
@@ -643,12 +674,12 @@ class ComposerToolbarViewModelTests: XCTestCase {
try await deferred.fulfill()
}
func testRestoreDoesntOverwriteInitialText() async {
@Test
func restoreDoesntOverwriteInitialText() async {
let sharedText = "Some shared text"
let expectation = expectation(description: "Wait for draft to be restored")
expectation.isInverted = true
var draftLoadCalled = false
setUpViewModel(initialText: sharedText) {
defer { expectation.fulfill() }
draftLoadCalled = true
return .success(.init(plainText: "Hello world!",
htmlText: nil,
draftType: .newMessage))
@@ -656,15 +687,16 @@ class ComposerToolbarViewModelTests: XCTestCase {
viewModel.context.composerFormattingEnabled = false
await viewModel.loadDraft()
await fulfillment(of: [expectation], timeout: 1)
XCTAssertFalse(viewModel.context.composerFormattingEnabled)
XCTAssertEqual(viewModel.state.composerMode, .default)
XCTAssertEqual(viewModel.context.plainComposerText, NSAttributedString(string: sharedText))
#expect(!draftLoadCalled)
#expect(!viewModel.context.composerFormattingEnabled)
#expect(viewModel.state.composerMode == .default)
#expect(viewModel.context.plainComposerText == NSAttributedString(string: sharedText))
}
// MARK: - Identity Violation
func testVerificationViolationDisablesComposer() async throws {
@Test
func verificationViolationDisablesComposer() async throws {
let mockCompletionSuggestionService = CompletionSuggestionServiceMock(configuration: .init())
let roomProxyMock = JoinedRoomProxyMock(.init(name: "Test"))
@@ -695,7 +727,8 @@ class ComposerToolbarViewModelTests: XCTestCase {
try await fulfillment.fulfill()
}
func testMultipleViolation() async throws {
@Test
func multipleViolation() async throws {
let mockCompletionSuggestionService = CompletionSuggestionServiceMock(configuration: .init())
let roomProxyMock = JoinedRoomProxyMock(.init(name: "Test"))
@@ -743,7 +776,8 @@ class ComposerToolbarViewModelTests: XCTestCase {
try await fulfillment.fulfill()
}
func testPinViolationDoesNotDisableComposer() {
@Test
func pinViolationDoesNotDisableComposer() async throws {
let mockCompletionSuggestionService = CompletionSuggestionServiceMock(configuration: .init())
let roomProxyMock = JoinedRoomProxyMock(.init(name: "Test"))
@@ -764,19 +798,8 @@ class ComposerToolbarViewModelTests: XCTestCase {
analyticsService: ServiceLocator.shared.analytics,
composerDraftService: draftServiceMock)
let expectation = expectation(description: "Composer should be enabled")
let cancellable = viewModel
.context
.$viewState
.map(\.canSend)
.sink { canSend in
if canSend {
expectation.fulfill()
}
}
wait(for: [expectation], timeout: 2.0)
cancellable.cancel()
let deferred = deferFulfillment(viewModel.context.$viewState, message: "Composer should be enabled") { $0.canSend == true }
try await deferred.fulfill()
}
// MARK: - Helpers

View File

@@ -8,10 +8,11 @@
import Combine
@testable import ElementX
import XCTest
import Testing
@Suite
@MainActor
class CreateRoomScreenViewModelTests: XCTestCase {
final class CreateRoomScreenViewModelTests {
var viewModel: CreateRoomScreenViewModelProtocol!
var clientProxy: ClientProxyMock!
var spaceService: SpaceServiceProxyMock!
@@ -23,7 +24,7 @@ class CreateRoomScreenViewModelTests: XCTestCase {
viewModel.context
}
override func tearDown() {
deinit {
AppSettings.resetAllSettings()
viewModel = nil
clientProxy = nil
@@ -31,28 +32,31 @@ class CreateRoomScreenViewModelTests: XCTestCase {
userSession = nil
}
func testDefaultState() {
@Test
func defaultState() {
setup()
XCTAssertEqual(context.viewState.bindings.selectedAccessType, .private)
XCTAssertNil(context.selectedSpace)
XCTAssertEqual(context.viewState.availableAccessTypes, [.public, .askToJoin, .private])
XCTAssertTrue(context.viewState.canSelectSpace)
#expect(context.viewState.bindings.selectedAccessType == .private)
#expect(context.selectedSpace == nil)
#expect(context.viewState.availableAccessTypes == [.public, .askToJoin, .private])
#expect(context.viewState.canSelectSpace)
}
func testCreateRoomRequirements() {
@Test
func createRoomRequirements() {
setup()
XCTAssertFalse(context.viewState.canCreateRoom)
#expect(!context.viewState.canCreateRoom)
context.send(viewAction: .updateRoomName("A"))
XCTAssertTrue(context.viewState.canCreateRoom)
#expect(context.viewState.canCreateRoom)
}
func testCreateRoom() async throws {
@Test
func createRoom() async throws {
setup()
// Given a form with a blank topic.
context.send(viewAction: .updateRoomName("A"))
context.roomTopic = ""
context.selectedAccessType = .private
XCTAssertTrue(context.viewState.canCreateRoom)
#expect(context.viewState.canCreateRoom)
// When creating the room.
clientProxy.createRoomNameTopicAccessTypeIsSpaceUserIDsAvatarURLAliasLocalPartReturnValue = .success("1")
@@ -64,14 +68,15 @@ class CreateRoomScreenViewModelTests: XCTestCase {
try await deferred.fulfill()
// Then the room should be created and a topic should not be set.
XCTAssertTrue(clientProxy.createRoomNameTopicAccessTypeIsSpaceUserIDsAvatarURLAliasLocalPartCalled)
XCTAssertEqual(clientProxy.createRoomNameTopicAccessTypeIsSpaceUserIDsAvatarURLAliasLocalPartReceivedArguments?.name, "A")
XCTAssertNil(clientProxy.createRoomNameTopicAccessTypeIsSpaceUserIDsAvatarURLAliasLocalPartReceivedArguments?.topic,
"The topic should be sent as nil when it is empty.")
XCTAssertEqual(clientProxy.createRoomNameTopicAccessTypeIsSpaceUserIDsAvatarURLAliasLocalPartReceivedArguments?.accessType, .private)
#expect(clientProxy.createRoomNameTopicAccessTypeIsSpaceUserIDsAvatarURLAliasLocalPartCalled)
#expect(clientProxy.createRoomNameTopicAccessTypeIsSpaceUserIDsAvatarURLAliasLocalPartReceivedArguments?.name == "A")
#expect(clientProxy.createRoomNameTopicAccessTypeIsSpaceUserIDsAvatarURLAliasLocalPartReceivedArguments?.topic == nil,
"The topic should be sent as nil when it is empty.")
#expect(clientProxy.createRoomNameTopicAccessTypeIsSpaceUserIDsAvatarURLAliasLocalPartReceivedArguments?.accessType == .private)
}
func testCreateSpace() async throws {
@Test
func createSpace() async throws {
setup(isSpace: true)
clientProxy = ClientProxyMock(.init(userIDServerName: "matrix.org",
userID: "@a:b.com",
@@ -92,7 +97,7 @@ class CreateRoomScreenViewModelTests: XCTestCase {
context.send(viewAction: .updateRoomName("A"))
context.roomTopic = ""
context.selectedAccessType = .private
XCTAssertTrue(context.viewState.canCreateRoom)
#expect(context.viewState.canCreateRoom)
// When creating the room.
clientProxy.createRoomNameTopicAccessTypeIsSpaceUserIDsAvatarURLAliasLocalPartReturnValue = .success("1")
@@ -106,14 +111,15 @@ class CreateRoomScreenViewModelTests: XCTestCase {
try await deferred.fulfill()
// Then the room should be created and a topic should not be set.
XCTAssertTrue(clientProxy.createRoomNameTopicAccessTypeIsSpaceUserIDsAvatarURLAliasLocalPartCalled)
XCTAssertEqual(clientProxy.createRoomNameTopicAccessTypeIsSpaceUserIDsAvatarURLAliasLocalPartReceivedArguments?.name, "A")
XCTAssertNil(clientProxy.createRoomNameTopicAccessTypeIsSpaceUserIDsAvatarURLAliasLocalPartReceivedArguments?.topic,
"The topic should be sent as nil when it is empty.")
XCTAssertEqual(clientProxy.createRoomNameTopicAccessTypeIsSpaceUserIDsAvatarURLAliasLocalPartReceivedArguments?.accessType, .private)
#expect(clientProxy.createRoomNameTopicAccessTypeIsSpaceUserIDsAvatarURLAliasLocalPartCalled)
#expect(clientProxy.createRoomNameTopicAccessTypeIsSpaceUserIDsAvatarURLAliasLocalPartReceivedArguments?.name == "A")
#expect(clientProxy.createRoomNameTopicAccessTypeIsSpaceUserIDsAvatarURLAliasLocalPartReceivedArguments?.topic == nil,
"The topic should be sent as nil when it is empty.")
#expect(clientProxy.createRoomNameTopicAccessTypeIsSpaceUserIDsAvatarURLAliasLocalPartReceivedArguments?.accessType == .private)
}
func testCreateKnockingRoom() async {
@Test
func createKnockingRoom() async {
setup()
context.send(viewAction: .updateRoomName("A"))
context.roomTopic = "B"
@@ -121,20 +127,21 @@ class CreateRoomScreenViewModelTests: XCTestCase {
// When setting the room as private we always reset the knocking state to the default value of false
// so we need to wait a main actor cycle to ensure the view state is updated
await Task.yield()
XCTAssertTrue(context.viewState.canCreateRoom)
#expect(context.viewState.canCreateRoom)
let expectation = expectation(description: "Wait for the room to be created")
clientProxy.createRoomNameTopicAccessTypeIsSpaceUserIDsAvatarURLAliasLocalPartClosure = { _, _, accessType, _, _, _, localAliasPart in
XCTAssertEqual(accessType, .askToJoin)
XCTAssertEqual(localAliasPart, "a")
defer { expectation.fulfill() }
return .success("")
await waitForConfirmation("Wait for the room to be created") { confirmation in
clientProxy.createRoomNameTopicAccessTypeIsSpaceUserIDsAvatarURLAliasLocalPartClosure = { _, _, accessType, _, _, _, localAliasPart in
#expect(accessType == .askToJoin)
#expect(localAliasPart == "a")
defer { confirmation() }
return .success("")
}
context.send(viewAction: .createRoom)
}
context.send(viewAction: .createRoom)
await fulfillment(of: [expectation])
}
func testCreatePublicRoomFailsForInvalidAlias() async throws {
@Test
func createPublicRoomFailsForInvalidAlias() async throws {
setup()
context.send(viewAction: .updateRoomName("A"))
context.roomTopic = "B"
@@ -154,10 +161,11 @@ class CreateRoomScreenViewModelTests: XCTestCase {
// blocked it
context.send(viewAction: .createRoom)
await Task.yield()
XCTAssertFalse(clientProxy.createRoomNameTopicAccessTypeIsSpaceUserIDsAvatarURLAliasLocalPartCalled)
#expect(!clientProxy.createRoomNameTopicAccessTypeIsSpaceUserIDsAvatarURLAliasLocalPartCalled)
}
func testCreatePublicRoomFailsForExistingAlias() async throws {
@Test
func createPublicRoomFailsForExistingAlias() async throws {
setup()
clientProxy.isAliasAvailableReturnValue = .success(false)
context.send(viewAction: .updateRoomName("A"))
@@ -176,61 +184,61 @@ class CreateRoomScreenViewModelTests: XCTestCase {
// We also want to force the room creation in case the user may tap the button before the debounce
// blocked it
let expectation = expectation(description: "Wait for the alias to be checked again")
clientProxy.isAliasAvailableClosure = { _ in
defer {
expectation.fulfill()
await waitForConfirmation("Wait for the alias to be checked again") { confirmation in
clientProxy.isAliasAvailableClosure = { _ in
defer { confirmation() }
return .success(false)
}
return .success(false)
context.send(viewAction: .createRoom)
}
context.send(viewAction: .createRoom)
await fulfillment(of: [expectation])
XCTAssertEqual(clientProxy.isAliasAvailableCallsCount, 2)
XCTAssertFalse(clientProxy.createRoomNameTopicAccessTypeIsSpaceUserIDsAvatarURLAliasLocalPartCalled)
#expect(clientProxy.isAliasAvailableCallsCount == 2)
#expect(!clientProxy.createRoomNameTopicAccessTypeIsSpaceUserIDsAvatarURLAliasLocalPartCalled)
}
func testNameAndAddressSync() async {
@Test
func nameAndAddressSync() async {
setup()
context.selectedAccessType = .private
await Task.yield()
context.send(viewAction: .updateRoomName("abc"))
XCTAssertEqual(context.viewState.aliasLocalPart, "abc")
XCTAssertEqual(context.viewState.roomName, "abc")
#expect(context.viewState.aliasLocalPart == "abc")
#expect(context.viewState.roomName == "abc")
context.send(viewAction: .updateRoomName("DEF"))
XCTAssertEqual(context.viewState.roomName, "DEF")
XCTAssertEqual(context.viewState.aliasLocalPart, "def")
#expect(context.viewState.roomName == "DEF")
#expect(context.viewState.aliasLocalPart == "def")
context.send(viewAction: .updateRoomName("a b c"))
XCTAssertEqual(context.viewState.aliasLocalPart, "a-b-c")
XCTAssertEqual(context.viewState.roomName, "a b c")
#expect(context.viewState.aliasLocalPart == "a-b-c")
#expect(context.viewState.roomName == "a b c")
context.send(viewAction: .updateAliasLocalPart("hello-world"))
// This removes the sync
XCTAssertEqual(context.viewState.aliasLocalPart, "hello-world")
XCTAssertEqual(context.viewState.roomName, "a b c")
#expect(context.viewState.aliasLocalPart == "hello-world")
#expect(context.viewState.roomName == "a b c")
context.send(viewAction: .updateRoomName("Hello Matrix!"))
XCTAssertEqual(context.viewState.aliasLocalPart, "hello-world")
XCTAssertEqual(context.viewState.roomName, "Hello Matrix!")
#expect(context.viewState.aliasLocalPart == "hello-world")
#expect(context.viewState.roomName == "Hello Matrix!")
// Deleting the whole name will restore the sync
context.send(viewAction: .updateRoomName(""))
XCTAssertEqual(context.viewState.aliasLocalPart, "")
XCTAssertEqual(context.viewState.roomName, "")
#expect(context.viewState.aliasLocalPart == "")
#expect(context.viewState.roomName == "")
context.send(viewAction: .updateRoomName("Hello# Matrix!"))
XCTAssertEqual(context.viewState.aliasLocalPart, "hello-matrix!")
XCTAssertEqual(context.viewState.roomName, "Hello# Matrix!")
#expect(context.viewState.aliasLocalPart == "hello-matrix!")
#expect(context.viewState.roomName == "Hello# Matrix!")
}
func testCreateRoomInASelectedSpaceFromTheList() async throws {
@Test
func createRoomInASelectedSpaceFromTheList() async throws {
let spaces = [SpaceServiceRoom].mockJoinedSpaces2
setup()
context.send(viewAction: .updateRoomName("A"))
context.selectedAccessType = .public
XCTAssertTrue(context.viewState.canCreateRoom)
XCTAssertNil(context.selectedSpace)
XCTAssertEqual(context.viewState.availableAccessTypes, [.public, .askToJoin, .private])
XCTAssertTrue(context.viewState.canSelectSpace)
#expect(context.viewState.canCreateRoom)
#expect(context.selectedSpace == nil)
#expect(context.viewState.availableAccessTypes == [.public, .askToJoin, .private])
#expect(context.viewState.canSelectSpace)
var deferred = deferFulfillment(context.$viewState) { viewState in
viewState.editableSpaces.map(\.id) == spaces.map(\.id)
@@ -248,64 +256,62 @@ class CreateRoomScreenViewModelTests: XCTestCase {
// When creating the room.
clientProxy.createRoomNameTopicAccessTypeIsSpaceUserIDsAvatarURLAliasLocalPartReturnValue = .success("1")
let expectation = expectation(description: "Wait for the addChild function to be called")
spaceService.addChildToClosure = { roomID, spaceID in
defer { expectation.fulfill() }
XCTAssertEqual(roomID, "1")
XCTAssertEqual(spaceID, spaces[0].id)
return .success(())
try await confirmation("Wait for the addChild function to be called") { confirm in
let deferredAction = deferFulfillment(viewModel.actions) { action in
guard case .createdRoom(let roomProxy, nil) = action, roomProxy.id == "1" else { return false }
return true
}
spaceService.addChildToClosure = { roomID, spaceID in
defer { confirm() }
#expect(roomID == "1")
#expect(spaceID == spaces[0].id)
return .success(())
}
context.send(viewAction: .createRoom)
try await deferredAction.fulfill()
}
let deferredAction = deferFulfillment(viewModel.actions) { action in
guard case .createdRoom(let roomProxy, nil) = action, roomProxy.id == "1" else { return false }
return true
}
context.send(viewAction: .createRoom)
await fulfillment(of: [expectation])
try await deferredAction.fulfill()
XCTAssertTrue(clientProxy.createRoomNameTopicAccessTypeIsSpaceUserIDsAvatarURLAliasLocalPartCalled)
XCTAssertEqual(clientProxy.createRoomNameTopicAccessTypeIsSpaceUserIDsAvatarURLAliasLocalPartReceivedArguments?.name, "A")
XCTAssertEqual(clientProxy.createRoomNameTopicAccessTypeIsSpaceUserIDsAvatarURLAliasLocalPartReceivedArguments?.accessType, .private)
#expect(clientProxy.createRoomNameTopicAccessTypeIsSpaceUserIDsAvatarURLAliasLocalPartCalled)
#expect(clientProxy.createRoomNameTopicAccessTypeIsSpaceUserIDsAvatarURLAliasLocalPartReceivedArguments?.name == "A")
#expect(clientProxy.createRoomNameTopicAccessTypeIsSpaceUserIDsAvatarURLAliasLocalPartReceivedArguments?.accessType == .private)
}
func testCreateRoomInAnAlreadySelectedSpace() async throws {
@Test
func createRoomInAnAlreadySelectedSpace() async throws {
let space = SpaceServiceRoom.mock(isSpace: true, joinRule: .invite)
setup(spacesSelectionMode: .editableSpacesList(preSelectedSpace: space))
context.send(viewAction: .updateRoomName("A"))
context.selectedAccessType = .spaceMembers
XCTAssertTrue(context.viewState.canCreateRoom)
XCTAssertEqual(context.selectedSpace?.id, space.id)
XCTAssertEqual(context.viewState.availableAccessTypes, [.spaceMembers, .askToJoinWithSpaceMembers, .private])
XCTAssertTrue(context.viewState.canSelectSpace)
#expect(context.viewState.canCreateRoom)
#expect(context.selectedSpace?.id == space.id)
#expect(context.viewState.availableAccessTypes == [.spaceMembers, .askToJoinWithSpaceMembers, .private])
#expect(context.viewState.canSelectSpace)
// When creating the room.
clientProxy.createRoomNameTopicAccessTypeIsSpaceUserIDsAvatarURLAliasLocalPartReturnValue = .success("1")
let expectation = expectation(description: "Wait for the addChild function to be called")
spaceService.addChildToClosure = { roomID, spaceID in
defer { expectation.fulfill() }
XCTAssertEqual(roomID, "1")
XCTAssertEqual(spaceID, space.id)
return .success(())
try await confirmation("Wait for the addChild function to be called") { confirm in
let deferredAction = deferFulfillment(viewModel.actions) { action in
guard case .createdRoom(let roomProxy, nil) = action, roomProxy.id == "1" else { return false }
return true
}
spaceService.addChildToClosure = { roomID, spaceID in
defer { confirm() }
#expect(roomID == "1")
#expect(spaceID == space.id)
return .success(())
}
context.send(viewAction: .createRoom)
try await deferredAction.fulfill()
}
let deferredAction = deferFulfillment(viewModel.actions) { action in
guard case .createdRoom(let roomProxy, nil) = action, roomProxy.id == "1" else { return false }
return true
}
context.send(viewAction: .createRoom)
await fulfillment(of: [expectation])
try await deferredAction.fulfill()
XCTAssertTrue(clientProxy.createRoomNameTopicAccessTypeIsSpaceUserIDsAvatarURLAliasLocalPartCalled)
XCTAssertEqual(clientProxy.createRoomNameTopicAccessTypeIsSpaceUserIDsAvatarURLAliasLocalPartReceivedArguments?.name, "A")
XCTAssertEqual(clientProxy.createRoomNameTopicAccessTypeIsSpaceUserIDsAvatarURLAliasLocalPartReceivedArguments?.accessType, .spaceMembers(spaceID: space.id))
#expect(clientProxy.createRoomNameTopicAccessTypeIsSpaceUserIDsAvatarURLAliasLocalPartCalled)
#expect(clientProxy.createRoomNameTopicAccessTypeIsSpaceUserIDsAvatarURLAliasLocalPartReceivedArguments?.name == "A")
#expect(clientProxy.createRoomNameTopicAccessTypeIsSpaceUserIDsAvatarURLAliasLocalPartReceivedArguments?.accessType == .spaceMembers(spaceID: space.id))
}
func testCreateRoomInAnPublicSpaceAvailableTypes() {
@Test
func createRoomInAnPublicSpaceAvailableTypes() {
let space = SpaceServiceRoom.mock(isSpace: true, joinRule: .public)
setup(spacesSelectionMode: .editableSpacesList(preSelectedSpace: space))
@@ -313,10 +319,10 @@ class CreateRoomScreenViewModelTests: XCTestCase {
context.send(viewAction: .updateRoomName("A"))
context.roomTopic = ""
context.selectedAccessType = .spaceMembers
XCTAssertTrue(context.viewState.canCreateRoom)
XCTAssertEqual(context.selectedSpace?.id, space.id)
XCTAssertEqual(context.viewState.availableAccessTypes, [.public, .askToJoin, .private])
XCTAssertTrue(context.viewState.canSelectSpace)
#expect(context.viewState.canCreateRoom)
#expect(context.selectedSpace?.id == space.id)
#expect(context.viewState.availableAccessTypes == [.public, .askToJoin, .private])
#expect(context.viewState.canSelectSpace)
}
private func setup(isSpace: Bool = false, spacesSelectionMode: CreateRoomScreenSpaceSelectionMode = .editableSpacesList(preSelectedSpace: nil)) {

View File

@@ -7,17 +7,19 @@
//
@testable import ElementX
import XCTest
import Testing
@Suite
@MainActor
class EditRoomAddressScreenViewModelTests: XCTestCase {
struct EditRoomAddressScreenViewModelTests {
var viewModel: EditRoomAddressScreenViewModelProtocol!
var context: EditRoomAddressScreenViewModelType.Context {
viewModel.context
}
func testCanonicalAliasChosen() async throws {
@Test
mutating func canonicalAliasChosen() async throws {
let roomProxy = JoinedRoomProxyMock(.init(name: "Room Name", canonicalAlias: "#room-name:matrix.org",
alternativeAliases: ["#beta:homeserver.io",
"#alternative-room-name:matrix.org"]))
@@ -34,7 +36,8 @@ class EditRoomAddressScreenViewModelTests: XCTestCase {
}
/// Priority should be given to aliases from the current user's homeserver as they can edit those.
func testAlternativeAliasChosen() async throws {
@Test
mutating func alternativeAliasChosen() async throws {
let roomProxy = JoinedRoomProxyMock(.init(name: "Room Name", canonicalAlias: "#alpha:homeserver.io",
alternativeAliases: ["#beta:homeserver.io",
"#room-name:matrix.org",
@@ -51,7 +54,8 @@ class EditRoomAddressScreenViewModelTests: XCTestCase {
try await deferred.fulfill()
}
func testBuildAliasFromDisplayName() async throws {
@Test
mutating func buildAliasFromDisplayName() async throws {
let roomProxy = JoinedRoomProxyMock(.init(name: "Room Name"))
viewModel = EditRoomAddressScreenViewModel(roomProxy: roomProxy,
@@ -65,7 +69,8 @@ class EditRoomAddressScreenViewModelTests: XCTestCase {
try await deferred.fulfill()
}
func testCorrectMethodsCalledOnSaveWhenNoAliasExists() async {
@Test
mutating func correctMethodsCalledOnSaveWhenNoAliasExists() async {
let clientProxy = ClientProxyMock(.init(userIDServerName: "matrix.org"))
clientProxy.isAliasAvailableReturnValue = .success(true)
let roomProxy = JoinedRoomProxyMock(.init(name: "Room Name"))
@@ -74,30 +79,33 @@ class EditRoomAddressScreenViewModelTests: XCTestCase {
clientProxy: clientProxy,
userIndicatorController: UserIndicatorControllerMock())
XCTAssertNil(roomProxy.infoPublisher.value.canonicalAlias)
XCTAssertEqual(viewModel.context.viewState.bindings.desiredAliasLocalPart, "room-name")
#expect(roomProxy.infoPublisher.value.canonicalAlias == nil)
#expect(viewModel.context.viewState.bindings.desiredAliasLocalPart == "room-name")
let publishingExpectation = expectation(description: "Wait for publishing")
roomProxy.publishRoomAliasInRoomDirectoryClosure = { roomAlias in
defer { publishingExpectation.fulfill() }
XCTAssertEqual(roomAlias, "#room-name:matrix.org")
return .success(true)
await waitForConfirmation("Wait for save", expectedCount: 2) { confirm in
roomProxy.publishRoomAliasInRoomDirectoryClosure = { roomAlias in
#expect(roomAlias == "#room-name:matrix.org")
confirm()
return .success(true)
}
roomProxy.updateCanonicalAliasAltAliasesClosure = { roomAlias, altAliases in
#expect(altAliases == [])
#expect(roomAlias == "#room-name:matrix.org")
confirm()
return .success(())
}
context.send(viewAction: .save)
}
let updateAliasExpectation = expectation(description: "Wait for alias update")
roomProxy.updateCanonicalAliasAltAliasesClosure = { roomAlias, altAliases in
defer { updateAliasExpectation.fulfill() }
XCTAssertEqual(altAliases, [])
XCTAssertEqual(roomAlias, "#room-name:matrix.org")
return .success(())
}
context.send(viewAction: .save)
await fulfillment(of: [publishingExpectation, updateAliasExpectation], timeout: 1.0)
XCTAssertFalse(roomProxy.removeRoomAliasFromRoomDirectoryCalled)
#expect(roomProxy.publishRoomAliasInRoomDirectoryCalled)
#expect(roomProxy.updateCanonicalAliasAltAliasesCalled)
#expect(!roomProxy.removeRoomAliasFromRoomDirectoryCalled)
}
func testCorrectMethodsCalledOnSaveWhenAliasOnSameHomeserverExists() async {
@Test
mutating func correctMethodsCalledOnSaveWhenAliasOnSameHomeserverExists() async {
let clientProxy = ClientProxyMock(.init(userIDServerName: "matrix.org"))
clientProxy.isAliasAvailableReturnValue = .success(true)
let roomProxy = JoinedRoomProxyMock(.init(name: "Room Name", canonicalAlias: "#old-room-name:matrix.org"))
@@ -108,33 +116,36 @@ class EditRoomAddressScreenViewModelTests: XCTestCase {
context.desiredAliasLocalPart = "room-name"
let publishingExpectation = expectation(description: "Wait for publishing")
roomProxy.publishRoomAliasInRoomDirectoryClosure = { roomAlias in
defer { publishingExpectation.fulfill() }
XCTAssertEqual(roomAlias, "#room-name:matrix.org")
return .success(true)
await waitForConfirmation("Wait for save", expectedCount: 3) { confirm in
roomProxy.publishRoomAliasInRoomDirectoryClosure = { roomAlias in
#expect(roomAlias == "#room-name:matrix.org")
confirm()
return .success(true)
}
roomProxy.updateCanonicalAliasAltAliasesClosure = { roomAlias, altAliases in
#expect(altAliases == [])
#expect(roomAlias == "#room-name:matrix.org")
confirm()
return .success(())
}
roomProxy.removeRoomAliasFromRoomDirectoryClosure = { roomAlias in
#expect(roomAlias == "#old-room-name:matrix.org")
confirm()
return .success(true)
}
context.send(viewAction: .save)
}
let updateAliasExpectation = expectation(description: "Wait for alias update")
roomProxy.updateCanonicalAliasAltAliasesClosure = { roomAlias, altAliases in
defer { updateAliasExpectation.fulfill() }
XCTAssertEqual(altAliases, [])
XCTAssertEqual(roomAlias, "#room-name:matrix.org")
return .success(())
}
let removeAliasExpectation = expectation(description: "Wait for alias removal")
roomProxy.removeRoomAliasFromRoomDirectoryClosure = { roomAlias in
defer { removeAliasExpectation.fulfill() }
XCTAssertEqual(roomAlias, "#old-room-name:matrix.org")
return .success(true)
}
context.send(viewAction: .save)
await fulfillment(of: [publishingExpectation, updateAliasExpectation, removeAliasExpectation], timeout: 1.0)
#expect(roomProxy.publishRoomAliasInRoomDirectoryCalled)
#expect(roomProxy.updateCanonicalAliasAltAliasesCalled)
#expect(roomProxy.removeRoomAliasFromRoomDirectoryCalled)
}
func testCorrectMethodsCalledOnSaveWhenAliasOnOtherHomeserverExists() async {
@Test
mutating func correctMethodsCalledOnSaveWhenAliasOnOtherHomeserverExists() async {
let clientProxy = ClientProxyMock(.init(userIDServerName: "matrix.org"))
clientProxy.isAliasAvailableReturnValue = .success(true)
let roomProxy = JoinedRoomProxyMock(.init(name: "Room Name", canonicalAlias: "#old-room-name:element.io"))
@@ -145,23 +156,25 @@ class EditRoomAddressScreenViewModelTests: XCTestCase {
context.desiredAliasLocalPart = "room-name"
let publishingExpectation = expectation(description: "Wait for publishing")
roomProxy.publishRoomAliasInRoomDirectoryClosure = { roomAlias in
defer { publishingExpectation.fulfill() }
XCTAssertEqual(roomAlias, "#room-name:matrix.org")
return .success(true)
await waitForConfirmation("Wait for save", expectedCount: 2) { confirm in
roomProxy.publishRoomAliasInRoomDirectoryClosure = { roomAlias in
#expect(roomAlias == "#room-name:matrix.org")
confirm()
return .success(true)
}
roomProxy.updateCanonicalAliasAltAliasesClosure = { roomAlias, altAliases in
#expect(altAliases == ["#room-name:matrix.org"])
#expect(roomAlias == "#old-room-name:element.io")
confirm()
return .success(())
}
context.send(viewAction: .save)
}
let updateAliasExpectation = expectation(description: "Wait for alias update")
roomProxy.updateCanonicalAliasAltAliasesClosure = { roomAlias, altAliases in
defer { updateAliasExpectation.fulfill() }
XCTAssertEqual(altAliases, ["#room-name:matrix.org"])
XCTAssertEqual(roomAlias, "#old-room-name:element.io")
return .success(())
}
context.send(viewAction: .save)
await fulfillment(of: [publishingExpectation, updateAliasExpectation], timeout: 1.0)
XCTAssertFalse(roomProxy.removeRoomAliasFromRoomDirectoryCalled)
#expect(roomProxy.publishRoomAliasInRoomDirectoryCalled)
#expect(roomProxy.updateCanonicalAliasAltAliasesCalled)
#expect(!roomProxy.removeRoomAliasFromRoomDirectoryCalled)
}
}

View File

@@ -7,10 +7,11 @@
//
@testable import ElementX
import XCTest
import Testing
@MainActor
class JoinRoomScreenViewModelTests: XCTestCase {
@Suite
final class JoinRoomScreenViewModelTests {
private enum TestMode {
case joined
case knocked
@@ -27,74 +28,79 @@ class JoinRoomScreenViewModelTests: XCTestCase {
viewModel.context
}
override func setUp() {
init() {
AppSettings.resetAllSettings()
appSettings = AppSettings()
ServiceLocator.shared.register(appSettings: appSettings)
}
override func tearDown() {
deinit {
viewModel = nil
clientProxy = nil
AppSettings.resetAllSettings()
}
func testInteraction() async throws {
XCTAssertTrue(appSettings.seenInvites.isEmpty, "There shouldn't be any seen invites before running the tests.")
@Test
func interaction() async throws {
#expect(appSettings.seenInvites.isEmpty, "There shouldn't be any seen invites before running the tests.")
setupViewModel()
try await deferFulfillment(viewModel.context.$viewState) { $0.mode == .joinable }.fulfill()
XCTAssertTrue(appSettings.seenInvites.isEmpty, "Only an invited room should register the room ID as a seen invite.")
#expect(appSettings.seenInvites.isEmpty, "Only an invited room should register the room ID as a seen invite.")
let deferred = deferFulfillment(viewModel.actionsPublisher) { $0 == .joined(.roomID("1")) }
context.send(viewAction: .join)
try await deferred.fulfill()
}
func testAcceptInviteInteraction() async throws {
XCTAssertTrue(appSettings.seenInvites.isEmpty, "There shouldn't be any seen invites before running the tests.")
@Test
func acceptInviteInteraction() async throws {
#expect(appSettings.seenInvites.isEmpty, "There shouldn't be any seen invites before running the tests.")
setupViewModel(mode: .invited)
try await deferFulfillment(viewModel.context.$viewState) { $0.mode == .invited(isDM: false) }.fulfill()
XCTAssertEqual(appSettings.seenInvites, ["1"], "The invited room's ID should be registered as a seen invite.")
#expect(appSettings.seenInvites == ["1"], "The invited room's ID should be registered as a seen invite.")
let deferred = deferFulfillment(viewModel.actionsPublisher) { $0 == .joined(.roomID("1")) }
context.send(viewAction: .acceptInvite)
try await deferred.fulfill()
XCTAssertTrue(appSettings.seenInvites.isEmpty, "The after accepting an invite the invite should be forgotten in case the user leaves.")
#expect(appSettings.seenInvites.isEmpty, "The after accepting an invite the invite should be forgotten in case the user leaves.")
}
func testDeclineInviteInteraction() async throws {
XCTAssertTrue(appSettings.seenInvites.isEmpty, "There shouldn't be any seen invites before running the tests.")
@Test
func declineInviteInteraction() async throws {
#expect(appSettings.seenInvites.isEmpty, "There shouldn't be any seen invites before running the tests.")
setupViewModel(mode: .invited)
try await deferFulfillment(viewModel.context.$viewState) { $0.mode == .invited(isDM: false) }.fulfill()
XCTAssertEqual(appSettings.seenInvites, ["1"], "The invited room's ID should be registered as a seen invite.")
#expect(appSettings.seenInvites == ["1"], "The invited room's ID should be registered as a seen invite.")
context.send(viewAction: .declineInvite)
XCTAssertEqual(viewModel.context.alertInfo?.id, .declineInvite)
#expect(viewModel.context.alertInfo?.id == .declineInvite)
let deferred = deferFulfillment(viewModel.actionsPublisher) { $0 == .dismiss }
context.alertInfo?.secondaryButton?.action?()
try await deferred.fulfill()
XCTAssertTrue(appSettings.seenInvites.isEmpty, "The after declining an invite the invite should be forgotten in case another invite is received.")
#expect(appSettings.seenInvites.isEmpty, "The after declining an invite the invite should be forgotten in case another invite is received.")
}
func testKnockedState() async throws {
XCTAssertTrue(appSettings.seenInvites.isEmpty, "There shouldn't be any seen invites before running the tests.")
@Test
func knockedState() async throws {
#expect(appSettings.seenInvites.isEmpty, "There shouldn't be any seen invites before running the tests.")
setupViewModel(mode: .knocked)
try await deferFulfillment(viewModel.context.$viewState) { $0.mode == .knocked }.fulfill()
XCTAssertTrue(appSettings.seenInvites.isEmpty, "Only an invited room should register the room ID as a seen invite.")
#expect(appSettings.seenInvites.isEmpty, "Only an invited room should register the room ID as a seen invite.")
}
func testCancelKnock() async throws {
@Test
func cancelKnock() async throws {
setupViewModel(mode: .knocked)
try await deferFulfillment(viewModel.context.$viewState) { state in
@@ -102,7 +108,7 @@ class JoinRoomScreenViewModelTests: XCTestCase {
}.fulfill()
context.send(viewAction: .cancelKnock)
XCTAssertEqual(viewModel.context.alertInfo?.id, .cancelKnock)
#expect(viewModel.context.alertInfo?.id == .cancelKnock)
let deferred = deferFulfillment(viewModel.actionsPublisher) { action in
action == .dismiss
@@ -111,32 +117,36 @@ class JoinRoomScreenViewModelTests: XCTestCase {
try await deferred.fulfill()
}
func testDeclineAndBlockInviteLegacyInteraction() async throws {
@Test
func declineAndBlockInviteLegacyInteraction() async throws {
setupViewModel(mode: .invited)
clientProxy.underlyingIsReportRoomSupported = false
let expectation = expectation(description: "Wait for the user to be ignored")
clientProxy.ignoreUserClosure = { userID in
defer { expectation.fulfill() }
XCTAssertEqual(userID, "@test:matrix.org")
return .success(())
}
try await deferFulfillment(viewModel.context.$viewState) { $0.roomDetails != nil }.fulfill()
context.send(viewAction: .declineInviteAndBlock(userID: "@test:matrix.org"))
try await deferFulfillment(viewModel.context.$viewState) { $0.bindings.alertInfo != nil }.fulfill()
XCTAssertEqual(viewModel.context.alertInfo?.id, .declineInviteAndBlock)
#expect(viewModel.context.alertInfo?.id == .declineInviteAndBlock)
let deferred = deferFulfillment(viewModel.actionsPublisher) { action in
action == .dismiss
}
context.alertInfo?.secondaryButton?.action?()
await fulfillment(of: [expectation], timeout: 10)
await waitForConfirmation("Wait for the user to be ignored") { confirm in
clientProxy.ignoreUserClosure = { userID in
defer { confirm() }
#expect(userID == "@test:matrix.org")
return .success(())
}
context.alertInfo?.secondaryButton?.action?()
}
try await deferred.fulfill()
}
func testDeclineAndBlockInviteInteraction() async throws {
@Test
func declineAndBlockInviteInteraction() async throws {
setupViewModel(mode: .invited)
try await deferFulfillment(viewModel.context.$viewState) { $0.roomDetails != nil }.fulfill()
let deferredAction = deferFulfillment(viewModel.actionsPublisher) { $0 == .presentDeclineAndBlock(userID: "@test:matrix.org") }
@@ -144,7 +154,8 @@ class JoinRoomScreenViewModelTests: XCTestCase {
try await deferredAction.fulfill()
}
func testForgetRoom() async throws {
@Test
func forgetRoom() async throws {
setupViewModel(mode: .banned)
try await deferFulfillment(viewModel.context.$viewState) { $0.roomDetails != nil }.fulfill()

View File

@@ -8,10 +8,12 @@
@testable import ElementX
import SwiftUI
import XCTest
import Testing
final class CollapsibleFlowLayoutTests: XCTestCase {
func testFlowLayoutWithExpandAndCollapse() {
@Suite
struct CollapsibleFlowLayoutTests {
@Test
func flowLayoutWithExpandAndCollapse() {
let containerSize = CGSize(width: 250, height: 400)
var flowLayout = CollapsibleReactionLayout(itemSpacing: 5, rowSpacing: 5, rowsBeforeCollapsible: 2)
@@ -25,7 +27,7 @@ final class CollapsibleFlowLayoutTests: XCTestCase {
var size = flowLayout.sizeThatFits(proposal: ProposedViewSize(containerSize), subviews: subviewsMock, cache: &a)
// Collapsed target layout has 2 rows of 2 items, so just 1 spacing between items hence 205, 105
XCTAssertEqual(size, CGSize(width: 205, height: 105))
#expect(size == CGSize(width: 205, height: 105))
flowLayout.placeSubviews(in: CGRect(origin: .zero, size: size), proposal: ProposedViewSize(containerSize), subviews: subviewsMock, cache: &a)
// 4 items are hidden in the collapsed state (put in the centre with zero size)
@@ -39,7 +41,7 @@ final class CollapsibleFlowLayoutTests: XCTestCase {
CGRect(x: -10000, y: -10000, width: 0, height: 0),
CGRect(x: -10000, y: -10000, width: 0, height: 0)
]
XCTAssertEqual(placedViews, targetPlacements)
#expect(placedViews == targetPlacements)
flowLayout.collapsed = false
placedViews = []
@@ -47,7 +49,7 @@ final class CollapsibleFlowLayoutTests: XCTestCase {
size = flowLayout.sizeThatFits(proposal: ProposedViewSize(containerSize), subviews: subviewsMock, cache: &a)
// Expanded target layout has 4 rows and no more than 2 items per row
XCTAssertEqual(size, CGSize(width: 205, height: 215))
#expect(size == CGSize(width: 205, height: 215))
flowLayout.placeSubviews(in: CGRect(origin: .zero, size: size), proposal: ProposedViewSize(containerSize), subviews: subviewsMock, cache: &a)
@@ -61,10 +63,11 @@ final class CollapsibleFlowLayoutTests: XCTestCase {
CGRect(x: 0, y: 190, width: 100, height: 50),
CGRect(x: 105.0, y: 190, width: 100, height: 50)
]
XCTAssertEqual(placedViews, targetPlacements)
#expect(placedViews == targetPlacements)
}
func testFlowLayoutWithExpandButtonAndAddMoreIsHidden() {
@Test
func flowLayoutWithExpandButtonAndAddMoreIsHidden() {
let containerSize = CGSize(width: 250, height: 400)
let flowLayout = CollapsibleReactionLayout(itemSpacing: 5, rowSpacing: 5, rowsBeforeCollapsible: 2)
@@ -78,7 +81,7 @@ final class CollapsibleFlowLayoutTests: XCTestCase {
var a: () = ()
let size = flowLayout.sizeThatFits(proposal: ProposedViewSize(containerSize), subviews: subviewsMock, cache: &a)
XCTAssertEqual(size, CGSize(width: 205, height: 105))
#expect(size == CGSize(width: 205, height: 105))
flowLayout.placeSubviews(in: CGRect(origin: .zero, size: size), proposal: ProposedViewSize(containerSize), subviews: subviewsMock, cache: &a)
let targetPlacements: [CGRect] = [
@@ -90,10 +93,11 @@ final class CollapsibleFlowLayoutTests: XCTestCase {
// Expand/Collapse button is hidden
CGRect(x: -10000, y: -10000, width: 0, height: 0)
]
XCTAssertEqual(placedViews, targetPlacements)
#expect(placedViews == targetPlacements)
}
func testHeightIsCorrectGivenASmallerAddButton() {
@Test
func heightIsCorrectGivenASmallerAddButton() {
let containerSize = CGSize(width: 250, height: 400)
let flowLayout = CollapsibleReactionLayout(itemSpacing: 5, rowSpacing: 5, rowsBeforeCollapsible: 2)
@@ -110,7 +114,7 @@ final class CollapsibleFlowLayoutTests: XCTestCase {
var a: () = ()
let size = flowLayout.sizeThatFits(proposal: ProposedViewSize(containerSize), subviews: subviewsMock, cache: &a)
XCTAssertEqual(size, CGSize(width: 205, height: 105))
#expect(size == CGSize(width: 205, height: 105))
flowLayout.placeSubviews(in: CGRect(origin: .zero, size: size), proposal: ProposedViewSize(containerSize), subviews: subviewsMock, cache: &a)
let targetPlacements: [CGRect] = [
@@ -121,10 +125,11 @@ final class CollapsibleFlowLayoutTests: XCTestCase {
// Expand/Collapse button is hidden
CGRect(x: -10000, y: -10000, width: 0, height: 0)
]
XCTAssertEqual(placedViews, targetPlacements)
#expect(placedViews == targetPlacements)
}
func testFlowLayoutEmptyState() {
@Test
func flowLayoutEmptyState() {
let containerSize = CGSize(width: 250, height: 400)
let flowLayout = CollapsibleReactionLayout(itemSpacing: 5, rowSpacing: 5, rowsBeforeCollapsible: 2)
@@ -137,7 +142,7 @@ final class CollapsibleFlowLayoutTests: XCTestCase {
var a: () = ()
let size = flowLayout.sizeThatFits(proposal: ProposedViewSize(containerSize), subviews: subviewsMock, cache: &a)
XCTAssertEqual(size, CGSize(width: 0, height: 0))
#expect(size == CGSize(width: 0, height: 0))
flowLayout.placeSubviews(in: CGRect(origin: .zero, size: size), proposal: ProposedViewSize(containerSize), subviews: subviewsMock, cache: &a)
let targetPlacements: [CGRect] = [
@@ -145,7 +150,7 @@ final class CollapsibleFlowLayoutTests: XCTestCase {
CGRect(x: -10000, y: -10000, width: 0, height: 0),
CGRect(x: -10000, y: -10000, width: 0, height: 0)
]
XCTAssertEqual(placedViews, targetPlacements)
#expect(placedViews == targetPlacements)
}
func createReactionLayoutSubviews(with sizes: [CGSize],

View File

@@ -9,17 +9,19 @@
import Combine
@testable import ElementX
import Kingfisher
import XCTest
import SwiftUI
import Testing
@Suite
@MainActor
final class MediaProviderTests: XCTestCase {
private var mediaLoader: MediaLoaderMock!
private var imageCache: MockImageCache!
struct MediaProviderTests {
private var mediaLoader: MediaLoaderMock
private var imageCache: MockImageCache
private var reachabilitySubject = CurrentValueSubject<NetworkMonitorReachability, Never>(.reachable)
var mediaProvider: MediaProvider!
override func setUp() {
init() {
mediaLoader = MediaLoaderMock()
imageCache = MockImageCache(name: "Test")
@@ -28,12 +30,10 @@ final class MediaProviderTests: XCTestCase {
homeserverReachabilityPublisher: reachabilitySubject.asCurrentValuePublisher())
}
func testLoadingRetriedOnReconnection() async throws {
@Test
func loadingRetriedOnReconnection() async throws {
let testImage = try loadTestImage()
guard let pngData = testImage.pngData() else {
XCTFail("Test image should contain valid .png data")
return
}
let pngData = try #require(testImage.pngData(), "Test image should contain valid .png data")
let loadTask = try mediaProvider.loadImageRetryingOnReconnection(MediaSourceProxy(url: .mockMXCImage, mimeType: "image/jpeg"))
@@ -51,11 +51,12 @@ final class MediaProviderTests: XCTestCase {
let result = try? await loadTask.value
XCTAssertNotNil(result)
XCTAssertEqual(mediaLoader.loadMediaContentForSourceCallsCount, 2)
#expect(result != nil)
#expect(mediaLoader.loadMediaContentForSourceCallsCount == 2)
}
func testLoadingRetriedOnReconnectionCancelsAfterSecondFailure() async throws {
@Test
func loadingRetriedOnReconnectionCancelsAfterSecondFailure() async throws {
let loadTask = try mediaProvider.loadImageRetryingOnReconnection(MediaSourceProxy(url: .mockMXCImage, mimeType: "image/jpeg"))
reachabilitySubject.send(.reachable)
@@ -64,15 +65,17 @@ final class MediaProviderTests: XCTestCase {
let result = try? await loadTask.value
XCTAssertNil(result)
#expect(result == nil)
}
func test_whenImageFromSourceWithSourceNil_nilReturned() {
@Test
func whenImageFromSourceWithSourceNil_nilReturned() {
let image = mediaProvider.imageFromSource(nil, size: Avatars.Size.room(on: .timeline).scaledSize)
XCTAssertNil(image)
#expect(image == nil)
}
func test_whenImageFromSourceWithSourceNotNilAndImageCacheContainsImage_ImageIsReturned() throws {
@Test
func whenImageFromSourceWithSourceNotNilAndImageCacheContainsImage_ImageIsReturned() throws {
let avatarSize = Avatars.Size.room(on: .timeline)
let url = URL.mockMXCImage
let key = "\(url.absoluteString){\(avatarSize.scaledValue),\(avatarSize.scaledValue)}"
@@ -80,16 +83,18 @@ final class MediaProviderTests: XCTestCase {
imageCache.retrievedImagesInMemory[key] = imageForKey
let image = try mediaProvider.imageFromSource(MediaSourceProxy(url: url, mimeType: "image/jpeg"),
size: avatarSize.scaledSize)
XCTAssertEqual(image, imageForKey)
#expect(image == imageForKey)
}
func test_whenImageFromSourceWithSourceNotNilAndImageNotCached_nilReturned() throws {
@Test
func whenImageFromSourceWithSourceNotNilAndImageNotCached_nilReturned() throws {
let image = try mediaProvider.imageFromSource(MediaSourceProxy(url: .mockMXCImage, mimeType: "image/jpeg"),
size: Avatars.Size.room(on: .timeline).scaledSize)
XCTAssertNil(image)
#expect(image == nil)
}
func test_whenLoadImageFromSourceAndImageCacheContainsImage_successIsReturned() async throws {
@Test
func whenLoadImageFromSourceAndImageCacheContainsImage_successIsReturned() async throws {
let avatarSize = Avatars.Size.room(on: .timeline)
let url = URL.mockMXCImage
let key = "\(url.absoluteString){\(avatarSize.scaledValue),\(avatarSize.scaledValue)}"
@@ -97,10 +102,11 @@ final class MediaProviderTests: XCTestCase {
imageCache.retrievedImagesInMemory[key] = imageForKey
let result = try await mediaProvider.loadImageFromSource(MediaSourceProxy(url: url, mimeType: "image/jpeg"),
size: avatarSize.scaledSize)
XCTAssertEqual(Result.success(imageForKey), result)
#expect(Result.success(imageForKey) == result)
}
func test_whenLoadImageFromSourceAndImageNotCachedAndRetrieveImageSucceeds_successIsReturned() async throws {
@Test
func whenLoadImageFromSourceAndImageNotCachedAndRetrieveImageSucceeds_successIsReturned() async throws {
let avatarSize = Avatars.Size.room(on: .timeline)
let url = URL.mockMXCImage
let key = "\(url.absoluteString){\(avatarSize.scaledValue),\(avatarSize.scaledValue)}"
@@ -108,10 +114,11 @@ final class MediaProviderTests: XCTestCase {
imageCache.retrievedImages[key] = imageForKey
let result = try await mediaProvider.loadImageFromSource(MediaSourceProxy(url: url, mimeType: "image/jpeg"),
size: avatarSize.scaledSize)
XCTAssertEqual(Result.success(imageForKey), result)
#expect(Result.success(imageForKey) == result)
}
func test_whenLoadImageFromSourceAndImageNotCachedAndRetrieveImageFails_imageThumbnailIsLoaded() async throws {
@Test
func whenLoadImageFromSourceAndImageNotCachedAndRetrieveImageFails_imageThumbnailIsLoaded() async throws {
let avatarSize = Avatars.Size.room(on: .timeline)
let expectedImage = try loadTestImage()
@@ -121,13 +128,14 @@ final class MediaProviderTests: XCTestCase {
size: avatarSize.scaledSize)
switch result {
case .success(let image):
XCTAssertEqual(image.pngData(), expectedImage.pngData())
#expect(image.pngData() == expectedImage.pngData())
case .failure:
XCTFail("Should be success")
Issue.record("Should be success")
}
}
func test_whenLoadImageFromSourceAndImageNotCachedAndRetrieveImageFails_imageIsStored() async throws {
@Test
func whenLoadImageFromSourceAndImageNotCachedAndRetrieveImageFails_imageIsStored() async throws {
let avatarSize = Avatars.Size.room(on: .timeline)
let url = URL.mockMXCImage
let key = "\(url.absoluteString){\(avatarSize.scaledValue),\(avatarSize.scaledValue)}"
@@ -137,11 +145,12 @@ final class MediaProviderTests: XCTestCase {
_ = try await mediaProvider.loadImageFromSource(MediaSourceProxy(url: url, mimeType: "image/jpeg"),
size: avatarSize.scaledSize)
let storedImage = try XCTUnwrap(imageCache.storedImages[key])
XCTAssertEqual(expectedImage.pngData(), storedImage.pngData())
let storedImage = try #require(imageCache.storedImages[key])
#expect(expectedImage.pngData() == storedImage.pngData())
}
func test_whenLoadImageFromSourceAndImageNotCachedAndRetrieveImageFailsAndNoAvatarSize_imageContentIsLoaded() async throws {
@Test
func whenLoadImageFromSourceAndImageNotCachedAndRetrieveImageFailsAndNoAvatarSize_imageContentIsLoaded() async throws {
let expectedImage = try loadTestImage()
mediaLoader.loadMediaContentForSourceReturnValue = expectedImage.pngData()
@@ -150,53 +159,56 @@ final class MediaProviderTests: XCTestCase {
size: nil)
switch result {
case .success(let image):
XCTAssertEqual(image.pngData(), expectedImage.pngData())
#expect(image.pngData() == expectedImage.pngData())
case .failure:
XCTFail("Should be success")
Issue.record("Should be success")
}
}
func test_whenLoadImageFromSourceAndImageNotCachedAndRetrieveImageFailsAndLoadImageThumbnailFails_errorIsThrown() async throws {
@Test
func whenLoadImageFromSourceAndImageNotCachedAndRetrieveImageFailsAndLoadImageThumbnailFails_errorIsThrown() async throws {
mediaLoader.loadMediaThumbnailForSourceWidthHeightThrowableError = MediaProviderTestsError.error
let result = try await mediaProvider.loadImageFromSource(MediaSourceProxy(url: .mockMXCImage, mimeType: "image/jpeg"),
size: Avatars.Size.room(on: .timeline).scaledSize)
switch result {
case .success:
XCTFail("Should fail")
Issue.record("Should fail")
case .failure(let error):
XCTAssertEqual(error, MediaProviderError.failedRetrievingImage)
#expect(error == MediaProviderError.failedRetrievingImage)
}
}
func test_whenLoadImageFromSourceAndImageNotCachedAndRetrieveImageFailsAndNoAvatarSizeAndLoadImageContentFails_errorIsThrown() async throws {
@Test
func whenLoadImageFromSourceAndImageNotCachedAndRetrieveImageFailsAndNoAvatarSizeAndLoadImageContentFails_errorIsThrown() async throws {
mediaLoader.loadMediaContentForSourceThrowableError = MediaProviderTestsError.error
let result = try await mediaProvider.loadImageFromSource(MediaSourceProxy(url: .mockMXCImage, mimeType: "image/jpeg"),
size: nil)
switch result {
case .success:
XCTFail("Should fail")
Issue.record("Should fail")
case .failure(let error):
XCTAssertEqual(error, MediaProviderError.failedRetrievingImage)
#expect(error == MediaProviderError.failedRetrievingImage)
}
}
func test_whenLoadImageFromSourceAndImageNotCachedAndRetrieveImageFailsAndImageThumbnailIsLoadedWithCorruptedData_errorIsThrown() async throws {
@Test
func whenLoadImageFromSourceAndImageNotCachedAndRetrieveImageFailsAndImageThumbnailIsLoadedWithCorruptedData_errorIsThrown() async throws {
mediaLoader.loadMediaThumbnailForSourceWidthHeightReturnValue = Data()
let result = try await mediaProvider.loadImageFromSource(MediaSourceProxy(url: .mockMXCImage, mimeType: "image/jpeg"),
size: Avatars.Size.room(on: .timeline).scaledSize)
switch result {
case .success:
XCTFail("Should fail")
Issue.record("Should fail")
case .failure(let error):
XCTAssertEqual(error, MediaProviderError.invalidImageData)
#expect(error == MediaProviderError.invalidImageData)
}
}
private func loadTestImage() throws -> UIImage {
guard let path = Bundle(for: Self.self).path(forResource: "test_image", ofType: "png"),
guard let path = Bundle(for: UnitTestsAppCoordinator.self).path(forResource: "test_image", ofType: "png"),
let image = UIImage(contentsOfFile: path) else {
throw MediaProviderTestsError.error
}

View File

@@ -7,10 +7,12 @@
//
@testable import ElementX
import XCTest
import Foundation
import Testing
@Suite
@MainActor
class MediaUploadPreviewScreenViewModelTests: XCTestCase {
final class MediaUploadPreviewScreenViewModelTests {
var timelineProxy: TimelineProxyMock!
var clientProxy: ClientProxyMock!
var userIndicatorController: UserIndicatorControllerMock!
@@ -25,7 +27,7 @@ class MediaUploadPreviewScreenViewModelTests: XCTestCase {
case unknown
}
override func setUp() {
init() {
AppSettings.resetAllSettings()
let appSettings = AppSettings()
appSettings.optimizeMediaUploads = false
@@ -36,190 +38,205 @@ class MediaUploadPreviewScreenViewModelTests: XCTestCase {
AppSettings.resetAllSettings()
}
func testImageUploadWithoutCaption() async throws {
@Test
func imageUploadWithoutCaption() async throws {
setUpViewModel(urls: [imageURL], expectedCaption: nil)
context.caption = .init("")
try await send()
}
func testImageUploadWithBlankCaption() async throws {
@Test
func imageUploadWithBlankCaption() async throws {
setUpViewModel(urls: [imageURL], expectedCaption: nil)
context.caption = .init(" ")
try await send()
}
func testImageUploadWithCaption() async throws {
@Test
func imageUploadWithCaption() async throws {
let caption = "This is a really great image!"
setUpViewModel(urls: [imageURL], expectedCaption: caption)
context.caption = .init(string: caption)
try await send()
}
func testVideoUploadWithoutCaption() async throws {
@Test
func videoUploadWithoutCaption() async throws {
setUpViewModel(urls: [videoURL], expectedCaption: nil)
context.caption = .init("")
try await send()
}
func testVideoUploadWithCaption() async throws {
@Test
func videoUploadWithCaption() async throws {
let caption = "Check out this video!"
setUpViewModel(urls: [videoURL], expectedCaption: caption)
context.caption = .init(string: caption)
try await send()
}
func testAudioUploadWithoutCaption() async throws {
@Test
func audioUploadWithoutCaption() async throws {
setUpViewModel(urls: [audioURL], expectedCaption: nil)
context.caption = .init("")
try await send()
}
func testAudioUploadWithCaption() async throws {
@Test
func audioUploadWithCaption() async throws {
let caption = "Listen to this!"
setUpViewModel(urls: [audioURL], expectedCaption: caption)
context.caption = .init(string: caption)
try await send()
}
func testFileUploadWithoutCaption() async throws {
@Test
func fileUploadWithoutCaption() async throws {
setUpViewModel(urls: [fileURL], expectedCaption: nil)
context.caption = .init("")
try await send()
}
func testFileUploadWithCaption() async throws {
@Test
func fileUploadWithCaption() async throws {
let caption = "Please will you check my article."
setUpViewModel(urls: [fileURL], expectedCaption: caption)
context.caption = .init(string: caption)
try await send()
}
func testProcessingFailure() async throws {
@Test
func processingFailure() async throws {
// Given an upload screen for a non-existent file.
setUpViewModel(urls: [badImageURL], expectedCaption: nil)
XCTAssertFalse(context.viewState.shouldDisableInteraction)
XCTAssertEqual(userIndicatorController.submitIndicatorDelayCallsCount, 0)
#expect(!context.viewState.shouldDisableInteraction)
#expect(userIndicatorController.submitIndicatorDelayCallsCount == 0)
// When attempting to send the file
let deferredFailure = deferFailure(viewModel.actions, timeout: 1, message: "The screen should remain visible.") { $0 == .dismiss }
let deferredFailure = deferFailure(viewModel.actions, timeout: .seconds(1), message: "The screen should remain visible.") { $0 == .dismiss }
context.send(viewAction: .send)
XCTAssertTrue(context.viewState.shouldDisableInteraction, "The interaction should be disabled while sending.")
XCTAssertEqual(userIndicatorController.submitIndicatorDelayCallsCount, 1) // Loading indicator
#expect(context.viewState.shouldDisableInteraction, "The interaction should be disabled while sending.")
#expect(userIndicatorController.submitIndicatorDelayCallsCount == 1) // Loading indicator
// Then the failure should occur preventing the screen from being dismissed.
try await deferredFailure.fulfill()
XCTAssertFalse(context.viewState.shouldDisableInteraction)
XCTAssertEqual(userIndicatorController.submitIndicatorDelayCallsCount, 2, "An error indicator should be shown.")
#expect(!context.viewState.shouldDisableInteraction)
#expect(userIndicatorController.submitIndicatorDelayCallsCount == 2, "An error indicator should be shown.")
}
func testUploadWithUnknownMaxUploadSize() async throws {
@Test
func uploadWithUnknownMaxUploadSize() async throws {
// Given an upload screen that is unable to fetch the max upload size.
setUpViewModel(urls: [imageURL], expectedCaption: nil, maxUploadSizeResult: .failure(.sdkError(ClientProxyMockError.generic)))
XCTAssertFalse(context.viewState.shouldDisableInteraction)
XCTAssertNil(context.alertInfo)
#expect(!context.viewState.shouldDisableInteraction)
#expect(context.alertInfo == nil)
// When attempting to send the media.
let deferredAlert = deferFulfillment(context.observe(\.viewState.bindings.alertInfo)) { $0 != nil }
let deferredFailure = deferFailure(viewModel.actions, timeout: 1, message: "The screen should remain visible.") { $0 == .dismiss }
let deferredFailure = deferFailure(viewModel.actions, timeout: .seconds(1), message: "The screen should remain visible.") { $0 == .dismiss }
context.send(viewAction: .send)
XCTAssertTrue(context.viewState.shouldDisableInteraction, "The interaction should be disabled while sending.")
#expect(context.viewState.shouldDisableInteraction, "The interaction should be disabled while sending.")
// Then alert should be shown to tell the user it failed.
try await deferredAlert.fulfill()
try await deferredFailure.fulfill()
XCTAssertFalse(context.viewState.shouldDisableInteraction)
XCTAssertEqual(context.alertInfo?.id, .maxUploadSizeUnknown)
#expect(!context.viewState.shouldDisableInteraction)
#expect(context.alertInfo?.id == .maxUploadSizeUnknown)
// When trying with the max upload size now available.
let deferredDismiss = deferFulfillment(viewModel.actions) { $0 == .dismiss }
clientProxy.underlyingMaxMediaUploadSize = .success(100 * 1024 * 1024)
context.alertInfo?.primaryButton.action?()
XCTAssertTrue(context.viewState.shouldDisableInteraction, "The interaction should be disabled while retrying.")
#expect(context.viewState.shouldDisableInteraction, "The interaction should be disabled while retrying.")
// Then the file should upload successfully.
try await deferredDismiss.fulfill()
}
func testUploadExceedingMaxUploadSize() async throws {
@Test
func uploadExceedingMaxUploadSize() async throws {
// Given an upload screen with a really small max upload size.
setUpViewModel(urls: [imageURL], expectedCaption: nil, maxUploadSizeResult: .success(100))
XCTAssertFalse(context.viewState.shouldDisableInteraction)
XCTAssertNil(context.alertInfo)
#expect(!context.viewState.shouldDisableInteraction)
#expect(context.alertInfo == nil)
// When attempting to send an image that is larger the limit.
let deferredAlert = deferFulfillment(context.observe(\.viewState.bindings.alertInfo)) { $0 != nil }
let deferredFailure = deferFailure(viewModel.actions, timeout: 1, message: "The screen should remain visible.") { $0 == .dismiss }
let deferredFailure = deferFailure(viewModel.actions, timeout: .seconds(1), message: "The screen should remain visible.") { $0 == .dismiss }
context.send(viewAction: .send)
XCTAssertTrue(context.viewState.shouldDisableInteraction, "The interaction should be disabled while sending.")
#expect(context.viewState.shouldDisableInteraction, "The interaction should be disabled while sending.")
// Then an alert should be shown to inform the user of the max upload size.
try await deferredAlert.fulfill()
try await deferredFailure.fulfill()
XCTAssertFalse(context.viewState.shouldDisableInteraction)
XCTAssertEqual(context.alertInfo?.id, .maxUploadSizeExceeded(limit: 100))
#expect(!context.viewState.shouldDisableInteraction)
#expect(context.alertInfo?.id == .maxUploadSizeExceeded(limit: 100))
}
func testMultipleFiles() async throws {
@Test
func multipleFiles() async throws {
// Given an upload screen with multiple media files.
setUpViewModel(urls: [fileURL, imageURL, fileURL], expectedCaption: nil)
XCTAssertFalse(context.viewState.shouldDisableInteraction)
XCTAssertEqual(userIndicatorController.submitIndicatorDelayCallsCount, 0)
#expect(!context.viewState.shouldDisableInteraction)
#expect(userIndicatorController.submitIndicatorDelayCallsCount == 0)
// When attempting to send the files.
let deferredDismiss = deferFulfillment(viewModel.actions) { $0 == .dismiss }
context.send(viewAction: .send)
XCTAssertTrue(context.viewState.shouldDisableInteraction, "The interaction should be disabled while sending.")
XCTAssertEqual(userIndicatorController.submitIndicatorDelayCallsCount, 1) // Loading indicator
#expect(context.viewState.shouldDisableInteraction, "The interaction should be disabled while sending.")
#expect(userIndicatorController.submitIndicatorDelayCallsCount == 1) // Loading indicator
// Then the screen should be dismissed once all of the files have been sent.
try await deferredDismiss.fulfill()
XCTAssertEqual(timelineProxy.sendImageUrlThumbnailURLImageInfoCaptionRequestHandleCallsCount, 1)
XCTAssertEqual(timelineProxy.sendFileUrlFileInfoCaptionRequestHandleCallsCount, 2)
XCTAssertEqual(userIndicatorController.submitIndicatorDelayCallsCount, 1, "Only a loading indicator should be shown.")
#expect(timelineProxy.sendImageUrlThumbnailURLImageInfoCaptionRequestHandleCallsCount == 1)
#expect(timelineProxy.sendFileUrlFileInfoCaptionRequestHandleCallsCount == 2)
#expect(userIndicatorController.submitIndicatorDelayCallsCount == 1, "Only a loading indicator should be shown.")
}
func testMultipleFilesWithProcessingFailure() async throws {
@Test
func multipleFilesWithProcessingFailure() async throws {
// Given an upload screen for a non-existent file.
setUpViewModel(urls: [imageURL, fileURL, badImageURL], expectedCaption: nil)
XCTAssertFalse(context.viewState.shouldDisableInteraction)
XCTAssertEqual(userIndicatorController.submitIndicatorDelayCallsCount, 0)
#expect(!context.viewState.shouldDisableInteraction)
#expect(userIndicatorController.submitIndicatorDelayCallsCount == 0)
// When attempting to send the file
let deferredFailure = deferFailure(viewModel.actions, timeout: 1, message: "The screen should remain visible.") { $0 == .dismiss }
let deferredFailure = deferFailure(viewModel.actions, timeout: .seconds(1), message: "The screen should remain visible.") { $0 == .dismiss }
context.send(viewAction: .send)
XCTAssertTrue(context.viewState.shouldDisableInteraction, "The interaction should be disabled while sending.")
XCTAssertEqual(userIndicatorController.submitIndicatorDelayCallsCount, 1) // Loading indicator
#expect(context.viewState.shouldDisableInteraction, "The interaction should be disabled while sending.")
#expect(userIndicatorController.submitIndicatorDelayCallsCount == 1) // Loading indicator
// Then the failure should occur preventing the screen from being dismissed.
try await deferredFailure.fulfill()
XCTAssertFalse(context.viewState.shouldDisableInteraction)
XCTAssertEqual(userIndicatorController.submitIndicatorDelayCallsCount, 2, "An error indicator should be shown.")
#expect(!context.viewState.shouldDisableInteraction)
#expect(userIndicatorController.submitIndicatorDelayCallsCount == 2, "An error indicator should be shown.")
}
func testMultipleFilesWithSendFailure() async throws {
@Test
func multipleFilesWithSendFailure() async throws {
// Given an upload screen with multiple media files where one of the files will fail to send.
setUpViewModel(urls: [fileURL, imageURL, imageURL, fileURL], expectedCaption: nil, simulateImageSendFailures: true)
XCTAssertFalse(context.viewState.shouldDisableInteraction)
XCTAssertEqual(userIndicatorController.submitIndicatorDelayCallsCount, 0)
#expect(!context.viewState.shouldDisableInteraction)
#expect(userIndicatorController.submitIndicatorDelayCallsCount == 0)
// When attempting to send the files.
let deferredDismiss = deferFulfillment(viewModel.actions) { $0 == .dismiss }
context.send(viewAction: .send)
XCTAssertTrue(context.viewState.shouldDisableInteraction, "The interaction should be disabled while sending.")
XCTAssertEqual(userIndicatorController.submitIndicatorDelayCallsCount, 1) // Loading indicator
#expect(context.viewState.shouldDisableInteraction, "The interaction should be disabled while sending.")
#expect(userIndicatorController.submitIndicatorDelayCallsCount == 1) // Loading indicator
// Then the screen should be dismissed so the user can see which files made it into the timeline.
try await deferredDismiss.fulfill()
XCTAssertEqual(timelineProxy.sendImageUrlThumbnailURLImageInfoCaptionRequestHandleCallsCount, 2)
XCTAssertEqual(timelineProxy.sendFileUrlFileInfoCaptionRequestHandleCallsCount, 2)
XCTAssertEqual(userIndicatorController.submitIndicatorDelayCallsCount, 3, "Error indicators for each failure should be shown.")
#expect(timelineProxy.sendImageUrlThumbnailURLImageInfoCaptionRequestHandleCallsCount == 2)
#expect(timelineProxy.sendFileUrlFileInfoCaptionRequestHandleCallsCount == 2)
#expect(userIndicatorController.submitIndicatorDelayCallsCount == 3, "Error indicators for each failure should be shown.")
}
// MARK: - Helpers
@@ -244,7 +261,7 @@ class MediaUploadPreviewScreenViewModelTests: XCTestCase {
private func assertResourceURL(filename: String) -> URL {
guard let url = Bundle(for: Self.self).url(forResource: filename, withExtension: nil) else {
XCTFail("Failed retrieving test asset")
Issue.record("Failed retrieving test asset")
return .picturesDirectory
}
return url
@@ -288,19 +305,19 @@ class MediaUploadPreviewScreenViewModelTests: XCTestCase {
private func verifyCaption(_ caption: String?, expectedCaption: String?) -> Result<Void, TimelineProxyError> {
guard caption == expectedCaption else {
XCTFail("The sent caption '\(caption ?? "nil")' does not match the expected value '\(expectedCaption ?? "nil")'").self
Issue.record("The sent caption '\(caption ?? "nil")' does not match the expected value '\(expectedCaption ?? "nil")'")
return .failure(.sdkError(TestError.unexpectedParameter))
}
return .success(())
}
private func send() async throws {
XCTAssertFalse(context.viewState.shouldDisableInteraction, "Attempting to send when interaction is disabled.")
#expect(!context.viewState.shouldDisableInteraction, "Attempting to send when interaction is disabled.")
let deferred = deferFulfillment(viewModel.actions) { $0 == .dismiss }
context.send(viewAction: .send)
XCTAssertTrue(context.viewState.shouldDisableInteraction, "The interaction should be disabled while sending.")
#expect(context.viewState.shouldDisableInteraction, "The interaction should be disabled while sending.")
try await deferred.fulfill()
}

View File

@@ -7,15 +7,17 @@
//
@testable import ElementX
import SwiftUI
import Testing
import UniformTypeIdentifiers
import XCTest
final class MediaUploadingPreprocessorTests: XCTestCase {
@Suite
final class MediaUploadingPreprocessorTests {
let maxUploadSize: UInt = 100 * 1024 * 1024
var appSettings: AppSettings!
var mediaUploadingPreprocessor: MediaUploadingPreprocessor!
override func setUp() {
init() {
AppSettings.resetAllSettings()
appSettings = AppSettings()
appSettings.optimizeMediaUploads = false
@@ -23,450 +25,426 @@ final class MediaUploadingPreprocessorTests: XCTestCase {
mediaUploadingPreprocessor = MediaUploadingPreprocessor(appSettings: appSettings)
}
override func tearDown() {
deinit {
AppSettings.resetAllSettings()
}
func testAudioFileProcessing() async {
guard let url = Bundle(for: Self.self).url(forResource: "test_audio.mp3", withExtension: nil) else {
XCTFail("Failed retrieving test asset")
return
}
@Test
func audioFileProcessing() async throws {
let url = try #require(Bundle(for: Self.self).url(forResource: "test_audio.mp3", withExtension: nil), "Failed retrieving test asset")
guard case let .success(result) = await mediaUploadingPreprocessor.processMedia(at: url, maxUploadSize: maxUploadSize),
case let .audio(audioURL, audioInfo) = result else {
XCTFail("Failed processing asset")
Issue.record("Failed processing asset")
return
}
// Check that the file name is preserved
XCTAssertEqual(audioURL.lastPathComponent, "test_audio.mp3")
#expect(audioURL.lastPathComponent == "test_audio.mp3")
XCTAssertEqual(audioInfo.mimetype, "audio/mpeg")
XCTAssertEqual(audioInfo.duration ?? 0, 27, accuracy: 100)
XCTAssertEqual(audioInfo.size ?? 0, 194_811, accuracy: 100)
#expect(audioInfo.mimetype == "audio/mpeg")
#expect(isEqual(audioInfo.duration ?? 0, 27, within: 100))
#expect(isEqual(audioInfo.size ?? 0, 194_811, within: 100))
}
func testLandscapeMovVideoProcessing() async {
// Allow an increased execution time as we encode the video twice now.
executionTimeAllowance = 180
guard let url = Bundle(for: Self.self).url(forResource: "landscape_test_video.mov", withExtension: nil) else {
XCTFail("Failed retrieving test asset")
return
}
@Test
func landscapeMovVideoProcessing() async throws {
let url = try #require(Bundle(for: Self.self).url(forResource: "landscape_test_video.mov", withExtension: nil), "Failed retrieving test asset")
guard case let .success(result) = await mediaUploadingPreprocessor.processMedia(at: url, maxUploadSize: maxUploadSize),
case let .video(videoURL, thumbnailURL, videoInfo) = result else {
XCTFail("Failed processing asset")
Issue.record("Failed processing asset")
return
}
// Check that the file name is preserved
XCTAssertEqual(videoURL.lastPathComponent, "landscape_test_video.mp4")
XCTAssertEqual(videoURL.pathExtension, "mp4", "The file extension should match the container we use.")
#expect(videoURL.lastPathComponent == "landscape_test_video.mp4")
#expect(videoURL.pathExtension == "mp4", "The file extension should match the container we use.")
// Check that the thumbnail is generated correctly
guard let thumbnailData = try? Data(contentsOf: thumbnailURL),
let thumbnail = UIImage(data: thumbnailData) else {
XCTFail("Invalid thumbnail")
return
}
let thumbnailData = try Data(contentsOf: thumbnailURL)
let thumbnail = try #require(UIImage(data: thumbnailData), "Invalid thumbnail")
XCTAssert(thumbnail.size.width <= MediaUploadingPreprocessor.Constants.maximumThumbnailSize.width)
XCTAssert(thumbnail.size.height <= MediaUploadingPreprocessor.Constants.maximumThumbnailSize.height)
#expect(thumbnail.size.width <= MediaUploadingPreprocessor.Constants.maximumThumbnailSize.width)
#expect(thumbnail.size.height <= MediaUploadingPreprocessor.Constants.maximumThumbnailSize.height)
// Check resulting video info
XCTAssertEqual(videoInfo.mimetype, "video/mp4")
XCTAssertEqual(videoInfo.blurhash, "K9F$LJZ9,+8yA9-:yT,@%1")
XCTAssertEqual(videoInfo.size ?? 0, 4_016_620, accuracy: 100)
XCTAssertEqual(videoInfo.width, 1280)
XCTAssertEqual(videoInfo.height, 720)
XCTAssertEqual(videoInfo.duration ?? 0, 30, accuracy: 100)
#expect(videoInfo.mimetype == "video/mp4")
#expect(videoInfo.blurhash == "K9F$LJZ9,+8yA9-:yT,@%1")
#expect(isEqual(videoInfo.size ?? 0, 4_016_620, within: 100))
#expect(videoInfo.width == 1280)
#expect(videoInfo.height == 720)
#expect(isEqual(videoInfo.duration ?? 0, 30, within: 100))
XCTAssertNotNil(videoInfo.thumbnailInfo)
XCTAssertEqual(videoInfo.thumbnailInfo?.mimetype, "image/jpeg")
XCTAssertEqual(videoInfo.thumbnailInfo?.size ?? 0, 183_093, accuracy: 100)
XCTAssertEqual(videoInfo.thumbnailInfo?.width, 800)
XCTAssertEqual(videoInfo.thumbnailInfo?.height, 450)
#expect(videoInfo.thumbnailInfo != nil)
#expect(videoInfo.thumbnailInfo?.mimetype == "image/jpeg")
#expect(isEqual(videoInfo.thumbnailInfo?.size ?? 0, 183_093, within: 100))
#expect(videoInfo.thumbnailInfo?.width == 800)
#expect(videoInfo.thumbnailInfo?.height == 450)
// Repeat with optimised media setting
appSettings.optimizeMediaUploads = true
guard case let .success(optimizedResult) = await mediaUploadingPreprocessor.processMedia(at: url, maxUploadSize: maxUploadSize),
case let .video(optimizedVideoURL, _, optimizedVideoInfo) = optimizedResult else {
XCTFail("Failed processing asset")
Issue.record("Failed processing asset")
return
}
XCTAssertEqual(optimizedVideoURL.pathExtension, "mp4", "The file extension should match the container we use.")
#expect(optimizedVideoURL.pathExtension == "mp4", "The file extension should match the container we use.")
// Check optimised video info
XCTAssertEqual(optimizedVideoInfo.mimetype, "video/mp4")
XCTAssertEqual(optimizedVideoInfo.blurhash, "K9F$LJZ9,+8yA9-:yT,@%1")
XCTAssertEqual(optimizedVideoInfo.size ?? 0, 4_016_620, accuracy: 100) // Note: The video is already 720p so it doesn't change size.
XCTAssertEqual(optimizedVideoInfo.width, 1280)
XCTAssertEqual(optimizedVideoInfo.height, 720)
XCTAssertEqual(optimizedVideoInfo.duration ?? 0, 30, accuracy: 100)
#expect(optimizedVideoInfo.mimetype == "video/mp4")
#expect(optimizedVideoInfo.blurhash == "K9F$LJZ9,+8yA9-:yT,@%1")
#expect(isEqual(optimizedVideoInfo.size ?? 0, 4_016_620, within: 100)) // Note: The video is already 720p so it doesn't change size.
#expect(optimizedVideoInfo.width == 1280)
#expect(optimizedVideoInfo.height == 720)
#expect(isEqual(optimizedVideoInfo.duration ?? 0, 30, within: 100))
}
func testPortraitMp4VideoProcessing() async {
// Allow an increased execution time as we encode the video twice now.
executionTimeAllowance = 180
guard let url = Bundle(for: Self.self).url(forResource: "portrait_test_video.mp4", withExtension: nil) else {
XCTFail("Failed retrieving test asset")
return
}
@Test
func portraitMp4VideoProcessing() async throws {
let url = try #require(Bundle(for: Self.self).url(forResource: "portrait_test_video.mp4", withExtension: nil), "Failed retrieving test asset")
guard case let .success(result) = await mediaUploadingPreprocessor.processMedia(at: url, maxUploadSize: maxUploadSize),
case let .video(videoURL, thumbnailURL, videoInfo) = result else {
XCTFail("Failed processing asset")
Issue.record("Failed processing asset")
return
}
// Check that the file name is preserved
XCTAssertEqual(videoURL.lastPathComponent, "portrait_test_video.mp4")
XCTAssertEqual(videoURL.pathExtension, "mp4", "The file extension should match the container we use.")
#expect(videoURL.lastPathComponent == "portrait_test_video.mp4")
#expect(videoURL.pathExtension == "mp4", "The file extension should match the container we use.")
// Check that the thumbnail is generated correctly
guard let thumbnailData = try? Data(contentsOf: thumbnailURL),
let thumbnail = UIImage(data: thumbnailData) else {
XCTFail("Invalid thumbnail")
return
}
let thumbnailData = try Data(contentsOf: thumbnailURL)
let thumbnail = try #require(UIImage(data: thumbnailData), "Invalid thumbnail")
XCTAssert(thumbnail.size.width <= MediaUploadingPreprocessor.Constants.maximumThumbnailSize.width)
XCTAssert(thumbnail.size.height <= MediaUploadingPreprocessor.Constants.maximumThumbnailSize.height)
#expect(thumbnail.size.width <= MediaUploadingPreprocessor.Constants.maximumThumbnailSize.width)
#expect(thumbnail.size.height <= MediaUploadingPreprocessor.Constants.maximumThumbnailSize.height)
// Check resulting video info
XCTAssertEqual(videoInfo.mimetype, "video/mp4")
XCTAssertEqual(videoInfo.blurhash, "KSB{R8O]MuwQS4oJvcaIt8")
XCTAssertEqual(videoInfo.size ?? 0, 5_824_946, accuracy: 100)
XCTAssertEqual(videoInfo.width, 1080)
XCTAssertEqual(videoInfo.height, 1920)
XCTAssertEqual(videoInfo.duration ?? 0, 21, accuracy: 100)
#expect(videoInfo.mimetype == "video/mp4")
#expect(videoInfo.blurhash == "KSB{R8O]MuwQS4oJvcaIt8")
#expect(isEqual(videoInfo.size ?? 0, 5_824_946, within: 100))
#expect(videoInfo.width == 1080)
#expect(videoInfo.height == 1920)
#expect(isEqual(videoInfo.duration ?? 0, 21, within: 100))
XCTAssertNotNil(videoInfo.thumbnailInfo)
XCTAssertEqual(videoInfo.thumbnailInfo?.mimetype, "image/jpeg")
XCTAssertEqual(videoInfo.thumbnailInfo?.size ?? 0, 40976, accuracy: 100)
XCTAssertEqual(videoInfo.thumbnailInfo?.width, 337)
XCTAssertEqual(videoInfo.thumbnailInfo?.height, 600)
#expect(videoInfo.thumbnailInfo != nil)
#expect(videoInfo.thumbnailInfo?.mimetype == "image/jpeg")
#expect(isEqual(videoInfo.thumbnailInfo?.size ?? 0, 40976, within: 100))
#expect(videoInfo.thumbnailInfo?.width == 337)
#expect(videoInfo.thumbnailInfo?.height == 600)
// Repeat with optimised media setting
appSettings.optimizeMediaUploads = true
guard case let .success(optimizedResult) = await mediaUploadingPreprocessor.processMedia(at: url, maxUploadSize: maxUploadSize),
case let .video(optimizedVideoURL, _, optimizedVideoInfo) = optimizedResult else {
XCTFail("Failed processing asset")
Issue.record("Failed processing asset")
return
}
XCTAssertEqual(optimizedVideoURL.pathExtension, "mp4", "The file extension should match the container we use.")
#expect(optimizedVideoURL.pathExtension == "mp4", "The file extension should match the container we use.")
// Check optimised video info
XCTAssertEqual(optimizedVideoInfo.mimetype, "video/mp4")
XCTAssertEqual(optimizedVideoInfo.blurhash, "KSC5.vO]MuwQS4oJvcaIt8")
XCTAssertEqual(optimizedVideoInfo.size ?? 0, 12_169_117, accuracy: 100) // Note: This is slightly stupid because it is larger now 🤦
XCTAssertEqual(optimizedVideoInfo.width, 720)
XCTAssertEqual(optimizedVideoInfo.height, 1280)
XCTAssertEqual(optimizedVideoInfo.duration ?? 0, 30, accuracy: 100)
#expect(optimizedVideoInfo.mimetype == "video/mp4")
#expect(optimizedVideoInfo.blurhash == "KSC5.vO]MuwQS4oJvcaIt8")
#expect(isEqual(optimizedVideoInfo.size ?? 0, 12_169_117, within: 100)) // Note: This is slightly stupid because it is larger now 🤦
#expect(optimizedVideoInfo.width == 720)
#expect(optimizedVideoInfo.height == 1280)
#expect(isEqual(optimizedVideoInfo.duration ?? 0, 30, within: 100))
}
func testLandscapeImageProcessing() async {
guard let url = Bundle(for: Self.self).url(forResource: "landscape_test_image.jpg", withExtension: nil) else {
XCTFail("Failed retrieving test asset")
return
}
@Test
func landscapeImageProcessing() async throws {
let url = try #require(Bundle(for: Self.self).url(forResource: "landscape_test_image.jpg", withExtension: nil), "Failed retrieving test asset")
guard case let .success(result) = await mediaUploadingPreprocessor.processMedia(at: url, maxUploadSize: maxUploadSize),
case let .image(convertedImageURL, thumbnailURL, imageInfo) = result else {
XCTFail("Failed processing asset")
Issue.record("Failed processing asset")
return
}
compare(originalImageAt: url, toConvertedImageAt: convertedImageURL, withThumbnailAt: thumbnailURL)
try compare(originalImageAt: url, toConvertedImageAt: convertedImageURL, withThumbnailAt: thumbnailURL)
// Check resulting image info
XCTAssertEqual(imageInfo.mimetype, "image/jpeg")
XCTAssertEqual(imageInfo.blurhash, "K%I#.NofkC_4ayayxujsWB")
XCTAssertEqual(imageInfo.size ?? 0, 3_305_795, accuracy: 100)
XCTAssertEqual(imageInfo.width, 6103)
XCTAssertEqual(imageInfo.height, 2621)
#expect(imageInfo.mimetype == "image/jpeg")
#expect(imageInfo.blurhash == "K%I#.NofkC_4ayayxujsWB")
#expect(isEqual(imageInfo.size ?? 0, 3_305_795, within: 100))
#expect(imageInfo.width == 6103)
#expect(imageInfo.height == 2621)
XCTAssertNotNil(imageInfo.thumbnailInfo)
XCTAssertEqual(imageInfo.thumbnailInfo?.mimetype, "image/jpeg")
XCTAssertEqual(imageInfo.thumbnailInfo?.size ?? 0, 87733, accuracy: 100)
XCTAssertEqual(imageInfo.thumbnailInfo?.width, 800)
XCTAssertEqual(imageInfo.thumbnailInfo?.height, 344)
#expect(imageInfo.thumbnailInfo != nil)
#expect(imageInfo.thumbnailInfo?.mimetype == "image/jpeg")
#expect(isEqual(imageInfo.thumbnailInfo?.size ?? 0, 87733, within: 100))
#expect(imageInfo.thumbnailInfo?.width == 800)
#expect(imageInfo.thumbnailInfo?.height == 344)
// Repeat with optimised media setting
appSettings.optimizeMediaUploads = true
guard case let .success(optimizedResult) = await mediaUploadingPreprocessor.processMedia(at: url, maxUploadSize: maxUploadSize),
case let .image(optimizedImageURL, thumbnailURL, optimizedImageInfo) = optimizedResult else {
XCTFail("Failed processing asset")
Issue.record("Failed processing asset")
return
}
compare(originalImageAt: url, toConvertedImageAt: optimizedImageURL, withThumbnailAt: thumbnailURL)
try compare(originalImageAt: url, toConvertedImageAt: optimizedImageURL, withThumbnailAt: thumbnailURL)
// Check optimised image info
XCTAssertEqual(optimizedImageInfo.mimetype, "image/jpeg")
XCTAssertEqual(optimizedImageInfo.blurhash, "K%I#.NofkC_4ayaxxujsWB")
XCTAssertEqual(optimizedImageInfo.size ?? 0, 524_226, accuracy: 100)
XCTAssertEqual(optimizedImageInfo.width, 2048)
XCTAssertEqual(optimizedImageInfo.height, 879)
#expect(optimizedImageInfo.mimetype == "image/jpeg")
#expect(optimizedImageInfo.blurhash == "K%I#.NofkC_4ayaxxujsWB")
#expect(isEqual(optimizedImageInfo.size ?? 0, 524_226, within: 100))
#expect(optimizedImageInfo.width == 2048)
#expect(optimizedImageInfo.height == 879)
}
func testPortraitImageProcessing() async {
guard let url = Bundle(for: Self.self).url(forResource: "portrait_test_image.jpg", withExtension: nil) else {
XCTFail("Failed retrieving test asset")
return
}
@Test
func portraitImageProcessing() async throws {
let url = try #require(Bundle(for: Self.self).url(forResource: "portrait_test_image.jpg", withExtension: nil), "Failed retrieving test asset")
guard case let .success(result) = await mediaUploadingPreprocessor.processMedia(at: url, maxUploadSize: maxUploadSize),
case let .image(convertedImageURL, thumbnailURL, imageInfo) = result else {
XCTFail("Failed processing asset")
Issue.record("Failed processing asset")
return
}
compare(originalImageAt: url, toConvertedImageAt: convertedImageURL, withThumbnailAt: thumbnailURL)
try compare(originalImageAt: url, toConvertedImageAt: convertedImageURL, withThumbnailAt: thumbnailURL)
// Check resulting image info
XCTAssertEqual(imageInfo.mimetype, "image/jpeg")
XCTAssertEqual(imageInfo.blurhash, "KdE|0Ls+RP^-n*RP%OWAV@")
XCTAssertEqual(imageInfo.size ?? 0, 4_414_666, accuracy: 100)
XCTAssertEqual(imageInfo.width, 3024)
XCTAssertEqual(imageInfo.height, 4032)
#expect(imageInfo.mimetype == "image/jpeg")
#expect(imageInfo.blurhash == "KdE|0Ls+RP^-n*RP%OWAV@")
#expect(isEqual(imageInfo.size ?? 0, 4_414_666, within: 100))
#expect(imageInfo.width == 3024)
#expect(imageInfo.height == 4032)
XCTAssertNotNil(imageInfo.thumbnailInfo)
XCTAssertEqual(imageInfo.thumbnailInfo?.mimetype, "image/jpeg")
XCTAssertEqual(imageInfo.thumbnailInfo?.size ?? 0, 258_914, accuracy: 100)
XCTAssertEqual(imageInfo.thumbnailInfo?.width, 600)
XCTAssertEqual(imageInfo.thumbnailInfo?.height, 800)
#expect(imageInfo.thumbnailInfo != nil)
#expect(imageInfo.thumbnailInfo?.mimetype == "image/jpeg")
#expect(isEqual(imageInfo.thumbnailInfo?.size ?? 0, 258_914, within: 100))
#expect(imageInfo.thumbnailInfo?.width == 600)
#expect(imageInfo.thumbnailInfo?.height == 800)
// Repeat with optimised media setting
appSettings.optimizeMediaUploads = true
guard case let .success(optimizedResult) = await mediaUploadingPreprocessor.processMedia(at: url, maxUploadSize: maxUploadSize),
case let .image(optimizedImageURL, thumbnailURL, optimizedImageInfo) = optimizedResult else {
XCTFail("Failed processing asset")
Issue.record("Failed processing asset")
return
}
compare(originalImageAt: url, toConvertedImageAt: optimizedImageURL, withThumbnailAt: thumbnailURL)
try compare(originalImageAt: url, toConvertedImageAt: optimizedImageURL, withThumbnailAt: thumbnailURL)
// Check optimised image info
XCTAssertEqual(optimizedImageInfo.mimetype, "image/jpeg")
XCTAssertEqual(optimizedImageInfo.blurhash, "KdE|0Ls+RP^-n*RP%OWAV@")
XCTAssertEqual(optimizedImageInfo.size ?? 0, 1_462_937, accuracy: 100)
XCTAssertEqual(optimizedImageInfo.width, 1536)
XCTAssertEqual(optimizedImageInfo.height, 2048)
#expect(optimizedImageInfo.mimetype == "image/jpeg")
#expect(optimizedImageInfo.blurhash == "KdE|0Ls+RP^-n*RP%OWAV@")
#expect(isEqual(optimizedImageInfo.size ?? 0, 1_462_937, within: 100))
#expect(optimizedImageInfo.width == 1536)
#expect(optimizedImageInfo.height == 2048)
}
func testPNGImageProcessing() async {
guard let url = Bundle(for: Self.self).url(forResource: "test_image.png", withExtension: nil) else {
XCTFail("Failed retrieving test asset")
return
}
@Test
func pngImageProcessing() async throws {
let url = try #require(Bundle(for: Self.self).url(forResource: "test_image.png", withExtension: nil), "Failed retrieving test asset")
guard case let .success(result) = await mediaUploadingPreprocessor.processMedia(at: url, maxUploadSize: maxUploadSize),
case let .image(convertedImageURL, _, imageInfo) = result else {
XCTFail("Failed processing asset")
Issue.record("Failed processing asset")
return
}
// Make sure the output file matches the image info.
XCTAssertEqual(mimeType(from: convertedImageURL), "image/png", "PNGs should always be sent as PNG to preserve the alpha channel.")
XCTAssertEqual(convertedImageURL.pathExtension, "png", "The file extension should match the MIME type.")
#expect(mimeType(from: convertedImageURL) == "image/png", "PNGs should always be sent as PNG to preserve the alpha channel.")
#expect(convertedImageURL.pathExtension == "png", "The file extension should match the MIME type.")
// Check resulting image info
XCTAssertEqual(imageInfo.mimetype, "image/png")
XCTAssertEqual(imageInfo.blurhash, "K0TSUA~qfQ~qj[fQfQfQfQ")
XCTAssertEqual(imageInfo.size ?? 0, 4868, accuracy: 100)
XCTAssertEqual(imageInfo.width, 240)
XCTAssertEqual(imageInfo.height, 240)
#expect(imageInfo.mimetype == "image/png")
#expect(imageInfo.blurhash == "K0TSUA~qfQ~qj[fQfQfQfQ")
#expect(isEqual(imageInfo.size ?? 0, 4868, within: 100))
#expect(imageInfo.width == 240)
#expect(imageInfo.height == 240)
XCTAssertNotNil(imageInfo.thumbnailInfo)
XCTAssertEqual(imageInfo.thumbnailInfo?.mimetype, "image/jpeg")
XCTAssertEqual(imageInfo.thumbnailInfo?.size ?? 0, 1725, accuracy: 100)
XCTAssertEqual(imageInfo.thumbnailInfo?.width, 240)
XCTAssertEqual(imageInfo.thumbnailInfo?.height, 240)
#expect(imageInfo.thumbnailInfo != nil)
#expect(imageInfo.thumbnailInfo?.mimetype == "image/jpeg")
#expect(isEqual(imageInfo.thumbnailInfo?.size ?? 0, 1725, within: 100))
#expect(imageInfo.thumbnailInfo?.width == 240)
#expect(imageInfo.thumbnailInfo?.height == 240)
// Repeat with optimised media setting
appSettings.optimizeMediaUploads = true
guard case let .success(optimizedResult) = await mediaUploadingPreprocessor.processMedia(at: url, maxUploadSize: maxUploadSize),
case let .image(optimizedImageURL, _, optimizedImageInfo) = optimizedResult else {
XCTFail("Failed processing asset")
Issue.record("Failed processing asset")
return
}
// Make sure the output file matches the image info.
XCTAssertEqual(mimeType(from: optimizedImageURL), "image/png", "PNGs should always be sent as PNG to preserve the alpha channel.")
XCTAssertEqual(optimizedImageURL.pathExtension, "png", "The file extension should match the MIME type.")
#expect(mimeType(from: optimizedImageURL) == "image/png", "PNGs should always be sent as PNG to preserve the alpha channel.")
#expect(optimizedImageURL.pathExtension == "png", "The file extension should match the MIME type.")
// Check optimised image info
XCTAssertEqual(optimizedImageInfo.mimetype, "image/png")
XCTAssertEqual(optimizedImageInfo.blurhash, "K0TSUA~qfQ~qj[fQfQfQfQ")
XCTAssertEqual(optimizedImageInfo.size ?? 0, 8199, accuracy: 100)
#expect(optimizedImageInfo.mimetype == "image/png")
#expect(optimizedImageInfo.blurhash == "K0TSUA~qfQ~qj[fQfQfQfQ")
#expect(isEqual(optimizedImageInfo.size ?? 0, 8199, within: 100))
// Assert that resizing didn't upscale to the maxPixelSize.
XCTAssertEqual(optimizedImageInfo.width, 240)
XCTAssertEqual(optimizedImageInfo.height, 240)
#expect(optimizedImageInfo.width == 240)
#expect(optimizedImageInfo.height == 240)
}
func testHEICImageProcessing() async {
guard let url = Bundle(for: Self.self).url(forResource: "test_apple_image.heic", withExtension: nil) else {
XCTFail("Failed retrieving test asset")
return
}
@Test
func heicImageProcessing() async throws {
let url = try #require(Bundle(for: Self.self).url(forResource: "test_apple_image.heic", withExtension: nil), "Failed retrieving test asset")
guard case let .success(result) = await mediaUploadingPreprocessor.processMedia(at: url, maxUploadSize: maxUploadSize),
case let .image(convertedImageURL, thumbnailURL, imageInfo) = result else {
XCTFail("Failed processing asset")
Issue.record("Failed processing asset")
return
}
compare(originalImageAt: url, toConvertedImageAt: convertedImageURL, withThumbnailAt: thumbnailURL)
try compare(originalImageAt: url, toConvertedImageAt: convertedImageURL, withThumbnailAt: thumbnailURL)
// Make sure the output file matches the image info.
XCTAssertEqual(mimeType(from: convertedImageURL), "image/heic", "Unoptimised HEICs should always be sent as is.")
XCTAssertEqual(convertedImageURL.pathExtension, "heic", "The file extension should match the MIME type.")
#expect(mimeType(from: convertedImageURL) == "image/heic", "Unoptimised HEICs should always be sent as is.")
#expect(convertedImageURL.pathExtension == "heic", "The file extension should match the MIME type.")
// Check resulting image info
XCTAssertEqual(imageInfo.mimetype, "image/heic")
XCTAssertEqual(imageInfo.blurhash, "KGD]3ns:T00$kWxFXmt6xv")
XCTAssertEqual(imageInfo.size ?? 0, 1_848_525, accuracy: 100)
XCTAssertEqual(imageInfo.width, 3024)
XCTAssertEqual(imageInfo.height, 4032)
#expect(imageInfo.mimetype == "image/heic")
#expect(imageInfo.blurhash == "KGD]3ns:T00$kWxFXmt6xv")
#expect(isEqual(imageInfo.size ?? 0, 1_848_525, within: 100))
#expect(imageInfo.width == 3024)
#expect(imageInfo.height == 4032)
XCTAssertNotNil(imageInfo.thumbnailInfo)
XCTAssertEqual(imageInfo.thumbnailInfo?.mimetype, "image/jpeg")
XCTAssertEqual(imageInfo.thumbnailInfo?.size ?? 0, 218_108, accuracy: 100)
XCTAssertEqual(imageInfo.thumbnailInfo?.width, 600)
XCTAssertEqual(imageInfo.thumbnailInfo?.height, 800)
#expect(imageInfo.thumbnailInfo != nil)
#expect(imageInfo.thumbnailInfo?.mimetype == "image/jpeg")
#expect(isEqual(imageInfo.thumbnailInfo?.size ?? 0, 218_108, within: 100))
#expect(imageInfo.thumbnailInfo?.width == 600)
#expect(imageInfo.thumbnailInfo?.height == 800)
// Repeat with optimised media setting
appSettings.optimizeMediaUploads = true
guard case let .success(optimizedResult) = await mediaUploadingPreprocessor.processMedia(at: url, maxUploadSize: maxUploadSize),
case let .image(optimizedImageURL, thumbnailURL, optimizedImageInfo) = optimizedResult else {
XCTFail("Failed processing asset")
Issue.record("Failed processing asset")
return
}
compare(originalImageAt: url, toConvertedImageAt: optimizedImageURL, withThumbnailAt: thumbnailURL)
try compare(originalImageAt: url, toConvertedImageAt: optimizedImageURL, withThumbnailAt: thumbnailURL)
// Make sure the output file matches the image info.
XCTAssertEqual(mimeType(from: optimizedImageURL), "image/jpeg", "Optimised HEICs should always be converted to JPEG for compatibility.")
XCTAssertEqual(optimizedImageURL.pathExtension, "jpeg", "The file extension should match the MIME type.")
#expect(mimeType(from: optimizedImageURL) == "image/jpeg", "Optimised HEICs should always be converted to JPEG for compatibility.")
#expect(optimizedImageURL.pathExtension == "jpeg", "The file extension should match the MIME type.")
// Check optimised image info
XCTAssertEqual(optimizedImageInfo.mimetype, "image/jpeg")
XCTAssertEqual(optimizedImageInfo.blurhash, "KGD]3ns:T00#kWxFb^s:xv")
XCTAssertEqual(optimizedImageInfo.size ?? 0, 1_049_393, accuracy: 100)
XCTAssertEqual(optimizedImageInfo.width, 1536)
XCTAssertEqual(optimizedImageInfo.height, 2048)
#expect(optimizedImageInfo.mimetype == "image/jpeg")
#expect(optimizedImageInfo.blurhash == "KGD]3ns:T00#kWxFb^s:xv")
#expect(isEqual(optimizedImageInfo.size ?? 0, 1_049_393, within: 100))
#expect(optimizedImageInfo.width == 1536)
#expect(optimizedImageInfo.height == 2048)
}
func testGIFImageProcessing() async {
guard let url = Bundle(for: Self.self).url(forResource: "test_animated_image.gif", withExtension: nil) else {
XCTFail("Failed retrieving test asset")
return
}
guard let originalSize = try? FileManager.default.sizeForItem(at: url), originalSize > 0 else {
XCTFail("Failed fetching test asset's original size")
return
}
@Test
func gifImageProcessing() async throws {
let url = try #require(Bundle(for: Self.self).url(forResource: "test_animated_image.gif", withExtension: nil), "Failed retrieving test asset")
let originalSizeValue = try UInt64(FileManager.default.sizeForItem(at: url))
let originalSize = try #require(originalSizeValue > 0 ? originalSizeValue : nil, "File size must be greater than zero")
guard case let .success(result) = await mediaUploadingPreprocessor.processMedia(at: url, maxUploadSize: maxUploadSize),
case let .image(convertedImageURL, _, imageInfo) = result else {
XCTFail("Failed processing asset")
Issue.record("Failed processing asset")
return
}
// Make sure the output file matches the image info.
XCTAssertEqual(mimeType(from: convertedImageURL), "image/gif", "GIFs should always be sent as GIF to preserve the animation.")
XCTAssertEqual(convertedImageURL.pathExtension, "gif", "The file extension should match the MIME type.")
#expect(mimeType(from: convertedImageURL) == "image/gif", "GIFs should always be sent as GIF to preserve the animation.")
#expect(convertedImageURL.pathExtension == "gif", "The file extension should match the MIME type.")
// Check resulting image info
XCTAssertEqual(imageInfo.mimetype, "image/gif")
XCTAssertEqual(imageInfo.blurhash, "KpRMPTj[_NxuaeRj%MofMx")
XCTAssertEqual(imageInfo.size ?? 0, UInt64(originalSize), accuracy: 100)
XCTAssertEqual(imageInfo.width, 331)
XCTAssertEqual(imageInfo.height, 472)
#expect(imageInfo.mimetype == "image/gif")
#expect(imageInfo.blurhash == "KpRMPTj[_NxuaeRj%MofMx")
#expect(isEqual(imageInfo.size ?? 0, originalSize, within: 100))
#expect(imageInfo.width == 331)
#expect(imageInfo.height == 472)
XCTAssertNotNil(imageInfo.thumbnailInfo)
XCTAssertEqual(imageInfo.thumbnailInfo?.mimetype, "image/jpeg")
XCTAssertEqual(imageInfo.thumbnailInfo?.size ?? 0, 34215, accuracy: 100)
XCTAssertEqual(imageInfo.thumbnailInfo?.width, 331)
XCTAssertEqual(imageInfo.thumbnailInfo?.height, 472)
#expect(imageInfo.thumbnailInfo != nil)
#expect(imageInfo.thumbnailInfo?.mimetype == "image/jpeg")
#expect(isEqual(imageInfo.thumbnailInfo?.size ?? 0, 34215, within: 100))
#expect(imageInfo.thumbnailInfo?.width == 331)
#expect(imageInfo.thumbnailInfo?.height == 472)
// Repeat with optimised media setting
appSettings.optimizeMediaUploads = true
guard case let .success(optimizedResult) = await mediaUploadingPreprocessor.processMedia(at: url, maxUploadSize: maxUploadSize),
case let .image(optimizedImageURL, _, optimizedImageInfo) = optimizedResult else {
XCTFail("Failed processing asset")
Issue.record("Failed processing asset")
return
}
// Make sure the output file matches the image info.
XCTAssertEqual(mimeType(from: optimizedImageURL), "image/gif", "GIFs should always be sent as GIF to preserve the animation.")
XCTAssertEqual(optimizedImageURL.pathExtension, "gif", "The file extension should match the MIME type.")
#expect(mimeType(from: optimizedImageURL) == "image/gif", "GIFs should always be sent as GIF to preserve the animation.")
#expect(optimizedImageURL.pathExtension == "gif", "The file extension should match the MIME type.")
// Ensure optimised image is still the same as the original image.
XCTAssertEqual(optimizedImageInfo.mimetype, "image/gif")
XCTAssertEqual(optimizedImageInfo.blurhash, "KpRMPTj[_NxuaeRj%MofMx")
XCTAssertEqual(optimizedImageInfo.size ?? 0, UInt64(originalSize), accuracy: 100)
XCTAssertEqual(optimizedImageInfo.width, 331)
XCTAssertEqual(optimizedImageInfo.height, 472)
#expect(optimizedImageInfo.mimetype == "image/gif")
#expect(optimizedImageInfo.blurhash == "KpRMPTj[_NxuaeRj%MofMx")
#expect(isEqual(optimizedImageInfo.size ?? 0, originalSize, within: 100))
#expect(optimizedImageInfo.width == 331)
#expect(optimizedImageInfo.height == 472)
}
func testRotatedImageProcessing() async {
guard let url = Bundle(for: Self.self).url(forResource: "test_rotated_image.jpg", withExtension: nil) else {
XCTFail("Failed retrieving test asset")
return
}
@Test
func rotatedImageProcessing() async throws {
let url = try #require(Bundle(for: Self.self).url(forResource: "test_rotated_image.jpg", withExtension: nil), "Failed retrieving test asset")
guard case let .success(result) = await mediaUploadingPreprocessor.processMedia(at: url, maxUploadSize: maxUploadSize),
case let .image(convertedImageURL, thumbnailURL, imageInfo) = result else {
XCTFail("Failed processing asset")
Issue.record("Failed processing asset")
return
}
compare(originalImageAt: url, toConvertedImageAt: convertedImageURL, withThumbnailAt: thumbnailURL)
try compare(originalImageAt: url, toConvertedImageAt: convertedImageURL, withThumbnailAt: thumbnailURL)
// Check resulting image info
XCTAssertEqual(imageInfo.mimetype, "image/jpeg")
XCTAssertEqual(imageInfo.width, 2848)
XCTAssertEqual(imageInfo.height, 4272)
#expect(imageInfo.mimetype == "image/jpeg")
#expect(imageInfo.width == 2848)
#expect(imageInfo.height == 4272)
XCTAssertNotNil(imageInfo.thumbnailInfo)
XCTAssertEqual(imageInfo.thumbnailInfo?.width, 533)
XCTAssertEqual(imageInfo.thumbnailInfo?.height, 800)
#expect(imageInfo.thumbnailInfo != nil)
#expect(imageInfo.thumbnailInfo?.width == 533)
#expect(imageInfo.thumbnailInfo?.height == 800)
// Repeat with optimised media setting
appSettings.optimizeMediaUploads = true
guard case let .success(optimizedResult) = await mediaUploadingPreprocessor.processMedia(at: url, maxUploadSize: maxUploadSize),
case let .image(optimizedImageURL, thumbnailURL, optimizedImageInfo) = optimizedResult else {
XCTFail("Failed processing asset")
Issue.record("Failed processing asset")
return
}
compare(originalImageAt: url, toConvertedImageAt: optimizedImageURL, withThumbnailAt: thumbnailURL)
try compare(originalImageAt: url, toConvertedImageAt: optimizedImageURL, withThumbnailAt: thumbnailURL)
// Check optimised image info
XCTAssertEqual(optimizedImageInfo.mimetype, "image/jpeg")
XCTAssertEqual(optimizedImageInfo.width, 1365)
XCTAssertEqual(optimizedImageInfo.height, 2048)
#expect(optimizedImageInfo.mimetype == "image/jpeg")
#expect(optimizedImageInfo.width == 1365)
#expect(optimizedImageInfo.height == 2048)
}
// MARK: - Private
private func compare(originalImageAt originalImageURL: URL, toConvertedImageAt convertedImageURL: URL, withThumbnailAt thumbnailURL: URL) {
private func isEqual<N: UnsignedInteger>(_ lhs: N, _ rhs: N, within tolerance: N) -> Bool {
isEqual(Double(lhs), Double(rhs), within: Double(tolerance))
}
private func isEqual<N: SignedNumeric & Comparable>(_ lhs: N, _ rhs: N, within tolerance: N) -> Bool {
abs(lhs - rhs) <= tolerance
}
private func compare(originalImageAt originalImageURL: URL, toConvertedImageAt convertedImageURL: URL, withThumbnailAt thumbnailURL: URL) throws {
guard let originalImageData = try? Data(contentsOf: originalImageURL),
let originalImage = UIImage(data: originalImageData),
let convertedImageData = try? Data(contentsOf: convertedImageURL),
@@ -476,53 +454,41 @@ final class MediaUploadingPreprocessorTests: XCTestCase {
if appSettings.optimizeMediaUploads {
// Check that new image has been scaled within the requirements for an optimised image
XCTAssert(convertedImage.size.width <= MediaUploadingPreprocessor.Constants.optimizedMaxPixelSize)
XCTAssert(convertedImage.size.height <= MediaUploadingPreprocessor.Constants.optimizedMaxPixelSize)
#expect(convertedImage.size.width <= MediaUploadingPreprocessor.Constants.optimizedMaxPixelSize)
#expect(convertedImage.size.height <= MediaUploadingPreprocessor.Constants.optimizedMaxPixelSize)
} else {
// Check that the file name is preserved
XCTAssertEqual(originalImageURL.lastPathComponent, convertedImageURL.lastPathComponent)
#expect(originalImageURL.lastPathComponent == convertedImageURL.lastPathComponent)
// Check that new image is the same size as the original one
XCTAssertEqual(originalImage.size, convertedImage.size)
#expect(originalImage.size == convertedImage.size)
}
// Check that the GPS data has been stripped
let originalMetadata = metadata(from: originalImageData)
XCTAssertNotNil(originalMetadata.value(forKeyPath: "\(kCGImagePropertyGPSDictionary)"))
let originalMetadata = try metadata(from: originalImageData)
#expect(originalMetadata.value(forKeyPath: "\(kCGImagePropertyGPSDictionary)") != nil)
let convertedMetadata = metadata(from: convertedImageData)
XCTAssertNil(convertedMetadata.value(forKeyPath: "\(kCGImagePropertyGPSDictionary)"))
let convertedMetadata = try metadata(from: convertedImageData)
#expect(convertedMetadata.value(forKeyPath: "\(kCGImagePropertyGPSDictionary)") == nil)
// Check that the thumbnail is generated correctly
guard let thumbnailData = try? Data(contentsOf: thumbnailURL),
let thumbnail = UIImage(data: thumbnailData) else {
XCTFail("Invalid thumbnail")
return
}
let thumbnailData = try Data(contentsOf: thumbnailURL)
let thumbnail = try #require(UIImage(data: thumbnailData), "Invalid thumbnail")
if thumbnail.size.width > thumbnail.size.height {
XCTAssert(thumbnail.size.width <= MediaUploadingPreprocessor.Constants.maximumThumbnailSize.width)
XCTAssert(thumbnail.size.height <= MediaUploadingPreprocessor.Constants.maximumThumbnailSize.height)
#expect(thumbnail.size.width <= MediaUploadingPreprocessor.Constants.maximumThumbnailSize.width)
#expect(thumbnail.size.height <= MediaUploadingPreprocessor.Constants.maximumThumbnailSize.height)
} else {
XCTAssert(thumbnail.size.width <= MediaUploadingPreprocessor.Constants.maximumThumbnailSize.height)
XCTAssert(thumbnail.size.height <= MediaUploadingPreprocessor.Constants.maximumThumbnailSize.width)
#expect(thumbnail.size.width <= MediaUploadingPreprocessor.Constants.maximumThumbnailSize.height)
#expect(thumbnail.size.height <= MediaUploadingPreprocessor.Constants.maximumThumbnailSize.width)
}
let thumbnailMetadata = metadata(from: thumbnailData)
XCTAssertNil(thumbnailMetadata.value(forKeyPath: "\(kCGImagePropertyGPSDictionary)"))
let thumbnailMetadata = try metadata(from: thumbnailData)
#expect(thumbnailMetadata.value(forKeyPath: "\(kCGImagePropertyGPSDictionary)") == nil)
}
private func metadata(from imageData: Data) -> NSDictionary {
guard let imageSource = CGImageSourceCreateWithData(imageData as NSData, nil) else {
XCTFail("Invalid asset")
return [:]
}
guard let convertedMetadata: NSDictionary = CGImageSourceCopyPropertiesAtIndex(imageSource, 0, nil) else {
XCTFail("Test asset is expected to contain metadata")
return [:]
}
return convertedMetadata
private func metadata(from imageData: Data) throws -> NSDictionary {
let imageSource = try #require(CGImageSourceCreateWithData(imageData as NSData, nil), "Invalid asset")
return try #require(CGImageSourceCopyPropertiesAtIndex(imageSource, 0, nil) as NSDictionary?, "Test asset is expected to contain metadata")
}
private func mimeType(from url: URL) -> String? {
@@ -530,7 +496,7 @@ final class MediaUploadingPreprocessorTests: XCTestCase {
let typeIdentifier = CGImageSourceGetType(imageSource),
let type = UTType(typeIdentifier as String),
let mimeType = type.preferredMIMEType else {
XCTFail("Failed to get mimetype from URL.")
Issue.record("Failed to get mimetype from URL.")
return nil
}
return mimeType

View File

@@ -7,37 +7,42 @@
//
@testable import ElementX
import XCTest
import Foundation
import Testing
@Suite
@MainActor
class NavigationSplitCoordinatorTests: XCTestCase {
private var navigationSplitCoordinator: NavigationSplitCoordinator!
struct NavigationSplitCoordinatorTests {
private var navigationSplitCoordinator: NavigationSplitCoordinator
override func setUp() {
init() {
navigationSplitCoordinator = NavigationSplitCoordinator(placeholderCoordinator: SomeTestCoordinator())
}
func testSidebar() {
XCTAssertNil(navigationSplitCoordinator.sidebarCoordinator)
XCTAssertNil(navigationSplitCoordinator.detailCoordinator)
@Test
func sidebar() {
#expect(navigationSplitCoordinator.sidebarCoordinator == nil)
#expect(navigationSplitCoordinator.detailCoordinator == nil)
let sidebarCoordinator = SomeTestCoordinator()
navigationSplitCoordinator.setSidebarCoordinator(sidebarCoordinator)
assertCoordinatorsEqual(sidebarCoordinator, navigationSplitCoordinator.sidebarCoordinator)
}
func testDetail() {
XCTAssertNil(navigationSplitCoordinator.sidebarCoordinator)
XCTAssertNil(navigationSplitCoordinator.detailCoordinator)
@Test
func detail() {
#expect(navigationSplitCoordinator.sidebarCoordinator == nil)
#expect(navigationSplitCoordinator.detailCoordinator == nil)
let detailCoordinator = SomeTestCoordinator()
navigationSplitCoordinator.setDetailCoordinator(detailCoordinator)
assertCoordinatorsEqual(detailCoordinator, navigationSplitCoordinator.detailCoordinator)
}
func testSidebarAndDetail() {
XCTAssertNil(navigationSplitCoordinator.sidebarCoordinator)
XCTAssertNil(navigationSplitCoordinator.detailCoordinator)
@Test
func sidebarAndDetail() {
#expect(navigationSplitCoordinator.sidebarCoordinator == nil)
#expect(navigationSplitCoordinator.detailCoordinator == nil)
let sidebarCoordinator = SomeTestCoordinator()
navigationSplitCoordinator.setSidebarCoordinator(sidebarCoordinator)
@@ -49,7 +54,8 @@ class NavigationSplitCoordinatorTests: XCTestCase {
assertCoordinatorsEqual(detailCoordinator, navigationSplitCoordinator.detailCoordinator)
}
func testSingleSheet() {
@Test
func singleSheet() {
let sidebarCoordinator = SomeTestCoordinator()
navigationSplitCoordinator.setSidebarCoordinator(sidebarCoordinator)
let detailCoordinator = SomeTestCoordinator()
@@ -66,10 +72,11 @@ class NavigationSplitCoordinatorTests: XCTestCase {
assertCoordinatorsEqual(sidebarCoordinator, navigationSplitCoordinator.sidebarCoordinator)
assertCoordinatorsEqual(detailCoordinator, navigationSplitCoordinator.detailCoordinator)
XCTAssertNil(navigationSplitCoordinator.sheetCoordinator)
#expect(navigationSplitCoordinator.sheetCoordinator == nil)
}
func testMultipleSheets() {
@Test
func multipleSheets() {
let sidebarCoordinator = SomeTestCoordinator()
navigationSplitCoordinator.setSidebarCoordinator(sidebarCoordinator)
let detailCoordinator = SomeTestCoordinator()
@@ -90,7 +97,8 @@ class NavigationSplitCoordinatorTests: XCTestCase {
assertCoordinatorsEqual(someOtherSheetCoordinator, navigationSplitCoordinator.sheetCoordinator)
}
func testFullScreenCover() {
@Test
func fullScreenCover() {
let sidebarCoordinator = SomeTestCoordinator()
navigationSplitCoordinator.setSidebarCoordinator(sidebarCoordinator)
let detailCoordinator = SomeTestCoordinator()
@@ -107,62 +115,67 @@ class NavigationSplitCoordinatorTests: XCTestCase {
assertCoordinatorsEqual(sidebarCoordinator, navigationSplitCoordinator.sidebarCoordinator)
assertCoordinatorsEqual(detailCoordinator, navigationSplitCoordinator.detailCoordinator)
XCTAssertNil(navigationSplitCoordinator.fullScreenCoverCoordinator)
#expect(navigationSplitCoordinator.fullScreenCoverCoordinator == nil)
}
// MARK: - Dismissal Callbacks
func testSidebarReplacementCallbacks() {
@Test
func sidebarReplacementCallbacks() async {
let sidebarCoordinator = SomeTestCoordinator()
let expectation = expectation(description: "Wait for callback")
navigationSplitCoordinator.setSidebarCoordinator(sidebarCoordinator) {
expectation.fulfill()
await waitForConfirmation("Wait for callback", timeout: .seconds(1)) { confirm in
navigationSplitCoordinator.setSidebarCoordinator(sidebarCoordinator) {
confirm()
}
navigationSplitCoordinator.setSidebarCoordinator(nil)
}
navigationSplitCoordinator.setSidebarCoordinator(nil)
waitForExpectations(timeout: 1.0)
}
func testDetailReplacementCallbacks() {
@Test
func detailReplacementCallbacks() async {
let detailCoordinator = SomeTestCoordinator()
let expectation = expectation(description: "Wait for callback")
navigationSplitCoordinator.setDetailCoordinator(detailCoordinator) {
expectation.fulfill()
await waitForConfirmation("Wait for callback", timeout: .seconds(1)) { confirm in
navigationSplitCoordinator.setDetailCoordinator(detailCoordinator) {
confirm()
}
navigationSplitCoordinator.setDetailCoordinator(nil)
}
navigationSplitCoordinator.setDetailCoordinator(nil)
waitForExpectations(timeout: 1.0)
}
func testSheetDismissalCallback() {
@Test
func sheetDismissalCallback() async {
let sheetCoordinator = SomeTestCoordinator()
let expectation = expectation(description: "Wait for callback")
navigationSplitCoordinator.setSheetCoordinator(sheetCoordinator) {
expectation.fulfill()
await waitForConfirmation("Wait for callback", timeout: .seconds(1)) { confirm in
navigationSplitCoordinator.setSheetCoordinator(sheetCoordinator) {
confirm()
}
navigationSplitCoordinator.setSheetCoordinator(nil)
}
navigationSplitCoordinator.setSheetCoordinator(nil)
waitForExpectations(timeout: 1.0)
}
func testFullScreenCoverDismissalCallback() {
@Test
func fullScreenCoverDismissalCallback() async {
let fullScreenCoordinator = SomeTestCoordinator()
let expectation = expectation(description: "Wait for callback")
navigationSplitCoordinator.setFullScreenCoverCoordinator(fullScreenCoordinator) {
expectation.fulfill()
await waitForConfirmation("Wait for callback", timeout: .seconds(1)) { confirm in
navigationSplitCoordinator.setFullScreenCoverCoordinator(fullScreenCoordinator) {
confirm()
}
navigationSplitCoordinator.setFullScreenCoverCoordinator(nil)
}
navigationSplitCoordinator.setFullScreenCoverCoordinator(nil)
waitForExpectations(timeout: 1.0)
}
// MARK: - Advanced
func testEmbeddedStackPresentsSheetThroughSplit() {
@Test
func embeddedStackPresentsSheetThroughSplit() {
let sidebarNavigationStackCoordinator = NavigationStackCoordinator(navigationSplitCoordinator: navigationSplitCoordinator)
sidebarNavigationStackCoordinator.setRootCoordinator(SomeTestCoordinator())
@@ -175,7 +188,8 @@ class NavigationSplitCoordinatorTests: XCTestCase {
assertCoordinatorsEqual(sheetCoordinator, navigationSplitCoordinator.sheetCoordinator)
}
func testSplitTracksEmbeddedStackRootChanges() {
@Test
func splitTracksEmbeddedStackRootChanges() async {
let sidebarNavigationStackCoordinator = NavigationStackCoordinator(navigationSplitCoordinator: navigationSplitCoordinator)
sidebarNavigationStackCoordinator.setRootCoordinator(SomeTestCoordinator())
@@ -185,36 +199,38 @@ class NavigationSplitCoordinatorTests: XCTestCase {
sidebarNavigationStackCoordinator.setRootCoordinator(SomeTestCoordinator())
let expectation = expectation(description: "Coordinators should match")
DispatchQueue.main.async {
self.assertCoordinatorsEqual(sidebarNavigationStackCoordinator.rootCoordinator, self.navigationSplitCoordinator.compactLayoutRootCoordinator)
expectation.fulfill()
}
waitForExpectations(timeout: 1.0)
}
func testSplitTracksEmbeddedStackChanges() {
let sidebarNavigationStackCoordinator = NavigationStackCoordinator(navigationSplitCoordinator: navigationSplitCoordinator)
sidebarNavigationStackCoordinator.setRootCoordinator(SomeTestCoordinator())
navigationSplitCoordinator.setSidebarCoordinator(sidebarNavigationStackCoordinator)
assertCoordinatorsEqual(sidebarNavigationStackCoordinator.rootCoordinator, navigationSplitCoordinator.compactLayoutRootCoordinator)
sidebarNavigationStackCoordinator.push(SomeTestCoordinator())
let expectation = expectation(description: "Coordinators should match")
DispatchQueue.main.async {
XCTAssertEqual(sidebarNavigationStackCoordinator.stackCoordinators.count, self.navigationSplitCoordinator.compactLayoutStackCoordinators.count)
for index in sidebarNavigationStackCoordinator.stackCoordinators.indices {
self.assertCoordinatorsEqual(sidebarNavigationStackCoordinator.stackCoordinators[index], self.navigationSplitCoordinator.compactLayoutStackCoordinators[index])
await waitForConfirmation("Coordinators should match", timeout: .seconds(1)) { confirm in
DispatchQueue.main.async {
assertCoordinatorsEqual(sidebarNavigationStackCoordinator.rootCoordinator, navigationSplitCoordinator.compactLayoutRootCoordinator)
confirm()
}
expectation.fulfill()
}
waitForExpectations(timeout: 1.0)
}
func testSplitPropagatesCompactStackChanges() {
@Test
func splitTracksEmbeddedStackChanges() async {
let sidebarNavigationStackCoordinator = NavigationStackCoordinator(navigationSplitCoordinator: navigationSplitCoordinator)
sidebarNavigationStackCoordinator.setRootCoordinator(SomeTestCoordinator())
navigationSplitCoordinator.setSidebarCoordinator(sidebarNavigationStackCoordinator)
assertCoordinatorsEqual(sidebarNavigationStackCoordinator.rootCoordinator, navigationSplitCoordinator.compactLayoutRootCoordinator)
sidebarNavigationStackCoordinator.push(SomeTestCoordinator())
await waitForConfirmation("Coordinators should match", timeout: .seconds(1)) { confirm in
DispatchQueue.main.async {
#expect(sidebarNavigationStackCoordinator.stackCoordinators.count == navigationSplitCoordinator.compactLayoutStackCoordinators.count)
for index in sidebarNavigationStackCoordinator.stackCoordinators.indices {
assertCoordinatorsEqual(sidebarNavigationStackCoordinator.stackCoordinators[index], navigationSplitCoordinator.compactLayoutStackCoordinators[index])
}
confirm()
}
}
}
@Test
func splitPropagatesCompactStackChanges() {
let sidebarNavigationStackCoordinator = NavigationStackCoordinator(navigationSplitCoordinator: navigationSplitCoordinator)
sidebarNavigationStackCoordinator.setRootCoordinator(SomeTestCoordinator())
sidebarNavigationStackCoordinator.push(SomeTestCoordinator())
@@ -222,14 +238,15 @@ class NavigationSplitCoordinatorTests: XCTestCase {
navigationSplitCoordinator.setSidebarCoordinator(sidebarNavigationStackCoordinator)
assertCoordinatorsEqual(sidebarNavigationStackCoordinator.rootCoordinator, navigationSplitCoordinator.compactLayoutRootCoordinator)
XCTAssertEqual(sidebarNavigationStackCoordinator.stackCoordinators.count, navigationSplitCoordinator.compactLayoutStackCoordinators.count)
#expect(sidebarNavigationStackCoordinator.stackCoordinators.count == navigationSplitCoordinator.compactLayoutStackCoordinators.count)
navigationSplitCoordinator.compactLayoutStackModules.removeAll()
XCTAssertTrue(sidebarNavigationStackCoordinator.stackCoordinators.isEmpty)
#expect(sidebarNavigationStackCoordinator.stackCoordinators.isEmpty)
}
func testCompactStackCreation() {
@Test
func compactStackCreation() async {
let sidebarCoordinator = NavigationStackCoordinator()
sidebarCoordinator.setRootCoordinator(SomeTestCoordinator())
sidebarCoordinator.push(SomeTestCoordinator())
@@ -242,19 +259,20 @@ class NavigationSplitCoordinatorTests: XCTestCase {
navigationSplitCoordinator.setSidebarCoordinator(sidebarCoordinator)
navigationSplitCoordinator.setDetailCoordinator(detailCoordinator)
let expectation = expectation(description: "Coordinators should match")
DispatchQueue.main.async {
self.assertCoordinatorsEqual(self.navigationSplitCoordinator.compactLayoutRootCoordinator, sidebarCoordinator.rootCoordinator)
self.assertCoordinatorsEqual(self.navigationSplitCoordinator.compactLayoutStackModules[0].coordinator, sidebarCoordinator.stackCoordinators.first)
self.assertCoordinatorsEqual(self.navigationSplitCoordinator.compactLayoutStackModules[1].coordinator, detailCoordinator.rootCoordinator)
self.assertCoordinatorsEqual(self.navigationSplitCoordinator.compactLayoutStackModules[2].coordinator, detailCoordinator.stackCoordinators.first)
self.assertCoordinatorsEqual(self.navigationSplitCoordinator.compactLayoutStackModules[3].coordinator, detailCoordinator.stackCoordinators.last)
expectation.fulfill()
await waitForConfirmation("Coordinators should match", timeout: .seconds(1)) { confirm in
DispatchQueue.main.async {
assertCoordinatorsEqual(navigationSplitCoordinator.compactLayoutRootCoordinator, sidebarCoordinator.rootCoordinator)
assertCoordinatorsEqual(navigationSplitCoordinator.compactLayoutStackModules[0].coordinator, sidebarCoordinator.stackCoordinators.first)
assertCoordinatorsEqual(navigationSplitCoordinator.compactLayoutStackModules[1].coordinator, detailCoordinator.rootCoordinator)
assertCoordinatorsEqual(navigationSplitCoordinator.compactLayoutStackModules[2].coordinator, detailCoordinator.stackCoordinators.first)
assertCoordinatorsEqual(navigationSplitCoordinator.compactLayoutStackModules[3].coordinator, detailCoordinator.stackCoordinators.last)
confirm()
}
}
waitForExpectations(timeout: 1.0)
}
func testRemovesDetailRootFromCompactStack() {
@Test
func removesDetailRootFromCompactStack() async {
let sidebarCoordinator = NavigationStackCoordinator()
sidebarCoordinator.setRootCoordinator(SomeTestCoordinator())
@@ -265,25 +283,26 @@ class NavigationSplitCoordinatorTests: XCTestCase {
navigationSplitCoordinator.setSidebarCoordinator(sidebarCoordinator)
navigationSplitCoordinator.setDetailCoordinator(detailCoordinator)
let expectation = expectation(description: "Coordinators should match")
DispatchQueue.main.async {
self.assertCoordinatorsEqual(self.navigationSplitCoordinator.compactLayoutRootCoordinator, sidebarCoordinator.rootCoordinator)
self.assertCoordinatorsEqual(self.navigationSplitCoordinator.compactLayoutStackModules[0].coordinator, detailCoordinator.rootCoordinator)
self.assertCoordinatorsEqual(self.navigationSplitCoordinator.compactLayoutStackModules[1].coordinator, detailCoordinator.stackCoordinators.first)
detailCoordinator.setRootCoordinator(nil)
await waitForConfirmation("Coordinators should match", timeout: .seconds(1)) { confirm in
DispatchQueue.main.async {
self.assertCoordinatorsEqual(self.navigationSplitCoordinator.compactLayoutRootCoordinator, sidebarCoordinator.rootCoordinator)
self.assertCoordinatorsEqual(self.navigationSplitCoordinator.compactLayoutStackModules[0].coordinator, detailCoordinator.stackCoordinators.first)
assertCoordinatorsEqual(navigationSplitCoordinator.compactLayoutRootCoordinator, sidebarCoordinator.rootCoordinator)
assertCoordinatorsEqual(navigationSplitCoordinator.compactLayoutStackModules[0].coordinator, detailCoordinator.rootCoordinator)
assertCoordinatorsEqual(navigationSplitCoordinator.compactLayoutStackModules[1].coordinator, detailCoordinator.stackCoordinators.first)
detailCoordinator.setRootCoordinator(nil)
DispatchQueue.main.async {
assertCoordinatorsEqual(navigationSplitCoordinator.compactLayoutRootCoordinator, sidebarCoordinator.rootCoordinator)
assertCoordinatorsEqual(navigationSplitCoordinator.compactLayoutStackModules[0].coordinator, detailCoordinator.stackCoordinators.first)
}
confirm()
}
expectation.fulfill()
}
waitForExpectations(timeout: 1.0)
}
func testSetRootDetailToNilAfterPoppingToRoot() {
@Test
mutating func setRootDetailToNilAfterPoppingToRoot() async {
navigationSplitCoordinator = NavigationSplitCoordinator(placeholderCoordinator: SomeTestCoordinator())
let sidebarCoordinator = NavigationStackCoordinator()
sidebarCoordinator.setRootCoordinator(SomeTestCoordinator())
@@ -295,35 +314,36 @@ class NavigationSplitCoordinatorTests: XCTestCase {
navigationSplitCoordinator.setSidebarCoordinator(sidebarCoordinator)
navigationSplitCoordinator.setDetailCoordinator(detailCoordinator)
let expectation = expectation(description: "Details coordinator should be nil, and the compact layout revert to the sidebar root")
DispatchQueue.main.async {
detailCoordinator.popToRoot(animated: true)
self.navigationSplitCoordinator.setDetailCoordinator(nil)
await waitForConfirmation("Details coordinator should be nil, and the compact layout revert to the sidebar root",
timeout: .seconds(1)) { [navigationSplitCoordinator] confirm in
DispatchQueue.main.async {
XCTAssertNil(self.navigationSplitCoordinator.detailCoordinator)
self.assertCoordinatorsEqual(self.navigationSplitCoordinator.compactLayoutRootCoordinator, sidebarCoordinator.rootCoordinator)
XCTAssertTrue(self.navigationSplitCoordinator.compactLayoutStackModules.isEmpty)
expectation.fulfill()
detailCoordinator.popToRoot(animated: true)
navigationSplitCoordinator.setDetailCoordinator(nil)
DispatchQueue.main.async {
#expect(navigationSplitCoordinator.detailCoordinator == nil)
assertCoordinatorsEqual(navigationSplitCoordinator.compactLayoutRootCoordinator, sidebarCoordinator.rootCoordinator)
#expect(navigationSplitCoordinator.compactLayoutStackModules.isEmpty)
confirm()
}
}
}
waitForExpectations(timeout: 1.0)
}
}
// MARK: - Private
private func assertCoordinatorsEqual(_ lhs: CoordinatorProtocol?, _ rhs: CoordinatorProtocol?) {
if lhs == nil, rhs == nil {
return
}
// MARK: - Private
private func assertCoordinatorsEqual(_ lhs: CoordinatorProtocol?, _ rhs: CoordinatorProtocol?) {
if lhs == nil, rhs == nil {
return
}
guard let lhs = lhs as? SomeTestCoordinator,
let rhs = rhs as? SomeTestCoordinator else {
XCTFail("Coordinators are not the same: \(String(describing: lhs)) != \(String(describing: rhs))")
return
}
XCTAssertEqual(lhs.id, rhs.id)
guard let lhs = lhs as? SomeTestCoordinator,
let rhs = rhs as? SomeTestCoordinator else {
Issue.record("Coordinators are not the same: \(String(describing: lhs)) != \(String(describing: rhs))")
return
}
#expect(lhs.id == rhs.id)
}
private class SomeTestCoordinator: CoordinatorProtocol {

View File

@@ -9,10 +9,11 @@
import Combine
@testable import ElementX
import NotificationCenter
import XCTest
import Testing
@Suite
@MainActor
final class NotificationManagerTests: XCTestCase {
final class NotificationManagerTests {
var notificationManager: NotificationManager!
private let clientProxy = ClientProxyMock(.init(userID: "@test:user.net"))
private lazy var mockUserSession = UserSessionMock(.init(clientProxy: clientProxy))
@@ -27,7 +28,7 @@ final class NotificationManagerTests: XCTestCase {
ServiceLocator.shared.settings
}
override func setUp() {
init() {
AppSettings.resetAllSettings()
notificationCenter = UserNotificationCenterMock()
notificationCenter.requestAuthorizationOptionsReturnValue = true
@@ -39,80 +40,88 @@ final class NotificationManagerTests: XCTestCase {
notificationManager.setUserSession(mockUserSession)
}
override func tearDown() {
deinit {
notificationCenter = nil
notificationManager = nil
}
func test_whenRegistered_pusherIsCalled() async {
@Test
func whenRegistered_pusherIsCalled() async {
_ = await notificationManager.register(with: Data())
XCTAssertTrue(clientProxy.setPusherWithCalled)
#expect(clientProxy.setPusherWithCalled)
}
func test_whenRegisteredSuccess_completionSuccessIsCalled() async {
@Test
func whenRegisteredSuccess_completionSuccessIsCalled() async {
let success = await notificationManager.register(with: Data())
XCTAssertTrue(success)
#expect(success)
}
func test_whenRegisteredAndPusherThrowsError_completionFalseIsCalled() async {
@Test
func whenRegisteredAndPusherThrowsError_completionFalseIsCalled() async {
enum TestError: Error {
case someError
}
clientProxy.setPusherWithThrowableError = TestError.someError
let success = await notificationManager.register(with: Data())
XCTAssertFalse(success)
#expect(!success)
}
func test_whenRegistered_pusherIsCalledWithCorrectValues() async throws {
@Test
func whenRegistered_pusherIsCalledWithCorrectValues() async throws {
let pushkeyData = Data("1234".utf8)
_ = await notificationManager.register(with: pushkeyData)
guard let configuration = clientProxy.setPusherWithReceivedInvocations.first else {
XCTFail("Invalid pusher configuration sent")
Issue.record("Invalid pusher configuration sent")
return
}
XCTAssertEqual(configuration.identifiers.pushkey, pushkeyData.base64EncodedString())
XCTAssertEqual(configuration.identifiers.appId, appSettings.pusherAppID)
XCTAssertEqual(configuration.appDisplayName, "\(InfoPlistReader.main.bundleDisplayName) (iOS)")
XCTAssertEqual(configuration.deviceDisplayName, UIDevice.current.name)
XCTAssertNotNil(configuration.profileTag)
XCTAssertEqual(configuration.lang, Bundle.app.preferredLocalizations.first)
#expect(configuration.identifiers.pushkey == pushkeyData.base64EncodedString())
#expect(configuration.identifiers.appId == appSettings.pusherAppID)
#expect(configuration.appDisplayName == "\(InfoPlistReader.main.bundleDisplayName) (iOS)")
#expect(configuration.deviceDisplayName == UIDevice.current.name)
#expect(configuration.profileTag != nil)
#expect(configuration.lang == Bundle.app.preferredLocalizations.first)
guard case let .http(data) = configuration.kind else {
XCTFail("Http kind expected")
Issue.record("Http kind expected")
return
}
XCTAssertEqual(data.url, appSettings.pushGatewayNotifyEndpoint.absoluteString)
XCTAssertEqual(data.format, .eventIdOnly)
#expect(data.url == appSettings.pushGatewayNotifyEndpoint.absoluteString)
#expect(data.format == .eventIdOnly)
let defaultPayload = APNSPayload(aps: APSInfo(mutableContent: 1,
alert: APSAlert(locKey: "Notification",
locArgs: [])),
pusherNotificationClientIdentifier: nil)
XCTAssertEqual(data.defaultPayload, try defaultPayload.toJsonString())
#expect(try data.defaultPayload == (defaultPayload.toJsonString()))
}
func test_whenRegisteredAndPusherTagNotSetInSettings_tagGeneratedAndSavedInSettings() async {
@Test
func whenRegisteredAndPusherTagNotSetInSettings_tagGeneratedAndSavedInSettings() async {
appSettings.pusherProfileTag = nil
_ = await notificationManager.register(with: Data())
XCTAssertNotNil(appSettings.pusherProfileTag)
#expect(appSettings.pusherProfileTag != nil)
}
func test_whenRegisteredAndPusherTagIsSetInSettings_tagNotGenerated() async {
@Test
func whenRegisteredAndPusherTagIsSetInSettings_tagNotGenerated() async {
appSettings.pusherProfileTag = "12345"
_ = await notificationManager.register(with: Data())
XCTAssertEqual(appSettings.pusherProfileTag, "12345")
#expect(appSettings.pusherProfileTag == "12345")
}
func test_whenShowLocalNotification_notificationRequestGetsAdded() async throws {
@Test
func whenShowLocalNotification_notificationRequestGetsAdded() async throws {
await notificationManager.showLocalNotification(with: "Title", subtitle: "Subtitle")
let request = try XCTUnwrap(notificationCenter.addReceivedRequest)
XCTAssertEqual(request.content.title, "Title")
XCTAssertEqual(request.content.subtitle, "Subtitle")
let request = try #require(notificationCenter.addReceivedRequest)
#expect(request.content.title == "Title")
#expect(request.content.subtitle == "Subtitle")
}
func test_whenStart_notificationCategoriesAreSet() {
@Test
func whenStart_notificationCategoriesAreSet() {
let replyAction = UNTextInputNotificationAction(identifier: NotificationConstants.Action.inlineReply,
title: L10n.actionQuickReply,
options: [])
@@ -125,39 +134,42 @@ final class NotificationManagerTests: XCTestCase {
actions: [],
intentIdentifiers: [],
options: [])
XCTAssertEqual(notificationCenter.setNotificationCategoriesReceivedCategories, [messageCategory, inviteCategory])
#expect(notificationCenter.setNotificationCategoriesReceivedCategories == [messageCategory, inviteCategory])
}
func test_whenStart_delegateIsSet() throws {
let delegate = try XCTUnwrap(notificationCenter.delegate)
XCTAssertTrue(delegate.isEqual(notificationManager))
@Test
func whenStart_delegateIsSet() throws {
let delegate = try #require(notificationCenter.delegate)
#expect(delegate.isEqual(notificationManager))
}
func test_whenStart_requestAuthorizationCalledWithCorrectParams() async {
let expectation = expectation(description: "requestAuthorization should be called")
notificationCenter.requestAuthorizationOptionsClosure = { _ in
expectation.fulfill()
return true
@Test
func whenStart_requestAuthorizationCalledWithCorrectParams() async {
await waitForConfirmation("requestAuthorization should be called", timeout: .seconds(10)) { confirm in
notificationCenter.requestAuthorizationOptionsClosure = { _ in
confirm()
return true
}
notificationManager.requestAuthorization()
}
notificationManager.requestAuthorization()
await fulfillment(of: [expectation])
XCTAssertEqual(notificationCenter.requestAuthorizationOptionsReceivedOptions, [.alert, .sound, .badge])
#expect(notificationCenter.requestAuthorizationOptionsReceivedOptions == [.alert, .sound, .badge])
}
func test_whenStartAndAuthorizationGranted_delegateCalled() async {
@Test
func whenStartAndAuthorizationGranted_delegateCalled() async {
authorizationStatusWasGranted = false
notificationManager.delegate = self
let expectation: XCTestExpectation = expectation(description: "registerForRemoteNotifications delegate function should be called")
expectation.assertForOverFulfill = false
registerForRemoteNotificationsDelegateCalled = {
expectation.fulfill()
await waitForConfirmation("registerForRemoteNotifications delegate function should be called", timeout: .seconds(10)) { confirm in
registerForRemoteNotificationsDelegateCalled = {
confirm()
}
notificationManager.requestAuthorization()
}
notificationManager.requestAuthorization()
await fulfillment(of: [expectation])
XCTAssertTrue(authorizationStatusWasGranted)
#expect(authorizationStatusWasGranted)
}
func test_whenStartAndAuthorizedAndNotificationDisabled_registerForRemoteNotificationsNotCalled() async throws {
@Test
func whenStartAndAuthorizedAndNotificationDisabled_registerForRemoteNotificationsNotCalled() async throws {
appSettings.enableNotifications = false
notificationCenter.authorizationStatusReturnValue = .authorized
notificationManager.delegate = self
@@ -165,65 +177,69 @@ final class NotificationManagerTests: XCTestCase {
notificationManager.setUserSession(UserSessionMock(.init()))
try await Task.sleep(for: .seconds(1))
XCTAssertFalse(authorizationStatusWasGranted)
#expect(!authorizationStatusWasGranted)
}
func test_whenStartAndAuthorized_registerForRemoteNotificationsCalled() async {
@Test
func whenStartAndAuthorized_registerForRemoteNotificationsCalled() async {
appSettings.enableNotifications = true
notificationCenter.authorizationStatusReturnValue = .authorized
notificationManager.delegate = self
let expectation: XCTestExpectation = expectation(description: "registerForRemoteNotifications delegate function should be called")
expectation.assertForOverFulfill = false
registerForRemoteNotificationsDelegateCalled = {
expectation.fulfill()
await waitForConfirmation("registerForRemoteNotifications delegate function should be called", timeout: .seconds(10)) { confirm in
registerForRemoteNotificationsDelegateCalled = {
confirm()
}
notificationManager.setUserSession(UserSessionMock(.init()))
}
notificationManager.setUserSession(UserSessionMock(.init()))
await fulfillment(of: [expectation])
XCTAssertTrue(authorizationStatusWasGranted)
#expect(authorizationStatusWasGranted)
}
func test_whenWillPresentNotificationsDelegateNotSet_CorrectPresentationOptionsReturned() async throws {
@Test
func whenWillPresentNotificationsDelegateNotSet_CorrectPresentationOptionsReturned() async throws {
let archiver = MockCoder(requiringSecureCoding: false)
let notification = try XCTUnwrap(UNNotification(coder: archiver))
let notification = try #require(UNNotification(coder: archiver))
let options = await notificationManager.userNotificationCenter(UNUserNotificationCenter.current(), willPresent: notification)
XCTAssertEqual(options, [.badge, .sound, .list, .banner])
#expect(options == [.badge, .sound, .list, .banner])
}
func test_whenWillPresentNotificationsDelegateSetAndNotificationsShoudNotBeDisplayed_CorrectPresentationOptionsReturned() async throws {
@Test
func whenWillPresentNotificationsDelegateSetAndNotificationsShoudNotBeDisplayed_CorrectPresentationOptionsReturned() async throws {
shouldDisplayInAppNotificationReturnValue = false
notificationManager.delegate = self
let notification = try UNNotification.with(userInfo: [AnyHashable: Any]())
let options = await notificationManager.userNotificationCenter(UNUserNotificationCenter.current(), willPresent: notification)
XCTAssertEqual(options, [])
#expect(options == [])
}
func test_whenWillPresentNotificationsDelegateSetAndNotificationsShoudBeDisplayed_CorrectPresentationOptionsReturned() async throws {
@Test
func whenWillPresentNotificationsDelegateSetAndNotificationsShoudBeDisplayed_CorrectPresentationOptionsReturned() async throws {
shouldDisplayInAppNotificationReturnValue = true
notificationManager.delegate = self
let notification = try UNNotification.with(userInfo: [AnyHashable: Any]())
let options = await notificationManager.userNotificationCenter(UNUserNotificationCenter.current(), willPresent: notification)
XCTAssertEqual(options, [.badge, .sound, .list, .banner])
#expect(options == [.badge, .sound, .list, .banner])
}
func test_whenNotificationCenterReceivedResponseInLineReply_delegateIsCalled() async throws {
@Test
func whenNotificationCenterReceivedResponseInLineReply_delegateIsCalled() async throws {
handleInlineReplyDelegateCalled = false
notificationManager.delegate = self
let response = try UNTextInputNotificationResponse.with(userInfo: [AnyHashable: Any](), actionIdentifier: NotificationConstants.Action.inlineReply)
await notificationManager.userNotificationCenter(UNUserNotificationCenter.current(), didReceive: response)
XCTAssertTrue(handleInlineReplyDelegateCalled)
#expect(handleInlineReplyDelegateCalled)
}
func test_whenNotificationCenterReceivedResponseWithActionIdentifier_delegateIsCalled() async throws {
@Test
func whenNotificationCenterReceivedResponseWithActionIdentifier_delegateIsCalled() async throws {
notificationTappedDelegateCalled = false
notificationManager.delegate = self
let response = try UNTextInputNotificationResponse.with(userInfo: [AnyHashable: Any](), actionIdentifier: UNNotificationDefaultActionIdentifier)
await notificationManager.userNotificationCenter(UNUserNotificationCenter.current(), didReceive: response)
XCTAssertTrue(notificationTappedDelegateCalled)
#expect(notificationTappedDelegateCalled)
}
}

View File

@@ -8,10 +8,11 @@
@testable import ElementX
import MatrixRustSDK
import XCTest
import Testing
@Suite
@MainActor
class NotificationSettingsEditScreenViewModelTests: XCTestCase {
struct NotificationSettingsEditScreenViewModelTests {
private var viewModel: NotificationSettingsEditScreenViewModelProtocol!
private var notificationSettingsProxy: NotificationSettingsProxyMock!
private var userSession: UserSessionMock!
@@ -21,7 +22,7 @@ class NotificationSettingsEditScreenViewModelTests: XCTestCase {
viewModel.context
}
@MainActor override func setUpWithError() throws {
init() throws {
notificationSettingsProxy = NotificationSettingsProxyMock(with: NotificationSettingsProxyMockConfiguration())
notificationSettingsProxy.getDefaultRoomNotificationModeIsEncryptedIsOneToOneReturnValue = .allMessages
@@ -29,7 +30,8 @@ class NotificationSettingsEditScreenViewModelTests: XCTestCase {
userSession = UserSessionMock(.init(clientProxy: clientProxy))
}
func testFetchSettings() async throws {
@Test
mutating func fetchSettings() async throws {
notificationSettingsProxy.getDefaultRoomNotificationModeIsEncryptedIsOneToOneClosure = { isEncrypted, isOneToOne in
switch (isEncrypted, isOneToOne) {
case (_, true):
@@ -49,21 +51,22 @@ class NotificationSettingsEditScreenViewModelTests: XCTestCase {
// `getDefaultRoomNotificationModeIsEncryptedIsOneToOne` must have been called twice (for encrypted and unencrypted group chats)
let invocations = notificationSettingsProxy.getDefaultRoomNotificationModeIsEncryptedIsOneToOneReceivedInvocations
XCTAssertEqual(invocations.count, 2)
#expect(invocations.count == 2)
// First call for encrypted group chats
XCTAssertEqual(invocations[0].isEncrypted, true)
XCTAssertEqual(invocations[0].isOneToOne, false)
#expect(invocations[0].isEncrypted == true)
#expect(invocations[0].isOneToOne == false)
// Second call for unencrypted group chats
XCTAssertEqual(invocations[1].isEncrypted, false)
XCTAssertEqual(invocations[1].isOneToOne, false)
#expect(invocations[1].isEncrypted == false)
#expect(invocations[1].isOneToOne == false)
XCTAssertEqual(context.viewState.defaultMode, .mentionsAndKeywordsOnly)
XCTAssertNil(context.viewState.bindings.alertInfo)
XCTAssertFalse(context.viewState.canPushEncryptedEvents)
XCTAssertNotNil(context.viewState.description(for: .mentionsAndKeywordsOnly))
#expect(context.viewState.defaultMode == .mentionsAndKeywordsOnly)
#expect(context.viewState.bindings.alertInfo == nil)
#expect(!context.viewState.canPushEncryptedEvents)
#expect(context.viewState.description(for: .mentionsAndKeywordsOnly) != nil)
}
func testFetchSettingsWithCanPushEncryptedEvents() async throws {
@Test
mutating func fetchSettingsWithCanPushEncryptedEvents() async throws {
notificationSettingsProxy.getDefaultRoomNotificationModeIsEncryptedIsOneToOneClosure = { isEncrypted, isOneToOne in
switch (isEncrypted, isOneToOne) {
case (_, true):
@@ -86,21 +89,22 @@ class NotificationSettingsEditScreenViewModelTests: XCTestCase {
// `getDefaultRoomNotificationModeIsEncryptedIsOneToOne` must have been called twice (for encrypted and unencrypted group chats)
let invocations = notificationSettingsProxy.getDefaultRoomNotificationModeIsEncryptedIsOneToOneReceivedInvocations
XCTAssertEqual(invocations.count, 2)
#expect(invocations.count == 2)
// First call for encrypted group chats
XCTAssertEqual(invocations[0].isEncrypted, true)
XCTAssertEqual(invocations[0].isOneToOne, false)
#expect(invocations[0].isEncrypted == true)
#expect(invocations[0].isOneToOne == false)
// Second call for unencrypted group chats
XCTAssertEqual(invocations[1].isEncrypted, false)
XCTAssertEqual(invocations[1].isOneToOne, false)
#expect(invocations[1].isEncrypted == false)
#expect(invocations[1].isOneToOne == false)
XCTAssertEqual(context.viewState.defaultMode, .mentionsAndKeywordsOnly)
XCTAssertNil(context.viewState.bindings.alertInfo)
XCTAssertTrue(context.viewState.canPushEncryptedEvents)
XCTAssertNil(context.viewState.description(for: .mentionsAndKeywordsOnly))
#expect(context.viewState.defaultMode == .mentionsAndKeywordsOnly)
#expect(context.viewState.bindings.alertInfo == nil)
#expect(context.viewState.canPushEncryptedEvents)
#expect(context.viewState.description(for: .mentionsAndKeywordsOnly) == nil)
}
func testSetModeAllMessages() async throws {
@Test
mutating func setModeAllMessages() async throws {
notificationSettingsProxy.getDefaultRoomNotificationModeIsEncryptedIsOneToOneReturnValue = .mentionsAndKeywordsOnly
viewModel = NotificationSettingsEditScreenViewModel(chatType: .groupChat, userSession: userSession)
let deferred = deferFulfillment(viewModel.context.observe(\.viewState.defaultMode)) { $0 != nil }
@@ -118,26 +122,27 @@ class NotificationSettingsEditScreenViewModelTests: XCTestCase {
// `setDefaultRoomNotificationModeIsEncryptedIsOneToOneMode` must have been called twice (for encrypted and unencrypted group chats)
let invocations = notificationSettingsProxy.setDefaultRoomNotificationModeIsEncryptedIsOneToOneModeReceivedInvocations
XCTAssertEqual(notificationSettingsProxy.setDefaultRoomNotificationModeIsEncryptedIsOneToOneModeCallsCount, 2)
#expect(notificationSettingsProxy.setDefaultRoomNotificationModeIsEncryptedIsOneToOneModeCallsCount == 2)
// First call for encrypted group chats
XCTAssertEqual(invocations[0].isEncrypted, true)
XCTAssertEqual(invocations[0].isOneToOne, false)
XCTAssertEqual(invocations[0].mode, .allMessages)
#expect(invocations[0].isEncrypted == true)
#expect(invocations[0].isOneToOne == false)
#expect(invocations[0].mode == .allMessages)
// Second call for unencrypted group chats
XCTAssertEqual(invocations[1].isEncrypted, false)
XCTAssertEqual(invocations[1].isOneToOne, false)
XCTAssertEqual(invocations[1].mode, .allMessages)
#expect(invocations[1].isEncrypted == false)
#expect(invocations[1].isOneToOne == false)
#expect(invocations[1].mode == .allMessages)
deferredViewState = deferFulfillment(viewModel.context.observe(\.viewState.defaultMode),
transitionValues: [.allMessages])
try await deferredViewState.fulfill()
XCTAssertEqual(context.viewState.defaultMode, .allMessages)
XCTAssertNil(context.viewState.bindings.alertInfo)
#expect(context.viewState.defaultMode == .allMessages)
#expect(context.viewState.bindings.alertInfo == nil)
}
func testSetModeMentions() async throws {
@Test
mutating func setModeMentions() async throws {
viewModel = NotificationSettingsEditScreenViewModel(chatType: .groupChat, userSession: userSession)
let deferred = deferFulfillment(viewModel.context.observe(\.viewState.defaultMode)) { $0 != nil }
@@ -155,26 +160,27 @@ class NotificationSettingsEditScreenViewModelTests: XCTestCase {
// `setDefaultRoomNotificationModeIsEncryptedIsOneToOneMode` must have been called twice (for encrypted and unencrypted group chats)
let invocations = notificationSettingsProxy.setDefaultRoomNotificationModeIsEncryptedIsOneToOneModeReceivedInvocations
XCTAssertEqual(notificationSettingsProxy.setDefaultRoomNotificationModeIsEncryptedIsOneToOneModeCallsCount, 2)
#expect(notificationSettingsProxy.setDefaultRoomNotificationModeIsEncryptedIsOneToOneModeCallsCount == 2)
// First call for encrypted group chats
XCTAssertEqual(invocations[0].isEncrypted, true)
XCTAssertEqual(invocations[0].isOneToOne, false)
XCTAssertEqual(invocations[0].mode, .mentionsAndKeywordsOnly)
#expect(invocations[0].isEncrypted == true)
#expect(invocations[0].isOneToOne == false)
#expect(invocations[0].mode == .mentionsAndKeywordsOnly)
// Second call for unencrypted group chats
XCTAssertEqual(invocations[1].isEncrypted, false)
XCTAssertEqual(invocations[1].isOneToOne, false)
XCTAssertEqual(invocations[1].mode, .mentionsAndKeywordsOnly)
#expect(invocations[1].isEncrypted == false)
#expect(invocations[1].isOneToOne == false)
#expect(invocations[1].mode == .mentionsAndKeywordsOnly)
deferredViewState = deferFulfillment(viewModel.context.observe(\.viewState.defaultMode),
transitionValues: [.mentionsAndKeywordsOnly])
try await deferredViewState.fulfill()
XCTAssertEqual(context.viewState.defaultMode, .mentionsAndKeywordsOnly)
XCTAssertNil(context.viewState.bindings.alertInfo)
#expect(context.viewState.defaultMode == .mentionsAndKeywordsOnly)
#expect(context.viewState.bindings.alertInfo == nil)
}
func testSetModeDirectChats() async throws {
@Test
mutating func setModeDirectChats() async throws {
notificationSettingsProxy.getDefaultRoomNotificationModeIsEncryptedIsOneToOneReturnValue = .mentionsAndKeywordsOnly
// Initialize for direct chats
viewModel = NotificationSettingsEditScreenViewModel(chatType: .oneToOneChat, userSession: userSession)
@@ -194,18 +200,19 @@ class NotificationSettingsEditScreenViewModelTests: XCTestCase {
// `setDefaultRoomNotificationModeIsEncryptedIsOneToOneMode` must have been called twice (for encrypted and unencrypted direct chats)
let invocations = notificationSettingsProxy.setDefaultRoomNotificationModeIsEncryptedIsOneToOneModeReceivedInvocations
XCTAssertEqual(notificationSettingsProxy.setDefaultRoomNotificationModeIsEncryptedIsOneToOneModeCallsCount, 2)
#expect(notificationSettingsProxy.setDefaultRoomNotificationModeIsEncryptedIsOneToOneModeCallsCount == 2)
// First call for encrypted direct chats
XCTAssertEqual(invocations[0].isEncrypted, true)
XCTAssertEqual(invocations[0].isOneToOne, true)
XCTAssertEqual(invocations[0].mode, .allMessages)
#expect(invocations[0].isEncrypted == true)
#expect(invocations[0].isOneToOne == true)
#expect(invocations[0].mode == .allMessages)
// Second call for unencrypted direct chats
XCTAssertEqual(invocations[1].isEncrypted, false)
XCTAssertEqual(invocations[1].isOneToOne, true)
XCTAssertEqual(invocations[1].mode, .allMessages)
#expect(invocations[1].isEncrypted == false)
#expect(invocations[1].isOneToOne == true)
#expect(invocations[1].mode == .allMessages)
}
func testSetModeFailure() async throws {
@Test
mutating func setModeFailure() async throws {
notificationSettingsProxy.getDefaultRoomNotificationModeIsEncryptedIsOneToOneReturnValue = .mentionsAndKeywordsOnly
notificationSettingsProxy.setDefaultRoomNotificationModeIsEncryptedIsOneToOneModeThrowableError = NotificationSettingsError.Generic(msg: "error")
viewModel = NotificationSettingsEditScreenViewModel(chatType: .oneToOneChat, userSession: userSession)
@@ -223,10 +230,11 @@ class NotificationSettingsEditScreenViewModelTests: XCTestCase {
try await deferredViewState.fulfill()
XCTAssertNotNil(context.viewState.bindings.alertInfo)
#expect(context.viewState.bindings.alertInfo != nil)
}
func testSelectRoom() async throws {
@Test
mutating func selectRoom() async throws {
let roomID = "!roomidentifier:matrix.org"
viewModel = NotificationSettingsEditScreenViewModel(chatType: .oneToOneChat, userSession: userSession)
@@ -243,7 +251,7 @@ class NotificationSettingsEditScreenViewModelTests: XCTestCase {
let expectedAction = NotificationSettingsEditScreenViewModelAction.requestRoomNotificationSettingsPresentation(roomID: roomID)
guard case let .requestRoomNotificationSettingsPresentation(roomID: receivedRoomID) = sentAction, receivedRoomID == roomID else {
XCTFail("Expected action \(expectedAction), but was \(sentAction)")
Issue.record("Expected action \(expectedAction), but was \(sentAction)")
return
}
}

View File

@@ -8,17 +8,18 @@
@testable import ElementX
import MatrixRustSDK
import XCTest
import Testing
@Suite
@MainActor
class NotificationSettingsScreenViewModelTests: XCTestCase {
private var viewModel: NotificationSettingsScreenViewModelProtocol!
private var context: NotificationSettingsScreenViewModelType.Context!
private var appSettings: AppSettings!
private var userNotificationCenter: UserNotificationCenterMock!
private var notificationSettingsProxy: NotificationSettingsProxyMock!
struct NotificationSettingsScreenViewModelTests {
private var viewModel: NotificationSettingsScreenViewModelProtocol
private var context: NotificationSettingsScreenViewModelType.Context
private var appSettings: AppSettings
private var userNotificationCenter: UserNotificationCenterMock
private var notificationSettingsProxy: NotificationSettingsProxyMock
@MainActor override func setUpWithError() throws {
init() throws {
AppSettings.resetAllSettings()
userNotificationCenter = UserNotificationCenterMock()
@@ -36,19 +37,22 @@ class NotificationSettingsScreenViewModelTests: XCTestCase {
context = viewModel.context
}
func testEnableNotifications() {
@Test
func enableNotifications() {
appSettings.enableNotifications = false
context.send(viewAction: .changedEnableNotifications)
XCTAssertTrue(appSettings.enableNotifications)
#expect(appSettings.enableNotifications)
}
func testDisableNotifications() {
@Test
func disableNotifications() {
appSettings.enableNotifications = true
context.send(viewAction: .changedEnableNotifications)
XCTAssertFalse(appSettings.enableNotifications)
#expect(!appSettings.enableNotifications)
}
func testFetchSettings() async throws {
@Test
func fetchSettings() async throws {
notificationSettingsProxy.getDefaultRoomNotificationModeIsEncryptedIsOneToOneClosure = { isEncrypted, isOneToOne in
switch (isEncrypted, isOneToOne) {
case (_, true):
@@ -64,17 +68,18 @@ class NotificationSettingsScreenViewModelTests: XCTestCase {
try await deferred.fulfill()
XCTAssertEqual(notificationSettingsProxy.getDefaultRoomNotificationModeIsEncryptedIsOneToOneCallsCount, 4)
XCTAssert(notificationSettingsProxy.isRoomMentionEnabledCalled)
XCTAssert(notificationSettingsProxy.isCallEnabledCalled)
#expect(notificationSettingsProxy.getDefaultRoomNotificationModeIsEncryptedIsOneToOneCallsCount == 4)
#expect(notificationSettingsProxy.isRoomMentionEnabledCalled)
#expect(notificationSettingsProxy.isCallEnabledCalled)
XCTAssertEqual(context.viewState.settings?.groupChatsMode, .mentionsAndKeywordsOnly)
XCTAssertEqual(context.viewState.settings?.directChatsMode, .allMessages)
XCTAssertEqual(context.viewState.settings?.inconsistentSettings, [])
XCTAssertNil(context.viewState.bindings.alertInfo)
#expect(context.viewState.settings?.groupChatsMode == .mentionsAndKeywordsOnly)
#expect(context.viewState.settings?.directChatsMode == .allMessages)
#expect(context.viewState.settings?.inconsistentSettings == [])
#expect(context.viewState.bindings.alertInfo == nil)
}
func testInconsistentGroupChatsSettings() async throws {
@Test
func inconsistentGroupChatsSettings() async throws {
notificationSettingsProxy.getDefaultRoomNotificationModeIsEncryptedIsOneToOneClosure = { isEncrypted, isOneToOne in
switch (isEncrypted, isOneToOne) {
case (true, false):
@@ -92,11 +97,12 @@ class NotificationSettingsScreenViewModelTests: XCTestCase {
try await deferred.fulfill()
XCTAssertEqual(context.viewState.settings?.groupChatsMode, .allMessages)
XCTAssertEqual(context.viewState.settings?.inconsistentSettings, [.init(chatType: .groupChat, isEncrypted: false)])
#expect(context.viewState.settings?.groupChatsMode == .allMessages)
#expect(context.viewState.settings?.inconsistentSettings == [.init(chatType: .groupChat, isEncrypted: false)])
}
func testInconsistentDirectChatsSettings() async throws {
@Test
func inconsistentDirectChatsSettings() async throws {
notificationSettingsProxy.getDefaultRoomNotificationModeIsEncryptedIsOneToOneClosure = { isEncrypted, isOneToOne in
switch (isEncrypted, isOneToOne) {
case (true, true):
@@ -114,11 +120,12 @@ class NotificationSettingsScreenViewModelTests: XCTestCase {
try await deferred.fulfill()
XCTAssertEqual(context.viewState.settings?.directChatsMode, .allMessages)
XCTAssertEqual(context.viewState.settings?.inconsistentSettings, [.init(chatType: .oneToOneChat, isEncrypted: false)])
#expect(context.viewState.settings?.directChatsMode == .allMessages)
#expect(context.viewState.settings?.inconsistentSettings == [.init(chatType: .oneToOneChat, isEncrypted: false)])
}
func testFixInconsistentSettings() async throws {
@Test
func fixInconsistentSettings() async throws {
// Initialize with a configuration mismatch where encrypted one-to-one chats is `.allMessages` and unencrypted one-to-one chats is `.mentionsAndKeywordsOnly`
notificationSettingsProxy.getDefaultRoomNotificationModeIsEncryptedIsOneToOneClosure = { isEncrypted, isOneToOne in
switch (isEncrypted, isOneToOne) {
@@ -137,8 +144,8 @@ class NotificationSettingsScreenViewModelTests: XCTestCase {
try await deferredSettings.fulfill()
XCTAssertEqual(context.viewState.settings?.directChatsMode, .allMessages)
XCTAssertEqual(context.viewState.settings?.inconsistentSettings, [.init(chatType: .oneToOneChat, isEncrypted: false)])
#expect(context.viewState.settings?.directChatsMode == .allMessages)
#expect(context.viewState.settings?.inconsistentSettings == [.init(chatType: .oneToOneChat, isEncrypted: false)])
let deferredMismatch = deferFulfillment(viewModel.context.observe(\.viewState.fixingConfigurationMismatch),
transitionValues: [false, true, false])
@@ -148,14 +155,15 @@ class NotificationSettingsScreenViewModelTests: XCTestCase {
try await deferredMismatch.fulfill()
// Ensure we only fix the invalid setting: unencrypted one-to-one chats should be set to `.allMessages` (to match encrypted one-to-one chats)
XCTAssertEqual(notificationSettingsProxy.setDefaultRoomNotificationModeIsEncryptedIsOneToOneModeCallsCount, 1)
#expect(notificationSettingsProxy.setDefaultRoomNotificationModeIsEncryptedIsOneToOneModeCallsCount == 1)
let callArguments = notificationSettingsProxy.setDefaultRoomNotificationModeIsEncryptedIsOneToOneModeReceivedArguments
XCTAssertEqual(callArguments?.isEncrypted, false)
XCTAssertEqual(callArguments?.isOneToOne, true)
XCTAssertEqual(callArguments?.mode, .allMessages)
#expect(callArguments?.isEncrypted == false)
#expect(callArguments?.isOneToOne == true)
#expect(callArguments?.mode == .allMessages)
}
func testFixAllInconsistentSettings() async throws {
@Test
func fixAllInconsistentSettings() async throws {
// Initialize with a configuration mismatch where
// - encrypted one-to-one chats is `.allMessages` and unencrypted one-to-one chats is `.mentionsAndKeywordsOnly`
// - encrypted group chats is `.allMessages` and unencrypted group chats is `.mentionsAndKeywordsOnly`
@@ -174,8 +182,8 @@ class NotificationSettingsScreenViewModelTests: XCTestCase {
try await deferredSettings.fulfill()
XCTAssertEqual(context.viewState.settings?.directChatsMode, .allMessages)
XCTAssertEqual(context.viewState.settings?.inconsistentSettings, [.init(chatType: .groupChat, isEncrypted: false), .init(chatType: .oneToOneChat, isEncrypted: false)])
#expect(context.viewState.settings?.directChatsMode == .allMessages)
#expect(context.viewState.settings?.inconsistentSettings == [.init(chatType: .groupChat, isEncrypted: false), .init(chatType: .oneToOneChat, isEncrypted: false)])
var deferredMismatch = deferFulfillment(viewModel.context.observe(\.viewState.fixingConfigurationMismatch)) { $0 }
@@ -188,19 +196,20 @@ class NotificationSettingsScreenViewModelTests: XCTestCase {
try await deferredMismatch.fulfill()
// All problems should be fixed
XCTAssertEqual(notificationSettingsProxy.setDefaultRoomNotificationModeIsEncryptedIsOneToOneModeCallsCount, 2)
#expect(notificationSettingsProxy.setDefaultRoomNotificationModeIsEncryptedIsOneToOneModeCallsCount == 2)
let callArguments = notificationSettingsProxy.setDefaultRoomNotificationModeIsEncryptedIsOneToOneModeReceivedInvocations
// Ensure we fix the invalid unencrypted group chats setting (it should be set to `.allMessages` to match encrypted group chats)
XCTAssertEqual(callArguments[0].isEncrypted, false)
XCTAssertEqual(callArguments[0].isOneToOne, false)
XCTAssertEqual(callArguments[0].mode, .allMessages)
#expect(callArguments[0].isEncrypted == false)
#expect(callArguments[0].isOneToOne == false)
#expect(callArguments[0].mode == .allMessages)
// Ensure we fix the invalid unencrypted one-to-one chats setting (it should be set to `.allMessages` to match encrypted one-to-one chats)
XCTAssertEqual(callArguments[1].isEncrypted, false)
XCTAssertEqual(callArguments[1].isOneToOne, true)
XCTAssertEqual(callArguments[1].mode, .allMessages)
#expect(callArguments[1].isEncrypted == false)
#expect(callArguments[1].isOneToOne == true)
#expect(callArguments[1].mode == .allMessages)
}
func testToggleRoomMentionOff() async throws {
@Test
func toggleRoomMentionOff() async throws {
notificationSettingsProxy.isRoomMentionEnabledReturnValue = true
let deferredState = deferFulfillment(viewModel.context.observe(\.viewState.settings)) { $0 != nil }
@@ -219,11 +228,12 @@ class NotificationSettingsScreenViewModelTests: XCTestCase {
try await deferred.fulfill()
XCTAssert(notificationSettingsProxy.setRoomMentionEnabledEnabledCalled)
XCTAssertEqual(notificationSettingsProxy.setRoomMentionEnabledEnabledReceivedEnabled, false)
#expect(notificationSettingsProxy.setRoomMentionEnabledEnabledCalled)
#expect(notificationSettingsProxy.setRoomMentionEnabledEnabledReceivedEnabled == false)
}
func testToggleRoomMentionOn() async throws {
@Test
func toggleRoomMentionOn() async throws {
notificationSettingsProxy.isRoomMentionEnabledReturnValue = false
let deferredInitialFetch = deferFulfillment(viewModel.context.observe(\.viewState.settings)) { $0 != nil }
@@ -241,11 +251,12 @@ class NotificationSettingsScreenViewModelTests: XCTestCase {
try await deferred.fulfill()
XCTAssert(notificationSettingsProxy.setRoomMentionEnabledEnabledCalled)
XCTAssertEqual(notificationSettingsProxy.setRoomMentionEnabledEnabledReceivedEnabled, true)
#expect(notificationSettingsProxy.setRoomMentionEnabledEnabledCalled)
#expect(notificationSettingsProxy.setRoomMentionEnabledEnabledReceivedEnabled == true)
}
func testToggleRoomMentionFailure() async throws {
@Test
func toggleRoomMentionFailure() async throws {
notificationSettingsProxy.setRoomMentionEnabledEnabledThrowableError = NotificationSettingsError.Generic(msg: "error")
notificationSettingsProxy.isRoomMentionEnabledReturnValue = false
@@ -267,10 +278,11 @@ class NotificationSettingsScreenViewModelTests: XCTestCase {
try await deferred.fulfill()
XCTAssertNotNil(context.alertInfo)
#expect(context.alertInfo != nil)
}
func testToggleCallsOff() async throws {
@Test
func toggleCallsOff() async throws {
notificationSettingsProxy.isCallEnabledReturnValue = true
let deferredInitialFetch = deferFulfillment(viewModel.context.observe(\.viewState.settings)) { $0 != nil }
@@ -288,11 +300,12 @@ class NotificationSettingsScreenViewModelTests: XCTestCase {
try await deferred.fulfill()
XCTAssert(notificationSettingsProxy.setCallEnabledEnabledCalled)
XCTAssertEqual(notificationSettingsProxy.setCallEnabledEnabledReceivedEnabled, false)
#expect(notificationSettingsProxy.setCallEnabledEnabledCalled)
#expect(notificationSettingsProxy.setCallEnabledEnabledReceivedEnabled == false)
}
func testToggleCallsOn() async throws {
@Test
func toggleCallsOn() async throws {
notificationSettingsProxy.isCallEnabledReturnValue = false
let deferredInitialFetch = deferFulfillment(viewModel.context.observe(\.viewState.settings)) { $0 != nil }
@@ -311,11 +324,12 @@ class NotificationSettingsScreenViewModelTests: XCTestCase {
try await deferred.fulfill()
XCTAssert(notificationSettingsProxy.setCallEnabledEnabledCalled)
XCTAssertEqual(notificationSettingsProxy.setCallEnabledEnabledReceivedEnabled, true)
#expect(notificationSettingsProxy.setCallEnabledEnabledCalled)
#expect(notificationSettingsProxy.setCallEnabledEnabledReceivedEnabled == true)
}
func testToggleCallsFailure() async throws {
@Test
func toggleCallsFailure() async throws {
notificationSettingsProxy.setCallEnabledEnabledThrowableError = NotificationSettingsError.Generic(msg: "error")
notificationSettingsProxy.isCallEnabledReturnValue = false
@@ -337,10 +351,11 @@ class NotificationSettingsScreenViewModelTests: XCTestCase {
try await deferred.fulfill()
XCTAssertNotNil(context.alertInfo)
#expect(context.alertInfo != nil)
}
func testToggleInvitationsOff() async throws {
@Test
func toggleInvitationsOff() async throws {
notificationSettingsProxy.isInviteForMeEnabledReturnValue = true
let deferredInitialFetch = deferFulfillment(viewModel.context.observe(\.viewState.settings)) { $0 != nil }
@@ -358,11 +373,12 @@ class NotificationSettingsScreenViewModelTests: XCTestCase {
try await deferred.fulfill()
XCTAssert(notificationSettingsProxy.setInviteForMeEnabledEnabledCalled)
XCTAssertEqual(notificationSettingsProxy.setInviteForMeEnabledEnabledReceivedEnabled, false)
#expect(notificationSettingsProxy.setInviteForMeEnabledEnabledCalled)
#expect(notificationSettingsProxy.setInviteForMeEnabledEnabledReceivedEnabled == false)
}
func testToggleInvitationsOn() async throws {
@Test
func toggleInvitationsOn() async throws {
notificationSettingsProxy.isInviteForMeEnabledReturnValue = false
let deferredInitialFetch = deferFulfillment(viewModel.context.observe(\.viewState.settings)) { $0 != nil }
@@ -381,11 +397,12 @@ class NotificationSettingsScreenViewModelTests: XCTestCase {
try await deferred.fulfill()
XCTAssert(notificationSettingsProxy.setInviteForMeEnabledEnabledCalled)
XCTAssertEqual(notificationSettingsProxy.setInviteForMeEnabledEnabledReceivedEnabled, true)
#expect(notificationSettingsProxy.setInviteForMeEnabledEnabledCalled)
#expect(notificationSettingsProxy.setInviteForMeEnabledEnabledReceivedEnabled == true)
}
func testToggleInvitesFailure() async throws {
@Test
func toggleInvitesFailure() async throws {
notificationSettingsProxy.setInviteForMeEnabledEnabledThrowableError = NotificationSettingsError.Generic(msg: "error")
notificationSettingsProxy.isInviteForMeEnabledReturnValue = false
@@ -407,6 +424,6 @@ class NotificationSettingsScreenViewModelTests: XCTestCase {
try await deferred.fulfill()
XCTAssertNotNil(context.alertInfo)
#expect(context.alertInfo != nil)
}
}

View File

@@ -8,10 +8,11 @@
@testable import ElementX
import MatrixRustSDK
import XCTest
import Testing
@Suite
@MainActor
class RoomDetailsEditScreenViewModelTests: XCTestCase {
struct RoomDetailsEditScreenViewModelTests {
var viewModel: RoomDetailsEditScreenViewModel!
var userIndicatorController: UserIndicatorControllerMock!
@@ -20,65 +21,74 @@ class RoomDetailsEditScreenViewModelTests: XCTestCase {
viewModel.context
}
func testCannotSaveOnLanding() {
@Test
mutating func cannotSaveOnLanding() {
setupViewModel(roomProxyConfiguration: .init(name: "Some room", members: [.mockMeAdmin]))
XCTAssertFalse(context.viewState.canSave)
#expect(!context.viewState.canSave)
}
func testCanEdit() async throws {
@Test
mutating func canEdit() async throws {
setupViewModel(roomProxyConfiguration: .init(name: "Some room", members: [.mockMeAdmin]))
let deferred = deferFulfillment(context.$viewState) { $0.canEditName }
try await deferred.fulfill()
XCTAssertTrue(context.viewState.canEditAvatar)
XCTAssertTrue(context.viewState.canEditName)
XCTAssertTrue(context.viewState.canEditTopic)
#expect(context.viewState.canEditAvatar)
#expect(context.viewState.canEditName)
#expect(context.viewState.canEditTopic)
}
func testCannotEdit() {
@Test
mutating func cannotEdit() {
setupViewModel(roomProxyConfiguration: .init(name: "Some room", members: [.mockMe]))
XCTAssertFalse(context.viewState.canEditAvatar)
XCTAssertFalse(context.viewState.canEditName)
XCTAssertFalse(context.viewState.canEditTopic)
#expect(!context.viewState.canEditAvatar)
#expect(!context.viewState.canEditName)
#expect(!context.viewState.canEditTopic)
}
func testNameDidChange() {
@Test
mutating func nameDidChange() {
setupViewModel(roomProxyConfiguration: .init(name: "Some room", members: [.mockMeAdmin]))
context.name = "name"
XCTAssertTrue(context.viewState.nameDidChange)
XCTAssertTrue(context.viewState.canSave)
#expect(context.viewState.nameDidChange)
#expect(context.viewState.canSave)
}
func testTopicDidChange() {
@Test
mutating func topicDidChange() {
setupViewModel(roomProxyConfiguration: .init(name: "Some room", members: [.mockMeAdmin]))
context.topic = "topic"
XCTAssertTrue(context.viewState.topicDidChange)
XCTAssertTrue(context.viewState.canSave)
#expect(context.viewState.topicDidChange)
#expect(context.viewState.canSave)
}
func testAvatarDidChange() {
@Test
mutating func avatarDidChange() {
setupViewModel(roomProxyConfiguration: .init(name: "Some room", avatarURL: .mockMXCAvatar, members: [.mockMeAdmin]))
context.send(viewAction: .removeImage)
XCTAssertTrue(context.viewState.avatarDidChange)
XCTAssertTrue(context.viewState.canSave)
#expect(context.viewState.avatarDidChange)
#expect(context.viewState.canSave)
}
func testEmptyNameCannotBeSaved() {
@Test
mutating func emptyNameCannotBeSaved() {
setupViewModel(roomProxyConfiguration: .init(name: "Some room", members: [.mockMeAdmin]))
context.name = ""
XCTAssertFalse(context.viewState.canSave)
#expect(!context.viewState.canSave)
}
func testAvatarPickerShowsSheet() {
@Test
mutating func avatarPickerShowsSheet() {
setupViewModel(roomProxyConfiguration: .init(name: "Some room", members: [.mockMeAdmin]))
context.name = "name"
XCTAssertFalse(context.showMediaSheet)
#expect(!context.showMediaSheet)
context.send(viewAction: .presentMediaSource)
XCTAssertTrue(context.showMediaSheet)
#expect(context.showMediaSheet)
}
func testSaveTriggersViewModelAction() async throws {
@Test
mutating func saveTriggersViewModelAction() async throws {
setupViewModel(roomProxyConfiguration: .init(name: "Some room", members: [.mockMeAdmin]))
let deferred = deferFulfillment(viewModel.actions) { action in
@@ -89,67 +99,72 @@ class RoomDetailsEditScreenViewModelTests: XCTestCase {
context.send(viewAction: .save)
let action = try await deferred.fulfill()
XCTAssertEqual(action, .saveFinished)
#expect(action == .saveFinished)
}
func testCancelWithoutChanges() async throws {
@Test
mutating func cancelWithoutChanges() async throws {
setupViewModel(roomProxyConfiguration: .init(name: "Some room", members: [.mockMeAdmin]))
XCTAssertFalse(context.viewState.canSave)
XCTAssertNil(context.alertInfo)
#expect(!context.viewState.canSave)
#expect(context.alertInfo == nil)
let deferred = deferFulfillment(viewModel.actions) { $0 == .cancel }
context.send(viewAction: .cancel)
try await deferred.fulfill()
XCTAssertNil(context.alertInfo)
#expect(context.alertInfo == nil)
}
func testCancelWithChangesAndDiscard() async throws {
@Test
mutating func cancelWithChangesAndDiscard() async throws {
setupViewModel(roomProxyConfiguration: .init(name: "Some room", members: [.mockMeAdmin]))
context.name = "name"
XCTAssertTrue(context.viewState.canSave)
XCTAssertNil(context.alertInfo)
#expect(context.viewState.canSave)
#expect(context.alertInfo == nil)
context.send(viewAction: .cancel)
XCTAssertNotNil(context.alertInfo)
#expect(context.alertInfo != nil)
let deferred = deferFulfillment(viewModel.actions) { $0 == .cancel }
context.alertInfo?.secondaryButton?.action?() // Discard
try await deferred.fulfill()
}
func testCancelWithChangesAndSave() async throws {
@Test
mutating func cancelWithChangesAndSave() async throws {
setupViewModel(roomProxyConfiguration: .init(name: "Some room", members: [.mockMeAdmin]))
context.name = "name"
XCTAssertTrue(context.viewState.canSave)
XCTAssertNil(context.alertInfo)
#expect(context.viewState.canSave)
#expect(context.alertInfo == nil)
context.send(viewAction: .cancel)
XCTAssertNotNil(context.alertInfo)
#expect(context.alertInfo != nil)
let deferred = deferFulfillment(viewModel.actions) { $0 == .saveFinished }
context.alertInfo?.primaryButton.action?() // Save
try await deferred.fulfill()
}
func testErrorShownOnFailedFetchOfMedia() async {
@Test
mutating func errorShownOnFailedFetchOfMedia() async {
setupViewModel(roomProxyConfiguration: .init(name: "Some room", members: [.mockMeAdmin]))
viewModel.didSelectMediaUrl(url: .picturesDirectory)
try? await Task.sleep(for: .milliseconds(100))
XCTAssertNotNil(context.alertInfo)
#expect(context.alertInfo != nil)
}
func testDeleteAvatar() {
@Test
mutating func deleteAvatar() {
setupViewModel(roomProxyConfiguration: .init(name: "Some room", avatarURL: .mockMXCAvatar, members: [.mockMeAdmin]))
XCTAssertNotNil(context.viewState.avatarURL)
#expect(context.viewState.avatarURL != nil)
context.send(viewAction: .removeImage)
XCTAssertNil(context.viewState.avatarURL)
#expect(context.viewState.avatarURL == nil)
}
// MARK: - Private
private func setupViewModel(roomProxyConfiguration: JoinedRoomProxyMockConfiguration) {
private mutating func setupViewModel(roomProxyConfiguration: JoinedRoomProxyMockConfiguration) {
userIndicatorController = UserIndicatorControllerMock.default
viewModel = .init(roomProxy: JoinedRoomProxyMock(roomProxyConfiguration),
userSession: UserSessionMock(.init()),

View File

@@ -11,10 +11,11 @@ import Combine
@testable import ElementX
import MatrixRustSDK
import SwiftUI
import XCTest
import Testing
@Suite
@MainActor
class RoomDetailsScreenViewModelTests: XCTestCase {
struct RoomDetailsScreenViewModelTests {
var viewModel: RoomDetailsScreenViewModel!
var roomProxyMock: JoinedRoomProxyMock!
var notificationSettingsProxyMock: NotificationSettingsProxyMock!
@@ -24,7 +25,7 @@ class RoomDetailsScreenViewModelTests: XCTestCase {
var cancellables = Set<AnyCancellable>()
override func setUp() {
init() {
AppSettings.resetAllSettings()
cancellables.removeAll()
roomProxyMock = JoinedRoomProxyMock(.init(name: "Test"))
@@ -38,7 +39,8 @@ class RoomDetailsScreenViewModelTests: XCTestCase {
appSettings: ServiceLocator.shared.settings)
}
func testLeaveRoomTappedWhenPublic() async throws {
@Test
mutating func leaveRoomTappedWhenPublic() async throws {
let mockedMembers: [RoomMemberProxyMock] = [.mockBob, .mockAlice]
roomProxyMock = JoinedRoomProxyMock(.init(name: "Test", members: mockedMembers, joinRule: .public))
viewModel = RoomDetailsScreenViewModel(roomProxy: roomProxyMock,
@@ -53,11 +55,12 @@ class RoomDetailsScreenViewModelTests: XCTestCase {
context.send(viewAction: .processTapLeave)
try await deferred.fulfill()
XCTAssertEqual(context.viewState.bindings.leaveRoomAlertItem?.state, .public)
XCTAssertEqual(context.viewState.bindings.leaveRoomAlertItem?.subtitle, L10n.leaveRoomAlertSubtitle)
#expect(context.viewState.bindings.leaveRoomAlertItem?.state == .public)
#expect(context.viewState.bindings.leaveRoomAlertItem?.subtitle == L10n.leaveRoomAlertSubtitle)
}
func testLeaveRoomTappedWhenRoomNotPublic() async throws {
@Test
mutating func leaveRoomTappedWhenRoomNotPublic() async throws {
let mockedMembers: [RoomMemberProxyMock] = [.mockBob, .mockAlice]
roomProxyMock = JoinedRoomProxyMock(.init(name: "Test", members: mockedMembers))
viewModel = RoomDetailsScreenViewModel(roomProxy: roomProxyMock,
@@ -73,11 +76,12 @@ class RoomDetailsScreenViewModelTests: XCTestCase {
try await deferred.fulfill()
XCTAssertEqual(context.viewState.bindings.leaveRoomAlertItem?.state, .private)
XCTAssertEqual(context.viewState.bindings.leaveRoomAlertItem?.subtitle, L10n.leaveRoomAlertPrivateSubtitle)
#expect(context.viewState.bindings.leaveRoomAlertItem?.state == .private)
#expect(context.viewState.bindings.leaveRoomAlertItem?.subtitle == L10n.leaveRoomAlertPrivateSubtitle)
}
func testLeaveRoomTappedWithLessThanTwoMembers() {
@Test
mutating func leaveRoomTappedWithLessThanTwoMembers() {
let mockedMembers: [RoomMemberProxyMock] = [.mockAlice]
roomProxyMock = JoinedRoomProxyMock(.init(name: "Test", members: mockedMembers))
viewModel = RoomDetailsScreenViewModel(roomProxy: roomProxyMock,
@@ -89,11 +93,12 @@ class RoomDetailsScreenViewModelTests: XCTestCase {
appSettings: ServiceLocator.shared.settings)
context.send(viewAction: .processTapLeave)
XCTAssertEqual(context.leaveRoomAlertItem?.state, .empty)
XCTAssertEqual(context.leaveRoomAlertItem?.subtitle, L10n.leaveRoomAlertEmptySubtitle)
#expect(context.leaveRoomAlertItem?.state == .empty)
#expect(context.leaveRoomAlertItem?.subtitle == L10n.leaveRoomAlertEmptySubtitle)
}
func testLeaveRoomSuccess() async throws {
@Test
func leaveRoomSuccess() async throws {
roomProxyMock.leaveRoomClosure = {
.success(())
}
@@ -111,24 +116,29 @@ class RoomDetailsScreenViewModelTests: XCTestCase {
try await deferred.fulfill()
XCTAssertEqual(roomProxyMock.leaveRoomCallsCount, 1)
#expect(roomProxyMock.leaveRoomCallsCount == 1)
}
func testLeaveRoomError() async {
let expectation = expectation(description: #function)
roomProxyMock.leaveRoomClosure = {
defer {
expectation.fulfill()
@Test
func leaveRoomError() async throws {
try await confirmation("leaveRoomError") { confirm in
roomProxyMock.leaveRoomClosure = {
defer {
confirm()
}
return .failure(.sdkError(ClientProxyMockError.generic))
}
return .failure(.sdkError(ClientProxyMockError.generic))
let deferred = deferFulfillment(context.observe(\.alertInfo)) { $0 != nil }
context.send(viewAction: .confirmLeave)
try await deferred.fulfill()
}
context.send(viewAction: .confirmLeave)
await fulfillment(of: [expectation])
XCTAssertEqual(roomProxyMock.leaveRoomCallsCount, 1)
XCTAssertNotNil(context.alertInfo)
#expect(roomProxyMock.leaveRoomCallsCount == 1)
}
func testInitialDMDetailsState() async throws {
@Test
mutating func initialDMDetailsState() async throws {
let recipient = RoomMemberProxyMock.mockDan
let mockedMembers: [RoomMemberProxyMock] = [.mockMe, recipient]
roomProxyMock = JoinedRoomProxyMock(.init(name: "Test", isDirect: true, isEncrypted: true, members: mockedMembers))
@@ -144,10 +154,11 @@ class RoomDetailsScreenViewModelTests: XCTestCase {
try await deferred.fulfill()
XCTAssertEqual(context.viewState.dmRecipientInfo?.member, RoomMemberDetails(withProxy: recipient))
#expect(context.viewState.dmRecipientInfo?.member == RoomMemberDetails(withProxy: recipient))
}
func testIgnoreSuccess() async throws {
@Test
mutating func ignoreSuccess() async throws {
let recipient = RoomMemberProxyMock.mockDan
let mockedMembers: [RoomMemberProxyMock] = [.mockMe, recipient]
@@ -164,7 +175,7 @@ class RoomDetailsScreenViewModelTests: XCTestCase {
try await deferredRecipient.fulfill()
XCTAssertEqual(context.viewState.dmRecipientInfo?.member, RoomMemberDetails(withProxy: recipient))
#expect(context.viewState.dmRecipientInfo?.member == RoomMemberDetails(withProxy: recipient))
let deferredProcessing = deferFulfillment(viewModel.context.observe(\.viewState.isProcessingIgnoreRequest),
transitionValues: [false, true, false])
@@ -173,10 +184,11 @@ class RoomDetailsScreenViewModelTests: XCTestCase {
try await deferredProcessing.fulfill()
XCTAssert(context.viewState.dmRecipientInfo?.member.isIgnored == true)
#expect(context.viewState.dmRecipientInfo?.member.isIgnored == true)
}
func testIgnoreFailure() async throws {
@Test
mutating func ignoreFailure() async throws {
let recipient = RoomMemberProxyMock.mockDan
let mockedMembers: [RoomMemberProxyMock] = [.mockMe, recipient]
let clientProxy = ClientProxyMock(.init())
@@ -194,7 +206,7 @@ class RoomDetailsScreenViewModelTests: XCTestCase {
try await deferredRecipient.fulfill()
XCTAssertEqual(context.viewState.dmRecipientInfo?.member, RoomMemberDetails(withProxy: recipient))
#expect(context.viewState.dmRecipientInfo?.member == RoomMemberDetails(withProxy: recipient))
let deferredProcessing = deferFulfillment(viewModel.context.observe(\.viewState.isProcessingIgnoreRequest),
transitionValues: [false, true, false])
@@ -203,11 +215,12 @@ class RoomDetailsScreenViewModelTests: XCTestCase {
try await deferredProcessing.fulfill()
XCTAssert(context.viewState.dmRecipientInfo?.member.isIgnored == false)
XCTAssertNotNil(context.alertInfo)
#expect(context.viewState.dmRecipientInfo?.member.isIgnored == false)
#expect(context.alertInfo != nil)
}
func testUnignoreSuccess() async throws {
@Test
mutating func unignoreSuccess() async throws {
let recipient = RoomMemberProxyMock.mockIgnored
let mockedMembers: [RoomMemberProxyMock] = [.mockMe, recipient]
roomProxyMock = JoinedRoomProxyMock(.init(name: "Test", isDirect: true, isEncrypted: true, members: mockedMembers))
@@ -223,7 +236,7 @@ class RoomDetailsScreenViewModelTests: XCTestCase {
try await deferredRecipient.fulfill()
XCTAssertEqual(context.viewState.dmRecipientInfo?.member, RoomMemberDetails(withProxy: recipient))
#expect(context.viewState.dmRecipientInfo?.member == RoomMemberDetails(withProxy: recipient))
let deferredProcessing = deferFulfillment(viewModel.context.observe(\.viewState.isProcessingIgnoreRequest),
transitionValues: [false, true, false])
@@ -232,10 +245,11 @@ class RoomDetailsScreenViewModelTests: XCTestCase {
try await deferredProcessing.fulfill()
XCTAssert(context.viewState.dmRecipientInfo?.member.isIgnored == false)
#expect(context.viewState.dmRecipientInfo?.member.isIgnored == false)
}
func testUnignoreFailure() async throws {
@Test
mutating func unignoreFailure() async throws {
let recipient = RoomMemberProxyMock.mockIgnored
let mockedMembers: [RoomMemberProxyMock] = [.mockMe, recipient]
let clientProxy = ClientProxyMock(.init())
@@ -253,7 +267,7 @@ class RoomDetailsScreenViewModelTests: XCTestCase {
try await deferredRecipient.fulfill()
XCTAssertEqual(context.viewState.dmRecipientInfo?.member, RoomMemberDetails(withProxy: recipient))
#expect(context.viewState.dmRecipientInfo?.member == RoomMemberDetails(withProxy: recipient))
let deferredProcessing = deferFulfillment(viewModel.context.observe(\.viewState.isProcessingIgnoreRequest),
transitionValues: [false, true, false])
@@ -262,11 +276,12 @@ class RoomDetailsScreenViewModelTests: XCTestCase {
try await deferredProcessing.fulfill()
XCTAssert(context.viewState.dmRecipientInfo?.member.isIgnored == true)
XCTAssertNotNil(context.alertInfo)
#expect(context.viewState.dmRecipientInfo?.member.isIgnored == true)
#expect(context.alertInfo != nil)
}
func testCannotInvitePeople() async {
@Test
mutating func cannotInvitePeople() async {
let mockedMembers: [RoomMemberProxyMock] = [.mockMe, .mockAlice]
roomProxyMock = JoinedRoomProxyMock(.init(name: "Test",
members: mockedMembers,
@@ -282,10 +297,11 @@ class RoomDetailsScreenViewModelTests: XCTestCase {
_ = await context.observe(\.viewState).debounce(for: .milliseconds(100)).first()
XCTAssertFalse(context.viewState.canInviteUsers)
#expect(!context.viewState.canInviteUsers)
}
func testInvitePeople() async {
@Test
mutating func invitePeople() async {
let mockedMembers: [RoomMemberProxyMock] = [.mockMe, .mockBob, .mockAlice]
roomProxyMock = JoinedRoomProxyMock(.init(name: "Test", members: mockedMembers, joinRule: .public))
viewModel = RoomDetailsScreenViewModel(roomProxy: roomProxyMock,
@@ -298,7 +314,7 @@ class RoomDetailsScreenViewModelTests: XCTestCase {
_ = await context.observe(\.viewState).debounce(for: .milliseconds(100)).first()
XCTAssertTrue(context.viewState.canInviteUsers)
#expect(context.viewState.canInviteUsers)
var callbackCorrectlyCalled = false
viewModel.actions
@@ -314,10 +330,11 @@ class RoomDetailsScreenViewModelTests: XCTestCase {
context.send(viewAction: .processTapInvite)
await Task.yield()
XCTAssertTrue(callbackCorrectlyCalled)
#expect(callbackCorrectlyCalled)
}
func testCanEditAvatar() async {
@Test
mutating func canEditAvatar() async {
let mockedMembers: [RoomMemberProxyMock] = [.mockMe, .mockBob, .mockAlice]
let configuration = JoinedRoomProxyMockConfiguration(name: "Test",
@@ -349,13 +366,14 @@ class RoomDetailsScreenViewModelTests: XCTestCase {
_ = await context.observe(\.viewState).debounce(for: .milliseconds(100)).first()
XCTAssertTrue(context.viewState.canEditRoomAvatar)
XCTAssertFalse(context.viewState.canEditRoomName)
XCTAssertFalse(context.viewState.canEditRoomTopic)
XCTAssertTrue(context.viewState.canEditBaseInfo)
#expect(context.viewState.canEditRoomAvatar)
#expect(!context.viewState.canEditRoomName)
#expect(!context.viewState.canEditRoomTopic)
#expect(context.viewState.canEditBaseInfo)
}
func testCanEditName() async {
@Test
mutating func canEditName() async {
let mockedMembers: [RoomMemberProxyMock] = [.mockMe, .mockBob, .mockAlice]
let configuration = JoinedRoomProxyMockConfiguration(name: "Test",
@@ -387,13 +405,14 @@ class RoomDetailsScreenViewModelTests: XCTestCase {
_ = await context.observe(\.viewState).debounce(for: .milliseconds(100)).first()
XCTAssertFalse(context.viewState.canEditRoomAvatar)
XCTAssertTrue(context.viewState.canEditRoomName)
XCTAssertFalse(context.viewState.canEditRoomTopic)
XCTAssertTrue(context.viewState.canEditBaseInfo)
#expect(!context.viewState.canEditRoomAvatar)
#expect(context.viewState.canEditRoomName)
#expect(!context.viewState.canEditRoomTopic)
#expect(context.viewState.canEditBaseInfo)
}
func testCanEditTopic() async {
@Test
mutating func canEditTopic() async {
let mockedMembers: [RoomMemberProxyMock] = [.mockMe, .mockBob, .mockAlice]
let configuration = JoinedRoomProxyMockConfiguration(name: "Test",
@@ -425,13 +444,14 @@ class RoomDetailsScreenViewModelTests: XCTestCase {
_ = await context.observe(\.viewState).debounce(for: .milliseconds(100)).first()
XCTAssertFalse(context.viewState.canEditRoomAvatar)
XCTAssertFalse(context.viewState.canEditRoomName)
XCTAssertTrue(context.viewState.canEditRoomTopic)
XCTAssertTrue(context.viewState.canEditBaseInfo)
#expect(!context.viewState.canEditRoomAvatar)
#expect(!context.viewState.canEditRoomName)
#expect(context.viewState.canEditRoomTopic)
#expect(context.viewState.canEditBaseInfo)
}
func testCannotEditRoom() async {
@Test
mutating func cannotEditRoom() async {
let mockedMembers: [RoomMemberProxyMock] = [.mockMe, .mockBob, .mockAlice]
roomProxyMock = JoinedRoomProxyMock(.init(name: "Test", isDirect: false, members: mockedMembers))
viewModel = RoomDetailsScreenViewModel(roomProxy: roomProxyMock,
@@ -444,13 +464,14 @@ class RoomDetailsScreenViewModelTests: XCTestCase {
_ = await context.observe(\.viewState).debounce(for: .milliseconds(100)).first()
XCTAssertFalse(context.viewState.canEditRoomAvatar)
XCTAssertFalse(context.viewState.canEditRoomName)
XCTAssertFalse(context.viewState.canEditRoomTopic)
XCTAssertFalse(context.viewState.canEditBaseInfo)
#expect(!context.viewState.canEditRoomAvatar)
#expect(!context.viewState.canEditRoomName)
#expect(!context.viewState.canEditRoomTopic)
#expect(!context.viewState.canEditBaseInfo)
}
func testCannotEditDirectRoom() async {
@Test
mutating func cannotEditDirectRoom() async {
let mockedMembers: [RoomMemberProxyMock] = [.mockMeAdmin, .mockBob, .mockAlice]
roomProxyMock = JoinedRoomProxyMock(.init(name: "Test", isDirect: true, members: mockedMembers))
viewModel = RoomDetailsScreenViewModel(roomProxy: roomProxyMock,
@@ -463,12 +484,13 @@ class RoomDetailsScreenViewModelTests: XCTestCase {
_ = await context.observe(\.viewState).debounce(for: .milliseconds(100)).first()
XCTAssertFalse(context.viewState.canEditBaseInfo)
#expect(!context.viewState.canEditBaseInfo)
}
// MARK: - Notifications
func testNotificationLoadingSettingsFailure() async throws {
@Test
mutating func notificationLoadingSettingsFailure() async throws {
notificationSettingsProxyMock.getNotificationSettingsRoomIdIsEncryptedIsOneToOneThrowableError = NotificationSettingsError.Generic(msg: "error")
viewModel = RoomDetailsScreenViewModel(roomProxy: roomProxyMock,
userSession: UserSessionMock(.init()),
@@ -491,12 +513,13 @@ class RoomDetailsScreenViewModelTests: XCTestCase {
let expectedAlertInfo = AlertInfo(id: RoomDetailsScreenErrorType.alert,
title: L10n.commonError,
message: L10n.screenRoomDetailsErrorLoadingNotificationSettings)
XCTAssertEqual(context.viewState.bindings.alertInfo?.id, expectedAlertInfo.id)
XCTAssertEqual(context.viewState.bindings.alertInfo?.title, expectedAlertInfo.title)
XCTAssertEqual(context.viewState.bindings.alertInfo?.message, expectedAlertInfo.message)
#expect(context.viewState.bindings.alertInfo?.id == expectedAlertInfo.id)
#expect(context.viewState.bindings.alertInfo?.title == expectedAlertInfo.title)
#expect(context.viewState.bindings.alertInfo?.message == expectedAlertInfo.message)
}
func testNotificationDefaultMode() async throws {
@Test
func notificationDefaultMode() async throws {
notificationSettingsProxyMock.getNotificationSettingsRoomIdIsEncryptedIsOneToOneReturnValue = RoomNotificationSettingsProxyMock(with: .init(mode: .allMessages, isDefault: true))
let deferred = deferFulfillment(context.observe(\.viewState.notificationSettingsState)) { $0.isLoaded }
@@ -504,10 +527,11 @@ class RoomDetailsScreenViewModelTests: XCTestCase {
notificationSettingsProxyMock.callbacks.send(.settingsDidChange)
try await deferred.fulfill()
XCTAssertEqual(context.viewState.notificationSettingsState.label, "Default")
#expect(context.viewState.notificationSettingsState.label == "Default")
}
func testNotificationCustomMode() async throws {
@Test
func notificationCustomMode() async throws {
notificationSettingsProxyMock.getNotificationSettingsRoomIdIsEncryptedIsOneToOneReturnValue = RoomNotificationSettingsProxyMock(with: .init(mode: .allMessages, isDefault: false))
let deferred = deferFulfillment(context.observe(\.viewState.notificationSettingsState)) { $0.isCustom }
@@ -515,10 +539,11 @@ class RoomDetailsScreenViewModelTests: XCTestCase {
notificationSettingsProxyMock.callbacks.send(.settingsDidChange)
try await deferred.fulfill()
XCTAssertEqual(context.viewState.notificationSettingsState.label, "Custom")
#expect(context.viewState.notificationSettingsState.label == "Custom")
}
func testNotificationRoomMuted() async throws {
@Test
func notificationRoomMuted() async throws {
notificationSettingsProxyMock.getNotificationSettingsRoomIdIsEncryptedIsOneToOneReturnValue = RoomNotificationSettingsProxyMock(with: .init(mode: .mute, isDefault: false))
let deferred = deferFulfillment(context.observe(\.viewState.notificationSettingsState)) { $0.isLoaded }
@@ -528,11 +553,12 @@ class RoomDetailsScreenViewModelTests: XCTestCase {
_ = await context.observe(\.viewState).debounce(for: .milliseconds(100)).first()
XCTAssertEqual(context.viewState.notificationShortcutButtonTitle, L10n.commonUnmute)
XCTAssertEqual(context.viewState.notificationShortcutButtonIcon, \.notificationsOff)
#expect(context.viewState.notificationShortcutButtonTitle == L10n.commonUnmute)
#expect(context.viewState.notificationShortcutButtonIcon == \.notificationsOff)
}
func testNotificationRoomNotMuted() async throws {
@Test
func notificationRoomNotMuted() async throws {
notificationSettingsProxyMock.getNotificationSettingsRoomIdIsEncryptedIsOneToOneReturnValue = RoomNotificationSettingsProxyMock(with: .init(mode: .mentionsAndKeywordsOnly, isDefault: false))
let deferred = deferFulfillment(context.observe(\.viewState.notificationSettingsState)) { $0.isLoaded }
@@ -540,72 +566,81 @@ class RoomDetailsScreenViewModelTests: XCTestCase {
notificationSettingsProxyMock.callbacks.send(.settingsDidChange)
try await deferred.fulfill()
XCTAssertEqual(context.viewState.notificationShortcutButtonTitle, L10n.commonMute)
XCTAssertEqual(context.viewState.notificationShortcutButtonIcon, \.notifications)
#expect(context.viewState.notificationShortcutButtonTitle == L10n.commonMute)
#expect(context.viewState.notificationShortcutButtonIcon == \.notifications)
}
func testUnmuteTappedFailure() async throws {
try await testNotificationRoomMuted()
@Test
func unmuteTappedFailure() async throws {
try await notificationRoomMuted()
let expectation = expectation(description: #function)
notificationSettingsProxyMock.unmuteRoomRoomIdIsEncryptedIsOneToOneClosure = { _, _, _ in
defer {
expectation.fulfill()
try await confirmation("unmuteTappedFailure") { confirm in
notificationSettingsProxyMock.unmuteRoomRoomIdIsEncryptedIsOneToOneClosure = { _, _, _ in
defer {
confirm()
}
throw NotificationSettingsError.Generic(msg: "unmute error")
}
throw NotificationSettingsError.Generic(msg: "unmute error")
context.send(viewAction: .processToggleMuteNotifications)
try await deferFulfillment(context.observe(\.alertInfo)) { $0 != nil }.fulfill()
}
context.send(viewAction: .processToggleMuteNotifications)
await fulfillment(of: [expectation])
XCTAssertFalse(context.viewState.isProcessingMuteToggleAction)
XCTAssertEqual(context.viewState.notificationShortcutButtonTitle, L10n.commonUnmute)
#expect(!context.viewState.isProcessingMuteToggleAction)
#expect(context.viewState.notificationShortcutButtonTitle == L10n.commonUnmute)
let expectedAlertInfo = AlertInfo(id: RoomDetailsScreenErrorType.alert,
title: L10n.commonError,
message: L10n.screenRoomDetailsErrorUnmuting)
XCTAssertEqual(context.viewState.bindings.alertInfo?.id, expectedAlertInfo.id)
XCTAssertEqual(context.viewState.bindings.alertInfo?.title, expectedAlertInfo.title)
XCTAssertEqual(context.viewState.bindings.alertInfo?.message, expectedAlertInfo.message)
#expect(context.viewState.bindings.alertInfo?.id == expectedAlertInfo.id)
#expect(context.viewState.bindings.alertInfo?.title == expectedAlertInfo.title)
#expect(context.viewState.bindings.alertInfo?.message == expectedAlertInfo.message)
}
func testMuteTappedFailure() async throws {
try await testNotificationRoomNotMuted()
@Test
func muteTappedFailure() async throws {
try await notificationRoomNotMuted()
let expectation = expectation(description: #function)
notificationSettingsProxyMock.setNotificationModeRoomIdModeClosure = { _, _ in
defer {
expectation.fulfill()
try await confirmation("muteTappedFailure") { confirm in
notificationSettingsProxyMock.setNotificationModeRoomIdModeClosure = { _, _ in
defer {
confirm()
}
throw NotificationSettingsError.Generic(msg: "mute error")
}
throw NotificationSettingsError.Generic(msg: "mute error")
let deferred = deferFulfillment(context.observe(\.alertInfo)) { $0 != nil }
context.send(viewAction: .processToggleMuteNotifications)
try await deferred.fulfill()
}
context.send(viewAction: .processToggleMuteNotifications)
await fulfillment(of: [expectation])
XCTAssertFalse(context.viewState.isProcessingMuteToggleAction)
XCTAssertEqual(context.viewState.notificationShortcutButtonTitle, L10n.commonMute)
#expect(!context.viewState.isProcessingMuteToggleAction)
#expect(context.viewState.notificationShortcutButtonTitle == L10n.commonMute)
let expectedAlertInfo = AlertInfo(id: RoomDetailsScreenErrorType.alert,
title: L10n.commonError,
message: L10n.screenRoomDetailsErrorMuting)
XCTAssertEqual(context.viewState.bindings.alertInfo?.id, expectedAlertInfo.id)
XCTAssertEqual(context.viewState.bindings.alertInfo?.title, expectedAlertInfo.title)
XCTAssertEqual(context.viewState.bindings.alertInfo?.message, expectedAlertInfo.message)
#expect(context.viewState.bindings.alertInfo?.id == expectedAlertInfo.id)
#expect(context.viewState.bindings.alertInfo?.title == expectedAlertInfo.title)
#expect(context.viewState.bindings.alertInfo?.message == expectedAlertInfo.message)
}
func testMuteTapped() async throws {
try await testNotificationRoomNotMuted()
@Test
func muteTapped() async throws {
try await notificationRoomNotMuted()
let expectation = expectation(description: #function)
notificationSettingsProxyMock.setNotificationModeRoomIdModeClosure = { [weak notificationSettingsProxyMock] _, mode in
notificationSettingsProxyMock?.getNotificationSettingsRoomIdIsEncryptedIsOneToOneReturnValue = RoomNotificationSettingsProxyMock(with: .init(mode: mode, isDefault: false))
expectation.fulfill()
try await confirmation("muteTapped") { confirm in
notificationSettingsProxyMock.setNotificationModeRoomIdModeClosure = { [weak notificationSettingsProxyMock] _, mode in
notificationSettingsProxyMock?.getNotificationSettingsRoomIdIsEncryptedIsOneToOneReturnValue = RoomNotificationSettingsProxyMock(with: .init(mode: mode, isDefault: false))
confirm()
}
let deferred = deferFulfillment(context.observe(\.viewState.isProcessingMuteToggleAction),
transitionValues: [false, true, false])
context.send(viewAction: .processToggleMuteNotifications)
try await deferred.fulfill()
}
context.send(viewAction: .processToggleMuteNotifications)
await fulfillment(of: [expectation])
XCTAssertFalse(context.viewState.isProcessingMuteToggleAction)
let deferred = deferFulfillment(context.observe(\.viewState.notificationSettingsState)) { state in
switch state {
@@ -618,25 +653,28 @@ class RoomDetailsScreenViewModelTests: XCTestCase {
try await deferred.fulfill()
if case .loaded(let newNotificationSettingsState) = viewModel.state.notificationSettingsState {
XCTAssertFalse(newNotificationSettingsState.isDefault)
XCTAssertEqual(newNotificationSettingsState.mode, .mute)
#expect(!newNotificationSettingsState.isDefault)
#expect(newNotificationSettingsState.mode == .mute)
} else {
XCTFail("invalid state")
Issue.record("invalid state")
}
}
func testUnmuteTapped() async throws {
try await testNotificationRoomMuted()
@Test
func unmuteTapped() async throws {
try await notificationRoomMuted()
let expectation = expectation(description: #function)
notificationSettingsProxyMock.unmuteRoomRoomIdIsEncryptedIsOneToOneClosure = { [weak notificationSettingsProxyMock] _, _, _ in
notificationSettingsProxyMock?.getNotificationSettingsRoomIdIsEncryptedIsOneToOneReturnValue = RoomNotificationSettingsProxyMock(with: .init(mode: .allMessages, isDefault: false))
expectation.fulfill()
try await confirmation("unmuteTapped") { confirm in
notificationSettingsProxyMock.unmuteRoomRoomIdIsEncryptedIsOneToOneClosure = { [weak notificationSettingsProxyMock] _, _, _ in
notificationSettingsProxyMock?.getNotificationSettingsRoomIdIsEncryptedIsOneToOneReturnValue = RoomNotificationSettingsProxyMock(with: .init(mode: .allMessages, isDefault: false))
confirm()
}
let deferred = deferFulfillment(context.observe(\.viewState.isProcessingMuteToggleAction),
transitionValues: [false, true, false])
context.send(viewAction: .processToggleMuteNotifications)
try await deferred.fulfill()
}
context.send(viewAction: .processToggleMuteNotifications)
await fulfillment(of: [expectation])
XCTAssertFalse(context.viewState.isProcessingMuteToggleAction)
let deferred = deferFulfillment(context.observe(\.viewState.notificationSettingsState)) { state in
switch state {
@@ -649,16 +687,17 @@ class RoomDetailsScreenViewModelTests: XCTestCase {
try await deferred.fulfill()
if case .loaded(let newNotificationSettingsState) = viewModel.state.notificationSettingsState {
XCTAssertFalse(newNotificationSettingsState.isDefault)
XCTAssertEqual(newNotificationSettingsState.mode, .allMessages)
#expect(!newNotificationSettingsState.isDefault)
#expect(newNotificationSettingsState.mode == .allMessages)
} else {
XCTFail("invalid state")
Issue.record("invalid state")
}
}
// MARK: - Knock Requests
func testKnockRequestsCounter() async throws {
@Test
mutating func knockRequestsCounter() async throws {
ServiceLocator.shared.settings.knockingEnabled = true
let mockedRequests: [KnockRequestProxyMock] = [.init(), .init()]
roomProxyMock = JoinedRoomProxyMock(.init(name: "Test", isDirect: false, knockRequestsState: .loaded(mockedRequests), joinRule: .knock))
@@ -680,7 +719,8 @@ class RoomDetailsScreenViewModelTests: XCTestCase {
try await deferredAction.fulfill()
}
func testKnockRequestsCounterIsLoading() async throws {
@Test
mutating func knockRequestsCounterIsLoading() async throws {
ServiceLocator.shared.settings.knockingEnabled = true
roomProxyMock = JoinedRoomProxyMock(.init(name: "Test", isDirect: false, knockRequestsState: .loading, joinRule: .knock))
viewModel = RoomDetailsScreenViewModel(roomProxy: roomProxyMock,
@@ -698,7 +738,8 @@ class RoomDetailsScreenViewModelTests: XCTestCase {
try await deferred.fulfill()
}
func testKnockRequestsCounterIsNotShownIfNoPermissions() async throws {
@Test
mutating func knockRequestsCounterIsNotShownIfNoPermissions() async throws {
ServiceLocator.shared.settings.knockingEnabled = true
let mockedRequests: [KnockRequestProxyMock] = [.init(), .init()]
roomProxyMock = JoinedRoomProxyMock(.init(name: "Test",
@@ -724,7 +765,8 @@ class RoomDetailsScreenViewModelTests: XCTestCase {
try await deferred.fulfill()
}
func testKnockRequestsCounterIsNotShownIfDM() async throws {
@Test
mutating func knockRequestsCounterIsNotShownIfDM() async throws {
ServiceLocator.shared.settings.knockingEnabled = true
let mockedRequests: [KnockRequestProxyMock] = [.init(), .init()]
let mockedMembers: [RoomMemberProxyMock] = [.mockMe, .mockAlice]
@@ -749,7 +791,8 @@ class RoomDetailsScreenViewModelTests: XCTestCase {
// MARK: - History Sharing
func testHistorySharingPillDoesNotAppearIfFeatureFlagNotSet() async throws {
@Test
mutating func historySharingPillDoesNotAppearIfFeatureFlagNotSet() async throws {
ServiceLocator.shared.settings.enableKeyShareOnInvite = false
let configuration = JoinedRoomProxyMockConfiguration(historyVisibility: .shared)
@@ -766,14 +809,15 @@ class RoomDetailsScreenViewModelTests: XCTestCase {
appSettings: ServiceLocator.shared.settings)
let deferredInvisible = deferFailure(context.observe(\.viewState),
timeout: 1,
timeout: .seconds(1),
message: "The pill should not be shown as the feature flag is not set") { state in
state.details.historySharingState != nil
}
try await deferredInvisible.fulfill()
}
func testHistorySharingPillDisplayedIfHistoryVisibilityShared() async throws {
@Test
mutating func historySharingPillDisplayedIfHistoryVisibilityShared() async throws {
ServiceLocator.shared.settings.enableKeyShareOnInvite = true
let configuration = JoinedRoomProxyMockConfiguration(historyVisibility: .shared)

View File

@@ -9,161 +9,171 @@
import Combine
@testable import ElementX
import MatrixRustSDKMocks
import XCTest
import Testing
@Suite
@MainActor
class RoomFlowCoordinatorTests: XCTestCase {
final class RoomFlowCoordinatorTests {
var clientProxy: ClientProxyMock!
var timelineControllerFactory: TimelineControllerFactoryMock!
var roomFlowCoordinator: RoomFlowCoordinator!
var navigationStackCoordinator: NavigationStackCoordinator!
var cancellables = Set<AnyCancellable>()
override func tearDown() {
deinit {
AppSettings.resetAllSettings()
}
func testRoomPresentation() async throws {
@Test
func roomPresentation() async throws {
setupRoomFlowCoordinator()
try await process(route: .room(roomID: "1", via: []))
XCTAssert(navigationStackCoordinator.rootCoordinator is RoomScreenCoordinator)
#expect(navigationStackCoordinator.rootCoordinator is RoomScreenCoordinator)
try await clearRoute(expectedActions: [.finished])
XCTAssertNil(navigationStackCoordinator.rootCoordinator)
#expect(navigationStackCoordinator.rootCoordinator == nil)
}
func testRoomDetailsPresentation() async throws {
@Test
func roomDetailsPresentation() async throws {
setupRoomFlowCoordinator()
try await process(route: .roomDetails(roomID: "1"))
XCTAssert(navigationStackCoordinator.rootCoordinator is RoomDetailsScreenCoordinator)
#expect(navigationStackCoordinator.rootCoordinator is RoomDetailsScreenCoordinator)
try await clearRoute(expectedActions: [.finished])
XCTAssertNil(navigationStackCoordinator.rootCoordinator)
#expect(navigationStackCoordinator.rootCoordinator == nil)
}
func testNoOp() async throws {
@Test
func noOp() async throws {
setupRoomFlowCoordinator()
try await process(route: .roomDetails(roomID: "1"))
XCTAssert(navigationStackCoordinator.rootCoordinator is RoomDetailsScreenCoordinator)
#expect(navigationStackCoordinator.rootCoordinator is RoomDetailsScreenCoordinator)
let detailsCoordinator = navigationStackCoordinator.rootCoordinator
roomFlowCoordinator.handleAppRoute(.roomDetails(roomID: "1"), animated: true)
await Task.yield()
XCTAssert(navigationStackCoordinator.rootCoordinator is RoomDetailsScreenCoordinator)
XCTAssert(navigationStackCoordinator.rootCoordinator === detailsCoordinator)
#expect(navigationStackCoordinator.rootCoordinator is RoomDetailsScreenCoordinator)
#expect(navigationStackCoordinator.rootCoordinator === detailsCoordinator)
}
func testPushDetails() async throws {
@Test
func pushDetails() async throws {
setupRoomFlowCoordinator()
try await process(route: .room(roomID: "1", via: []))
XCTAssert(navigationStackCoordinator.rootCoordinator is RoomScreenCoordinator)
XCTAssertEqual(navigationStackCoordinator.stackCoordinators.count, 0)
#expect(navigationStackCoordinator.rootCoordinator is RoomScreenCoordinator)
#expect(navigationStackCoordinator.stackCoordinators.count == 0)
try await process(route: .roomDetails(roomID: "1"))
XCTAssert(navigationStackCoordinator.rootCoordinator is RoomScreenCoordinator)
XCTAssertEqual(navigationStackCoordinator.stackCoordinators.count, 1)
XCTAssert(navigationStackCoordinator.stackCoordinators.first is RoomDetailsScreenCoordinator)
#expect(navigationStackCoordinator.rootCoordinator is RoomScreenCoordinator)
#expect(navigationStackCoordinator.stackCoordinators.count == 1)
#expect(navigationStackCoordinator.stackCoordinators.first is RoomDetailsScreenCoordinator)
}
func testChildRoomFlow() async throws {
@Test
func childRoomFlow() async throws {
setupRoomFlowCoordinator()
try await process(route: .room(roomID: "1", via: []))
XCTAssert(navigationStackCoordinator.rootCoordinator is RoomScreenCoordinator)
XCTAssertEqual(navigationStackCoordinator.stackCoordinators.count, 0)
#expect(navigationStackCoordinator.rootCoordinator is RoomScreenCoordinator)
#expect(navigationStackCoordinator.stackCoordinators.count == 0)
try await process(route: .childRoom(roomID: "2", via: []))
XCTAssertEqual(navigationStackCoordinator.stackCoordinators.count, 1)
XCTAssert(navigationStackCoordinator.stackCoordinators.first is RoomScreenCoordinator)
#expect(navigationStackCoordinator.stackCoordinators.count == 1)
#expect(navigationStackCoordinator.stackCoordinators.first is RoomScreenCoordinator)
try await process(route: .childRoom(roomID: "3", via: []))
XCTAssertEqual(navigationStackCoordinator.stackCoordinators.count, 2)
XCTAssert(navigationStackCoordinator.stackCoordinators.first is RoomScreenCoordinator)
XCTAssert(navigationStackCoordinator.stackCoordinators.last is RoomScreenCoordinator)
#expect(navigationStackCoordinator.stackCoordinators.count == 2)
#expect(navigationStackCoordinator.stackCoordinators.first is RoomScreenCoordinator)
#expect(navigationStackCoordinator.stackCoordinators.last is RoomScreenCoordinator)
try await clearRoute(expectedActions: [.finished])
XCTAssertNil(navigationStackCoordinator.rootCoordinator)
XCTAssertEqual(navigationStackCoordinator.stackCoordinators.count, 0)
#expect(navigationStackCoordinator.rootCoordinator == nil)
#expect(navigationStackCoordinator.stackCoordinators.count == 0)
}
/// Tests the child flow teardown in isolation of it's parent.
func testChildFlowTearDown() async throws {
@Test
func childFlowTearDown() async throws {
setupRoomFlowCoordinator(asChildFlow: true)
navigationStackCoordinator.setRootCoordinator(BlankFormCoordinator())
try await process(route: .room(roomID: "1", via: []))
try await process(route: .roomDetails(roomID: "1"))
XCTAssertTrue(navigationStackCoordinator.rootCoordinator is BlankFormCoordinator, "A child room flow should push onto the stack, leaving the root alone.")
XCTAssertEqual(navigationStackCoordinator.stackCoordinators.count, 2)
XCTAssertTrue(navigationStackCoordinator.stackCoordinators.first is RoomScreenCoordinator)
XCTAssertTrue(navigationStackCoordinator.stackCoordinators.last is RoomDetailsScreenCoordinator)
#expect(navigationStackCoordinator.rootCoordinator is BlankFormCoordinator, "A child room flow should push onto the stack, leaving the root alone.")
#expect(navigationStackCoordinator.stackCoordinators.count == 2)
#expect(navigationStackCoordinator.stackCoordinators.first is RoomScreenCoordinator)
#expect(navigationStackCoordinator.stackCoordinators.last is RoomDetailsScreenCoordinator)
try await clearRoute(expectedActions: [.finished])
XCTAssertTrue(navigationStackCoordinator.rootCoordinator is BlankFormCoordinator)
XCTAssertEqual(navigationStackCoordinator.stackCoordinators.count, 2, "A child room flow should leave its parent to clean up the stack.")
XCTAssertTrue(navigationStackCoordinator.stackCoordinators.first is RoomScreenCoordinator, "A child room flow should leave its parent to clean up the stack.")
XCTAssertTrue(navigationStackCoordinator.stackCoordinators.last is RoomDetailsScreenCoordinator, "A child room flow should leave its parent to clean up the stack.")
#expect(navigationStackCoordinator.rootCoordinator is BlankFormCoordinator)
#expect(navigationStackCoordinator.stackCoordinators.count == 2, "A child room flow should leave its parent to clean up the stack.")
#expect(navigationStackCoordinator.stackCoordinators.first is RoomScreenCoordinator, "A child room flow should leave its parent to clean up the stack.")
#expect(navigationStackCoordinator.stackCoordinators.last is RoomDetailsScreenCoordinator, "A child room flow should leave its parent to clean up the stack.")
}
func testChildRoomMemberDetails() async throws {
@Test
func childRoomMemberDetails() async throws {
setupRoomFlowCoordinator()
try await process(route: .room(roomID: "1", via: []))
XCTAssert(navigationStackCoordinator.rootCoordinator is RoomScreenCoordinator)
XCTAssertEqual(navigationStackCoordinator.stackCoordinators.count, 0)
#expect(navigationStackCoordinator.rootCoordinator is RoomScreenCoordinator)
#expect(navigationStackCoordinator.stackCoordinators.count == 0)
try await process(route: .childRoom(roomID: "2", via: []))
XCTAssertEqual(navigationStackCoordinator.stackCoordinators.count, 1)
XCTAssert(navigationStackCoordinator.stackCoordinators.first is RoomScreenCoordinator)
#expect(navigationStackCoordinator.stackCoordinators.count == 1)
#expect(navigationStackCoordinator.stackCoordinators.first is RoomScreenCoordinator)
try await process(route: .roomMemberDetails(userID: RoomMemberProxyMock.mockMe.userID))
XCTAssertEqual(navigationStackCoordinator.stackCoordinators.count, 2)
XCTAssert(navigationStackCoordinator.stackCoordinators.first is RoomScreenCoordinator)
XCTAssert(navigationStackCoordinator.stackCoordinators.last is RoomMemberDetailsScreenCoordinator)
#expect(navigationStackCoordinator.stackCoordinators.count == 2)
#expect(navigationStackCoordinator.stackCoordinators.first is RoomScreenCoordinator)
#expect(navigationStackCoordinator.stackCoordinators.last is RoomMemberDetailsScreenCoordinator)
}
func testChildRoomIgnoresDirectDuplicate() async throws {
@Test
func childRoomIgnoresDirectDuplicate() async throws {
setupRoomFlowCoordinator()
try await process(route: .room(roomID: "1", via: []))
XCTAssert(navigationStackCoordinator.rootCoordinator is RoomScreenCoordinator)
XCTAssertEqual(navigationStackCoordinator.stackCoordinators.count, 0)
#expect(navigationStackCoordinator.rootCoordinator is RoomScreenCoordinator)
#expect(navigationStackCoordinator.stackCoordinators.count == 0)
try await process(route: .childRoom(roomID: "1", via: []))
XCTAssertEqual(navigationStackCoordinator.stackCoordinators.count, 0,
"A room flow shouldn't present a direct child for the same room.")
#expect(navigationStackCoordinator.stackCoordinators.count == 0,
"A room flow shouldn't present a direct child for the same room.")
try await process(route: .childRoom(roomID: "2", via: []))
XCTAssertEqual(navigationStackCoordinator.stackCoordinators.count, 1)
XCTAssert(navigationStackCoordinator.stackCoordinators.first is RoomScreenCoordinator)
#expect(navigationStackCoordinator.stackCoordinators.count == 1)
#expect(navigationStackCoordinator.stackCoordinators.first is RoomScreenCoordinator)
try await process(route: .childRoom(roomID: "1", via: []))
XCTAssertEqual(navigationStackCoordinator.stackCoordinators.count, 2,
"Presenting the same room multiple times should be allowed when it's not a direct child of itself.")
XCTAssert(navigationStackCoordinator.stackCoordinators.first is RoomScreenCoordinator)
XCTAssert(navigationStackCoordinator.stackCoordinators.last is RoomScreenCoordinator)
#expect(navigationStackCoordinator.stackCoordinators.count == 2,
"Presenting the same room multiple times should be allowed when it's not a direct child of itself.")
#expect(navigationStackCoordinator.stackCoordinators.first is RoomScreenCoordinator)
#expect(navigationStackCoordinator.stackCoordinators.last is RoomScreenCoordinator)
}
func testRoomMembershipInvite() async throws {
@Test
func roomMembershipInvite() async throws {
setupRoomFlowCoordinator(roomType: .invited(roomID: "InvitedRoomID"))
try await process(route: .room(roomID: "InvitedRoomID", via: []))
XCTAssert(navigationStackCoordinator.rootCoordinator is JoinRoomScreenCoordinator)
XCTAssertEqual(navigationStackCoordinator.stackCoordinators.count, 0)
#expect(navigationStackCoordinator.rootCoordinator is JoinRoomScreenCoordinator)
#expect(navigationStackCoordinator.stackCoordinators.count == 0)
try await clearRoute(expectedActions: [.finished])
XCTAssertNil(navigationStackCoordinator.rootCoordinator)
#expect(navigationStackCoordinator.rootCoordinator == nil)
setupRoomFlowCoordinator(roomType: .invited(roomID: "InvitedRoomID"))
try await process(route: .room(roomID: "InvitedRoomID", via: []))
XCTAssert(navigationStackCoordinator.rootCoordinator is JoinRoomScreenCoordinator)
XCTAssertEqual(navigationStackCoordinator.stackCoordinators.count, 0)
#expect(navigationStackCoordinator.rootCoordinator is JoinRoomScreenCoordinator)
#expect(navigationStackCoordinator.stackCoordinators.count == 0)
// "Join" the room
clientProxy.roomForIdentifierClosure = { _ in
@@ -171,29 +181,30 @@ class RoomFlowCoordinatorTests: XCTestCase {
}
try await process(route: .room(roomID: "InvitedRoomID", via: []))
XCTAssert(navigationStackCoordinator.rootCoordinator is RoomScreenCoordinator)
XCTAssertEqual(navigationStackCoordinator.stackCoordinators.count, 0)
#expect(navigationStackCoordinator.rootCoordinator is RoomScreenCoordinator)
#expect(navigationStackCoordinator.stackCoordinators.count == 0)
}
func testChildRoomMembershipInvite() async throws {
@Test
func childRoomMembershipInvite() async throws {
setupRoomFlowCoordinator(asChildFlow: true, roomType: .invited(roomID: "InvitedRoomID"))
navigationStackCoordinator.setRootCoordinator(BlankFormCoordinator())
try await process(route: .room(roomID: "InvitedRoomID", via: []))
XCTAssertTrue(navigationStackCoordinator.rootCoordinator is BlankFormCoordinator, "A child room flow should push onto the stack, leaving the root alone.")
XCTAssertEqual(navigationStackCoordinator.stackCoordinators.count, 1)
XCTAssertTrue(navigationStackCoordinator.stackCoordinators.last is JoinRoomScreenCoordinator)
#expect(navigationStackCoordinator.rootCoordinator is BlankFormCoordinator, "A child room flow should push onto the stack, leaving the root alone.")
#expect(navigationStackCoordinator.stackCoordinators.count == 1)
#expect(navigationStackCoordinator.stackCoordinators.last is JoinRoomScreenCoordinator)
try await clearRoute(expectedActions: [.finished])
XCTAssertNil(navigationStackCoordinator.stackCoordinators.last, "A child room flow should remove the join room scren on dismissal")
#expect(navigationStackCoordinator.stackCoordinators.last == nil, "A child room flow should remove the join room scren on dismissal")
setupRoomFlowCoordinator(asChildFlow: true, roomType: .invited(roomID: "InvitedRoomID"))
navigationStackCoordinator.setRootCoordinator(BlankFormCoordinator())
try await process(route: .room(roomID: "InvitedRoomID", via: []))
XCTAssertTrue(navigationStackCoordinator.rootCoordinator is BlankFormCoordinator, "A child room flow should push onto the stack, leaving the root alone.")
XCTAssertEqual(navigationStackCoordinator.stackCoordinators.count, 1)
XCTAssertTrue(navigationStackCoordinator.stackCoordinators.last is JoinRoomScreenCoordinator)
#expect(navigationStackCoordinator.rootCoordinator is BlankFormCoordinator, "A child room flow should push onto the stack, leaving the root alone.")
#expect(navigationStackCoordinator.stackCoordinators.count == 1)
#expect(navigationStackCoordinator.stackCoordinators.last is JoinRoomScreenCoordinator)
// "Join" the room
clientProxy.roomForIdentifierClosure = { _ in
@@ -201,29 +212,31 @@ class RoomFlowCoordinatorTests: XCTestCase {
}
try await process(route: .room(roomID: "InvitedRoomID", via: []))
XCTAssertTrue(navigationStackCoordinator.rootCoordinator is BlankFormCoordinator, "A child room flow should push onto the stack, leaving the root alone.")
XCTAssertEqual(navigationStackCoordinator.stackCoordinators.count, 1)
XCTAssertTrue(navigationStackCoordinator.stackCoordinators.last is RoomScreenCoordinator)
#expect(navigationStackCoordinator.rootCoordinator is BlankFormCoordinator, "A child room flow should push onto the stack, leaving the root alone.")
#expect(navigationStackCoordinator.stackCoordinators.count == 1)
#expect(navigationStackCoordinator.stackCoordinators.last is RoomScreenCoordinator)
}
func testEventRoute() async throws {
@Test
func eventRoute() async throws {
setupRoomFlowCoordinator()
try await process(route: .event(eventID: "1", roomID: "1", via: []))
XCTAssert(navigationStackCoordinator.rootCoordinator is RoomScreenCoordinator)
XCTAssertEqual(navigationStackCoordinator.stackCoordinators.count, 0)
#expect(navigationStackCoordinator.rootCoordinator is RoomScreenCoordinator)
#expect(navigationStackCoordinator.stackCoordinators.count == 0)
try await process(route: .childEvent(eventID: "2", roomID: "1", via: []))
XCTAssert(navigationStackCoordinator.rootCoordinator is RoomScreenCoordinator)
XCTAssertEqual(navigationStackCoordinator.stackCoordinators.count, 0)
#expect(navigationStackCoordinator.rootCoordinator is RoomScreenCoordinator)
#expect(navigationStackCoordinator.stackCoordinators.count == 0)
try await process(route: .childEvent(eventID: "3", roomID: "2", via: []))
XCTAssert(navigationStackCoordinator.rootCoordinator is RoomScreenCoordinator)
XCTAssertEqual(navigationStackCoordinator.stackCoordinators.count, 1)
XCTAssert(navigationStackCoordinator.stackCoordinators.first is RoomScreenCoordinator)
#expect(navigationStackCoordinator.rootCoordinator is RoomScreenCoordinator)
#expect(navigationStackCoordinator.stackCoordinators.count == 1)
#expect(navigationStackCoordinator.stackCoordinators.first is RoomScreenCoordinator)
}
func testThreadedEventRoutes() async throws {
@Test
func threadedEventRoutes() async throws {
ServiceLocator.shared.settings.threadsEnabled = true
setupRoomFlowCoordinator()
@@ -243,17 +256,17 @@ class RoomFlowCoordinatorTests: XCTestCase {
}
try await process(route: .event(eventID: "2", roomID: "1", via: []))
XCTAssert(navigationStackCoordinator.rootCoordinator is RoomScreenCoordinator)
XCTAssertEqual(navigationStackCoordinator.stackCoordinators.count, 1)
XCTAssert(navigationStackCoordinator.stackCoordinators[0] is ThreadTimelineScreenCoordinator)
#expect(navigationStackCoordinator.rootCoordinator is RoomScreenCoordinator)
try #require(navigationStackCoordinator.stackCoordinators.count == 1) // #require these counts so accessing by index is safe.
#expect(navigationStackCoordinator.stackCoordinators[0] is ThreadTimelineScreenCoordinator)
// From the thread screen, navigate to another threaded event in the same room, and in the same thread.
let threadCoordinator = navigationStackCoordinator.stackCoordinators[0] as? ThreadTimelineScreenCoordinator
try await process(route: .childEvent(eventID: "3", roomID: "1", via: []))
XCTAssert(navigationStackCoordinator.rootCoordinator is RoomScreenCoordinator)
XCTAssertEqual(navigationStackCoordinator.stackCoordinators.count, 1)
XCTAssert(navigationStackCoordinator.stackCoordinators[0] is ThreadTimelineScreenCoordinator)
XCTAssertIdentical(navigationStackCoordinator.stackCoordinators[0], threadCoordinator)
#expect(navigationStackCoordinator.rootCoordinator is RoomScreenCoordinator)
try #require(navigationStackCoordinator.stackCoordinators.count == 1)
#expect(navigationStackCoordinator.stackCoordinators[0] is ThreadTimelineScreenCoordinator)
#expect(navigationStackCoordinator.stackCoordinators[0] === threadCoordinator)
// Would be nice to test if the focusEvent function has been called but there is no way to mock that.
// From the thread screen, navigate to another threaded event in the same room, but in a different thread.
@@ -261,10 +274,10 @@ class RoomFlowCoordinatorTests: XCTestCase {
mockedEvent.threadRootEventIdReturnValue = "4"
roomProxy.loadOrFetchEventDetailsForReturnValue = .success(mockedEvent)
try await process(route: .childEvent(eventID: "5", roomID: "1", via: []))
XCTAssert(navigationStackCoordinator.rootCoordinator is RoomScreenCoordinator)
XCTAssertEqual(navigationStackCoordinator.stackCoordinators.count, 2)
XCTAssert(navigationStackCoordinator.stackCoordinators[0] is ThreadTimelineScreenCoordinator)
XCTAssert(navigationStackCoordinator.stackCoordinators[1] is ThreadTimelineScreenCoordinator)
#expect(navigationStackCoordinator.rootCoordinator is RoomScreenCoordinator)
try #require(navigationStackCoordinator.stackCoordinators.count == 2)
#expect(navigationStackCoordinator.stackCoordinators[0] is ThreadTimelineScreenCoordinator)
#expect(navigationStackCoordinator.stackCoordinators[1] is ThreadTimelineScreenCoordinator)
// From the thread screen, navigate to another threaded event in a different room.
configuration = JoinedRoomProxyMockConfiguration(id: "2")
@@ -282,12 +295,12 @@ class RoomFlowCoordinatorTests: XCTestCase {
}
try await process(route: .childEvent(eventID: "2", roomID: "2", via: []))
XCTAssert(navigationStackCoordinator.rootCoordinator is RoomScreenCoordinator)
XCTAssertEqual(navigationStackCoordinator.stackCoordinators.count, 4)
XCTAssert(navigationStackCoordinator.stackCoordinators[0] is ThreadTimelineScreenCoordinator)
XCTAssert(navigationStackCoordinator.stackCoordinators[1] is ThreadTimelineScreenCoordinator)
XCTAssert(navigationStackCoordinator.stackCoordinators[2] is RoomScreenCoordinator)
XCTAssert(navigationStackCoordinator.stackCoordinators[3] is ThreadTimelineScreenCoordinator)
#expect(navigationStackCoordinator.rootCoordinator is RoomScreenCoordinator)
try #require(navigationStackCoordinator.stackCoordinators.count == 4)
#expect(navigationStackCoordinator.stackCoordinators[0] is ThreadTimelineScreenCoordinator)
#expect(navigationStackCoordinator.stackCoordinators[1] is ThreadTimelineScreenCoordinator)
#expect(navigationStackCoordinator.stackCoordinators[2] is RoomScreenCoordinator)
#expect(navigationStackCoordinator.stackCoordinators[3] is ThreadTimelineScreenCoordinator)
// From the thread screen, navigate to an event of the same room that is not threaded
mockedEvent = TimelineEventSDKMock()
@@ -295,66 +308,69 @@ class RoomFlowCoordinatorTests: XCTestCase {
roomProxy.loadOrFetchEventDetailsForReturnValue = .success(mockedEvent)
try await process(route: .childEvent(eventID: "3", roomID: "2", via: []))
XCTAssert(navigationStackCoordinator.rootCoordinator is RoomScreenCoordinator)
XCTAssertEqual(navigationStackCoordinator.stackCoordinators.count, 5)
XCTAssert(navigationStackCoordinator.stackCoordinators[0] is ThreadTimelineScreenCoordinator)
XCTAssert(navigationStackCoordinator.stackCoordinators[1] is ThreadTimelineScreenCoordinator)
XCTAssert(navigationStackCoordinator.stackCoordinators[2] is RoomScreenCoordinator)
XCTAssert(navigationStackCoordinator.stackCoordinators[3] is ThreadTimelineScreenCoordinator)
XCTAssert(navigationStackCoordinator.stackCoordinators[4] is RoomScreenCoordinator)
#expect(navigationStackCoordinator.rootCoordinator is RoomScreenCoordinator)
try #require(navigationStackCoordinator.stackCoordinators.count == 5)
#expect(navigationStackCoordinator.stackCoordinators[0] is ThreadTimelineScreenCoordinator)
#expect(navigationStackCoordinator.stackCoordinators[1] is ThreadTimelineScreenCoordinator)
#expect(navigationStackCoordinator.stackCoordinators[2] is RoomScreenCoordinator)
#expect(navigationStackCoordinator.stackCoordinators[3] is ThreadTimelineScreenCoordinator)
#expect(navigationStackCoordinator.stackCoordinators[4] is RoomScreenCoordinator)
}
func testShareMediaRoute() async throws {
@Test
func shareMediaRoute() async throws {
setupRoomFlowCoordinator()
try await process(route: .room(roomID: "1", via: []))
XCTAssert(navigationStackCoordinator.rootCoordinator is RoomScreenCoordinator)
XCTAssertEqual(navigationStackCoordinator.stackCoordinators.count, 0)
#expect(navigationStackCoordinator.rootCoordinator is RoomScreenCoordinator)
#expect(navigationStackCoordinator.stackCoordinators.count == 0)
let sharePayload: ShareExtensionPayload = .mediaFiles(roomID: "1", mediaFiles: [.init(url: .picturesDirectory, suggestedName: nil)])
try await process(route: .share(sharePayload))
XCTAssert(navigationStackCoordinator.rootCoordinator is RoomScreenCoordinator)
XCTAssertEqual(navigationStackCoordinator.stackCoordinators.count, 0)
#expect(navigationStackCoordinator.rootCoordinator is RoomScreenCoordinator)
#expect(navigationStackCoordinator.stackCoordinators.count == 0)
XCTAssertTrue((navigationStackCoordinator.sheetCoordinator as? NavigationStackCoordinator)?.rootCoordinator is MediaUploadPreviewScreenCoordinator)
#expect((navigationStackCoordinator.sheetCoordinator as? NavigationStackCoordinator)?.rootCoordinator is MediaUploadPreviewScreenCoordinator)
try await process(route: .childRoom(roomID: "2", via: []))
XCTAssertNil(navigationStackCoordinator.sheetCoordinator)
XCTAssertEqual(navigationStackCoordinator.stackCoordinators.count, 1)
#expect(navigationStackCoordinator.sheetCoordinator == nil)
#expect(navigationStackCoordinator.stackCoordinators.count == 1)
try await process(route: .share(sharePayload))
XCTAssertEqual(navigationStackCoordinator.stackCoordinators.count, 0)
XCTAssertTrue((navigationStackCoordinator.sheetCoordinator as? NavigationStackCoordinator)?.rootCoordinator is MediaUploadPreviewScreenCoordinator)
#expect(navigationStackCoordinator.stackCoordinators.count == 0)
#expect((navigationStackCoordinator.sheetCoordinator as? NavigationStackCoordinator)?.rootCoordinator is MediaUploadPreviewScreenCoordinator)
}
func testShareTextRoute() async throws {
@Test
func shareTextRoute() async throws {
setupRoomFlowCoordinator()
try await process(route: .room(roomID: "1", via: []))
XCTAssert(navigationStackCoordinator.rootCoordinator is RoomScreenCoordinator)
XCTAssertEqual(navigationStackCoordinator.stackCoordinators.count, 0)
#expect(navigationStackCoordinator.rootCoordinator is RoomScreenCoordinator)
#expect(navigationStackCoordinator.stackCoordinators.count == 0)
let sharePayload: ShareExtensionPayload = .text(roomID: "1", text: "Important text")
try await process(route: .share(sharePayload))
XCTAssert(navigationStackCoordinator.rootCoordinator is RoomScreenCoordinator)
XCTAssertEqual(navigationStackCoordinator.stackCoordinators.count, 0)
#expect(navigationStackCoordinator.rootCoordinator is RoomScreenCoordinator)
#expect(navigationStackCoordinator.stackCoordinators.count == 0)
XCTAssertNil(navigationStackCoordinator.sheetCoordinator, "The media upload sheet shouldn't be shown when sharing text.")
#expect(navigationStackCoordinator.sheetCoordinator == nil, "The media upload sheet shouldn't be shown when sharing text.")
try await process(route: .childRoom(roomID: "2", via: []))
XCTAssertNil(navigationStackCoordinator.sheetCoordinator)
XCTAssertEqual(navigationStackCoordinator.stackCoordinators.count, 1)
#expect(navigationStackCoordinator.sheetCoordinator == nil)
#expect(navigationStackCoordinator.stackCoordinators.count == 1)
try await process(route: .share(sharePayload))
XCTAssertEqual(navigationStackCoordinator.stackCoordinators.count, 0)
XCTAssertNil(navigationStackCoordinator.sheetCoordinator, "The media upload sheet shouldn't be shown when sharing text.")
#expect(navigationStackCoordinator.stackCoordinators.count == 0)
#expect(navigationStackCoordinator.sheetCoordinator == nil, "The media upload sheet shouldn't be shown when sharing text.")
}
func testLeavingRoom() async throws {
@Test
func leavingRoom() async throws {
setupRoomFlowCoordinator()
var configuration = JoinedRoomProxyMockConfiguration()
@@ -381,15 +397,16 @@ class RoomFlowCoordinatorTests: XCTestCase {
// MARK: - Spaces
func testSpacePermalink() async throws {
@Test
func spacePermalink() async throws {
setupRoomFlowCoordinator()
try await process(route: .room(roomID: "1", via: []))
XCTAssert(navigationStackCoordinator.rootCoordinator is RoomScreenCoordinator)
#expect(navigationStackCoordinator.rootCoordinator is RoomScreenCoordinator)
try await process(route: .childRoom(roomID: "space1", via: []))
XCTAssert(navigationStackCoordinator.rootCoordinator is RoomScreenCoordinator)
XCTAssert(navigationStackCoordinator.stackCoordinators.first is SpaceScreenCoordinator)
#expect(navigationStackCoordinator.rootCoordinator is RoomScreenCoordinator)
#expect(navigationStackCoordinator.stackCoordinators.first is SpaceScreenCoordinator)
}
// MARK: - Private

View File

@@ -9,21 +9,22 @@
import Combine
@testable import ElementX
import MatrixRustSDK
import XCTest
import Testing
@Suite
@MainActor
class RoomNotificationSettingsScreenViewModelTests: XCTestCase {
struct RoomNotificationSettingsScreenViewModelTests {
var roomProxyMock: JoinedRoomProxyMock!
var notificationSettingsProxyMock: NotificationSettingsProxyMock!
var cancellables = Set<AnyCancellable>()
override func setUpWithError() throws {
cancellables.removeAll()
init() {
roomProxyMock = JoinedRoomProxyMock(.init(name: "Test"))
notificationSettingsProxyMock = NotificationSettingsProxyMock(with: NotificationSettingsProxyMockConfiguration())
}
func testInitialStateDefaultModeEncryptedRoom() async throws {
@Test
func initialStateDefaultModeEncryptedRoom() async throws {
let roomProxyMock = JoinedRoomProxyMock(.init(name: "Test", isEncrypted: true))
let notificationSettingsProxyMock = NotificationSettingsProxyMock(with: NotificationSettingsProxyMockConfiguration())
@@ -33,19 +34,20 @@ class RoomNotificationSettingsScreenViewModelTests: XCTestCase {
roomProxy: roomProxyMock,
displayAsUserDefinedRoomSettings: false)
let deferred = deferFulfillment(viewModel.context.$viewState) { state in
let deferred = deferFulfillment(viewModel.context.observe(\.viewState)) { state in
state.notificationSettingsState.isLoaded
}
notificationSettingsProxyMock.callbacks.send(.settingsDidChange)
try await deferred.fulfill()
XCTAssertFalse(viewModel.context.allowCustomSetting)
XCTAssertTrue(viewModel.context.viewState.shouldDisplayMentionsOnlyDisclaimer)
XCTAssertNotNil(viewModel.context.viewState.description(mode: .mentionsAndKeywordsOnly))
#expect(!viewModel.context.allowCustomSetting)
#expect(viewModel.context.viewState.shouldDisplayMentionsOnlyDisclaimer)
#expect(viewModel.context.viewState.description(mode: .mentionsAndKeywordsOnly) != nil)
}
func testInitialStateDefaultModeEncryptedRoomWithCanPushEncrypted() async throws {
@Test
func initialStateDefaultModeEncryptedRoomWithCanPushEncrypted() async throws {
let roomProxyMock = JoinedRoomProxyMock(.init(name: "Test", isEncrypted: true))
let notificationSettingsProxyMock = NotificationSettingsProxyMock(with: .init(canPushEncryptedEvents: true))
@@ -55,19 +57,20 @@ class RoomNotificationSettingsScreenViewModelTests: XCTestCase {
roomProxy: roomProxyMock,
displayAsUserDefinedRoomSettings: false)
let deferred = deferFulfillment(viewModel.context.$viewState) { state in
let deferred = deferFulfillment(viewModel.context.observe(\.viewState)) { state in
state.notificationSettingsState.isLoaded
}
notificationSettingsProxyMock.callbacks.send(.settingsDidChange)
try await deferred.fulfill()
XCTAssertFalse(viewModel.context.allowCustomSetting)
XCTAssertFalse(viewModel.context.viewState.shouldDisplayMentionsOnlyDisclaimer)
XCTAssertNil(viewModel.context.viewState.description(mode: .mentionsAndKeywordsOnly))
#expect(!viewModel.context.allowCustomSetting)
#expect(!viewModel.context.viewState.shouldDisplayMentionsOnlyDisclaimer)
#expect(viewModel.context.viewState.description(mode: .mentionsAndKeywordsOnly) == nil)
}
func testInitialStateDefaultModeUnencryptedRoom() async throws {
@Test
func initialStateDefaultModeUnencryptedRoom() async throws {
let roomProxyMock = JoinedRoomProxyMock(.init(name: "Test", isEncrypted: false))
let notificationSettingsProxyMock = NotificationSettingsProxyMock(with: NotificationSettingsProxyMockConfiguration())
@@ -77,39 +80,41 @@ class RoomNotificationSettingsScreenViewModelTests: XCTestCase {
roomProxy: roomProxyMock,
displayAsUserDefinedRoomSettings: false)
let deferred = deferFulfillment(viewModel.context.$viewState) { state in
let deferred = deferFulfillment(viewModel.context.observe(\.viewState)) { state in
state.notificationSettingsState.isLoaded
}
notificationSettingsProxyMock.callbacks.send(.settingsDidChange)
try await deferred.fulfill()
XCTAssertFalse(viewModel.context.allowCustomSetting)
XCTAssertFalse(viewModel.context.viewState.shouldDisplayMentionsOnlyDisclaimer)
XCTAssertNil(viewModel.context.viewState.description(mode: .mentionsAndKeywordsOnly))
#expect(!viewModel.context.allowCustomSetting)
#expect(!viewModel.context.viewState.shouldDisplayMentionsOnlyDisclaimer)
#expect(viewModel.context.viewState.description(mode: .mentionsAndKeywordsOnly) == nil)
}
func testInitialStateCustomMode() async throws {
@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.$viewState) { state in
let deferred = deferFulfillment(viewModel.context.observe(\.viewState)) { state in
state.notificationSettingsState.isLoaded
}
notificationSettingsProxyMock.callbacks.send(.settingsDidChange)
try await deferred.fulfill()
XCTAssertTrue(viewModel.context.allowCustomSetting)
#expect(viewModel.context.allowCustomSetting)
}
func testInitialStateFailure() async throws {
@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.$viewState) { state in
let deferred = deferFulfillment(viewModel.context.observe(\.viewState)) { state in
state.notificationSettingsState.isError
}
@@ -119,25 +124,25 @@ class RoomNotificationSettingsScreenViewModelTests: XCTestCase {
let expectedAlertInfo = AlertInfo(id: RoomNotificationSettingsScreenErrorType.loadingSettingsFailed,
title: L10n.commonError,
message: L10n.screenRoomNotificationSettingsErrorLoadingSettings)
XCTAssertEqual(viewModel.context.viewState.bindings.alertInfo?.id, expectedAlertInfo.id)
XCTAssertEqual(viewModel.context.viewState.bindings.alertInfo?.title, expectedAlertInfo.title)
XCTAssertEqual(viewModel.context.viewState.bindings.alertInfo?.message, expectedAlertInfo.message)
#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)
}
func testToggleAllCustomSettingOff() async throws {
@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.$viewState) { state in
let deferred = deferFulfillment(viewModel.context.observe(\.viewState)) { state in
state.notificationSettingsState.isLoaded
}
notificationSettingsProxyMock.callbacks.send(.settingsDidChange)
try await deferred.fulfill()
let deferredIsRestoringDefaultSettings = deferFulfillment(viewModel.context.$viewState,
keyPath: \.isRestoringDefaultSetting,
let deferredIsRestoringDefaultSettings = deferFulfillment(viewModel.context.observe(\.viewState.isRestoringDefaultSetting),
transitionValues: [false, true, false])
viewModel.state.bindings.allowCustomSetting = false
@@ -145,18 +150,19 @@ class RoomNotificationSettingsScreenViewModelTests: XCTestCase {
try await deferredIsRestoringDefaultSettings.fulfill()
XCTAssertEqual(notificationSettingsProxyMock.restoreDefaultNotificationModeRoomIdReceivedRoomId, roomProxyMock.id)
XCTAssertEqual(notificationSettingsProxyMock.restoreDefaultNotificationModeRoomIdCallsCount, 1)
#expect(notificationSettingsProxyMock.restoreDefaultNotificationModeRoomIdReceivedRoomId == roomProxyMock.id)
#expect(notificationSettingsProxyMock.restoreDefaultNotificationModeRoomIdCallsCount == 1)
}
func testToggleAllCustomSettingOffOn() async throws {
@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.$viewState) { state in
var deferred = deferFulfillment(viewModel.context.observe(\.viewState)) { state in
state.notificationSettingsState.isLoaded
}
@@ -164,82 +170,75 @@ class RoomNotificationSettingsScreenViewModelTests: XCTestCase {
try await deferred.fulfill()
deferred = deferFulfillment(viewModel.context.$viewState) { state in
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()
XCTAssertEqual(notificationSettingsProxyMock.setNotificationModeRoomIdModeReceivedArguments?.0, roomProxyMock.id)
XCTAssertEqual(notificationSettingsProxyMock.setNotificationModeRoomIdModeReceivedArguments?.1, .mentionsAndKeywordsOnly)
XCTAssertEqual(notificationSettingsProxyMock.setNotificationModeRoomIdModeCallsCount, 1)
}
func testSetCustomMode() async throws {
@Test
func setCustomMode() async throws {
notificationSettingsProxyMock.getNotificationSettingsRoomIdIsEncryptedIsOneToOneReturnValue = RoomNotificationSettingsProxyMock(with: .init(mode: .mentionsAndKeywordsOnly, isDefault: false))
let viewModel = RoomNotificationSettingsScreenViewModel(notificationSettingsProxy: notificationSettingsProxyMock,
roomProxy: roomProxyMock,
displayAsUserDefinedRoomSettings: false)
let deferredState = deferFulfillment(viewModel.context.$viewState) { state in
let deferred = deferFulfillment(viewModel.context.observe(\.viewState)) { state in
state.notificationSettingsState.isLoaded
}
notificationSettingsProxyMock.callbacks.send(.settingsDidChange)
try await deferredState.fulfill()
do {
viewModel.context.send(viewAction: .setCustomMode(.allMessages))
let deferredState = deferFulfillment(viewModel.context.$viewState) { state in
state.pendingCustomMode == nil
}
try await deferredState.fulfill()
XCTAssertEqual(notificationSettingsProxyMock.setNotificationModeRoomIdModeReceivedArguments?.0, roomProxyMock.id)
XCTAssertEqual(notificationSettingsProxyMock.setNotificationModeRoomIdModeReceivedArguments?.1, .allMessages)
XCTAssertEqual(notificationSettingsProxyMock.setNotificationModeRoomIdModeCallsCount, 1)
}
try await deferred.fulfill()
do {
viewModel.context.send(viewAction: .setCustomMode(.mute))
let deferredState = deferFulfillment(viewModel.context.$viewState) { state in
state.pendingCustomMode == nil
}
try await deferredState.fulfill()
XCTAssertEqual(notificationSettingsProxyMock.setNotificationModeRoomIdModeReceivedArguments?.0, roomProxyMock.id)
XCTAssertEqual(notificationSettingsProxyMock.setNotificationModeRoomIdModeReceivedArguments?.1, .mute)
XCTAssertEqual(notificationSettingsProxyMock.setNotificationModeRoomIdModeCallsCount, 2)
}
var deferredMode = deferFulfillment(viewModel.context.observe(\.viewState.pendingCustomMode),
transitionValues: [nil, .allMessages, nil])
viewModel.context.send(viewAction: .setCustomMode(.allMessages))
do {
viewModel.context.send(viewAction: .setCustomMode(.mentionsAndKeywordsOnly))
let deferredState = deferFulfillment(viewModel.context.$viewState) { state in
state.pendingCustomMode == nil
}
try await deferredState.fulfill()
XCTAssertEqual(notificationSettingsProxyMock.setNotificationModeRoomIdModeReceivedArguments?.0, roomProxyMock.id)
XCTAssertEqual(notificationSettingsProxyMock.setNotificationModeRoomIdModeReceivedArguments?.1, .mentionsAndKeywordsOnly)
XCTAssertEqual(notificationSettingsProxyMock.setNotificationModeRoomIdModeCallsCount, 3)
}
try await deferredMode.fulfill()
#expect(notificationSettingsProxyMock.setNotificationModeRoomIdModeReceivedArguments?.0 == roomProxyMock.id)
#expect(notificationSettingsProxyMock.setNotificationModeRoomIdModeReceivedArguments?.1 == .allMessages)
#expect(notificationSettingsProxyMock.setNotificationModeRoomIdModeCallsCount == 1)
deferredMode = deferFulfillment(viewModel.context.observe(\.viewState.pendingCustomMode),
transitionValues: [nil, .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)
deferredMode = deferFulfillment(viewModel.context.observe(\.viewState.pendingCustomMode),
transitionValues: [nil, .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)
}
func testDeleteCustomSettingTapped() async throws {
@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.$viewState) { state in
let deferred = deferFulfillment(viewModel.context.observe(\.viewState)) { state in
state.notificationSettingsState.isLoaded
}
@@ -253,8 +252,7 @@ class RoomNotificationSettingsScreenViewModelTests: XCTestCase {
}
.store(in: &cancellables)
let deferredViewState = deferFulfillment(viewModel.context.$viewState,
keyPath: \.deletingCustomSetting,
let deferredViewState = deferFulfillment(viewModel.context.observe(\.viewState.deletingCustomSetting),
transitionValues: [false, true, false])
viewModel.context.send(viewAction: .deleteCustomSettingTapped)
@@ -262,21 +260,22 @@ class RoomNotificationSettingsScreenViewModelTests: XCTestCase {
try await deferredViewState.fulfill()
// the `dismiss` action must have been sent
XCTAssertEqual(actionSent, .dismiss)
#expect(actionSent == .dismiss)
// `restoreDefaultNotificationMode` should have been called
XCTAssert(notificationSettingsProxyMock.restoreDefaultNotificationModeRoomIdCalled)
XCTAssertEqual(notificationSettingsProxyMock.restoreDefaultNotificationModeRoomIdReceivedInvocations, [roomProxyMock.id])
#expect(notificationSettingsProxyMock.restoreDefaultNotificationModeRoomIdCalled)
#expect(notificationSettingsProxyMock.restoreDefaultNotificationModeRoomIdReceivedInvocations == [roomProxyMock.id])
// and no alert is expected
XCTAssertNil(viewModel.context.alertInfo)
#expect(viewModel.context.alertInfo == nil)
}
func testDeleteCustomSettingTappedFailure() async throws {
@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.$viewState) { state in
let deferred = deferFulfillment(viewModel.context.observe(\.viewState)) { state in
state.notificationSettingsState.isLoaded
}
@@ -290,8 +289,7 @@ class RoomNotificationSettingsScreenViewModelTests: XCTestCase {
}
.store(in: &cancellables)
let deferredViewState = deferFulfillment(viewModel.context.$viewState,
keyPath: \.deletingCustomSetting,
let deferredViewState = deferFulfillment(viewModel.context.observe(\.viewState.deletingCustomSetting),
transitionValues: [false, true, false])
viewModel.context.send(viewAction: .deleteCustomSettingTapped)
@@ -299,8 +297,8 @@ class RoomNotificationSettingsScreenViewModelTests: XCTestCase {
try await deferredViewState.fulfill()
// an alert is expected
XCTAssertEqual(viewModel.context.alertInfo?.id, .restoreDefaultFailed)
#expect(viewModel.context.alertInfo?.id == .restoreDefaultFailed)
// the `dismiss` action must not have been sent
XCTAssertNil(actionSent)
#expect(actionSent == nil)
}
}

View File

@@ -7,15 +7,17 @@
//
@testable import ElementX
import XCTest
import Foundation
import Testing
@Suite
@MainActor
class RoomPollsHistoryScreenViewModelTests: XCTestCase {
struct RoomPollsHistoryScreenViewModelTests {
var viewModel: RoomPollsHistoryScreenViewModelProtocol!
var interactionHandler: PollInteractionHandlerMock!
var timelineController: MockTimelineController!
override func setUpWithError() throws {
init() throws {
interactionHandler = PollInteractionHandlerMock()
timelineController = MockTimelineController()
viewModel = RoomPollsHistoryScreenViewModel(pollInteractionHandler: interactionHandler,
@@ -23,7 +25,8 @@ class RoomPollsHistoryScreenViewModelTests: XCTestCase {
userIndicatorController: UserIndicatorControllerMock())
}
func testBackPaginate() async throws {
@Test
func backPaginate() async throws {
timelineController.backPaginationResponses = [
[PollRoomTimelineItem.mock(poll: .emptyDisclosed, isEditable: true),
PollRoomTimelineItem.mock(poll: .disclosed(createdByAccountOwner: true)),
@@ -37,11 +40,12 @@ class RoomPollsHistoryScreenViewModelTests: XCTestCase {
try await deferredViewState.fulfill()
XCTAssertEqual(viewModel.context.viewState.pollTimelineItems.count, 3)
XCTAssertFalse(viewModel.context.viewState.canBackPaginate)
#expect(viewModel.context.viewState.pollTimelineItems.count == 3)
#expect(!viewModel.context.viewState.canBackPaginate)
}
func testBackPaginateCanBackPaginate() async throws {
@Test
func backPaginateCanBackPaginate() async throws {
timelineController.backPaginationResponses = [
[PollRoomTimelineItem.mock(poll: .emptyDisclosed, isEditable: true),
PollRoomTimelineItem.mock(poll: .disclosed(createdByAccountOwner: true)),
@@ -56,11 +60,12 @@ class RoomPollsHistoryScreenViewModelTests: XCTestCase {
try await deferredViewState.fulfill()
XCTAssertEqual(viewModel.context.viewState.pollTimelineItems.count, 3)
XCTAssert(viewModel.context.viewState.canBackPaginate)
#expect(viewModel.context.viewState.pollTimelineItems.count == 3)
#expect(viewModel.context.viewState.canBackPaginate)
}
func testBackPaginateTwice() async throws {
@Test
func backPaginateTwice() async throws {
timelineController.backPaginationResponses = [
[PollRoomTimelineItem.mock(poll: .emptyDisclosed, isEditable: true),
PollRoomTimelineItem.mock(poll: .disclosed(createdByAccountOwner: true)),
@@ -74,11 +79,12 @@ class RoomPollsHistoryScreenViewModelTests: XCTestCase {
try await deferredViewState.fulfill()
XCTAssertEqual(viewModel.context.viewState.pollTimelineItems.count, 3)
XCTAssert(viewModel.context.viewState.canBackPaginate)
#expect(viewModel.context.viewState.pollTimelineItems.count == 3)
#expect(viewModel.context.viewState.canBackPaginate)
}
func testFilters() async throws {
@Test
func filters() async throws {
timelineController.backPaginationResponses = [
[PollRoomTimelineItem.mock(poll: .emptyDisclosed, isEditable: true),
PollRoomTimelineItem.mock(poll: .disclosed(createdByAccountOwner: true)),
@@ -96,13 +102,14 @@ class RoomPollsHistoryScreenViewModelTests: XCTestCase {
try await deferredViewState.fulfill()
XCTAssertEqual(viewModel.context.viewState.pollTimelineItems.count, 3)
#expect(viewModel.context.viewState.pollTimelineItems.count == 3)
viewModel.context.send(viewAction: .filter(.past))
XCTAssertEqual(viewModel.context.viewState.pollTimelineItems.count, 1)
#expect(viewModel.context.viewState.pollTimelineItems.count == 1)
}
func testEndPoll() async throws {
@Test
func endPoll() async throws {
let deferred = deferFulfillment(interactionHandler.publisher.delay(for: 0.1, scheduler: DispatchQueue.main)) { _ in true }
interactionHandler.endPollPollStartIDReturnValue = .success(())
@@ -110,11 +117,12 @@ class RoomPollsHistoryScreenViewModelTests: XCTestCase {
try await deferred.fulfill()
XCTAssert(interactionHandler.endPollPollStartIDCalled)
XCTAssertEqual(interactionHandler.endPollPollStartIDReceivedPollStartID, "somePollID")
#expect(interactionHandler.endPollPollStartIDCalled)
#expect(interactionHandler.endPollPollStartIDReceivedPollStartID == "somePollID")
}
func testEndPollFailure() async throws {
@Test
func endPollFailure() async throws {
let deferred = deferFulfillment(viewModel.context.$viewState) { value in
value.bindings.alertInfo != nil
}
@@ -124,11 +132,12 @@ class RoomPollsHistoryScreenViewModelTests: XCTestCase {
try await deferred.fulfill()
XCTAssert(interactionHandler.endPollPollStartIDCalled)
XCTAssertEqual(interactionHandler.endPollPollStartIDReceivedPollStartID, "somePollID")
#expect(interactionHandler.endPollPollStartIDCalled)
#expect(interactionHandler.endPollPollStartIDReceivedPollStartID == "somePollID")
}
func testSendPollResponse() async throws {
@Test
func sendPollResponse() async throws {
let deferred = deferFulfillment(interactionHandler.publisher.delay(for: 0.1, scheduler: DispatchQueue.main)) { _ in true }
interactionHandler.sendPollResponsePollStartIDOptionIDReturnValue = .success(())
@@ -136,12 +145,13 @@ class RoomPollsHistoryScreenViewModelTests: XCTestCase {
try await deferred.fulfill()
XCTAssert(interactionHandler.sendPollResponsePollStartIDOptionIDCalled)
XCTAssertEqual(interactionHandler.sendPollResponsePollStartIDOptionIDReceivedInvocations[0].pollStartID, "somePollID")
XCTAssertEqual(interactionHandler.sendPollResponsePollStartIDOptionIDReceivedInvocations[0].optionID, "someOptionID")
#expect(interactionHandler.sendPollResponsePollStartIDOptionIDCalled)
#expect(interactionHandler.sendPollResponsePollStartIDOptionIDReceivedInvocations[0].pollStartID == "somePollID")
#expect(interactionHandler.sendPollResponsePollStartIDOptionIDReceivedInvocations[0].optionID == "someOptionID")
}
func testSendPollResponseFailure() async throws {
@Test
func sendPollResponseFailure() async throws {
let deferred = deferFulfillment(viewModel.context.$viewState) { value in
value.bindings.alertInfo != nil
}
@@ -151,12 +161,13 @@ class RoomPollsHistoryScreenViewModelTests: XCTestCase {
try await deferred.fulfill()
XCTAssert(interactionHandler.sendPollResponsePollStartIDOptionIDCalled)
XCTAssertEqual(interactionHandler.sendPollResponsePollStartIDOptionIDReceivedInvocations[0].pollStartID, "somePollID")
XCTAssertEqual(interactionHandler.sendPollResponsePollStartIDOptionIDReceivedInvocations[0].optionID, "someOptionID")
#expect(interactionHandler.sendPollResponsePollStartIDOptionIDCalled)
#expect(interactionHandler.sendPollResponsePollStartIDOptionIDReceivedInvocations[0].pollStartID == "somePollID")
#expect(interactionHandler.sendPollResponsePollStartIDOptionIDReceivedInvocations[0].optionID == "someOptionID")
}
func testEditPoll() async throws {
@Test
func editPoll() async throws {
let expectedPoll: Poll = .emptyDisclosed
let expectedPollStartID = "someEventID"

View File

@@ -8,31 +8,33 @@
import Combine
@testable import ElementX
import Foundation
import MatrixRustSDK
import MatrixRustSDKMocks
import XCTest
import Testing
@Suite
@MainActor
class RoomScreenViewModelTests: XCTestCase {
final class RoomScreenViewModelTests {
private var viewModel: RoomScreenViewModel!
override func setUp() async throws {
init() async throws {
AppSettings.resetAllSettings()
}
override func tearDown() {
viewModel = nil
deinit {
AppSettings.resetAllSettings()
}
func testPinnedEventsBanner() async throws {
@Test
func pinnedEventsBanner() async throws {
var configuration = JoinedRoomProxyMockConfiguration()
let timelineSubject = PassthroughSubject<TimelineProxyProtocol, Never>()
let (stream, continuation) = AsyncStream.makeStream(of: TimelineProxyProtocol.self)
let infoSubject = CurrentValueSubject<RoomInfoProxyProtocol, Never>(RoomInfoProxyMock(configuration))
let roomProxyMock = JoinedRoomProxyMock(configuration)
// setup a way to inject the mock of the pinned events timeline
roomProxyMock.pinnedEventsTimelineClosure = {
guard let timeline = await timelineSubject.values.first() else {
guard let timeline = await stream.first() else {
fatalError()
}
@@ -55,8 +57,8 @@ class RoomScreenViewModelTests: XCTestCase {
viewState.pinnedEventsBannerState.count == 0
}
try await deferred.fulfill()
XCTAssertTrue(viewModel.context.viewState.pinnedEventsBannerState.isLoading)
XCTAssertFalse(viewModel.context.viewState.shouldShowPinnedEventsBanner)
#expect(viewModel.context.viewState.pinnedEventsBannerState.isLoading)
#expect(!viewModel.context.viewState.shouldShowPinnedEventsBanner)
// check if if after the pinned event ids are set the banner is still in a loading state, but is both loading and showing with a counter
deferred = deferFulfillment(viewModel.context.$viewState) { viewState in
@@ -65,9 +67,9 @@ class RoomScreenViewModelTests: XCTestCase {
configuration.pinnedEventIDs = ["test1", "test2"]
infoSubject.send(RoomInfoProxyMock(configuration))
try await deferred.fulfill()
XCTAssertTrue(viewModel.context.viewState.pinnedEventsBannerState.isLoading)
XCTAssertTrue(viewModel.context.viewState.shouldShowPinnedEventsBanner)
XCTAssertEqual(viewModel.context.viewState.pinnedEventsBannerState.selectedPinnedIndex, 1)
#expect(viewModel.context.viewState.pinnedEventsBannerState.isLoading)
#expect(viewModel.context.viewState.shouldShowPinnedEventsBanner)
#expect(viewModel.context.viewState.pinnedEventsBannerState.selectedPinnedIndex == 1)
// setup the loaded pinned events injection in the timeline
let pinnedTimelineMock = TimelineProxyMock()
@@ -82,11 +84,11 @@ class RoomScreenViewModelTests: XCTestCase {
deferred = deferFulfillment(viewModel.context.$viewState) { viewState in
!viewState.pinnedEventsBannerState.isLoading
}
timelineSubject.send(pinnedTimelineMock)
continuation.yield(pinnedTimelineMock)
try await deferred.fulfill()
XCTAssertEqual(viewModel.context.viewState.pinnedEventsBannerState.count, 2)
XCTAssertTrue(viewModel.context.viewState.shouldShowPinnedEventsBanner)
XCTAssertEqual(viewModel.context.viewState.pinnedEventsBannerState.selectedPinnedIndex, 1)
#expect(viewModel.context.viewState.pinnedEventsBannerState.count == 2)
#expect(viewModel.context.viewState.shouldShowPinnedEventsBanner)
#expect(viewModel.context.viewState.pinnedEventsBannerState.selectedPinnedIndex == 1)
// check if the banner is updating alongside the timeline
deferred = deferFulfillment(viewModel.context.$viewState) { viewState in
@@ -96,19 +98,20 @@ class RoomScreenViewModelTests: XCTestCase {
.event(.init(item: EventTimelineItem(configuration: .init(eventID: "test2")), uniqueID: .init("2"))),
.event(.init(item: EventTimelineItem(configuration: .init(eventID: "test3")), uniqueID: .init("3")))], .initial))
try await deferred.fulfill()
XCTAssertFalse(viewModel.context.viewState.pinnedEventsBannerState.isLoading)
XCTAssertTrue(viewModel.context.viewState.shouldShowPinnedEventsBanner)
XCTAssertEqual(viewModel.context.viewState.pinnedEventsBannerState.selectedPinnedIndex, 1)
#expect(!viewModel.context.viewState.pinnedEventsBannerState.isLoading)
#expect(viewModel.context.viewState.shouldShowPinnedEventsBanner)
#expect(viewModel.context.viewState.pinnedEventsBannerState.selectedPinnedIndex == 1)
// check how the scrolling changes the banner visibility
viewModel.timelineHasScrolled(direction: .top)
XCTAssertFalse(viewModel.context.viewState.shouldShowPinnedEventsBanner)
#expect(!viewModel.context.viewState.shouldShowPinnedEventsBanner)
viewModel.timelineHasScrolled(direction: .bottom)
XCTAssertTrue(viewModel.context.viewState.shouldShowPinnedEventsBanner)
#expect(viewModel.context.viewState.shouldShowPinnedEventsBanner)
}
func testPinnedEventsBannerSelection() async throws {
@Test
func pinnedEventsBannerSelection() async throws {
let roomProxyMock = JoinedRoomProxyMock(.init())
roomProxyMock.loadOrFetchEventDetailsForReturnValue = .success(TimelineEventSDKMock())
// setup a way to inject the mock of the pinned events timeline
@@ -135,10 +138,10 @@ class RoomScreenViewModelTests: XCTestCase {
!viewState.pinnedEventsBannerState.isLoading
}
try await deferred.fulfill()
XCTAssertEqual(viewModel.context.viewState.pinnedEventsBannerState.count, 3)
XCTAssertTrue(viewModel.context.viewState.shouldShowPinnedEventsBanner)
#expect(viewModel.context.viewState.pinnedEventsBannerState.count == 3)
#expect(viewModel.context.viewState.shouldShowPinnedEventsBanner)
// And that is actually displaying the `initialSelectedPinEventID` which is gthe first one in the list
XCTAssertEqual(viewModel.context.viewState.pinnedEventsBannerState.selectedPinnedIndex, 0)
#expect(viewModel.context.viewState.pinnedEventsBannerState.selectedPinnedIndex == 0)
// check if the banner scrolls when tapping the previous pin
deferred = deferFulfillment(viewModel.context.$viewState) { viewState in
@@ -162,7 +165,8 @@ class RoomScreenViewModelTests: XCTestCase {
try await deferred.fulfill()
}
func testPinnedEventsBannerThreadedSelection() async throws {
@Test
func pinnedEventsBannerThreadedSelection() async throws {
ServiceLocator.shared.settings.threadsEnabled = true
let roomProxyMock = JoinedRoomProxyMock(.init())
@@ -195,10 +199,10 @@ class RoomScreenViewModelTests: XCTestCase {
!viewState.pinnedEventsBannerState.isLoading
}
try await deferred.fulfill()
XCTAssertEqual(viewModel.context.viewState.pinnedEventsBannerState.count, 3)
XCTAssertTrue(viewModel.context.viewState.shouldShowPinnedEventsBanner)
#expect(viewModel.context.viewState.pinnedEventsBannerState.count == 3)
#expect(viewModel.context.viewState.shouldShowPinnedEventsBanner)
// And that is actually displaying the `initialSelectedPinEventID` which is gthe first one in the list
XCTAssertEqual(viewModel.context.viewState.pinnedEventsBannerState.selectedPinnedIndex, 0)
#expect(viewModel.context.viewState.pinnedEventsBannerState.selectedPinnedIndex == 0)
// check if the banner scrolls when tapping the previous pin
deferred = deferFulfillment(viewModel.context.$viewState) { viewState in
@@ -223,7 +227,8 @@ class RoomScreenViewModelTests: XCTestCase {
try await deferredAction2.fulfill()
}
func testRoomInfoUpdate() async throws {
@Test
func roomInfoUpdate() async throws {
var configuration = JoinedRoomProxyMockConfiguration(id: "TestID", name: "StartingName", avatarURL: nil, hasOngoingCall: false)
let roomProxyMock = JoinedRoomProxyMock(configuration)
@@ -248,10 +253,10 @@ class RoomScreenViewModelTests: XCTestCase {
userIndicatorController: ServiceLocator.shared.userIndicatorController)
self.viewModel = viewModel
XCTAssertEqual(viewModel.state.roomTitle, "StartingName")
XCTAssertEqual(viewModel.state.roomAvatar, .room(id: "TestID", name: "StartingName", avatarURL: nil))
XCTAssertFalse(viewModel.state.canJoinCall)
XCTAssertFalse(viewModel.state.hasOngoingCall)
#expect(viewModel.state.roomTitle == "StartingName")
#expect(viewModel.state.roomAvatar == .room(id: "TestID", name: "StartingName", avatarURL: nil))
#expect(!viewModel.state.canJoinCall)
#expect(!viewModel.state.hasOngoingCall)
let deferred = deferFulfillment(viewModel.context.$viewState) { viewState in
viewState.roomTitle == "NewName" &&
@@ -270,7 +275,8 @@ class RoomScreenViewModelTests: XCTestCase {
try await deferred.fulfill()
}
func testCallButtonVisibility() async throws {
@Test
func callButtonVisibility() async throws {
// Given a room screen with no ongoing call.
let ongoingCallRoomIDSubject = CurrentValueSubject<String?, Never>(nil)
let roomProxyMock = JoinedRoomProxyMock(.init(id: "MyRoomID"))
@@ -283,7 +289,7 @@ class RoomScreenViewModelTests: XCTestCase {
analyticsService: ServiceLocator.shared.analytics,
userIndicatorController: ServiceLocator.shared.userIndicatorController)
self.viewModel = viewModel
XCTAssertTrue(viewModel.state.shouldShowCallButton)
#expect(viewModel.state.shouldShowCallButton)
// When a call starts in this room.
var deferred = deferFulfillment(viewModel.context.$viewState) { !$0.shouldShowCallButton }
@@ -291,7 +297,7 @@ class RoomScreenViewModelTests: XCTestCase {
try await deferred.fulfill()
// Then the call button should be hidden.
XCTAssertFalse(viewModel.state.shouldShowCallButton)
#expect(!viewModel.state.shouldShowCallButton)
// When a call starts in a different room.
deferred = deferFulfillment(viewModel.context.$viewState) { $0.shouldShowCallButton }
@@ -299,41 +305,43 @@ class RoomScreenViewModelTests: XCTestCase {
try await deferred.fulfill()
// Then the call button should be shown again.
XCTAssertTrue(viewModel.state.shouldShowCallButton)
#expect(viewModel.state.shouldShowCallButton)
// When the call from the other room finishes.
let deferredFailure = deferFailure(viewModel.context.$viewState, timeout: 1) { !$0.shouldShowCallButton }
let deferredFailure = deferFailure(viewModel.context.$viewState, timeout: .seconds(1)) { !$0.shouldShowCallButton }
ongoingCallRoomIDSubject.send(nil)
try await deferredFailure.fulfill()
// Then the call button should remain visible shown.
XCTAssertTrue(viewModel.state.shouldShowCallButton)
#expect(viewModel.state.shouldShowCallButton)
}
func testRoomFullyRead() async {
let expectation = XCTestExpectation(description: "Wait for fully read")
let roomProxyMock = JoinedRoomProxyMock(.init(id: "MyRoomID"))
roomProxyMock.markAsReadReceiptTypeClosure = { readReceiptType in
XCTAssertEqual(readReceiptType, .fullyRead)
expectation.fulfill()
return .success(())
@Test
func roomFullyRead() async {
await waitForConfirmation("Wait for fully read") { confirm in
let roomProxyMock = JoinedRoomProxyMock(.init(id: "MyRoomID"))
roomProxyMock.markAsReadReceiptTypeClosure = { readReceiptType in
#expect(readReceiptType == .fullyRead)
confirm()
return .success(())
}
let viewModel = RoomScreenViewModel(userSession: UserSessionMock(.init()),
roomProxy: roomProxyMock,
initialSelectedPinnedEventID: nil,
ongoingCallRoomIDPublisher: .init(.init(nil)),
appSettings: ServiceLocator.shared.settings,
appHooks: AppHooks(),
analyticsService: ServiceLocator.shared.analytics,
userIndicatorController: ServiceLocator.shared.userIndicatorController)
self.viewModel = viewModel
viewModel.stop()
}
let viewModel = RoomScreenViewModel(userSession: UserSessionMock(.init()),
roomProxy: roomProxyMock,
initialSelectedPinnedEventID: nil,
ongoingCallRoomIDPublisher: .init(.init(nil)),
appSettings: ServiceLocator.shared.settings,
appHooks: AppHooks(),
analyticsService: ServiceLocator.shared.analytics,
userIndicatorController: ServiceLocator.shared.userIndicatorController)
self.viewModel = viewModel
viewModel.stop()
await fulfillment(of: [expectation])
}
// MARK: - Knock Requests
func testKnockRequestBanner() async throws {
@Test
func knockRequestBanner() async throws {
ServiceLocator.shared.settings.knockingEnabled = true
let roomProxyMock = JoinedRoomProxyMock(.init(knockRequestsState: .loaded([KnockRequestProxyMock(.init(eventID: "1", userID: "@alice:matrix.org", displayName: "Alice", reason: "Hello World!")),
// This one should be filtered
@@ -367,7 +375,8 @@ class RoomScreenViewModelTests: XCTestCase {
try await deferred.fulfill()
}
func testKnockRequestBannerMarkAsSeen() async throws {
@Test
func knockRequestBannerMarkAsSeen() async throws {
ServiceLocator.shared.settings.knockingEnabled = true
let roomProxyMock = JoinedRoomProxyMock(.init(knockRequestsState: .loaded([KnockRequestProxyMock(.init(eventID: "1", userID: "@alice:matrix.org", displayName: "Alice", reason: "Hello World!")),
// This one should be filtered
@@ -398,7 +407,8 @@ class RoomScreenViewModelTests: XCTestCase {
try await deferred.fulfill()
}
func testLoadingKnockRequests() async throws {
@Test
func loadingKnockRequests() async throws {
ServiceLocator.shared.settings.knockingEnabled = true
let roomProxyMock = JoinedRoomProxyMock(.init(knockRequestsState: .loading,
joinRule: .knock))
@@ -417,7 +427,8 @@ class RoomScreenViewModelTests: XCTestCase {
try await deferred.fulfill()
}
func testKnockRequestsBannerDoesNotAppearIfUserHasNoPermission() async throws {
@Test
func knockRequestsBannerDoesNotAppearIfUserHasNoPermission() async throws {
ServiceLocator.shared.settings.knockingEnabled = true
let roomProxyMock = JoinedRoomProxyMock(.init(knockRequestsState: .loaded([KnockRequestProxyMock(.init(eventID: "1", userID: "@alice:matrix.org", displayName: "Alice", reason: "Hello World!"))]),
joinRule: .knock,
@@ -441,7 +452,8 @@ class RoomScreenViewModelTests: XCTestCase {
// MARK: - History Sharing
func testRoomWithSharedHistoryDoesNotDisplayBadgeIfFeatureFlagNotSet() async throws {
@Test
func roomWithSharedHistoryDoesNotDisplayBadgeIfFeatureFlagNotSet() async throws {
ServiceLocator.shared.settings.enableKeyShareOnInvite = false
var configuration = JoinedRoomProxyMockConfiguration(historyVisibility: .joined)
@@ -461,7 +473,7 @@ class RoomScreenViewModelTests: XCTestCase {
self.viewModel = viewModel
let deferredInvisible = deferFailure(viewModel.context.$viewState,
timeout: 1,
timeout: .seconds(1),
message: "The icon should not be shown when the room history visibility is not .shared or .worldReadable") { viewState in
viewState.roomHistorySharingState != nil
}
@@ -470,14 +482,15 @@ class RoomScreenViewModelTests: XCTestCase {
configuration.historyVisibility = .shared
infoSubject.send(RoomInfoProxyMock(configuration))
let deferredShared = deferFailure(viewModel.context.$viewState,
timeout: 1,
timeout: .seconds(1),
message: "The icon should not be shown when the room history visibility is .shared, since the flag isn't set") { viewState in
viewState.roomHistorySharingState != nil
}
try await deferredShared.fulfill()
}
func testRoomWithSharedHistoryDisplaysBadgeWhenFeatureFlagSet() async throws {
@Test
func roomWithSharedHistoryDisplaysBadgeWhenFeatureFlagSet() async throws {
ServiceLocator.shared.settings.enableKeyShareOnInvite = true
var configuration = JoinedRoomProxyMockConfiguration(isEncrypted: false, historyVisibility: .joined)
@@ -497,7 +510,7 @@ class RoomScreenViewModelTests: XCTestCase {
self.viewModel = viewModel
let deferredInvisible = deferFailure(viewModel.context.$viewState,
timeout: 1,
timeout: .seconds(1),
message: "The icon should be hidden when the room history visibility is not .shared or .worldReadable") { viewState in
viewState.roomHistorySharingState != nil
}
@@ -506,7 +519,7 @@ class RoomScreenViewModelTests: XCTestCase {
configuration.historyVisibility = .shared
infoSubject.send(RoomInfoProxyMock(configuration))
let deferredInvisibleUnencrypted = deferFailure(viewModel.context.$viewState,
timeout: 1,
timeout: .seconds(1),
message: "The icon should not be shown when the room is unencrypted") { viewState in
viewState.roomHistorySharingState != nil
}

View File

@@ -9,10 +9,11 @@
import Combine
@testable import ElementX
import MatrixRustSDK
import XCTest
import Testing
@Suite
@MainActor
class SecurityAndPrivacyScreenViewModelTests: XCTestCase {
final class SecurityAndPrivacyScreenViewModelTests {
var viewModel: SecurityAndPrivacyScreenViewModelProtocol!
var spaceServiceProxy: SpaceServiceProxyMock!
var roomProxy: JoinedRoomProxyMock!
@@ -21,13 +22,18 @@ class SecurityAndPrivacyScreenViewModelTests: XCTestCase {
viewModel.context
}
override func tearDown() {
init() {
AppSettings.resetAllSettings()
}
deinit {
viewModel = nil
roomProxy = nil
AppSettings.resetAllSettings()
}
func testSetSingleJoinedSpaceMembersAccess() async throws {
@Test
func setSingleJoinedSpaceMembersAccess() async throws {
let singleRoom = [SpaceServiceRoom].mockSingleRoom
let space = singleRoom[0]
setupViewModel(joinedParentSpaces: singleRoom, joinRule: .public)
@@ -35,30 +41,31 @@ class SecurityAndPrivacyScreenViewModelTests: XCTestCase {
let deferred = deferFulfillment(context.$viewState) { $0.selectableJoinedSpaces.count == 1 }
try await deferred.fulfill()
XCTAssertEqual(context.viewState.currentSettings.accessType, .anyone)
XCTAssertTrue(context.viewState.isSaveDisabled)
XCTAssertTrue(context.viewState.isSpaceMembersOptionSelectable)
#expect(context.viewState.currentSettings.accessType == .anyone)
#expect(context.viewState.isSaveDisabled)
#expect(context.viewState.isSpaceMembersOptionSelectable)
guard case .singleJoined = context.viewState.spaceSelection else {
XCTFail("Expected spaceSelection to be .singleJoined")
Issue.record("Expected spaceSelection to be .singleJoined")
return
}
context.send(viewAction: .selectedSpaceMembersAccess)
XCTAssertEqual(context.desiredSettings.accessType, .spaceMembers(spaceIDs: [space.id]))
XCTAssertFalse(context.viewState.shouldShowAccessSectionFooter)
XCTAssertFalse(context.viewState.isSaveDisabled)
#expect(context.desiredSettings.accessType == .spaceMembers(spaceIDs: [space.id]))
#expect(!context.viewState.shouldShowAccessSectionFooter)
#expect(!context.viewState.isSaveDisabled)
let expectation = expectation(description: "Join rule has updated")
roomProxy.updateJoinRuleClosure = { value in
XCTAssertEqual(value, .restricted(rules: [.roomMembership(roomID: space.id)]))
expectation.fulfill()
return .success(())
await waitForConfirmation("Join rule has updated") { confirm in
roomProxy.updateJoinRuleClosure = { value in
#expect(value == .restricted(rules: [.roomMembership(roomID: space.id)]))
confirm()
return .success(())
}
context.send(viewAction: .save)
}
context.send(viewAction: .save)
await fulfillment(of: [expectation])
}
func testSetSingleJoinedAskToJoinWithSpaceMembersAccess() async throws {
@Test
func setSingleJoinedAskToJoinWithSpaceMembersAccess() async throws {
let singleRoom = [SpaceServiceRoom].mockSingleRoom
let space = singleRoom[0]
setupViewModel(joinedParentSpaces: singleRoom, joinRule: .public)
@@ -66,30 +73,31 @@ class SecurityAndPrivacyScreenViewModelTests: XCTestCase {
let deferred = deferFulfillment(context.$viewState) { $0.selectableJoinedSpaces.count == 1 }
try await deferred.fulfill()
XCTAssertEqual(context.viewState.currentSettings.accessType, .anyone)
XCTAssertTrue(context.viewState.isSaveDisabled)
XCTAssertTrue(context.viewState.isAskToJoinWithSpaceMembersOptionSelectable)
#expect(context.viewState.currentSettings.accessType == .anyone)
#expect(context.viewState.isSaveDisabled)
#expect(context.viewState.isAskToJoinWithSpaceMembersOptionSelectable)
guard case .singleJoined = context.viewState.spaceSelection else {
XCTFail("Expected spaceSelection to be .singleJoined")
Issue.record("Expected spaceSelection to be .singleJoined")
return
}
context.send(viewAction: .selectedAskToJoinWithSpaceMembersAccess)
XCTAssertEqual(context.desiredSettings.accessType, .askToJoinWithSpaceMembers(spaceIDs: [space.id]))
XCTAssertFalse(context.viewState.shouldShowAccessSectionFooter)
XCTAssertFalse(context.viewState.isSaveDisabled)
#expect(context.desiredSettings.accessType == .askToJoinWithSpaceMembers(spaceIDs: [space.id]))
#expect(!context.viewState.shouldShowAccessSectionFooter)
#expect(!context.viewState.isSaveDisabled)
let expectation = expectation(description: "Join rule has updated")
roomProxy.updateJoinRuleClosure = { value in
XCTAssertEqual(value, .knockRestricted(rules: [.roomMembership(roomID: space.id)]))
expectation.fulfill()
return .success(())
await waitForConfirmation("Join rule has updated") { confirm in
roomProxy.updateJoinRuleClosure = { value in
#expect(value == .knockRestricted(rules: [.roomMembership(roomID: space.id)]))
confirm()
return .success(())
}
context.send(viewAction: .save)
}
context.send(viewAction: .save)
await fulfillment(of: [expectation])
}
func testSingleUnknownSpaceMembersAccessCanBeReselected() async throws {
@Test
func singleUnknownSpaceMembersAccessCanBeReselected() async throws {
let singleRoom = [SpaceServiceRoom].mockSingleRoom
let space = singleRoom[0]
setupViewModel(joinedParentSpaces: [], joinRule: .restricted(rules: [.roomMembership(roomID: space.id)]))
@@ -97,41 +105,43 @@ class SecurityAndPrivacyScreenViewModelTests: XCTestCase {
let deferred = deferFulfillment(context.$viewState) { $0.selectableJoinedSpaces.count == 0 }
try await deferred.fulfill()
XCTAssertEqual(context.viewState.currentSettings.accessType, .spaceMembers(spaceIDs: [space.id]))
XCTAssertEqual(context.desiredSettings, context.viewState.currentSettings)
XCTAssertTrue(context.viewState.isSpaceMembersOptionSelectable)
XCTAssertFalse(context.viewState.shouldShowAccessSectionFooter)
XCTAssertTrue(context.viewState.isSaveDisabled)
#expect(context.viewState.currentSettings.accessType == .spaceMembers(spaceIDs: [space.id]))
#expect(context.desiredSettings == context.viewState.currentSettings)
#expect(context.viewState.isSpaceMembersOptionSelectable)
#expect(!context.viewState.shouldShowAccessSectionFooter)
#expect(context.viewState.isSaveDisabled)
guard case .singleUnknown = context.viewState.spaceSelection else {
XCTFail("Expected spaceSelection to be .singleUnknown")
Issue.record("Expected spaceSelection to be .singleUnknown")
return
}
let saveDeferred = deferFulfillment(context.$viewState) { !$0.isSaveDisabled }
context.desiredSettings.accessType = .anyone
XCTAssertTrue(context.viewState.isSpaceMembersOptionSelectable)
XCTAssertFalse(context.viewState.isSaveDisabled)
try await saveDeferred.fulfill()
#expect(context.viewState.isSpaceMembersOptionSelectable)
context.send(viewAction: .selectedSpaceMembersAccess)
XCTAssertTrue(context.viewState.isSaveDisabled)
XCTAssertEqual(context.desiredSettings.accessType, .spaceMembers(spaceIDs: [space.id]))
#expect(context.viewState.isSaveDisabled)
#expect(context.desiredSettings.accessType == .spaceMembers(spaceIDs: [space.id]))
guard case .singleUnknown = context.viewState.spaceSelection else {
XCTFail("Expected spaceSelection to be .singleUnknown")
Issue.record("Expected spaceSelection to be .singleUnknown")
return
}
}
func testMultipleKnownSpacesMembersSelection() async throws {
@Test
func multipleKnownSpacesMembersSelection() async throws {
let spaces = [SpaceServiceRoom].mockJoinedSpaces2
setupViewModel(joinedParentSpaces: spaces, joinRule: .public)
let deferred = deferFulfillment(context.$viewState) { $0.selectableJoinedSpaces.count == 3 }
try await deferred.fulfill()
XCTAssertEqual(context.viewState.currentSettings.accessType, .anyone)
XCTAssertTrue(context.viewState.isSaveDisabled)
XCTAssertTrue(context.viewState.isSpaceMembersOptionSelectable)
#expect(context.viewState.currentSettings.accessType == .anyone)
#expect(context.viewState.isSaveDisabled)
#expect(context.viewState.isSpaceMembersOptionSelectable)
guard case .multiple = context.viewState.spaceSelection else {
XCTFail("Expected spaceSelection to be .multiple")
Issue.record("Expected spaceSelection to be .multiple")
return
}
@@ -150,32 +160,33 @@ class SecurityAndPrivacyScreenViewModelTests: XCTestCase {
context.send(viewAction: .selectedSpaceMembersAccess)
try await deferredAction.fulfill()
selectedIDs.send([spaces[0].id])
XCTAssertEqual(context.desiredSettings.accessType, .spaceMembers(spaceIDs: [spaces[0].id]))
XCTAssertTrue(context.viewState.shouldShowAccessSectionFooter)
XCTAssertFalse(context.viewState.isSaveDisabled)
#expect(context.desiredSettings.accessType == .spaceMembers(spaceIDs: [spaces[0].id]))
#expect(context.viewState.shouldShowAccessSectionFooter)
#expect(!context.viewState.isSaveDisabled)
let expectation = expectation(description: "Join rule has updated")
roomProxy.updateJoinRuleClosure = { value in
XCTAssertEqual(value, .restricted(rules: [.roomMembership(roomID: spaces[0].id)]))
expectation.fulfill()
return .success(())
await waitForConfirmation("Join rule has updated") { confirm in
roomProxy.updateJoinRuleClosure = { value in
#expect(value == .restricted(rules: [.roomMembership(roomID: spaces[0].id)]))
confirm()
return .success(())
}
context.send(viewAction: .save)
}
context.send(viewAction: .save)
await fulfillment(of: [expectation])
}
func testMultipleKnownAskToJoinSpacesMembersSelection() async throws {
@Test
func multipleKnownAskToJoinSpacesMembersSelection() async throws {
let spaces = [SpaceServiceRoom].mockJoinedSpaces2
setupViewModel(joinedParentSpaces: spaces, joinRule: .public)
let deferred = deferFulfillment(context.$viewState) { $0.selectableJoinedSpaces.count == 3 }
try await deferred.fulfill()
XCTAssertEqual(context.viewState.currentSettings.accessType, .anyone)
XCTAssertTrue(context.viewState.isSaveDisabled)
XCTAssertTrue(context.viewState.isAskToJoinWithSpaceMembersOptionSelectable)
#expect(context.viewState.currentSettings.accessType == .anyone)
#expect(context.viewState.isSaveDisabled)
#expect(context.viewState.isAskToJoinWithSpaceMembersOptionSelectable)
guard case .multiple = context.viewState.spaceSelection else {
XCTFail("Expected spaceSelection to be .multiple")
Issue.record("Expected spaceSelection to be .multiple")
return
}
@@ -194,21 +205,22 @@ class SecurityAndPrivacyScreenViewModelTests: XCTestCase {
context.send(viewAction: .selectedAskToJoinWithSpaceMembersAccess)
try await deferredAction.fulfill()
selectedIDs.send([spaces[0].id])
XCTAssertEqual(context.desiredSettings.accessType, .askToJoinWithSpaceMembers(spaceIDs: [spaces[0].id]))
XCTAssertTrue(context.viewState.shouldShowAccessSectionFooter)
XCTAssertFalse(context.viewState.isSaveDisabled)
#expect(context.desiredSettings.accessType == .askToJoinWithSpaceMembers(spaceIDs: [spaces[0].id]))
#expect(context.viewState.shouldShowAccessSectionFooter)
#expect(!context.viewState.isSaveDisabled)
let expectation = expectation(description: "Join rule has updated")
roomProxy.updateJoinRuleClosure = { value in
XCTAssertEqual(value, .knockRestricted(rules: [.roomMembership(roomID: spaces[0].id)]))
expectation.fulfill()
return .success(())
await waitForConfirmation("Join rule has updated") { confirm in
roomProxy.updateJoinRuleClosure = { value in
#expect(value == .knockRestricted(rules: [.roomMembership(roomID: spaces[0].id)]))
confirm()
return .success(())
}
context.send(viewAction: .save)
}
context.send(viewAction: .save)
await fulfillment(of: [expectation])
}
func testMultipleSpacesMembersSelection() async throws {
@Test
func multipleSpacesMembersSelection() async throws {
let spaces = [SpaceServiceRoom].mockJoinedSpaces2
setupViewModel(joinedParentSpaces: spaces,
joinRule: .restricted(rules: [.roomMembership(roomID: "unknownSpaceID")]))
@@ -216,11 +228,11 @@ class SecurityAndPrivacyScreenViewModelTests: XCTestCase {
let deferred = deferFulfillment(context.$viewState) { $0.selectableSpacesCount == 4 }
try await deferred.fulfill()
XCTAssertTrue(context.viewState.currentSettings.accessType.isSpaceMembers)
XCTAssertTrue(context.viewState.isSaveDisabled)
XCTAssertTrue(context.viewState.isSpaceMembersOptionSelectable)
#expect(context.viewState.currentSettings.accessType.isSpaceMembers)
#expect(context.viewState.isSaveDisabled)
#expect(context.viewState.isSpaceMembersOptionSelectable)
guard case .multiple = context.viewState.spaceSelection else {
XCTFail("Expected spaceSelection to be .multiple")
Issue.record("Expected spaceSelection to be .multiple")
return
}
@@ -240,21 +252,22 @@ class SecurityAndPrivacyScreenViewModelTests: XCTestCase {
context.send(viewAction: .manageSpaces)
try await deferredAction.fulfill()
selectedIDs.send([spaces[0].id, "unknownSpaceID"])
XCTAssertEqual(context.desiredSettings.accessType, .spaceMembers(spaceIDs: [spaces[0].id, "unknownSpaceID"]))
XCTAssertTrue(context.viewState.shouldShowAccessSectionFooter)
XCTAssertFalse(context.viewState.isSaveDisabled)
#expect(context.desiredSettings.accessType == .spaceMembers(spaceIDs: [spaces[0].id, "unknownSpaceID"]))
#expect(context.viewState.shouldShowAccessSectionFooter)
#expect(!context.viewState.isSaveDisabled)
let expectation = expectation(description: "Join rule has updated")
roomProxy.updateJoinRuleClosure = { value in
XCTAssertEqual(value, .restricted(rules: [.roomMembership(roomID: spaces[0].id), .roomMembership(roomID: "unknownSpaceID")]))
expectation.fulfill()
return .success(())
await waitForConfirmation("Join rule has updated") { confirm in
roomProxy.updateJoinRuleClosure = { value in
#expect(value == .restricted(rules: [.roomMembership(roomID: spaces[0].id), .roomMembership(roomID: "unknownSpaceID")]))
confirm()
return .success(())
}
context.send(viewAction: .save)
}
context.send(viewAction: .save)
await fulfillment(of: [expectation])
}
func testMultipleSpacesMembersSelectionWithAnExistingNonParentButJoinedSpace() async throws {
@Test
func multipleSpacesMembersSelectionWithAnExistingNonParentButJoinedSpace() async throws {
let joinedParentSpaces = [SpaceServiceRoom].mockJoinedSpaces2
let singleRoom = [SpaceServiceRoom].mockSingleRoom
let space = singleRoom[0]
@@ -267,11 +280,11 @@ class SecurityAndPrivacyScreenViewModelTests: XCTestCase {
let deferred = deferFulfillment(context.$viewState) { $0.selectableSpacesCount == 5 }
try await deferred.fulfill()
XCTAssertTrue(context.viewState.currentSettings.accessType.isSpaceMembers)
XCTAssertTrue(context.viewState.isSaveDisabled)
XCTAssertTrue(context.viewState.isSpaceMembersOptionSelectable)
#expect(context.viewState.currentSettings.accessType.isSpaceMembers)
#expect(context.viewState.isSaveDisabled)
#expect(context.viewState.isSpaceMembersOptionSelectable)
guard case .multiple = context.viewState.spaceSelection else {
XCTFail("Expected spaceSelection to be .multiple")
Issue.record("Expected spaceSelection to be .multiple")
return
}
@@ -291,12 +304,13 @@ class SecurityAndPrivacyScreenViewModelTests: XCTestCase {
context.send(viewAction: .manageSpaces)
try await deferredAction.fulfill()
selectedIDs.send([allSpaces[0].id, "unknownSpaceID"])
XCTAssertEqual(context.desiredSettings.accessType, .spaceMembers(spaceIDs: [allSpaces[0].id, "unknownSpaceID"]))
XCTAssertTrue(context.viewState.shouldShowAccessSectionFooter)
XCTAssertFalse(context.viewState.isSaveDisabled)
#expect(context.desiredSettings.accessType == .spaceMembers(spaceIDs: [allSpaces[0].id, "unknownSpaceID"]))
#expect(context.viewState.shouldShowAccessSectionFooter)
#expect(!context.viewState.isSaveDisabled)
}
func testEmptySpaceMembersSelectionEdgeCase() async throws {
@Test
func emptySpaceMembersSelectionEdgeCase() async throws {
// Edge case where there is no available joined parents and the room has a restricted join rule.
// With no space ids in it
setupViewModel(joinedParentSpaces: [],
@@ -305,17 +319,18 @@ class SecurityAndPrivacyScreenViewModelTests: XCTestCase {
let deferred = deferFulfillment(context.$viewState) { $0.selectableSpacesCount == 0 }
try await deferred.fulfill()
XCTAssertTrue(context.viewState.currentSettings.accessType.isSpaceMembers)
XCTAssertTrue(context.viewState.isSaveDisabled)
XCTAssertFalse(context.viewState.isSpaceMembersOptionSelectable)
XCTAssertFalse(context.viewState.shouldShowAccessSectionFooter)
#expect(context.viewState.currentSettings.accessType.isSpaceMembers)
#expect(context.viewState.isSaveDisabled)
#expect(!context.viewState.isSpaceMembersOptionSelectable)
#expect(!context.viewState.shouldShowAccessSectionFooter)
guard case .empty = context.viewState.spaceSelection else {
XCTFail("Expected spaceSelection to be .empty")
Issue.record("Expected spaceSelection to be .empty")
return
}
}
func testEmptySpaceMembersSelectionWithJoinedParentEdgeCase() async throws {
@Test
func emptySpaceMembersSelectionWithJoinedParentEdgeCase() async throws {
// Edge case where there is one available joined parent but the room has a restricted join rule.
// With no space ids in it
let singleRoom = [SpaceServiceRoom].mockSingleRoom
@@ -325,12 +340,12 @@ class SecurityAndPrivacyScreenViewModelTests: XCTestCase {
let deferred = deferFulfillment(context.$viewState) { $0.selectableSpacesCount == 1 }
try await deferred.fulfill()
XCTAssertTrue(context.viewState.currentSettings.accessType.isSpaceMembers)
XCTAssertTrue(context.viewState.isSaveDisabled)
XCTAssertTrue(context.viewState.isSpaceMembersOptionSelectable)
XCTAssertTrue(context.viewState.shouldShowAccessSectionFooter)
#expect(context.viewState.currentSettings.accessType.isSpaceMembers)
#expect(context.viewState.isSaveDisabled)
#expect(context.viewState.isSpaceMembersOptionSelectable)
#expect(context.viewState.shouldShowAccessSectionFooter)
guard case .multiple = context.viewState.spaceSelection else {
XCTFail("Expected spaceSelection to be .multiple")
Issue.record("Expected spaceSelection to be .multiple")
return
}
@@ -348,11 +363,12 @@ class SecurityAndPrivacyScreenViewModelTests: XCTestCase {
try await deferredAction.fulfill()
}
func testSave() async throws {
@Test
func save() async throws {
setupViewModel(joinedParentSpaces: [], joinRule: .public)
// Saving shouldn't dismiss this screen (or trigger any other action).
let deferred = deferFailure(viewModel.actionsPublisher, timeout: 1) { _ in true }
let deferred = deferFailure(viewModel.actionsPublisher, timeout: .seconds(1)) { _ in true }
context.desiredSettings.accessType = .inviteOnly
context.send(viewAction: .save)
@@ -360,15 +376,16 @@ class SecurityAndPrivacyScreenViewModelTests: XCTestCase {
try await deferred.fulfill()
}
func testCancelWithChangesAndDiscard() async throws {
@Test
func cancelWithChangesAndDiscard() async throws {
setupViewModel(joinedParentSpaces: [], joinRule: .public)
context.desiredSettings.accessType = .inviteOnly
XCTAssertFalse(context.viewState.isSaveDisabled)
XCTAssertNil(context.alertInfo)
#expect(!context.viewState.isSaveDisabled)
#expect(context.alertInfo == nil)
context.send(viewAction: .cancel)
XCTAssertNotNil(context.alertInfo)
#expect(context.alertInfo != nil)
let deferred = deferFulfillment(viewModel.actionsPublisher) {
switch $0 {
@@ -382,15 +399,16 @@ class SecurityAndPrivacyScreenViewModelTests: XCTestCase {
try await deferred.fulfill()
}
func testCancelWithChangesAndSave() async throws {
@Test
func cancelWithChangesAndSave() async throws {
setupViewModel(joinedParentSpaces: [], joinRule: .public)
context.desiredSettings.accessType = .inviteOnly
XCTAssertFalse(context.viewState.isSaveDisabled)
XCTAssertNil(context.alertInfo)
#expect(!context.viewState.isSaveDisabled)
#expect(context.alertInfo == nil)
context.send(viewAction: .cancel)
XCTAssertNotNil(context.alertInfo)
#expect(context.alertInfo != nil)
let deferred = deferFulfillment(viewModel.actionsPublisher) {
switch $0 {
@@ -404,19 +422,20 @@ class SecurityAndPrivacyScreenViewModelTests: XCTestCase {
try await deferred.fulfill()
}
func testCancelWithChangesAndSaveWithFailure() async throws {
@Test
func cancelWithChangesAndSaveWithFailure() async throws {
setupViewModel(joinedParentSpaces: [], joinRule: .public)
roomProxy.updateJoinRuleReturnValue = .failure(.sdkError(RoomProxyMockError.generic))
context.desiredSettings.accessType = .inviteOnly
XCTAssertFalse(context.viewState.isSaveDisabled)
XCTAssertNil(context.alertInfo)
#expect(!context.viewState.isSaveDisabled)
#expect(context.alertInfo == nil)
context.send(viewAction: .cancel)
XCTAssertNotNil(context.alertInfo)
#expect(context.alertInfo != nil)
// The screen should not be dismissed if a failure occurred.
let deferred = deferFailure(viewModel.actionsPublisher, timeout: 1) { _ in true }
let deferred = deferFailure(viewModel.actionsPublisher, timeout: .seconds(1)) { _ in true }
context.alertInfo?.primaryButton.action?() // Save
try await deferred.fulfill()
}

View File

@@ -8,10 +8,12 @@
@testable import ElementX
import MatrixRustSDKMocks
import XCTest
import SwiftUI
import Testing
@Suite
@MainActor
class ServerConfirmationScreenViewModelTests: XCTestCase {
final class ServerConfirmationScreenViewModelTests {
var clientFactory: AuthenticationClientFactoryMock!
var client: ClientSDKMock!
var service: AuthenticationServiceProtocol!
@@ -22,26 +24,27 @@ class ServerConfirmationScreenViewModelTests: XCTestCase {
viewModel.context
}
override func setUp() {
init() {
AppSettings.resetAllSettings()
appSettings = AppSettings()
// These app settings are kept local to the tests on purpose as if they are registered in the
// ServiceLocator, the providers override that we apply will break other tests in the suite.
}
override func tearDown() {
deinit {
AppSettings.resetAllSettings()
}
// MARK: - Confirmation mode
func testConfirmLoginWithoutConfiguration() async throws {
@Test
func confirmLoginWithoutConfiguration() async throws {
// Given a view model for login using a service that hasn't been configured.
setupViewModel(authenticationFlow: .login)
XCTAssertEqual(service.homeserver.value.loginMode, .unknown)
XCTAssertEqual(context.viewState.mode, .confirmation(service.homeserver.value.address))
XCTAssertEqual(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount, 0)
XCTAssertEqual(client.urlForOidcOidcConfigurationPromptLoginHintDeviceIdAdditionalScopesCallsCount, 0)
#expect(service.homeserver.value.loginMode == .unknown)
#expect(context.viewState.mode == .confirmation(service.homeserver.value.address))
#expect(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 0)
#expect(client.urlForOidcOidcConfigurationPromptLoginHintDeviceIdAdditionalScopesCallsCount == 0)
// When continuing from the confirmation screen.
let deferred = deferFulfillment(viewModel.actions) { $0.isContinueWithOIDC }
@@ -49,23 +52,24 @@ class ServerConfirmationScreenViewModelTests: XCTestCase {
try await deferred.fulfill()
// Then a call to configure service should be made.
XCTAssertEqual(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount, 1)
XCTAssertEqual(client.urlForOidcOidcConfigurationPromptLoginHintDeviceIdAdditionalScopesCallsCount, 1)
XCTAssertEqual(client.urlForOidcOidcConfigurationPromptLoginHintDeviceIdAdditionalScopesReceivedArguments?.prompt, .consent)
XCTAssertEqual(service.homeserver.value.loginMode, .oidc(supportsCreatePrompt: true))
#expect(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 1)
#expect(client.urlForOidcOidcConfigurationPromptLoginHintDeviceIdAdditionalScopesCallsCount == 1)
#expect(client.urlForOidcOidcConfigurationPromptLoginHintDeviceIdAdditionalScopesReceivedArguments?.prompt == .consent)
#expect(service.homeserver.value.loginMode == .oidc(supportsCreatePrompt: true))
}
func testConfirmLoginAfterConfiguration() async throws {
@Test
func confirmLoginAfterConfiguration() async throws {
// Given a view model for login using a service that has already been configured (via the server selection screen).
setupViewModel(authenticationFlow: .login)
guard case .success = await service.configure(for: viewModel.state.homeserverAddress, flow: .login) else {
XCTFail("The configuration should succeed.")
Issue.record("The configuration should succeed.")
return
}
XCTAssertEqual(service.homeserver.value.loginMode, .oidc(supportsCreatePrompt: true))
XCTAssertEqual(context.viewState.mode, .confirmation(service.homeserver.value.address))
XCTAssertEqual(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount, 1)
XCTAssertEqual(client.urlForOidcOidcConfigurationPromptLoginHintDeviceIdAdditionalScopesCallsCount, 0)
#expect(service.homeserver.value.loginMode == .oidc(supportsCreatePrompt: true))
#expect(context.viewState.mode == .confirmation(service.homeserver.value.address))
#expect(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 1)
#expect(client.urlForOidcOidcConfigurationPromptLoginHintDeviceIdAdditionalScopesCallsCount == 0)
// When continuing from the confirmation screen.
let deferred = deferFulfillment(viewModel.actions) { $0.isContinueWithOIDC }
@@ -73,18 +77,19 @@ class ServerConfirmationScreenViewModelTests: XCTestCase {
try await deferred.fulfill()
// Then the configured homeserver should be used and no additional client should be built.
XCTAssertEqual(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount, 1)
XCTAssertEqual(client.urlForOidcOidcConfigurationPromptLoginHintDeviceIdAdditionalScopesCallsCount, 1)
XCTAssertEqual(client.urlForOidcOidcConfigurationPromptLoginHintDeviceIdAdditionalScopesReceivedArguments?.prompt, .consent)
#expect(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 1)
#expect(client.urlForOidcOidcConfigurationPromptLoginHintDeviceIdAdditionalScopesCallsCount == 1)
#expect(client.urlForOidcOidcConfigurationPromptLoginHintDeviceIdAdditionalScopesReceivedArguments?.prompt == .consent)
}
func testConfirmRegisterWithoutConfiguration() async throws {
@Test
func confirmRegisterWithoutConfiguration() async throws {
// Given a view model for registration using a service that hasn't been configured.
setupViewModel(authenticationFlow: .register)
XCTAssertEqual(service.homeserver.value.loginMode, .unknown)
XCTAssertEqual(context.viewState.mode, .confirmation(service.homeserver.value.address))
XCTAssertEqual(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount, 0)
XCTAssertEqual(client.urlForOidcOidcConfigurationPromptLoginHintDeviceIdAdditionalScopesCallsCount, 0)
#expect(service.homeserver.value.loginMode == .unknown)
#expect(context.viewState.mode == .confirmation(service.homeserver.value.address))
#expect(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 0)
#expect(client.urlForOidcOidcConfigurationPromptLoginHintDeviceIdAdditionalScopesCallsCount == 0)
// When continuing from the confirmation screen.
let deferred = deferFulfillment(viewModel.actions) { $0.isContinueWithOIDC }
@@ -92,24 +97,25 @@ class ServerConfirmationScreenViewModelTests: XCTestCase {
try await deferred.fulfill()
// Then a call to configure service should be made.
XCTAssertEqual(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount, 1)
XCTAssertEqual(client.urlForOidcOidcConfigurationPromptLoginHintDeviceIdAdditionalScopesCallsCount, 1)
#expect(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 1)
#expect(client.urlForOidcOidcConfigurationPromptLoginHintDeviceIdAdditionalScopesCallsCount == 1)
// The create prompt is broken: https://github.com/element-hq/matrix-authentication-service/issues/3429
// XCTAssertEqual(client.urlForOidcOidcConfigurationPromptReceivedArguments?.prompt, .create)
XCTAssertEqual(service.homeserver.value.loginMode, .oidc(supportsCreatePrompt: true))
// #expect(client.urlForOidcOidcConfigurationPromptReceivedArguments?.prompt == .create)
#expect(service.homeserver.value.loginMode == .oidc(supportsCreatePrompt: true))
}
func testConfirmRegisterAfterConfiguration() async throws {
@Test
func confirmRegisterAfterConfiguration() async throws {
// Given a view model for registration using a service that has already been configured (via the server selection screen).
setupViewModel(authenticationFlow: .register)
guard case .success = await service.configure(for: viewModel.state.homeserverAddress, flow: .register) else {
XCTFail("The configuration should succeed.")
Issue.record("The configuration should succeed.")
return
}
XCTAssertEqual(service.homeserver.value.loginMode, .oidc(supportsCreatePrompt: true))
XCTAssertEqual(context.viewState.mode, .confirmation(service.homeserver.value.address))
XCTAssertEqual(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount, 1)
XCTAssertEqual(client.urlForOidcOidcConfigurationPromptLoginHintDeviceIdAdditionalScopesCallsCount, 0)
#expect(service.homeserver.value.loginMode == .oidc(supportsCreatePrompt: true))
#expect(context.viewState.mode == .confirmation(service.homeserver.value.address))
#expect(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 1)
#expect(client.urlForOidcOidcConfigurationPromptLoginHintDeviceIdAdditionalScopesCallsCount == 0)
// When continuing from the confirmation screen.
let deferred = deferFulfillment(viewModel.actions) { $0.isContinueWithOIDC }
@@ -117,19 +123,20 @@ class ServerConfirmationScreenViewModelTests: XCTestCase {
try await deferred.fulfill()
// Then the configured homeserver should be used and no additional client should be built.
XCTAssertEqual(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount, 1)
#expect(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 1)
// The create prompt is broken: https://github.com/element-hq/matrix-authentication-service/issues/3429
// XCTAssertEqual(client.urlForOidcOidcConfigurationPromptReceivedArguments?.prompt, .create)
XCTAssertEqual(client.urlForOidcOidcConfigurationPromptLoginHintDeviceIdAdditionalScopesCallsCount, 1)
// #expect(client.urlForOidcOidcConfigurationPromptReceivedArguments?.prompt == .create)
#expect(client.urlForOidcOidcConfigurationPromptLoginHintDeviceIdAdditionalScopesCallsCount == 1)
}
func testConfirmPasswordLoginWithoutConfiguration() async throws {
@Test
func confirmPasswordLoginWithoutConfiguration() async throws {
// Given a view model for login using a service that hasn't been configured (against a server that doesn't support OIDC).
setupViewModel(authenticationFlow: .login, supportsOIDC: false)
XCTAssertEqual(service.homeserver.value.loginMode, .unknown)
XCTAssertEqual(context.viewState.mode, .confirmation(service.homeserver.value.address))
XCTAssertEqual(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount, 0)
XCTAssertEqual(client.urlForOidcOidcConfigurationPromptLoginHintDeviceIdAdditionalScopesCallsCount, 0)
#expect(service.homeserver.value.loginMode == .unknown)
#expect(context.viewState.mode == .confirmation(service.homeserver.value.address))
#expect(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 0)
#expect(client.urlForOidcOidcConfigurationPromptLoginHintDeviceIdAdditionalScopesCallsCount == 0)
// When continuing from the confirmation screen.
let deferred = deferFulfillment(viewModel.actions) { $0.isContinueWithPassword }
@@ -137,22 +144,23 @@ class ServerConfirmationScreenViewModelTests: XCTestCase {
try await deferred.fulfill()
// Then a call to configure service should be made, but not for the OIDC URL.
XCTAssertEqual(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount, 1)
XCTAssertEqual(client.urlForOidcOidcConfigurationPromptLoginHintDeviceIdAdditionalScopesCallsCount, 0)
XCTAssertEqual(service.homeserver.value.loginMode, .password)
#expect(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 1)
#expect(client.urlForOidcOidcConfigurationPromptLoginHintDeviceIdAdditionalScopesCallsCount == 0)
#expect(service.homeserver.value.loginMode == .password)
}
func testConfirmPasswordLoginAfterConfiguration() async throws {
@Test
func confirmPasswordLoginAfterConfiguration() async throws {
// Given a view model for login using a service that has already been configured (via the server selection screen).
setupViewModel(authenticationFlow: .login, supportsOIDC: false)
guard case .success = await service.configure(for: viewModel.state.homeserverAddress, flow: .login) else {
XCTFail("The configuration should succeed.")
Issue.record("The configuration should succeed.")
return
}
XCTAssertEqual(service.homeserver.value.loginMode, .password)
XCTAssertEqual(context.viewState.mode, .confirmation(service.homeserver.value.address))
XCTAssertEqual(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount, 1)
XCTAssertEqual(client.urlForOidcOidcConfigurationPromptLoginHintDeviceIdAdditionalScopesCallsCount, 0)
#expect(service.homeserver.value.loginMode == .password)
#expect(context.viewState.mode == .confirmation(service.homeserver.value.address))
#expect(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 1)
#expect(client.urlForOidcOidcConfigurationPromptLoginHintDeviceIdAdditionalScopesCallsCount == 0)
// When continuing from the confirmation screen.
let deferred = deferFulfillment(viewModel.actions) { $0.isContinueWithPassword }
@@ -160,17 +168,18 @@ class ServerConfirmationScreenViewModelTests: XCTestCase {
try await deferred.fulfill()
// Then the configured homeserver should be used and no additional client should be built, nor a call to get the OIDC URL.
XCTAssertEqual(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount, 1)
XCTAssertEqual(client.urlForOidcOidcConfigurationPromptLoginHintDeviceIdAdditionalScopesCallsCount, 0)
#expect(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 1)
#expect(client.urlForOidcOidcConfigurationPromptLoginHintDeviceIdAdditionalScopesCallsCount == 0)
}
func testRegistrationNotSupportedAlert() async throws {
@Test
func registrationNotSupportedAlert() async throws {
// Given a view model for registration using a service that hasn't been configured and the default server doesn't support registration.
// Note: We don't currently take the create prompt into account when determining registration support.
setupViewModel(authenticationFlow: .register, supportsOIDC: false, supportsOIDCCreatePrompt: false)
XCTAssertEqual(service.homeserver.value.loginMode, .unknown)
XCTAssertEqual(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount, 0)
XCTAssertNil(context.alertInfo)
#expect(service.homeserver.value.loginMode == .unknown)
#expect(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 0)
#expect(context.alertInfo == nil)
// When continuing from the confirmation screen.
let deferred = deferFulfillment(context.observe(\.alertInfo)) { $0 != nil }
@@ -178,16 +187,17 @@ class ServerConfirmationScreenViewModelTests: XCTestCase {
try await deferred.fulfill()
// Then the configuration should fail with an alert about not supporting registration.
XCTAssertEqual(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount, 1)
XCTAssertEqual(context.alertInfo?.id, .registration)
#expect(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 1)
#expect(context.alertInfo?.id == .registration)
}
func testLoginNotSupportedAlert() async throws {
@Test
func loginNotSupportedAlert() async throws {
// Given a view model for login using a service that hasn't been configured and the default server doesn't support login.
setupViewModel(authenticationFlow: .login, supportsOIDC: false, supportsOIDCCreatePrompt: false, supportsPasswordLogin: false)
XCTAssertEqual(service.homeserver.value.loginMode, .unknown)
XCTAssertEqual(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount, 0)
XCTAssertNil(context.alertInfo)
#expect(service.homeserver.value.loginMode == .unknown)
#expect(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 0)
#expect(context.alertInfo == nil)
// When continuing from the confirmation screen.
let deferred = deferFulfillment(context.observe(\.alertInfo)) { $0 != nil }
@@ -195,16 +205,17 @@ class ServerConfirmationScreenViewModelTests: XCTestCase {
try await deferred.fulfill()
// Then the configuration should fail with an alert about not supporting login.
XCTAssertEqual(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount, 1)
XCTAssertEqual(context.alertInfo?.id, .login)
#expect(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 1)
#expect(context.alertInfo?.id == .login)
}
func testElementProRequired() async throws {
@Test
func elementProRequired() async throws {
// Given a view model for login using a service that hasn't been configured and the default server requires Element Pro.
setupViewModel(authenticationFlow: .login, supportsOIDC: false, supportsOIDCCreatePrompt: false, supportsPasswordLogin: false, requiresElementPro: true)
XCTAssertEqual(service.homeserver.value.loginMode, .unknown)
XCTAssertEqual(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount, 0)
XCTAssertNil(context.alertInfo)
#expect(service.homeserver.value.loginMode == .unknown)
#expect(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 0)
#expect(context.alertInfo == nil)
// When continuing from the confirmation screen.
let deferred = deferFulfillment(context.observe(\.alertInfo)) { $0 != nil }
@@ -212,19 +223,20 @@ class ServerConfirmationScreenViewModelTests: XCTestCase {
try await deferred.fulfill()
// Then the configuration should fail with an alert telling the user to download Element Pro.
XCTAssertEqual(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount, 1)
XCTAssertEqual(context.alertInfo?.id, .elementProRequired(serverName: "matrix.org"))
#expect(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 1)
#expect(context.alertInfo?.id == .elementProRequired(serverName: "matrix.org"))
}
// MARK: - Picker mode
func testPickerWithoutConfiguration() async throws {
@Test
func pickerWithoutConfiguration() async throws {
// Given a view model for login using a service that hasn't been configured.
setupViewModel(authenticationFlow: .login, restrictedFlow: true)
XCTAssertEqual(service.homeserver.value.loginMode, .unknown)
XCTAssertEqual(context.viewState.mode, .picker(appSettings.accountProviders))
XCTAssertEqual(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount, 0)
XCTAssertEqual(client.urlForOidcOidcConfigurationPromptLoginHintDeviceIdAdditionalScopesCallsCount, 0)
#expect(service.homeserver.value.loginMode == .unknown)
#expect(context.viewState.mode == .picker(appSettings.accountProviders))
#expect(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 0)
#expect(client.urlForOidcOidcConfigurationPromptLoginHintDeviceIdAdditionalScopesCallsCount == 0)
// When continuing from the confirmation screen.
let deferred = deferFulfillment(viewModel.actions) { $0.isContinueWithOIDC }
@@ -232,23 +244,24 @@ class ServerConfirmationScreenViewModelTests: XCTestCase {
try await deferred.fulfill()
// Then a call to configure service should be made.
XCTAssertEqual(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount, 1)
XCTAssertEqual(client.urlForOidcOidcConfigurationPromptLoginHintDeviceIdAdditionalScopesCallsCount, 1)
XCTAssertEqual(client.urlForOidcOidcConfigurationPromptLoginHintDeviceIdAdditionalScopesReceivedArguments?.prompt, .consent)
XCTAssertEqual(service.homeserver.value.loginMode, .oidc(supportsCreatePrompt: true))
#expect(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 1)
#expect(client.urlForOidcOidcConfigurationPromptLoginHintDeviceIdAdditionalScopesCallsCount == 1)
#expect(client.urlForOidcOidcConfigurationPromptLoginHintDeviceIdAdditionalScopesReceivedArguments?.prompt == .consent)
#expect(service.homeserver.value.loginMode == .oidc(supportsCreatePrompt: true))
}
func testPickerAfterConfiguration() async throws {
@Test
func pickerAfterConfiguration() async throws {
// Given a view model for login using a service that has already been configured (via the server selection screen).
setupViewModel(authenticationFlow: .login, restrictedFlow: true)
guard case .success = await service.configure(for: appSettings.accountProviders[0], flow: .login) else {
XCTFail("The configuration should succeed.")
Issue.record("The configuration should succeed.")
return
}
XCTAssertEqual(service.homeserver.value.loginMode, .oidc(supportsCreatePrompt: true))
XCTAssertEqual(context.viewState.mode, .picker(appSettings.accountProviders))
XCTAssertEqual(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount, 1)
XCTAssertEqual(client.urlForOidcOidcConfigurationPromptLoginHintDeviceIdAdditionalScopesCallsCount, 0)
#expect(service.homeserver.value.loginMode == .oidc(supportsCreatePrompt: true))
#expect(context.viewState.mode == .picker(appSettings.accountProviders))
#expect(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 1)
#expect(client.urlForOidcOidcConfigurationPromptLoginHintDeviceIdAdditionalScopesCallsCount == 0)
// When continuing from the confirmation screen.
let deferred = deferFulfillment(viewModel.actions) { $0.isContinueWithOIDC }
@@ -256,18 +269,19 @@ class ServerConfirmationScreenViewModelTests: XCTestCase {
try await deferred.fulfill()
// Then the configured homeserver should be used and no additional client should be built.
XCTAssertEqual(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount, 1)
XCTAssertEqual(client.urlForOidcOidcConfigurationPromptLoginHintDeviceIdAdditionalScopesCallsCount, 1)
XCTAssertEqual(client.urlForOidcOidcConfigurationPromptLoginHintDeviceIdAdditionalScopesReceivedArguments?.prompt, .consent)
#expect(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 1)
#expect(client.urlForOidcOidcConfigurationPromptLoginHintDeviceIdAdditionalScopesCallsCount == 1)
#expect(client.urlForOidcOidcConfigurationPromptLoginHintDeviceIdAdditionalScopesReceivedArguments?.prompt == .consent)
}
func testPickerForPasswordLoginWithoutConfiguration() async throws {
@Test
func pickerForPasswordLoginWithoutConfiguration() async throws {
// Given a view model for login using a service that hasn't been configured (against a server that doesn't support OIDC).
setupViewModel(authenticationFlow: .login, supportsOIDC: false, restrictedFlow: true)
XCTAssertEqual(service.homeserver.value.loginMode, .unknown)
XCTAssertEqual(context.viewState.mode, .picker(appSettings.accountProviders))
XCTAssertEqual(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount, 0)
XCTAssertEqual(client.urlForOidcOidcConfigurationPromptLoginHintDeviceIdAdditionalScopesCallsCount, 0)
#expect(service.homeserver.value.loginMode == .unknown)
#expect(context.viewState.mode == .picker(appSettings.accountProviders))
#expect(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 0)
#expect(client.urlForOidcOidcConfigurationPromptLoginHintDeviceIdAdditionalScopesCallsCount == 0)
// When continuing from the confirmation screen.
let deferred = deferFulfillment(viewModel.actions) { $0.isContinueWithPassword }
@@ -275,22 +289,23 @@ class ServerConfirmationScreenViewModelTests: XCTestCase {
try await deferred.fulfill()
// Then a call to configure service should be made, but not for the OIDC URL.
XCTAssertEqual(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount, 1)
XCTAssertEqual(client.urlForOidcOidcConfigurationPromptLoginHintDeviceIdAdditionalScopesCallsCount, 0)
XCTAssertEqual(service.homeserver.value.loginMode, .password)
#expect(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 1)
#expect(client.urlForOidcOidcConfigurationPromptLoginHintDeviceIdAdditionalScopesCallsCount == 0)
#expect(service.homeserver.value.loginMode == .password)
}
func testPickerForPasswordLoginAfterConfiguration() async throws {
@Test
func pickerForPasswordLoginAfterConfiguration() async throws {
// Given a view model for login using a service that has already been configured (via the server selection screen).
setupViewModel(authenticationFlow: .login, supportsOIDC: false, restrictedFlow: true)
guard case .success = await service.configure(for: appSettings.accountProviders[0], flow: .login) else {
XCTFail("The configuration should succeed.")
Issue.record("The configuration should succeed.")
return
}
XCTAssertEqual(service.homeserver.value.loginMode, .password)
XCTAssertEqual(context.viewState.mode, .picker(appSettings.accountProviders))
XCTAssertEqual(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount, 1)
XCTAssertEqual(client.urlForOidcOidcConfigurationPromptLoginHintDeviceIdAdditionalScopesCallsCount, 0)
#expect(service.homeserver.value.loginMode == .password)
#expect(context.viewState.mode == .picker(appSettings.accountProviders))
#expect(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 1)
#expect(client.urlForOidcOidcConfigurationPromptLoginHintDeviceIdAdditionalScopesCallsCount == 0)
// When continuing from the confirmation screen.
let deferred = deferFulfillment(viewModel.actions) { $0.isContinueWithPassword }
@@ -298,8 +313,8 @@ class ServerConfirmationScreenViewModelTests: XCTestCase {
try await deferred.fulfill()
// Then the configured homeserver should be used and no additional client should be built, nor a call to get the OIDC URL.
XCTAssertEqual(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount, 1)
XCTAssertEqual(client.urlForOidcOidcConfigurationPromptLoginHintDeviceIdAdditionalScopesCallsCount, 0)
#expect(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 1)
#expect(client.urlForOidcOidcConfigurationPromptLoginHintDeviceIdAdditionalScopesCallsCount == 0)
}
// MARK: - Helpers

View File

@@ -8,15 +8,17 @@
import Combine
@testable import ElementX
import XCTest
import Foundation
import Testing
@Suite
@MainActor
class SessionVerificationViewModelTests: XCTestCase {
struct SessionVerificationViewModelTests {
var viewModel: SessionVerificationScreenViewModelProtocol!
var context: SessionVerificationViewModelType.Context!
var sessionVerificationController: SessionVerificationControllerProxyMock!
override func setUpWithError() throws {
init() throws {
sessionVerificationController = SessionVerificationControllerProxyMock.configureMock()
viewModel = SessionVerificationScreenViewModel(sessionVerificationControllerProxy: sessionVerificationController,
flow: .deviceInitiator,
@@ -25,24 +27,26 @@ class SessionVerificationViewModelTests: XCTestCase {
context = viewModel.context
}
func testRequestVerification() async throws {
XCTAssertEqual(context.viewState.verificationState, .initial)
@Test
func requestVerification() async throws {
#expect(context.viewState.verificationState == .initial)
context.send(viewAction: .requestVerification)
try await Task.sleep(for: .milliseconds(100))
XCTAssert(sessionVerificationController.requestDeviceVerificationCallsCount == 1)
XCTAssertEqual(context.viewState.verificationState, .requestingVerification)
#expect(sessionVerificationController.requestDeviceVerificationCallsCount == 1)
#expect(context.viewState.verificationState == .requestingVerification)
}
func testVerificationCancellation() async throws {
XCTAssertEqual(context.viewState.verificationState, .initial)
@Test
func verificationCancellation() async throws {
#expect(context.viewState.verificationState == .initial)
context.send(viewAction: .requestVerification)
viewModel.stop()
XCTAssertEqual(context.viewState.verificationState, .cancelling)
#expect(context.viewState.verificationState == .cancelling)
let deferred = deferFulfillment(context.$viewState) { state in
state.verificationState == .cancelled
@@ -50,114 +54,80 @@ class SessionVerificationViewModelTests: XCTestCase {
try await deferred.fulfill()
XCTAssertEqual(context.viewState.verificationState, .cancelled)
#expect(context.viewState.verificationState == .cancelled)
context.send(viewAction: .restart)
XCTAssertEqual(context.viewState.verificationState, .initial)
#expect(context.viewState.verificationState == .initial)
XCTAssert(sessionVerificationController.requestDeviceVerificationCallsCount == 1)
XCTAssert(sessionVerificationController.cancelVerificationCallsCount == 1)
#expect(sessionVerificationController.requestDeviceVerificationCallsCount == 1)
#expect(sessionVerificationController.cancelVerificationCallsCount == 1)
}
func testReceiveChallenge() {
setupChallengeReceived()
@Test
mutating func receiveChallenge() async throws {
try await setupChallengeReceived()
}
func testAcceptChallenge() {
setupChallengeReceived()
@Test
mutating func acceptChallenge() async throws {
try await setupChallengeReceived()
let waitForAcceptance = XCTestExpectation(description: "Wait for acceptance")
let cancellable = sessionVerificationController.actions
.delay(for: .seconds(0.1), scheduler: DispatchQueue.main) // Allow the view model to process the callback first.
.sink { callback in
switch callback {
case .finished:
waitForAcceptance.fulfill()
default:
XCTFail("Unexpected session verification controller callback")
}
let deferred = deferFulfillment(sessionVerificationController.actions
.delay(for: .seconds(0.1), scheduler: DispatchQueue.main)) { callback in
if case .finished = callback { return true }
return false
}
defer {
cancellable.cancel()
}
context.send(viewAction: .accept)
wait(for: [waitForAcceptance], timeout: 10.0)
try await deferred.fulfill()
XCTAssertEqual(context.viewState.verificationState, .verified)
XCTAssert(sessionVerificationController.approveVerificationCallsCount == 1)
#expect(context.viewState.verificationState == .verified)
#expect(sessionVerificationController.approveVerificationCallsCount == 1)
}
func testDeclineChallenge() {
setupChallengeReceived()
@Test
mutating func declineChallenge() async throws {
try await setupChallengeReceived()
let expectation = XCTestExpectation(description: "Wait for cancellation")
let cancellable = sessionVerificationController.actions
.delay(for: .seconds(0.1), scheduler: DispatchQueue.main) // Allow the view model to process the callback first.
.sink { callback in
switch callback {
case .cancelled:
expectation.fulfill()
default:
XCTFail("Unexpected session verification controller callback")
}
let deferred = deferFulfillment(sessionVerificationController.actions
.delay(for: .seconds(0.1), scheduler: DispatchQueue.main)) { callback in
if case .cancelled = callback { return true }
return false
}
defer {
cancellable.cancel()
}
context.send(viewAction: .decline)
wait(for: [expectation], timeout: 10.0)
try await deferred.fulfill()
XCTAssertEqual(context.viewState.verificationState, .cancelled)
XCTAssert(sessionVerificationController.declineVerificationCallsCount == 1)
#expect(context.viewState.verificationState == .cancelled)
#expect(sessionVerificationController.declineVerificationCallsCount == 1)
}
// MARK: - Private
private func setupChallengeReceived() {
let requestAcceptanceExpectation = XCTestExpectation(description: "Wait for request acceptance")
let sasVerificationStartExpectation = XCTestExpectation(description: "Wait for SaS verification start")
let verificationDataReceivalExpectation = XCTestExpectation(description: "Wait for Emoji data")
let cancellable = sessionVerificationController.actions
.delay(for: .seconds(0.1), scheduler: DispatchQueue.main) // Allow the view model to process the callback first.
.sink { callback in
switch callback {
case .acceptedVerificationRequest:
requestAcceptanceExpectation.fulfill()
case .startedSasVerification:
sasVerificationStartExpectation.fulfill()
case .receivedVerificationData:
verificationDataReceivalExpectation.fulfill()
default:
break
private mutating func setupChallengeReceived() async throws {
let actionsPublisher = sessionVerificationController.actions.delay(for: .seconds(0.1), scheduler: DispatchQueue.main)
let cancellable = actionsPublisher
.sink { [context] action in
if case .acceptedVerificationRequest = action {
context?.send(viewAction: .startSasVerification)
}
}
defer {
cancellable.cancel()
}
let deferred = deferFulfillment(actionsPublisher,
keyPath: \.self,
transitionValues: [.acceptedVerificationRequest,
.startedSasVerification,
.receivedVerificationData(SessionVerificationControllerProxyMock.emojis)])
context.send(viewAction: .requestVerification)
wait(for: [requestAcceptanceExpectation], timeout: 10.0)
XCTAssertEqual(context.viewState.verificationState, .verificationRequestAccepted)
try await deferred.fulfill()
context.send(viewAction: .startSasVerification)
wait(for: [sasVerificationStartExpectation], timeout: 10.0)
XCTAssertEqual(context.viewState.verificationState, .sasVerificationStarted)
#expect(context.viewState.verificationState == .showingChallenge(emojis: SessionVerificationControllerProxyMock.emojis))
#expect(sessionVerificationController.requestDeviceVerificationCallsCount == 1)
#expect(sessionVerificationController.startSasVerificationCallsCount == 1)
wait(for: [verificationDataReceivalExpectation], timeout: 10.0)
XCTAssertEqual(context.viewState.verificationState, .showingChallenge(emojis: SessionVerificationControllerProxyMock.emojis))
XCTAssert(sessionVerificationController.requestDeviceVerificationCallsCount == 1)
XCTAssert(sessionVerificationController.startSasVerificationCallsCount == 1)
cancellable.cancel()
}
}

View File

@@ -10,10 +10,11 @@ import Combine
@testable import ElementX
import MatrixRustSDK
import MatrixRustSDKMocks
import XCTest
import Testing
@Suite
@MainActor
class SpaceScreenViewModelTests: XCTestCase {
struct SpaceScreenViewModelTests {
var spaceRoomListProxy: SpaceRoomListProxyMock!
var spaceServiceProxy: SpaceServiceProxyMock!
let mockSpaceRooms = [SpaceServiceRoom].mockSpaceList
@@ -27,23 +28,25 @@ class SpaceScreenViewModelTests: XCTestCase {
viewModel.context
}
func testInitialState() {
@Test
mutating func initialState() {
setupViewModel()
XCTAssertEqual(context.viewState.paginationState, .idle)
XCTAssertTrue(context.viewState.rooms.isEmpty)
XCTAssertFalse(spaceRoomListProxy.paginateCalled)
#expect(context.viewState.paginationState == .idle)
#expect(context.viewState.rooms.isEmpty)
#expect(!spaceRoomListProxy.paginateCalled)
}
func testSinglePagination() async throws {
@Test
mutating func singlePagination() async throws {
// Given a space screen view model for a space with a single paginations worth of children.
let response = mockSpaceRooms.prefix(3)
setupViewModel(paginationResponses: [Array(response)])
XCTAssertEqual(context.viewState.paginationState, .idle)
XCTAssertTrue(context.viewState.rooms.isEmpty)
XCTAssertFalse(spaceRoomListProxy.paginateCalled)
XCTAssertFalse(response.isEmpty, "There should be some test rooms.")
#expect(context.viewState.paginationState == .idle)
#expect(context.viewState.rooms.isEmpty)
#expect(!spaceRoomListProxy.paginateCalled)
#expect(!response.isEmpty, "There should be some test rooms.")
// When the pagination is triggered.
var deferred = deferFulfillment(spaceRoomListProxy.paginationStatePublisher) { $0 == .loading }
@@ -51,30 +54,31 @@ class SpaceScreenViewModelTests: XCTestCase {
try await deferred.fulfill()
// Then the screen should show a paginating indicator.
XCTAssertEqual(context.viewState.paginationState, .paginating)
XCTAssertEqual(spaceRoomListProxy.paginateCallsCount, 1)
#expect(context.viewState.paginationState == .paginating)
#expect(spaceRoomListProxy.paginateCallsCount == 1)
// When waiting for the pagination to finish.
deferred = deferFulfillment(spaceRoomListProxy.paginationStatePublisher) { $0 == .idle(endReached: true) }
try await deferred.fulfill()
// Then no more pagination requests should be made the the space rooms should be populated.
XCTAssertEqual(context.viewState.paginationState, .endReached)
XCTAssertEqual(spaceRoomListProxy.paginateCallsCount, 1)
XCTAssertEqual(context.viewState.rooms.map(\.id), response.map(\.id))
#expect(context.viewState.paginationState == .endReached)
#expect(spaceRoomListProxy.paginateCallsCount == 1)
#expect(context.viewState.rooms.map(\.id) == response.map(\.id))
}
func testMultiplePaginations() async throws {
@Test
mutating func multiplePaginations() async throws {
// Given a space screen view model for a space with two distinct paginations worth of children.
let response1 = mockSpaceRooms.prefix(3)
let response2 = mockSpaceRooms.suffix(mockSpaceRooms.count - 3)
setupViewModel(paginationResponses: [Array(response1), Array(response2)])
XCTAssertEqual(context.viewState.paginationState, .idle)
XCTAssertTrue(context.viewState.rooms.isEmpty)
XCTAssertFalse(spaceRoomListProxy.paginateCalled)
XCTAssertFalse(response1.isEmpty, "There should be some test rooms.")
XCTAssertFalse(response2.isEmpty, "There should be more test rooms.")
#expect(context.viewState.paginationState == .idle)
#expect(context.viewState.rooms.isEmpty)
#expect(!spaceRoomListProxy.paginateCalled)
#expect(!response1.isEmpty, "There should be some test rooms.")
#expect(!response2.isEmpty, "There should be more test rooms.")
// When the pagination is triggered.
let deferredIsPaginating = deferFulfillment(context.observe(\.viewState.paginationState), transitionValues: [.paginating, .idle, .paginating, .endReached])
@@ -88,15 +92,16 @@ class SpaceScreenViewModelTests: XCTestCase {
try await deferredIsPaginating.fulfill()
try await deferredState.fulfill()
XCTAssertEqual(context.viewState.paginationState, .endReached)
XCTAssertEqual(spaceRoomListProxy.paginateCallsCount, 2)
XCTAssertEqual(context.viewState.rooms.map(\.id), mockSpaceRooms.map(\.id))
#expect(context.viewState.paginationState == .endReached)
#expect(spaceRoomListProxy.paginateCallsCount == 2)
#expect(context.viewState.rooms.map(\.id) == mockSpaceRooms.map(\.id))
}
func testSelectingSpace() async throws {
@Test
mutating func selectingSpace() async throws {
setupViewModel()
let selectedSpace = try XCTUnwrap(mockSpaceRooms.first { $0.isSpace && $0.state == .joined }, "There should be a space to select.")
let selectedSpace = try #require(mockSpaceRooms.first { $0.isSpace && $0.state == .joined }, "There should be a space to select.")
let deferred = deferFulfillment(viewModel.actionsPublisher) { _ in true }
viewModel.context.send(viewAction: .spaceAction(.select(selectedSpace)))
let action = try await deferred.fulfill()
@@ -105,14 +110,15 @@ class SpaceScreenViewModelTests: XCTestCase {
case .selectSpace(let spaceRoomListProxy) where spaceRoomListProxy.id == selectedSpace.id:
break
default:
XCTFail("The action should select the space.")
Issue.record("The action should select the space.")
}
}
func testSelectingUnjoinedSpace() async throws {
@Test
mutating func selectingUnjoinedSpace() async throws {
setupViewModel()
let selectedSpace = try XCTUnwrap(mockSpaceRooms.first { $0.isSpace && $0.state != .joined }, "There should be a space to select.")
let selectedSpace = try #require(mockSpaceRooms.first { $0.isSpace && $0.state != .joined }, "There should be a space to select.")
let deferred = deferFulfillment(viewModel.actionsPublisher) { _ in true }
viewModel.context.send(viewAction: .spaceAction(.select(selectedSpace)))
let action = try await deferred.fulfill()
@@ -121,14 +127,15 @@ class SpaceScreenViewModelTests: XCTestCase {
case .selectUnjoinedSpace(let spaceServiceRoom) where spaceServiceRoom.id == selectedSpace.id:
break
default:
XCTFail("The action should select the space.")
Issue.record("The action should select the space.")
}
}
func testSelectingRoom() async throws {
@Test
mutating func selectingRoom() async throws {
setupViewModel()
let selectedRoom = try XCTUnwrap(mockSpaceRooms.first { !$0.isSpace }, "There should be a room to select.")
let selectedRoom = try #require(mockSpaceRooms.first { !$0.isSpace }, "There should be a room to select.")
let deferred = deferFulfillment(viewModel.actionsPublisher) { _ in true }
viewModel.context.send(viewAction: .spaceAction(.select(selectedRoom)))
let action = try await deferred.fulfill()
@@ -137,106 +144,109 @@ class SpaceScreenViewModelTests: XCTestCase {
case .selectRoom(let roomID) where roomID == selectedRoom.id:
break
default:
XCTFail("The action should select the room.")
Issue.record("The action should select the room.")
}
}
func testJoiningSpace() async throws {
@Test
mutating func joiningSpace() async throws {
setupViewModel()
let selectedSpace = try XCTUnwrap(mockSpaceRooms.first { $0.isSpace && $0.state != .joined }, "There should be a space to select.")
let selectedSpace = try #require(mockSpaceRooms.first { $0.isSpace && $0.state != .joined }, "There should be a space to select.")
let expectation = XCTestExpectation(description: "Join room")
clientProxy.joinRoomViaClosure = { _, _ in
expectation.fulfill()
return .success(())
}
let deferredState = deferFulfillment(viewModel.context.observe(\.viewState.joiningRoomIDs), transitionValues: [[selectedSpace.id], []])
viewModel.context.send(viewAction: .spaceAction(.join(selectedSpace)))
await fulfillment(of: [expectation])
try await deferredState.fulfill()
try await confirmation("Join room") { confirm in
clientProxy.joinRoomViaClosure = { _, _ in
confirm()
return .success(())
}
viewModel.context.send(viewAction: .spaceAction(.join(selectedSpace)))
try await deferredState.fulfill()
}
}
func testJoiningRoom() async throws {
@Test
mutating func joiningRoom() async throws {
setupViewModel()
let selectedRoom = try XCTUnwrap(mockSpaceRooms.first { !$0.isSpace }, "There should be a room to select.")
let selectedRoom = try #require(mockSpaceRooms.first { !$0.isSpace }, "There should be a room to select.")
let expectation = XCTestExpectation(description: "Join room")
clientProxy.joinRoomViaClosure = { _, _ in
expectation.fulfill()
return .success(())
}
let deferredState = deferFulfillment(viewModel.context.observe(\.viewState.joiningRoomIDs), transitionValues: [[selectedRoom.id], []])
viewModel.context.send(viewAction: .spaceAction(.join(selectedRoom)))
await fulfillment(of: [expectation])
try await deferredState.fulfill()
try await confirmation("Join room") { confirm in
clientProxy.joinRoomViaClosure = { _, _ in
confirm()
return .success(())
}
viewModel.context.send(viewAction: .spaceAction(.join(selectedRoom)))
try await deferredState.fulfill()
}
}
func testManageRoomsWithoutRemoving() throws {
@Test
mutating func manageRoomsWithoutRemoving() throws {
setupViewModel(initialSpaceRooms: mockSpaceRooms)
XCTAssertEqual(context.viewState.editMode, .inactive)
XCTAssertTrue(context.viewState.editModeSelectedIDs.isEmpty)
XCTAssertTrue(context.viewState.visibleRooms.contains { $0.isSpace })
#expect(context.viewState.editMode == .inactive)
#expect(context.viewState.editModeSelectedIDs.isEmpty)
#expect(context.viewState.visibleRooms.contains { $0.isSpace })
context.send(viewAction: .manageChildren)
XCTAssertEqual(context.viewState.editMode, .transient, "Managing rooms should enable edit mode.")
XCTAssertTrue(context.viewState.editModeSelectedIDs.isEmpty, "No rooms should be selected to begin with.")
XCTAssertFalse(context.viewState.visibleRooms.contains { $0.isSpace }, "Spaces should be filtered out when managing rooms.")
#expect(context.viewState.editMode == .transient, "Managing rooms should enable edit mode.")
#expect(context.viewState.editModeSelectedIDs.isEmpty, "No rooms should be selected to begin with.")
#expect(!context.viewState.visibleRooms.contains { $0.isSpace }, "Spaces should be filtered out when managing rooms.")
let selectedRoom = try XCTUnwrap(mockSpaceRooms.first { !$0.isSpace }, "There should be a room to select.")
XCTAssertFalse(context.viewState.isSpaceIDSelected(selectedRoom.id))
let selectedRoom = try #require(mockSpaceRooms.first { !$0.isSpace }, "There should be a room to select.")
#expect(!context.viewState.isSpaceIDSelected(selectedRoom.id))
context.send(viewAction: .spaceAction(.select(selectedRoom)))
XCTAssertEqual(context.viewState.editModeSelectedIDs.count, 1, "The selected room should be included.")
XCTAssertTrue(context.viewState.isSpaceIDSelected(selectedRoom.id), "The room should be selected.")
#expect(context.viewState.editModeSelectedIDs.count == 1, "The selected room should be included.")
#expect(context.viewState.isSpaceIDSelected(selectedRoom.id), "The room should be selected.")
context.send(viewAction: .finishManagingChildren)
XCTAssertEqual(context.viewState.editMode, .inactive, "Cancelling should disable edit mode.")
XCTAssertTrue(context.viewState.editModeSelectedIDs.isEmpty, "Cancelling should clear all selected rooms.")
XCTAssertTrue(context.viewState.visibleRooms.contains { $0.isSpace }, "Cancelling should restore the hidden spaces.")
#expect(context.viewState.editMode == .inactive, "Cancelling should disable edit mode.")
#expect(context.viewState.editModeSelectedIDs.isEmpty, "Cancelling should clear all selected rooms.")
#expect(context.viewState.visibleRooms.contains { $0.isSpace }, "Cancelling should restore the hidden spaces.")
XCTAssertFalse(spaceServiceProxy.removeChildFromCalled, "There should be no attempt to remove children when cancelling.")
#expect(!spaceServiceProxy.removeChildFromCalled, "There should be no attempt to remove children when cancelling.")
}
func testManageRoomsRemovingChildren() async throws {
@Test
mutating func manageRoomsRemovingChildren() async throws {
setupViewModel(initialSpaceRooms: mockSpaceRooms)
XCTAssertEqual(context.viewState.editMode, .inactive)
XCTAssertTrue(context.viewState.editModeSelectedIDs.isEmpty)
XCTAssertTrue(context.viewState.visibleRooms.contains { $0.isSpace })
#expect(context.viewState.editMode == .inactive)
#expect(context.viewState.editModeSelectedIDs.isEmpty)
#expect(context.viewState.visibleRooms.contains { $0.isSpace })
context.send(viewAction: .manageChildren)
XCTAssertEqual(context.viewState.editMode, .transient, "Managing rooms should enable edit mode.")
XCTAssertTrue(context.viewState.editModeSelectedIDs.isEmpty, "No rooms should be selected to begin with.")
XCTAssertFalse(context.viewState.visibleRooms.contains { $0.isSpace }, "Spaces should be filtered out when managing rooms.")
#expect(context.viewState.editMode == .transient, "Managing rooms should enable edit mode.")
#expect(context.viewState.editModeSelectedIDs.isEmpty, "No rooms should be selected to begin with.")
#expect(!context.viewState.visibleRooms.contains { $0.isSpace }, "Spaces should be filtered out when managing rooms.")
let firstRoom = try XCTUnwrap(mockSpaceRooms.first { !$0.isSpace }, "There should be a room to select.")
let lastRoom = try XCTUnwrap(mockSpaceRooms.last { !$0.isSpace }, "There should be a room to select.")
XCTAssertNotEqual(firstRoom.id, lastRoom.id, "There should be more than one room in the list.")
let firstRoom = try #require(mockSpaceRooms.first { !$0.isSpace }, "There should be a room to select.")
let lastRoom = try #require(mockSpaceRooms.last { !$0.isSpace }, "There should be a room to select.")
#expect(firstRoom.id != lastRoom.id, "There should be more than one room in the list.")
context.send(viewAction: .spaceAction(.select(firstRoom)))
context.send(viewAction: .spaceAction(.select(lastRoom)))
XCTAssertEqual(context.viewState.editModeSelectedIDs.count, 2, "The selected rooms should be included.")
#expect(context.viewState.editModeSelectedIDs.count == 2, "The selected rooms should be included.")
context.send(viewAction: .removeSelectedChildren)
XCTAssertTrue(context.isPresentingRemoveChildrenConfirmation, "A confirmation prompt should be shown before removing children.")
XCTAssertFalse(spaceServiceProxy.removeChildFromCalled, "There should be no attempt to remove children before confirming.")
#expect(context.isPresentingRemoveChildrenConfirmation, "A confirmation prompt should be shown before removing children.")
#expect(!spaceServiceProxy.removeChildFromCalled, "There should be no attempt to remove children before confirming.")
let deferred = deferFulfillment(context.observe(\.viewState.editMode)) { $0 == .inactive }
context.send(viewAction: .confirmRemoveSelectedChildren)
try await deferred.fulfill()
XCTAssertFalse(context.isPresentingRemoveChildrenConfirmation, "Confirming should dismiss the confirmation prompt.")
XCTAssertEqual(context.viewState.editMode, .inactive, "Confirming should disable edit mode when done.")
XCTAssertTrue(context.viewState.editModeSelectedIDs.isEmpty, "Confirming should clear all selected rooms when done.")
XCTAssertTrue(context.viewState.visibleRooms.contains { $0.isSpace }, "Confirming should restore the hidden spaces when done.")
#expect(!context.isPresentingRemoveChildrenConfirmation, "Confirming should dismiss the confirmation prompt.")
#expect(context.viewState.editMode == .inactive, "Confirming should disable edit mode when done.")
#expect(context.viewState.editModeSelectedIDs.isEmpty, "Confirming should clear all selected rooms when done.")
#expect(context.viewState.visibleRooms.contains { $0.isSpace }, "Confirming should restore the hidden spaces when done.")
XCTAssertEqual(spaceServiceProxy.removeChildFromCallsCount, 2, "Each selected room should have been removed.")
XCTAssertTrue(spaceRoomListProxy.resetCalled, "The room list should be reset to pick up the changes.")
#expect(spaceServiceProxy.removeChildFromCallsCount == 2, "Each selected room should have been removed.")
#expect(spaceRoomListProxy.resetCalled, "The room list should be reset to pick up the changes.")
}
func testManageRoomsRemovingChildrenWithFailure() async throws {
@Test
mutating func manageRoomsRemovingChildrenWithFailure() async throws {
setupViewModel(initialSpaceRooms: mockSpaceRooms)
context.send(viewAction: .manageChildren)
@@ -245,10 +255,10 @@ class SpaceScreenViewModelTests: XCTestCase {
}
context.send(viewAction: .removeSelectedChildren)
XCTAssertEqual(context.viewState.editMode, .transient, "Managing rooms should enable edit mode.")
XCTAssertEqual(context.viewState.visibleRooms.count, 3, "There should be 3 rooms to begin with.")
XCTAssertEqual(context.viewState.editModeSelectedIDs.count, 3, "All of the visible rooms should be selected.")
XCTAssertTrue(context.isPresentingRemoveChildrenConfirmation, "A confirmation prompt should be shown before removing children.")
#expect(context.viewState.editMode == .transient, "Managing rooms should enable edit mode.")
#expect(context.viewState.visibleRooms.count == 3, "There should be 3 rooms to begin with.")
#expect(context.viewState.editModeSelectedIDs.count == 3, "All of the visible rooms should be selected.")
#expect(context.isPresentingRemoveChildrenConfirmation, "A confirmation prompt should be shown before removing children.")
let successfulIDs = context.viewState.editModeSelectedIDs.prefix(1)
spaceServiceProxy.removeChildFromClosure = { childID, _ in
@@ -260,54 +270,55 @@ class SpaceScreenViewModelTests: XCTestCase {
}
let deferred = deferFulfillment(context.observe(\.viewState.visibleRooms.count)) { $0 == 2 }
let deferredFailure = deferFailure(context.observe(\.viewState.editMode), timeout: 1) { $0 == .inactive }
let deferredFailure = deferFailure(context.observe(\.viewState.editMode), timeout: .seconds(1)) { $0 == .inactive }
context.send(viewAction: .confirmRemoveSelectedChildren)
try await deferred.fulfill()
try await deferredFailure.fulfill()
XCTAssertEqual(context.viewState.editMode, .transient, "The screen should remain in edit mode.")
XCTAssertEqual(context.viewState.visibleRooms.count, 2, "The removed rooms should no longer be listed for selection.")
XCTAssertEqual(context.viewState.editModeSelectedIDs.count, 2, "The removed rooms should no longer be selected.")
#expect(context.viewState.editMode == .transient, "The screen should remain in edit mode.")
#expect(context.viewState.visibleRooms.count == 2, "The removed rooms should no longer be listed for selection.")
#expect(context.viewState.editModeSelectedIDs.count == 2, "The removed rooms should no longer be selected.")
XCTAssertEqual(spaceServiceProxy.removeChildFromCallsCount, 2, "Each selected room should have been removed.")
XCTAssertFalse(spaceRoomListProxy.resetCalled, "The room list should be reset to pick up the changes.")
#expect(spaceServiceProxy.removeChildFromCallsCount == 2, "Each selected room should have been removed.")
#expect(!spaceRoomListProxy.resetCalled, "The room list should be reset to pick up the changes.")
}
func testLeavingSpace() async throws {
@Test
mutating func leavingSpace() async throws {
setupViewModel()
XCTAssertNil(context.leaveSpaceViewModel)
#expect(context.leaveSpaceViewModel == nil)
let deferredHandle = deferFulfillment(context.observe(\.leaveSpaceViewModel)) { $0 != nil }
context.send(viewAction: .leaveSpace)
try await deferredHandle.fulfill()
XCTAssertNotNil(context.leaveSpaceViewModel, "The leave action should show the leave view.")
#expect(context.leaveSpaceViewModel != nil, "The leave action should show the leave view.")
let leaveSpaceViewModel = try XCTUnwrap(context.leaveSpaceViewModel)
let handle = try XCTUnwrap(context.leaveSpaceViewModel?.state.leaveHandle)
let leaveSpaceViewModel = try #require(context.leaveSpaceViewModel)
let handle = try #require(context.leaveSpaceViewModel?.state.leaveHandle)
let selectedCount = handle.selectedCount
let firstSelectedRoom = try XCTUnwrap(handle.rooms.first { $0.isSelected })
XCTAssertGreaterThan(selectedCount, 0, "The leave view should have selected rooms to begin with")
let firstSelectedRoom = try #require(handle.rooms.first { $0.isSelected })
#expect(selectedCount > 0, "The leave view should have selected rooms to begin with")
leaveSpaceViewModel.context.send(viewAction: .deselectAll)
XCTAssertEqual(handle.selectedCount, 0, "Deselecting all should result in no selected rooms.")
#expect(handle.selectedCount == 0, "Deselecting all should result in no selected rooms.")
leaveSpaceViewModel.context.send(viewAction: .toggleRoom(roomID: firstSelectedRoom.spaceServiceRoom.id))
XCTAssertEqual(handle.selectedCount, 1, "Toggling a room should result in 1 selected room")
#expect(handle.selectedCount == 1, "Toggling a room should result in 1 selected room")
// Confirming the leave should leave the selected room and then the space.
let deferredAction = deferFulfillment(viewModel.actionsPublisher) { $0.isLeftSpace }
leaveSpaceViewModel.context.send(viewAction: .confirmLeaveSpace)
try await deferredAction.fulfill()
XCTAssertNil(context.leaveSpaceViewModel)
XCTAssertTrue(rustLeaveHandle.leaveRoomIdsCalled)
XCTAssertEqual(rustLeaveHandle.leaveRoomIdsReceivedRoomIds,
[firstSelectedRoom.spaceServiceRoom.id, spaceRoomListProxy.id],
"Confirming the leave should first leave the selected room and then the space.")
#expect(context.leaveSpaceViewModel == nil)
#expect(rustLeaveHandle.leaveRoomIdsCalled)
#expect(rustLeaveHandle.leaveRoomIdsReceivedRoomIds ==
[firstSelectedRoom.spaceServiceRoom.id, spaceRoomListProxy.id],
"Confirming the leave should first leave the selected room and then the space.")
}
// MARK: - Helpers
private func setupViewModel(initialSpaceRooms: [SpaceServiceRoom] = [], paginationResponses: [[SpaceServiceRoom]] = []) {
private mutating func setupViewModel(initialSpaceRooms: [SpaceServiceRoom] = [], paginationResponses: [[SpaceServiceRoom]] = []) {
spaceRoomListProxy = SpaceRoomListProxyMock(.init(spaceServiceRoom: SpaceServiceRoom.mock(isSpace: true),
initialSpaceRooms: initialSpaceRooms,
paginationStateSubject: paginationStateSubject,

View File

@@ -133,7 +133,7 @@ func deferFulfillment<Value>(_ asyncSequence: any AsyncSequence<Value, Never>,
defer { group.cancelAll() }
return try #require(try await group.next())
return try #require(await group.next())
}
}
}

View File

@@ -8,10 +8,11 @@
@testable import ElementX
import QuickLook
import XCTest
import Testing
@Suite
@MainActor
class TimelineMediaPreviewDataSourceTests: XCTestCase {
struct TimelineMediaPreviewDataSourceTests {
var initialMediaItems: [EventBasedMessageTimelineItemProtocol]!
var initialMediaViewStates: [RoomTimelineItemViewState]!
let initialItemIndex = 2
@@ -19,82 +20,67 @@ class TimelineMediaPreviewDataSourceTests: XCTestCase {
var initialPadding = 100
let previewController = QLPreviewController()
override func setUp() {
init() {
initialMediaItems = newChunk()
initialMediaViewStates = initialMediaItems.map { RoomTimelineItemViewState(item: $0, groupStyle: .single) }
}
func testInitialItems() throws -> TimelineMediaPreviewDataSource {
// Given a data source built with the initial items.
let dataSource = TimelineMediaPreviewDataSource(itemViewStates: initialMediaViewStates,
initialItem: initialMediaItems[initialItemIndex],
initialPadding: initialPadding,
paginationState: .initial)
// When the preview controller displays the data.
let previewItemCount = dataSource.numberOfPreviewItems(in: previewController)
let displayedItem = try XCTUnwrap(dataSource.previewController(previewController, previewItemAt: dataSource.initialItemIndex) as? TimelineMediaPreviewItem.Media,
"A preview item should be found.")
// Then the preview controller should be showing the initial item and the data source should reflect this.
XCTAssertEqual(dataSource.initialItemIndex, initialItemIndex + initialPadding, "The initial item index should be padded for the preview controller.")
XCTAssertEqual(displayedItem.id, initialMediaItems[initialItemIndex].id.eventOrTransactionID, "The displayed item should be the initial item.")
XCTAssertEqual(dataSource.currentMediaItemID, initialMediaItems[initialItemIndex].id.eventOrTransactionID, "The current item should also be the initial item.")
XCTAssertEqual(dataSource.previewItems.count, initialMediaViewStates.count, "The initial count of preview items should be correct.")
XCTAssertEqual(previewItemCount, initialMediaViewStates.count + (2 * initialPadding), "The initial item count should be padded for the preview controller.")
return dataSource
@Test
func initialItems() throws {
try assertInitialDataSource()
}
func testCurrentUpdateItem() throws {
@Test
func currentUpdateItem() throws {
// Given a data source built with the initial items.
let dataSource = TimelineMediaPreviewDataSource(itemViewStates: initialMediaViewStates,
initialItem: initialMediaItems[initialItemIndex],
paginationState: .initial)
// When a different item is displayed.
let previewItem = try XCTUnwrap(dataSource.previewController(previewController, previewItemAt: 1 + initialPadding) as? TimelineMediaPreviewItem.Media,
"A preview item should be found.")
let previewItem = try #require(dataSource.previewController(previewController, previewItemAt: 1 + initialPadding) as? TimelineMediaPreviewItem.Media,
"A preview item should be found.")
dataSource.updateCurrentItem(.media(previewItem))
// Then the data source should reflect the change of item.
XCTAssertEqual(dataSource.currentMediaItemID, previewItem.id, "The displayed item should be the initial item.")
#expect(dataSource.currentMediaItemID == previewItem.id, "The displayed item should be the initial item.")
// When a loading item is displayed.
guard let loadingItem = dataSource.previewController(previewController, previewItemAt: initialPadding - 1) as? TimelineMediaPreviewItem.Loading else {
XCTFail("A loading item should be be returned.")
Issue.record("A loading item should be be returned.")
return
}
dataSource.updateCurrentItem(.loading(loadingItem))
// Then the data source should show a loading item
XCTAssertEqual(dataSource.currentItem, .loading(loadingItem), "The displayed item should be the loading item.")
#expect(dataSource.currentItem == .loading(loadingItem), "The displayed item should be the loading item.")
}
func testUpdatedItems() async throws {
@Test
func updatedItems() async throws {
// Given a data source built with the initial items.
let dataSource = try testInitialItems()
let dataSource = try assertInitialDataSource()
// When one of the items changes but no pagination has occurred.
let deferred = deferFailure(dataSource.previewItemsPaginationPublisher, timeout: 1) { _ in true }
let deferred = deferFailure(dataSource.previewItemsPaginationPublisher, timeout: .seconds(1)) { _ in true }
dataSource.updatePreviewItems(itemViewStates: initialMediaViewStates)
// Then no pagination should be detected and none of the data should have changed.
try await deferred.fulfill()
let previewItemCount = dataSource.numberOfPreviewItems(in: previewController)
let displayedItem = try XCTUnwrap(dataSource.previewController(previewController, previewItemAt: dataSource.initialItemIndex) as? TimelineMediaPreviewItem.Media)
XCTAssertEqual(displayedItem.id, initialMediaItems[initialItemIndex].id.eventOrTransactionID, "The displayed item should not change.")
XCTAssertEqual(dataSource.currentMediaItemID, initialMediaItems[initialItemIndex].id.eventOrTransactionID, "The current item should not change.")
let displayedItem = try #require(dataSource.previewController(previewController, previewItemAt: dataSource.initialItemIndex) as? TimelineMediaPreviewItem.Media)
#expect(displayedItem.id == initialMediaItems[initialItemIndex].id.eventOrTransactionID, "The displayed item should not change.")
#expect(dataSource.currentMediaItemID == initialMediaItems[initialItemIndex].id.eventOrTransactionID, "The current item should not change.")
XCTAssertEqual(dataSource.previewItems.count, initialMediaViewStates.count, "The number of items should not change.")
XCTAssertEqual(previewItemCount, initialMediaViewStates.count + (2 * initialPadding), "The padded number of items should not change.")
#expect(dataSource.previewItems.count == initialMediaViewStates.count, "The number of items should not change.")
#expect(previewItemCount == initialMediaViewStates.count + (2 * initialPadding), "The padded number of items should not change.")
}
func testPagination() async throws {
@Test
func pagination() async throws {
// Given a data source built with the initial items.
let dataSource = try testInitialItems()
let dataSource = try assertInitialDataSource()
// When more items are loaded in a back pagination.
var deferred = deferFulfillment(dataSource.previewItemsPaginationPublisher) { _ in true }
@@ -104,13 +90,13 @@ class TimelineMediaPreviewDataSourceTests: XCTestCase {
// Then the new items should be added but the displayed item should not change or move in the array.
try await deferred.fulfill()
XCTAssertEqual(dataSource.previewItems.count, newViewStates.count, "The new items should be added.")
#expect(dataSource.previewItems.count == newViewStates.count, "The new items should be added.")
var previewItemCount = dataSource.numberOfPreviewItems(in: previewController)
var displayedItem = try XCTUnwrap(dataSource.previewController(previewController, previewItemAt: dataSource.initialItemIndex) as? TimelineMediaPreviewItem.Media)
XCTAssertEqual(displayedItem.id, initialMediaItems[initialItemIndex].id.eventOrTransactionID, "The displayed item should not change.")
XCTAssertEqual(dataSource.currentMediaItemID, initialMediaItems[initialItemIndex].id.eventOrTransactionID, "The current item should not change.")
XCTAssertEqual(previewItemCount, initialMediaViewStates.count + (2 * initialPadding), "The number of items should not change")
var displayedItem = try #require(dataSource.previewController(previewController, previewItemAt: dataSource.initialItemIndex) as? TimelineMediaPreviewItem.Media)
#expect(displayedItem.id == initialMediaItems[initialItemIndex].id.eventOrTransactionID, "The displayed item should not change.")
#expect(dataSource.currentMediaItemID == initialMediaItems[initialItemIndex].id.eventOrTransactionID, "The current item should not change.")
#expect(previewItemCount == initialMediaViewStates.count + (2 * initialPadding), "The number of items should not change")
// When more items are loaded in a forward pagination or sync.
deferred = deferFulfillment(dataSource.previewItemsPaginationPublisher) { _ in true }
@@ -120,36 +106,37 @@ class TimelineMediaPreviewDataSourceTests: XCTestCase {
// Then the new items should be added but the displayed item should not change or move in the array.
try await deferred.fulfill()
XCTAssertEqual(dataSource.previewItems.count, newViewStates.count, "The new items should be added.")
#expect(dataSource.previewItems.count == newViewStates.count, "The new items should be added.")
previewItemCount = dataSource.numberOfPreviewItems(in: previewController)
displayedItem = try XCTUnwrap(dataSource.previewController(previewController, previewItemAt: dataSource.initialItemIndex) as? TimelineMediaPreviewItem.Media)
XCTAssertEqual(displayedItem.id, initialMediaItems[initialItemIndex].id.eventOrTransactionID, "The displayed item should not change.")
XCTAssertEqual(dataSource.currentMediaItemID, initialMediaItems[initialItemIndex].id.eventOrTransactionID, "The current item should not change.")
XCTAssertEqual(previewItemCount, initialMediaViewStates.count + (2 * initialPadding), "The number of items should not change")
displayedItem = try #require(dataSource.previewController(previewController, previewItemAt: dataSource.initialItemIndex) as? TimelineMediaPreviewItem.Media)
#expect(displayedItem.id == initialMediaItems[initialItemIndex].id.eventOrTransactionID, "The displayed item should not change.")
#expect(dataSource.currentMediaItemID == initialMediaItems[initialItemIndex].id.eventOrTransactionID, "The current item should not change.")
#expect(previewItemCount == initialMediaViewStates.count + (2 * initialPadding), "The number of items should not change")
}
func testPaginationLimits() async throws {
@Test
mutating func paginationLimits() async throws {
// Given a data source with a small amount of padding remaining.
initialPadding = 2
let dataSource = try testInitialItems()
let dataSource = try assertInitialDataSource()
// When paginating backwards by more than the available padding.
var deferred = deferFulfillment(dataSource.previewItemsPaginationPublisher) { _ in true }
let backPaginationChunk = newChunk().map { RoomTimelineItemViewState(item: $0, groupStyle: .single) }
var newViewStates = backPaginationChunk + initialMediaViewStates
XCTAssertTrue(newViewStates.count > initialPadding)
#expect(newViewStates.count > initialPadding)
dataSource.updatePreviewItems(itemViewStates: newViewStates)
// Then all the items should be added but the preview-able count shouldn't grow and displayed item should not change or move.
try await deferred.fulfill()
XCTAssertEqual(dataSource.previewItems.count, newViewStates.count, "The new items should be added.")
#expect(dataSource.previewItems.count == newViewStates.count, "The new items should be added.")
var previewItemCount = dataSource.numberOfPreviewItems(in: previewController)
var displayedItem = try XCTUnwrap(dataSource.previewController(previewController, previewItemAt: dataSource.initialItemIndex) as? TimelineMediaPreviewItem.Media)
XCTAssertEqual(displayedItem.id, initialMediaItems[initialItemIndex].id.eventOrTransactionID, "The displayed item should not change.")
XCTAssertEqual(dataSource.currentMediaItemID, initialMediaItems[initialItemIndex].id.eventOrTransactionID, "The current item should not change.")
XCTAssertEqual(previewItemCount, initialMediaViewStates.count + (2 * initialPadding), "The number of items should not change")
var displayedItem = try #require(dataSource.previewController(previewController, previewItemAt: dataSource.initialItemIndex) as? TimelineMediaPreviewItem.Media)
#expect(displayedItem.id == initialMediaItems[initialItemIndex].id.eventOrTransactionID, "The displayed item should not change.")
#expect(dataSource.currentMediaItemID == initialMediaItems[initialItemIndex].id.eventOrTransactionID, "The current item should not change.")
#expect(previewItemCount == initialMediaViewStates.count + (2 * initialPadding), "The number of items should not change")
// When paginating forwards by more than the available padding.
deferred = deferFulfillment(dataSource.previewItemsPaginationPublisher) { _ in true }
@@ -159,16 +146,17 @@ class TimelineMediaPreviewDataSourceTests: XCTestCase {
// Then all the items should be added but the preview-able count shouldn't grow and displayed item should not change or move.
try await deferred.fulfill()
XCTAssertEqual(dataSource.previewItems.count, newViewStates.count, "The new items should be added.")
#expect(dataSource.previewItems.count == newViewStates.count, "The new items should be added.")
previewItemCount = dataSource.numberOfPreviewItems(in: previewController)
displayedItem = try XCTUnwrap(dataSource.previewController(previewController, previewItemAt: dataSource.initialItemIndex) as? TimelineMediaPreviewItem.Media)
XCTAssertEqual(displayedItem.id, initialMediaItems[initialItemIndex].id.eventOrTransactionID, "The displayed item should not change.")
XCTAssertEqual(dataSource.currentMediaItemID, initialMediaItems[initialItemIndex].id.eventOrTransactionID, "The current item should not change.")
XCTAssertEqual(previewItemCount, initialMediaViewStates.count + (2 * initialPadding), "The number of items should not change")
displayedItem = try #require(dataSource.previewController(previewController, previewItemAt: dataSource.initialItemIndex) as? TimelineMediaPreviewItem.Media)
#expect(displayedItem.id == initialMediaItems[initialItemIndex].id.eventOrTransactionID, "The displayed item should not change.")
#expect(dataSource.currentMediaItemID == initialMediaItems[initialItemIndex].id.eventOrTransactionID, "The current item should not change.")
#expect(previewItemCount == initialMediaViewStates.count + (2 * initialPadding), "The number of items should not change")
}
func testEmptyTimeline() async throws {
@Test
func emptyTimeline() async throws {
// Given a data source built with no timeline items loaded.
let initialItem = initialMediaItems[initialItemIndex]
let dataSource = TimelineMediaPreviewDataSource(itemViewStates: [],
@@ -178,16 +166,16 @@ class TimelineMediaPreviewDataSourceTests: XCTestCase {
// When the preview controller displays the data.
var previewItemCount = dataSource.numberOfPreviewItems(in: previewController)
var displayedItem = try XCTUnwrap(dataSource.previewController(previewController, previewItemAt: dataSource.initialItemIndex) as? TimelineMediaPreviewItem.Media,
"A preview item should be found.")
var displayedItem = try #require(dataSource.previewController(previewController, previewItemAt: dataSource.initialItemIndex) as? TimelineMediaPreviewItem.Media,
"A preview item should be found.")
// Then the preview controller should always show the initial item.
XCTAssertEqual(dataSource.previewItems.count, 1, "The initial item should be in the preview items array.")
XCTAssertEqual(previewItemCount, 1 + (2 * initialPadding), "The initial item count should be padded for the preview controller.")
XCTAssertEqual(dataSource.initialItemIndex, initialPadding, "The initial item index should be padded for the preview controller.")
#expect(dataSource.previewItems.count == 1, "The initial item should be in the preview items array.")
#expect(previewItemCount == 1 + (2 * initialPadding), "The initial item count should be padded for the preview controller.")
#expect(dataSource.initialItemIndex == initialPadding, "The initial item index should be padded for the preview controller.")
XCTAssertEqual(displayedItem.id, initialItem.id.eventOrTransactionID, "The displayed item should be the initial item.")
XCTAssertEqual(dataSource.currentMediaItemID, initialItem.id.eventOrTransactionID, "The current item should also be the initial item.")
#expect(displayedItem.id == initialItem.id.eventOrTransactionID, "The displayed item should be the initial item.")
#expect(dataSource.currentMediaItemID == initialItem.id.eventOrTransactionID, "The current item should also be the initial item.")
// When the timeline loads the initial items.
let deferred = deferFulfillment(dataSource.previewItemsPaginationPublisher) { _ in true }
@@ -197,18 +185,19 @@ class TimelineMediaPreviewDataSourceTests: XCTestCase {
// Then the preview controller should still show the initial item with the other items loaded around it.
previewItemCount = dataSource.numberOfPreviewItems(in: previewController)
displayedItem = try XCTUnwrap(dataSource.previewController(previewController, previewItemAt: dataSource.initialItemIndex) as? TimelineMediaPreviewItem.Media,
"A preview item should be found.")
displayedItem = try #require(dataSource.previewController(previewController, previewItemAt: dataSource.initialItemIndex) as? TimelineMediaPreviewItem.Media,
"A preview item should be found.")
XCTAssertEqual(dataSource.previewItems.count, initialMediaViewStates.count, "The preview items should now be loaded.")
XCTAssertEqual(previewItemCount, 1 + (2 * initialPadding), "The item count should not change as the padding will be reduced.")
XCTAssertEqual(dataSource.initialItemIndex, initialPadding, "The item index should not change.")
#expect(dataSource.previewItems.count == initialMediaViewStates.count, "The preview items should now be loaded.")
#expect(previewItemCount == 1 + (2 * initialPadding), "The item count should not change as the padding will be reduced.")
#expect(dataSource.initialItemIndex == initialPadding, "The item index should not change.")
XCTAssertEqual(displayedItem.id, initialMediaItems[initialItemIndex].id.eventOrTransactionID, "The displayed item should not change.")
XCTAssertEqual(dataSource.currentMediaItemID, initialMediaItems[initialItemIndex].id.eventOrTransactionID, "The current item should not change.")
#expect(displayedItem.id == initialMediaItems[initialItemIndex].id.eventOrTransactionID, "The displayed item should not change.")
#expect(dataSource.currentMediaItemID == initialMediaItems[initialItemIndex].id.eventOrTransactionID, "The current item should not change.")
}
func testTimelineUpdateWithoutInitialItem() async throws {
@Test
func timelineUpdateWithoutInitialItem() async throws {
// Given a data source built with no timeline items loaded.
let initialItem = initialMediaItems[initialItemIndex]
let dataSource = TimelineMediaPreviewDataSource(itemViewStates: [],
@@ -218,34 +207,34 @@ class TimelineMediaPreviewDataSourceTests: XCTestCase {
// When the preview controller displays the data.
var previewItemCount = dataSource.numberOfPreviewItems(in: previewController)
var displayedItem = try XCTUnwrap(dataSource.previewController(previewController, previewItemAt: dataSource.initialItemIndex) as? TimelineMediaPreviewItem.Media,
"A preview item should be found.")
var displayedItem = try #require(dataSource.previewController(previewController, previewItemAt: dataSource.initialItemIndex) as? TimelineMediaPreviewItem.Media,
"A preview item should be found.")
// Then the preview controller should always show the initial item.
XCTAssertEqual(dataSource.previewItems.count, 1, "The initial item should be in the preview items array.")
XCTAssertEqual(previewItemCount, 1 + (2 * initialPadding), "The initial item count should be padded for the preview controller.")
XCTAssertEqual(dataSource.initialItemIndex, initialPadding, "The initial item index should be padded for the preview controller.")
#expect(dataSource.previewItems.count == 1, "The initial item should be in the preview items array.")
#expect(previewItemCount == 1 + (2 * initialPadding), "The initial item count should be padded for the preview controller.")
#expect(dataSource.initialItemIndex == initialPadding, "The initial item index should be padded for the preview controller.")
XCTAssertEqual(displayedItem.id, initialItem.id.eventOrTransactionID, "The displayed item should be the initial item.")
XCTAssertEqual(dataSource.currentMediaItemID, initialItem.id.eventOrTransactionID, "The current item should also be the initial item.")
#expect(displayedItem.id == initialItem.id.eventOrTransactionID, "The displayed item should be the initial item.")
#expect(dataSource.currentMediaItemID == initialItem.id.eventOrTransactionID, "The current item should also be the initial item.")
// When the timeline loads more items but still doesn't include the initial item.
let failure = deferFailure(dataSource.previewItemsPaginationPublisher, timeout: 1) { _ in true }
let failure = deferFailure(dataSource.previewItemsPaginationPublisher, timeout: .seconds(1)) { _ in true }
let loadedItems = newChunk().map { RoomTimelineItemViewState(item: $0, groupStyle: .single) }
dataSource.updatePreviewItems(itemViewStates: loadedItems)
try await failure.fulfill()
// Then the preview controller shouldn't update the available preview items.
previewItemCount = dataSource.numberOfPreviewItems(in: previewController)
displayedItem = try XCTUnwrap(dataSource.previewController(previewController, previewItemAt: dataSource.initialItemIndex) as? TimelineMediaPreviewItem.Media,
"A preview item should be found.")
displayedItem = try #require(dataSource.previewController(previewController, previewItemAt: dataSource.initialItemIndex) as? TimelineMediaPreviewItem.Media,
"A preview item should be found.")
XCTAssertEqual(dataSource.previewItems.count, 1, "No new items should have been added to the array.")
XCTAssertEqual(previewItemCount, 1 + (2 * initialPadding), "The initial item count should not change.")
XCTAssertEqual(dataSource.initialItemIndex, initialPadding, "The initial item index should not change.")
#expect(dataSource.previewItems.count == 1, "No new items should have been added to the array.")
#expect(previewItemCount == 1 + (2 * initialPadding), "The initial item count should not change.")
#expect(dataSource.initialItemIndex == initialPadding, "The initial item index should not change.")
XCTAssertEqual(displayedItem.id, initialItem.id.eventOrTransactionID, "The displayed item should not change.")
XCTAssertEqual(dataSource.currentMediaItemID, initialItem.id.eventOrTransactionID, "The current item not change.")
#expect(displayedItem.id == initialItem.id.eventOrTransactionID, "The displayed item should not change.")
#expect(dataSource.currentMediaItemID == initialItem.id.eventOrTransactionID, "The current item not change.")
}
// MARK: Helpers
@@ -255,6 +244,30 @@ class TimelineMediaPreviewDataSourceTests: XCTestCase {
.compactMap { $0 as? EventBasedMessageTimelineItemProtocol }
.filter(\.supportsMediaCaption) // Voice messages can't be previewed (and don't support captions).
}
@discardableResult
private func assertInitialDataSource() throws -> TimelineMediaPreviewDataSource {
// Given a data source built with the initial items.
let dataSource = TimelineMediaPreviewDataSource(itemViewStates: initialMediaViewStates,
initialItem: initialMediaItems[initialItemIndex],
initialPadding: initialPadding,
paginationState: .initial)
// When the preview controller displays the data.
let previewItemCount = dataSource.numberOfPreviewItems(in: previewController)
let displayedItem = try #require(dataSource.previewController(previewController, previewItemAt: dataSource.initialItemIndex) as? TimelineMediaPreviewItem.Media,
"A preview item should be found.")
// Then the preview controller should be showing the initial item and the data source should reflect this.
#expect(dataSource.initialItemIndex == initialItemIndex + initialPadding, "The initial item index should be padded for the preview controller.")
#expect(displayedItem.id == initialMediaItems[initialItemIndex].id.eventOrTransactionID, "The displayed item should be the initial item.")
#expect(dataSource.currentMediaItemID == initialMediaItems[initialItemIndex].id.eventOrTransactionID, "The current item should also be the initial item.")
#expect(dataSource.previewItems.count == initialMediaViewStates.count, "The initial count of preview items should be correct.")
#expect(previewItemCount == initialMediaViewStates.count + (2 * initialPadding), "The initial item count should be padded for the preview controller.")
return dataSource
}
}
private extension TimelineMediaPreviewDataSource {

View File

@@ -11,10 +11,11 @@ import Combine
import MatrixRustSDK
import QuickLook
import SwiftUI
import XCTest
import Testing
@Suite
@MainActor
class TimelineMediaPreviewViewModelTests: XCTestCase {
struct TimelineMediaPreviewViewModelTests {
var viewModel: TimelineMediaPreviewViewModel!
var context: TimelineMediaPreviewViewModel.Context {
viewModel.context
@@ -24,49 +25,52 @@ class TimelineMediaPreviewViewModelTests: XCTestCase {
var photoLibraryManager: PhotoLibraryManagerMock!
var timelineController: MockTimelineController!
func testLoadingItem() async throws {
@Test
mutating func loadingItem() async throws {
// Given a fresh view model.
setupViewModel()
XCTAssertFalse(mediaProvider.loadFileFromSourceFilenameCalled)
XCTAssertEqual(context.viewState.currentItem, .media(context.viewState.dataSource.previewItems[0]))
XCTAssertNotNil(context.viewState.currentItemActions)
#expect(!mediaProvider.loadFileFromSourceFilenameCalled)
#expect(context.viewState.currentItem == .media(context.viewState.dataSource.previewItems[0]))
#expect(context.viewState.currentItemActions != nil)
// When the preview controller sets the current item.
try await loadInitialItem()
// Then the view model should load the item and update its view state.
XCTAssertTrue(mediaProvider.loadFileFromSourceFilenameCalled)
XCTAssertEqual(context.viewState.currentItem, .media(context.viewState.dataSource.previewItems[0]))
XCTAssertNotNil(context.viewState.currentItemActions)
#expect(mediaProvider.loadFileFromSourceFilenameCalled)
#expect(context.viewState.currentItem == .media(context.viewState.dataSource.previewItems[0]))
#expect(context.viewState.currentItemActions != nil)
}
func testLoadingItemFailure() async throws {
@Test
mutating func loadingItemFailure() async throws {
// Given a fresh view model.
setupViewModel()
guard case let .media(mediaItem) = context.viewState.currentItem else {
XCTFail("There should be a current item")
Issue.record("There should be a current item")
return
}
XCTAssertFalse(mediaProvider.loadFileFromSourceFilenameCalled)
XCTAssertEqual(mediaItem, context.viewState.dataSource.previewItems[0])
XCTAssertNil(mediaItem.downloadError)
#expect(!mediaProvider.loadFileFromSourceFilenameCalled)
#expect(mediaItem == context.viewState.dataSource.previewItems[0])
#expect(mediaItem.downloadError == nil)
// When the preview controller sets an item that fails to load.
mediaProvider.loadFileFromSourceFilenameClosure = { _, _ in .failure(.failedRetrievingFile) }
let failure = deferFailure(viewModel.state.previewControllerDriver, timeout: 1) { $0.isItemLoaded }
let failure = deferFailure(viewModel.state.previewControllerDriver, timeout: .seconds(1)) { $0.isItemLoaded }
context.send(viewAction: .updateCurrentItem(.media(context.viewState.dataSource.previewItems[0])))
try await failure.fulfill()
// Then the view model should load the item and update its view state.
XCTAssertTrue(mediaProvider.loadFileFromSourceFilenameCalled)
XCTAssertEqual(mediaItem, context.viewState.dataSource.previewItems[0])
XCTAssertNotNil(mediaItem.downloadError)
#expect(mediaProvider.loadFileFromSourceFilenameCalled)
#expect(mediaItem == context.viewState.dataSource.previewItems[0])
#expect(mediaItem.downloadError != nil)
}
func testSwipingBetweenItems() async throws {
@Test
mutating func swipingBetweenItems() async throws {
// Given a view model with a loaded item.
try await testLoadingItem()
try await loadingItem()
// When swiping to another item.
let deferred = deferFulfillment(viewModel.state.previewControllerDriver) { $0.isItemLoaded }
@@ -74,41 +78,43 @@ class TimelineMediaPreviewViewModelTests: XCTestCase {
try await deferred.fulfill()
// Then the view model should load the item and update its view state.
XCTAssertEqual(mediaProvider.loadFileFromSourceFilenameCallsCount, 2)
XCTAssertEqual(context.viewState.currentItem, .media(context.viewState.dataSource.previewItems[1]))
#expect(mediaProvider.loadFileFromSourceFilenameCallsCount == 2)
#expect(context.viewState.currentItem == .media(context.viewState.dataSource.previewItems[1]))
// When swiping back to the first item.
let failure = deferFailure(viewModel.state.previewControllerDriver, timeout: 1) { $0.isItemLoaded }
let failure = deferFailure(viewModel.state.previewControllerDriver, timeout: .seconds(1)) { $0.isItemLoaded }
context.send(viewAction: .updateCurrentItem(.media(context.viewState.dataSource.previewItems[0])))
try await failure.fulfill()
// Then the view model should not need to load the item, but should still update its view state.
XCTAssertEqual(mediaProvider.loadFileFromSourceFilenameCallsCount, 2)
XCTAssertEqual(context.viewState.currentItem, .media(context.viewState.dataSource.previewItems[0]))
#expect(mediaProvider.loadFileFromSourceFilenameCallsCount == 2)
#expect(context.viewState.currentItem == .media(context.viewState.dataSource.previewItems[0]))
}
func testLoadingMoreItems() async throws {
@Test
mutating func loadingMoreItems() async throws {
// Given a view model with a loaded item.
try await testLoadingItem()
XCTAssertEqual(timelineController.paginateBackwardsCallCount, 0)
try await loadingItem()
#expect(timelineController.paginateBackwardsCallCount == 0)
// When swiping to a "loading more" item and there are more media items to load.
timelineController.paginationState = .init(backward: .idle, forward: .endReached)
timelineController.backPaginationResponses.append(RoomTimelineItemFixtures.mediaChunk)
let failure = deferFailure(viewModel.state.previewControllerDriver, timeout: 1) { $0.isItemLoaded }
let failure = deferFailure(viewModel.state.previewControllerDriver, timeout: .seconds(1)) { $0.isItemLoaded }
context.send(viewAction: .updateCurrentItem(.loading(.paginatingBackwards)))
try await failure.fulfill()
// Then there should no longer be a media preview and instead of loading any media, a pagination request should be made.
XCTAssertEqual(mediaProvider.loadFileFromSourceFilenameCallsCount, 1)
XCTAssertEqual(context.viewState.currentItem, .loading(.paginatingBackwards)) // Note: This item only changes when the preview controller handles the new items.
XCTAssertEqual(timelineController.paginateBackwardsCallCount, 1)
#expect(mediaProvider.loadFileFromSourceFilenameCallsCount == 1)
#expect(context.viewState.currentItem == .loading(.paginatingBackwards)) // Note: This item only changes when the preview controller handles the new items.
#expect(timelineController.paginateBackwardsCallCount == 1)
}
func testPagination() async throws {
@Test
mutating func pagination() async throws {
// Given a view model with a loaded item.
try await testLoadingItem()
XCTAssertEqual(context.viewState.dataSource.previewItems.count, 3)
try await loadingItem()
#expect(context.viewState.dataSource.previewItems.count == 3)
// When more items are added via a back pagination.
let deferred = deferFulfillment(context.viewState.dataSource.previewItemsPaginationPublisher) { _ in true }
@@ -118,22 +124,23 @@ class TimelineMediaPreviewViewModelTests: XCTestCase {
// And the preview controller attempts to update the current item (now at a new index in the array but it hasn't changed in the data source).
mediaProvider.loadFileFromSourceFilenameClosure = { _, _ in .failure(.failedRetrievingFile) }
let failure = deferFailure(viewModel.state.previewControllerDriver, timeout: 1) { $0.isItemLoaded }
let failure = deferFailure(viewModel.state.previewControllerDriver, timeout: .seconds(1)) { $0.isItemLoaded }
context.send(viewAction: .updateCurrentItem(.media(context.viewState.dataSource.previewItems[3])))
try await failure.fulfill()
// Then the current item shouldn't need to be reloaded.
XCTAssertEqual(context.viewState.dataSource.previewItems.count, 6)
XCTAssertEqual(mediaProvider.loadFileFromSourceFilenameCallsCount, 1)
#expect(context.viewState.dataSource.previewItems.count == 6)
#expect(mediaProvider.loadFileFromSourceFilenameCallsCount == 1)
}
func testViewInRoomTimeline() async throws {
@Test
mutating func viewInRoomTimeline() async throws {
// Given a view model with a loaded item.
try await testLoadingItem()
try await loadingItem()
// When choosing to view the current item in the timeline.
guard case let .media(mediaItem) = context.viewState.currentItem else {
XCTFail("There should be a current item.")
Issue.record("There should be a current item.")
return
}
@@ -144,13 +151,14 @@ class TimelineMediaPreviewViewModelTests: XCTestCase {
try await deferred.fulfill()
}
func testRedactConfirmation() async throws {
@Test
mutating func redactConfirmation() async throws {
// Given a view model with a loaded item.
try await testLoadingItem()
XCTAssertNil(context.redactConfirmationItem)
XCTAssertFalse(timelineController.redactCalled)
try await loadingItem()
#expect(context.redactConfirmationItem == nil)
#expect(!timelineController.redactCalled)
guard case let .media(mediaItem) = context.viewState.currentItem else {
XCTFail("There should be a current item.")
Issue.record("There should be a current item.")
return
}
@@ -161,17 +169,17 @@ class TimelineMediaPreviewViewModelTests: XCTestCase {
// Then the details sheet should be presented.
let action = try await deferredDriver.fulfill()
guard case let .showItemDetails(mediaDetailsItem) = action else {
XCTFail("The action should include the media item.")
Issue.record("The action should include the media item.")
return
}
XCTAssertEqual(.media(mediaDetailsItem), context.viewState.currentItem)
#expect(.media(mediaDetailsItem) == context.viewState.currentItem)
// When choosing to redact the item.
context.send(viewAction: .menuAction(.redact, item: mediaItem))
// Then the confirmation sheet should be presented.
XCTAssertEqual(context.redactConfirmationItem, mediaItem)
XCTAssertFalse(timelineController.redactCalled)
#expect(context.redactConfirmationItem == mediaItem)
#expect(!timelineController.redactCalled)
// When confirming the redaction.
let deferred = deferFulfillment(viewModel.actions) { $0 == .dismiss }
@@ -179,37 +187,39 @@ class TimelineMediaPreviewViewModelTests: XCTestCase {
// Then the item should be redacted and the view should be dismissed.
try await deferred.fulfill()
XCTAssertTrue(timelineController.redactCalled)
#expect(timelineController.redactCalled)
}
func testSaveImage() async throws {
@Test
mutating func saveImage() async throws {
// Given a view model with a loaded image.
try await testLoadingItem()
try await loadingItem()
guard case let .media(mediaItem) = context.viewState.currentItem else {
XCTFail("There should be a current item")
Issue.record("There should be a current item")
return
}
XCTAssertEqual(mediaItem.contentType, "JPEG image")
#expect(mediaItem.contentType == "JPEG image")
// When choosing to save the image.
context.send(viewAction: .menuAction(.save, item: mediaItem))
try await Task.sleep(for: .seconds(0.5))
// Then the image should be saved as a photo to the user's photo library.
XCTAssertTrue(photoLibraryManager.addResourceAtCalled)
XCTAssertEqual(photoLibraryManager.addResourceAtReceivedArguments?.type, .photo)
XCTAssertEqual(photoLibraryManager.addResourceAtReceivedArguments?.url, mediaItem.fileHandle?.url)
#expect(photoLibraryManager.addResourceAtCalled)
#expect(photoLibraryManager.addResourceAtReceivedArguments?.type == .photo)
#expect(photoLibraryManager.addResourceAtReceivedArguments?.url == mediaItem.fileHandle?.url)
}
func testSaveImageWithoutAuthorization() async throws {
@Test
mutating func saveImageWithoutAuthorization() async throws {
// Given a view model with a loaded image where the user has denied access to the photo library.
setupViewModel(photoLibraryAuthorizationDenied: true)
try await loadInitialItem()
guard case let .media(mediaItem) = context.viewState.currentItem else {
XCTFail("There should be a current item")
Issue.record("There should be a current item")
return
}
XCTAssertEqual(mediaItem.contentType, "JPEG image")
#expect(mediaItem.contentType == "JPEG image")
// When choosing to save the image.
let deferred = deferFulfillment(context.viewState.previewControllerDriver) { $0.isAuthorizationRequired }
@@ -217,38 +227,40 @@ class TimelineMediaPreviewViewModelTests: XCTestCase {
// Then the user should be prompted to allow access.
try await deferred.fulfill()
XCTAssertTrue(photoLibraryManager.addResourceAtCalled)
#expect(photoLibraryManager.addResourceAtCalled)
}
func testSaveVideo() async throws {
@Test
mutating func saveVideo() async throws {
// Given a view model with a loaded video.
setupViewModel(initialItemIndex: 1)
try await loadInitialItem()
guard case let .media(mediaItem) = context.viewState.currentItem else {
XCTFail("There should be a current item")
Issue.record("There should be a current item")
return
}
XCTAssertEqual(mediaItem.contentType, "MPEG-4 movie")
#expect(mediaItem.contentType == "MPEG-4 movie")
// When choosing to save the video.
context.send(viewAction: .menuAction(.save, item: mediaItem))
try await Task.sleep(for: .seconds(0.5))
// Then the video should be saved as a video in the user's photo library.
XCTAssertTrue(photoLibraryManager.addResourceAtCalled)
XCTAssertEqual(photoLibraryManager.addResourceAtReceivedArguments?.type, .video)
XCTAssertEqual(photoLibraryManager.addResourceAtReceivedArguments?.url, mediaItem.fileHandle?.url)
#expect(photoLibraryManager.addResourceAtCalled)
#expect(photoLibraryManager.addResourceAtReceivedArguments?.type == .video)
#expect(photoLibraryManager.addResourceAtReceivedArguments?.url == mediaItem.fileHandle?.url)
}
func testSaveFile() async throws {
@Test
mutating func saveFile() async throws {
// Given a view model with a loaded file.
setupViewModel(initialItemIndex: 2)
try await loadInitialItem()
guard case let .media(mediaItem) = context.viewState.currentItem else {
XCTFail("There should be a current item")
Issue.record("There should be a current item")
return
}
XCTAssertEqual(mediaItem.contentType, "PDF document")
#expect(mediaItem.contentType == "PDF document")
// When choosing to save the file.
let deferred = deferFulfillment(context.viewState.previewControllerDriver) { $0.isExportFile }
@@ -256,13 +268,13 @@ class TimelineMediaPreviewViewModelTests: XCTestCase {
let exportAction = try await deferred.fulfill()
guard case let .exportFile(file) = exportAction else {
XCTFail("Unexpected action")
Issue.record("Unexpected action")
return
}
// Then the binding should be set for the user to export the file to their specified location.
XCTAssertFalse(photoLibraryManager.addResourceAtCalled)
XCTAssertEqual(file.url, mediaItem.fileHandle?.url)
#expect(!photoLibraryManager.addResourceAtCalled)
#expect(file.url == mediaItem.fileHandle?.url)
}
// MARK: - Helpers
@@ -272,14 +284,14 @@ class TimelineMediaPreviewViewModelTests: XCTestCase {
let initialItem = context.viewState.dataSource.previewController(QLPreviewController(),
previewItemAt: context.viewState.dataSource.initialItemIndex)
guard let initialPreviewItem = initialItem as? TimelineMediaPreviewItem.Media else {
XCTFail("The initial item should be a media preview.")
Issue.record("The initial item should be a media preview.")
return
}
context.send(viewAction: .updateCurrentItem(.media(initialPreviewItem)))
try await deferred.fulfill()
}
private func setupViewModel(initialItemIndex: Int = 0, photoLibraryAuthorizationDenied: Bool = false) {
private mutating func setupViewModel(initialItemIndex: Int = 0, photoLibraryAuthorizationDenied: Bool = false) {
let initialItems = makeItems()
timelineController = MockTimelineController(timelineKind: .media(.mediaFilesScreen))
timelineController.timelineItems = initialItems

View File

@@ -8,27 +8,30 @@
import Combine
@testable import ElementX
import Foundation
import MatrixRustSDK
import XCTest
import Testing
@Suite
@MainActor
class TimelineViewModelTests: XCTestCase {
final class TimelineViewModelTests {
var userIndicatorControllerMock: UserIndicatorControllerMock!
var cancellables = Set<AnyCancellable>()
override func setUp() async throws {
init() async throws {
AppSettings.resetAllSettings()
cancellables.removeAll()
userIndicatorControllerMock = UserIndicatorControllerMock.default
}
override func tearDown() async throws {
deinit {
userIndicatorControllerMock = nil
}
// MARK: - Message Grouping
func testMessageGrouping() {
@Test
func messageGrouping() {
// Given 3 messages from Bob.
let items = [
TextRoomTimelineItem(text: "Message 1",
@@ -45,12 +48,13 @@ class TimelineViewModelTests: XCTestCase {
let viewModel = makeViewModel(timelineController: timelineController)
// Then the messages should be grouped together.
XCTAssertEqual(viewModel.state.timelineState.itemViewStates[0].groupStyle, .first, "Nothing should prevent the first message from being grouped.")
XCTAssertEqual(viewModel.state.timelineState.itemViewStates[1].groupStyle, .middle, "Nothing should prevent the middle message from being grouped.")
XCTAssertEqual(viewModel.state.timelineState.itemViewStates[2].groupStyle, .last, "Nothing should prevent the last message from being grouped.")
#expect(viewModel.state.timelineState.itemViewStates[0].groupStyle == .first, "Nothing should prevent the first message from being grouped.")
#expect(viewModel.state.timelineState.itemViewStates[1].groupStyle == .middle, "Nothing should prevent the middle message from being grouped.")
#expect(viewModel.state.timelineState.itemViewStates[2].groupStyle == .last, "Nothing should prevent the last message from being grouped.")
}
func testMessageGroupingMultipleSenders() {
@Test
func messageGroupingMultipleSenders() {
// Given some interleaved messages from Bob and Alice.
let items = [
TextRoomTimelineItem(text: "Message 1",
@@ -73,15 +77,16 @@ class TimelineViewModelTests: XCTestCase {
let viewModel = makeViewModel(timelineController: timelineController)
// Then the messages should be grouped by sender.
XCTAssertEqual(viewModel.state.timelineState.itemViewStates[0].groupStyle, .single, "A message should not be grouped when the sender changes.")
XCTAssertEqual(viewModel.state.timelineState.itemViewStates[1].groupStyle, .single, "A message should not be grouped when the sender changes.")
XCTAssertEqual(viewModel.state.timelineState.itemViewStates[2].groupStyle, .first, "A group should start with a new sender if there are more messages from that sender.")
XCTAssertEqual(viewModel.state.timelineState.itemViewStates[3].groupStyle, .last, "A group should be ended when the sender changes in the next message.")
XCTAssertEqual(viewModel.state.timelineState.itemViewStates[4].groupStyle, .first, "A group should start with a new sender if there are more messages from that sender.")
XCTAssertEqual(viewModel.state.timelineState.itemViewStates[5].groupStyle, .last, "A group should be ended when the sender changes in the next message.")
#expect(viewModel.state.timelineState.itemViewStates[0].groupStyle == .single, "A message should not be grouped when the sender changes.")
#expect(viewModel.state.timelineState.itemViewStates[1].groupStyle == .single, "A message should not be grouped when the sender changes.")
#expect(viewModel.state.timelineState.itemViewStates[2].groupStyle == .first, "A group should start with a new sender if there are more messages from that sender.")
#expect(viewModel.state.timelineState.itemViewStates[3].groupStyle == .last, "A group should be ended when the sender changes in the next message.")
#expect(viewModel.state.timelineState.itemViewStates[4].groupStyle == .first, "A group should start with a new sender if there are more messages from that sender.")
#expect(viewModel.state.timelineState.itemViewStates[5].groupStyle == .last, "A group should be ended when the sender changes in the next message.")
}
func testMessageGroupingWithLeadingReactions() {
@Test
func messageGroupingWithLeadingReactions() {
// Given 3 messages from Bob where the first message has a reaction.
let items = [
TextRoomTimelineItem(text: "Message 1",
@@ -99,12 +104,13 @@ class TimelineViewModelTests: XCTestCase {
let viewModel = makeViewModel(timelineController: timelineController)
// Then the first message should not be grouped but the other two should.
XCTAssertEqual(viewModel.state.timelineState.itemViewStates[0].groupStyle, .single, "When the first message has reactions it should not be grouped.")
XCTAssertEqual(viewModel.state.timelineState.itemViewStates[1].groupStyle, .first, "A new group should be made when the preceding message has reactions.")
XCTAssertEqual(viewModel.state.timelineState.itemViewStates[2].groupStyle, .last, "Nothing should prevent the last message from being grouped.")
#expect(viewModel.state.timelineState.itemViewStates[0].groupStyle == .single, "When the first message has reactions it should not be grouped.")
#expect(viewModel.state.timelineState.itemViewStates[1].groupStyle == .first, "A new group should be made when the preceding message has reactions.")
#expect(viewModel.state.timelineState.itemViewStates[2].groupStyle == .last, "Nothing should prevent the last message from being grouped.")
}
func testMessageGroupingWithInnerReactions() {
@Test
func messageGroupingWithInnerReactions() {
// Given 3 messages from Bob where the middle message has a reaction.
let items = [
TextRoomTimelineItem(text: "Message 1",
@@ -122,12 +128,13 @@ class TimelineViewModelTests: XCTestCase {
let viewModel = makeViewModel(timelineController: timelineController)
// Then the first and second messages should be grouped and the last one should not.
XCTAssertEqual(viewModel.state.timelineState.itemViewStates[0].groupStyle, .first, "Nothing should prevent the first message from being grouped.")
XCTAssertEqual(viewModel.state.timelineState.itemViewStates[1].groupStyle, .last, "When the message has reactions, the group should end here.")
XCTAssertEqual(viewModel.state.timelineState.itemViewStates[2].groupStyle, .single, "The last message should not be grouped when the preceding message has reactions.")
#expect(viewModel.state.timelineState.itemViewStates[0].groupStyle == .first, "Nothing should prevent the first message from being grouped.")
#expect(viewModel.state.timelineState.itemViewStates[1].groupStyle == .last, "When the message has reactions, the group should end here.")
#expect(viewModel.state.timelineState.itemViewStates[2].groupStyle == .single, "The last message should not be grouped when the preceding message has reactions.")
}
func testMessageGroupingWithTrailingReactions() {
@Test
func messageGroupingWithTrailingReactions() {
// Given 3 messages from Bob where the last message has a reaction.
let items = [
TextRoomTimelineItem(text: "Message 1",
@@ -145,14 +152,15 @@ class TimelineViewModelTests: XCTestCase {
let viewModel = makeViewModel(timelineController: timelineController)
// Then the messages should be grouped together.
XCTAssertEqual(viewModel.state.timelineState.itemViewStates[0].groupStyle, .first, "Nothing should prevent the first message from being grouped.")
XCTAssertEqual(viewModel.state.timelineState.itemViewStates[1].groupStyle, .middle, "Nothing should prevent the second message from being grouped.")
XCTAssertEqual(viewModel.state.timelineState.itemViewStates[2].groupStyle, .last, "Reactions on the last message should not prevent it from being grouped.")
#expect(viewModel.state.timelineState.itemViewStates[0].groupStyle == .first, "Nothing should prevent the first message from being grouped.")
#expect(viewModel.state.timelineState.itemViewStates[1].groupStyle == .middle, "Nothing should prevent the second message from being grouped.")
#expect(viewModel.state.timelineState.itemViewStates[2].groupStyle == .last, "Reactions on the last message should not prevent it from being grouped.")
}
// MARK: - Focussing
func testFocusItem() async throws {
@Test
func focusItem() async throws {
// Given a room with 3 items loaded in a live timeline.
let items = [TextRoomTimelineItem(eventID: "t1"),
TextRoomTimelineItem(eventID: "t2"),
@@ -161,9 +169,9 @@ class TimelineViewModelTests: XCTestCase {
timelineController.timelineItems = items
let viewModel = makeViewModel(timelineController: timelineController)
XCTAssertEqual(timelineController.focusOnEventCallCount, 0)
XCTAssertTrue(viewModel.context.viewState.timelineState.isLive)
XCTAssertNil(viewModel.context.viewState.timelineState.focussedEvent)
#expect(timelineController.focusOnEventCallCount == 0)
#expect(viewModel.context.viewState.timelineState.isLive)
#expect(viewModel.context.viewState.timelineState.focussedEvent == nil)
// When focussing on an item that isn't loaded.
let deferred = deferFulfillment(viewModel.context.$viewState) { !$0.timelineState.isLive }
@@ -171,12 +179,13 @@ class TimelineViewModelTests: XCTestCase {
try await deferred.fulfill()
// Then a new timeline should be loaded and the room focussed on that event.
XCTAssertEqual(timelineController.focusOnEventCallCount, 1)
XCTAssertFalse(viewModel.context.viewState.timelineState.isLive)
XCTAssertEqual(viewModel.context.viewState.timelineState.focussedEvent, .init(eventID: "t4", appearance: .immediate))
#expect(timelineController.focusOnEventCallCount == 1)
#expect(!viewModel.context.viewState.timelineState.isLive)
#expect(viewModel.context.viewState.timelineState.focussedEvent == .init(eventID: "t4", appearance: .immediate))
}
func testFocusLoadedItem() async throws {
@Test
func focusLoadedItem() async throws {
// Given a room with 3 items loaded in a live timeline.
let items = [TextRoomTimelineItem(eventID: "t1"),
TextRoomTimelineItem(eventID: "t2"),
@@ -185,22 +194,23 @@ class TimelineViewModelTests: XCTestCase {
timelineController.timelineItems = items
let viewModel = makeViewModel(timelineController: timelineController)
XCTAssertEqual(timelineController.focusOnEventCallCount, 0)
XCTAssertTrue(viewModel.context.viewState.timelineState.isLive)
XCTAssertNil(viewModel.context.viewState.timelineState.focussedEvent)
#expect(timelineController.focusOnEventCallCount == 0)
#expect(viewModel.context.viewState.timelineState.isLive)
#expect(viewModel.context.viewState.timelineState.focussedEvent == nil)
// When focussing on a loaded item.
let deferred = deferFailure(viewModel.context.$viewState, timeout: 1) { !$0.timelineState.isLive }
let deferred = deferFailure(viewModel.context.$viewState, timeout: .seconds(1)) { !$0.timelineState.isLive }
await viewModel.focusOnEvent(eventID: "t1")
try await deferred.fulfill()
// Then the timeline should remain live and the item should be focussed.
XCTAssertEqual(timelineController.focusOnEventCallCount, 0)
XCTAssertTrue(viewModel.context.viewState.timelineState.isLive)
XCTAssertEqual(viewModel.context.viewState.timelineState.focussedEvent, .init(eventID: "t1", appearance: .animated))
#expect(timelineController.focusOnEventCallCount == 0)
#expect(viewModel.context.viewState.timelineState.isLive)
#expect(viewModel.context.viewState.timelineState.focussedEvent == .init(eventID: "t1", appearance: .animated))
}
func testFocusLive() async throws {
@Test
func focusLive() async throws {
// Given a room with a non-live timeline focussed on a particular event.
let items = [TextRoomTimelineItem(eventID: "t1"),
TextRoomTimelineItem(eventID: "t2"),
@@ -214,9 +224,9 @@ class TimelineViewModelTests: XCTestCase {
await viewModel.focusOnEvent(eventID: "t4")
try await deferred.fulfill()
XCTAssertEqual(timelineController.focusLiveCallCount, 0)
XCTAssertFalse(viewModel.context.viewState.timelineState.isLive)
XCTAssertEqual(viewModel.context.viewState.timelineState.focussedEvent, .init(eventID: "t4", appearance: .immediate))
#expect(timelineController.focusLiveCallCount == 0)
#expect(!viewModel.context.viewState.timelineState.isLive)
#expect(viewModel.context.viewState.timelineState.focussedEvent == .init(eventID: "t4", appearance: .immediate))
// When switching back to a live timeline.
deferred = deferFulfillment(viewModel.context.$viewState) { $0.timelineState.isLive }
@@ -224,21 +234,23 @@ class TimelineViewModelTests: XCTestCase {
try await deferred.fulfill()
// Then the timeline should switch back to being live and the event focus should be removed.
XCTAssertEqual(timelineController.focusLiveCallCount, 1)
XCTAssertTrue(viewModel.context.viewState.timelineState.isLive)
XCTAssertNil(viewModel.context.viewState.timelineState.focussedEvent)
#expect(timelineController.focusLiveCallCount == 1)
#expect(viewModel.context.viewState.timelineState.isLive)
#expect(viewModel.context.viewState.timelineState.focussedEvent == nil)
}
func testInitialFocusViewState() {
@Test
func initialFocusViewState() {
let timelineController = MockTimelineController()
let viewModel = makeViewModel(focussedEventID: "t10", timelineController: timelineController)
XCTAssertEqual(viewModel.context.viewState.timelineState.focussedEvent, .init(eventID: "t10", appearance: .immediate))
#expect(viewModel.context.viewState.timelineState.focussedEvent == .init(eventID: "t10", appearance: .immediate))
}
// MARK: - Read Receipts
func testSendReadReceipt() async throws {
@Test
func sendReadReceipt() async throws {
// Given a room with only text items in the timeline
let items = [TextRoomTimelineItem(eventID: "t1"),
TextRoomTimelineItem(eventID: "t2"),
@@ -246,17 +258,18 @@ class TimelineViewModelTests: XCTestCase {
let (viewModel, _, timelineProxy, _) = readReceiptsConfiguration(with: items)
// When sending a read receipt for the last item.
try viewModel.context.send(viewAction: .sendReadReceiptIfNeeded(XCTUnwrap(items.last?.id)))
try viewModel.context.send(viewAction: .sendReadReceiptIfNeeded(#require(items.last?.id)))
try await Task.sleep(for: .milliseconds(100))
// Then the receipt should be sent.
XCTAssertEqual(timelineProxy.sendReadReceiptForTypeCalled, true)
#expect(timelineProxy.sendReadReceiptForTypeCalled == true)
let arguments = timelineProxy.sendReadReceiptForTypeReceivedArguments
XCTAssertEqual(arguments?.eventID, "t3")
XCTAssertEqual(arguments?.type, .read)
#expect(arguments?.eventID == "t3")
#expect(arguments?.type == .read)
}
func testSendReadReceiptWithoutEvents() async throws {
@Test
func sendReadReceiptWithoutEvents() async throws {
// Given a room with only virtual items.
let items = [SeparatorRoomTimelineItem(uniqueID: .init("v1")),
SeparatorRoomTimelineItem(uniqueID: .init("v2")),
@@ -264,14 +277,15 @@ class TimelineViewModelTests: XCTestCase {
let (viewModel, _, timelineProxy, _) = readReceiptsConfiguration(with: items)
// When sending a read receipt for the last item.
try viewModel.context.send(viewAction: .sendReadReceiptIfNeeded(XCTUnwrap(items.last?.id)))
try viewModel.context.send(viewAction: .sendReadReceiptIfNeeded(#require(items.last?.id)))
try await Task.sleep(for: .milliseconds(100))
// Then nothing should be sent.
XCTAssertEqual(timelineProxy.sendReadReceiptForTypeCalled, false)
#expect(timelineProxy.sendReadReceiptForTypeCalled == false)
}
func testSendReadReceiptVirtualLast() async throws {
@Test
func sendReadReceiptVirtualLast() async throws {
// Given a room where the last event is a virtual item.
let items: [RoomTimelineItemProtocol] = [TextRoomTimelineItem(eventID: "t1"),
TextRoomTimelineItem(eventID: "t2"),
@@ -279,7 +293,7 @@ class TimelineViewModelTests: XCTestCase {
let (viewModel, _, _, _) = readReceiptsConfiguration(with: items)
// When sending a read receipt for the last item.
try viewModel.context.send(viewAction: .sendReadReceiptIfNeeded(XCTUnwrap(items.last?.id)))
try viewModel.context.send(viewAction: .sendReadReceiptIfNeeded(#require(items.last?.id)))
try await Task.sleep(for: .milliseconds(100))
}
@@ -314,7 +328,8 @@ class TimelineViewModelTests: XCTestCase {
return (viewModel, roomProxy, timelineProxy, timelineController)
}
func testShowReadReceipts() async throws {
@Test
func showReadReceipts() async throws {
let receipts: [ReadReceipt] = [.init(userID: "@alice:matrix.org", formattedTimestamp: "12:00"),
.init(userID: "@charlie:matrix.org", formattedTimestamp: "11:00")]
// Given 3 messages from Bob where the middle message has a reaction.
@@ -346,7 +361,8 @@ class TimelineViewModelTests: XCTestCase {
try await deferred.fulfill()
}
func testShowManageUserAsAdmin() async throws {
@Test
func showManageUserAsAdmin() async throws {
let viewModel = TimelineViewModel(roomProxy: JoinedRoomProxyMock(.init(name: "",
members: [RoomMemberProxyMock.mockAdmin,
RoomMemberProxyMock.mockAlice],
@@ -375,14 +391,15 @@ class TimelineViewModelTests: XCTestCase {
viewModel.context.send(viewAction: .tappedOnSenderDetails(sender: .init(with: RoomMemberProxyMock.mockAlice)))
try await deferred.fulfill()
XCTAssertEqual(viewModel.context.manageMemberViewModel?.id, RoomMemberProxyMock.mockAlice.userID)
XCTAssertEqual(viewModel.context.manageMemberViewModel?.state.permissions.canBan, true)
XCTAssertEqual(viewModel.context.manageMemberViewModel?.state.permissions.canKick, true)
XCTAssertEqual(viewModel.context.manageMemberViewModel?.state.isKickDisabled, false)
XCTAssertEqual(viewModel.context.manageMemberViewModel?.state.isBanUnbanDisabled, false)
#expect(viewModel.context.manageMemberViewModel?.id == RoomMemberProxyMock.mockAlice.userID)
#expect(viewModel.context.manageMemberViewModel?.state.permissions.canBan == true)
#expect(viewModel.context.manageMemberViewModel?.state.permissions.canKick == true)
#expect(viewModel.context.manageMemberViewModel?.state.isKickDisabled == false)
#expect(viewModel.context.manageMemberViewModel?.state.isBanUnbanDisabled == false)
}
func testShowDetailsForAnAdmin() async throws {
@Test
func showDetailsForAnAdmin() async throws {
let viewModel = TimelineViewModel(roomProxy: JoinedRoomProxyMock(.init(name: "",
members: [RoomMemberProxyMock.mockAdmin,
RoomMemberProxyMock.mockAlice],
@@ -411,14 +428,15 @@ class TimelineViewModelTests: XCTestCase {
viewModel.context.send(viewAction: .tappedOnSenderDetails(sender: .init(with: RoomMemberProxyMock.mockAdmin)))
try await deferredState.fulfill()
XCTAssertEqual(viewModel.context.manageMemberViewModel?.state.permissions.canBan, false)
XCTAssertEqual(viewModel.context.manageMemberViewModel?.state.permissions.canKick, false)
XCTAssertEqual(viewModel.context.manageMemberViewModel?.state.isKickDisabled, true)
XCTAssertEqual(viewModel.context.manageMemberViewModel?.state.isBanUnbanDisabled, true)
XCTAssertEqual(viewModel.context.manageMemberViewModel?.id, RoomMemberProxyMock.mockAdmin.userID)
#expect(viewModel.context.manageMemberViewModel?.state.permissions.canBan == false)
#expect(viewModel.context.manageMemberViewModel?.state.permissions.canKick == false)
#expect(viewModel.context.manageMemberViewModel?.state.isKickDisabled == true)
#expect(viewModel.context.manageMemberViewModel?.state.isBanUnbanDisabled == true)
#expect(viewModel.context.manageMemberViewModel?.id == RoomMemberProxyMock.mockAdmin.userID)
}
func testShowDetailsForABannedUser() async throws {
@Test
func showDetailsForABannedUser() async throws {
let viewModel = TimelineViewModel(roomProxy: JoinedRoomProxyMock(.init(name: "",
members: [RoomMemberProxyMock.mockAdmin,
RoomMemberProxyMock.mockBanned[0]],
@@ -447,17 +465,18 @@ class TimelineViewModelTests: XCTestCase {
viewModel.context.send(viewAction: .tappedOnSenderDetails(sender: .init(with: RoomMemberProxyMock.mockBanned[0])))
try await deferredState.fulfill()
XCTAssertEqual(viewModel.context.manageMemberViewModel?.state.permissions.canBan, true)
XCTAssertEqual(viewModel.context.manageMemberViewModel?.state.permissions.canKick, true)
XCTAssertEqual(viewModel.context.manageMemberViewModel?.state.isKickDisabled, true)
XCTAssertEqual(viewModel.context.manageMemberViewModel?.state.isBanUnbanDisabled, false)
XCTAssertEqual(viewModel.context.manageMemberViewModel?.state.isMemberBanned, true)
XCTAssertEqual(viewModel.context.manageMemberViewModel?.id, RoomMemberProxyMock.mockBanned[0].userID)
#expect(viewModel.context.manageMemberViewModel?.state.permissions.canBan == true)
#expect(viewModel.context.manageMemberViewModel?.state.permissions.canKick == true)
#expect(viewModel.context.manageMemberViewModel?.state.isKickDisabled == true)
#expect(viewModel.context.manageMemberViewModel?.state.isBanUnbanDisabled == false)
#expect(viewModel.context.manageMemberViewModel?.state.isMemberBanned == true)
#expect(viewModel.context.manageMemberViewModel?.id == RoomMemberProxyMock.mockBanned[0].userID)
}
// MARK: - Pins
func testPinnedEvents() async throws {
@Test
func pinnedEvents() async throws {
var configuration = JoinedRoomProxyMockConfiguration(name: "",
pinnedEventIDs: .init(["test1"]))
let roomProxyMock = JoinedRoomProxyMock(configuration)
@@ -475,7 +494,7 @@ class TimelineViewModelTests: XCTestCase {
emojiProvider: EmojiProvider(appSettings: ServiceLocator.shared.settings),
linkMetadataProvider: LinkMetadataProvider(),
timelineControllerFactory: TimelineControllerFactoryMock(.init()))
XCTAssertEqual(configuration.pinnedEventIDs, viewModel.context.viewState.pinnedEventIDs)
#expect(configuration.pinnedEventIDs == viewModel.context.viewState.pinnedEventIDs)
configuration.pinnedEventIDs = ["test1", "test2"]
let deferred = deferFulfillment(viewModel.context.$viewState) { value in
@@ -485,7 +504,8 @@ class TimelineViewModelTests: XCTestCase {
try await deferred.fulfill()
}
func testCanUserPinEvents() async throws {
@Test
func canUserPinEvents() async throws {
let configuration = JoinedRoomProxyMockConfiguration(name: "",
powerLevelsConfiguration: .init(canUserPin: true))
let roomProxyMock = JoinedRoomProxyMock(configuration)
@@ -526,32 +546,34 @@ class TimelineViewModelTests: XCTestCase {
// MARK: - Tap Actions
func testTapSendInfoEncryptionAuthentictyDisplaysAlert() {
@Test
func tapSendInfoEncryptionAuthentictyDisplaysAlert() {
// Given a room with an event whose authenticity could not be verified
let items = [TextRoomTimelineItem(eventID: "t1", encryptionAuthenticity: .verificationViolation(color: .red))]
let timelineController = MockTimelineController()
timelineController.timelineItems = items
let viewModel = makeViewModel(timelineController: timelineController)
XCTAssertNil(viewModel.state.bindings.alertInfo)
#expect(viewModel.state.bindings.alertInfo == nil)
viewModel.process(viewAction: .itemSendInfoTapped(itemID: items[0].id))
XCTAssertEqual(viewModel.state.bindings.alertInfo?.title, "Encrypted by a previously-verified user.")
#expect(viewModel.state.bindings.alertInfo?.title == "Encrypted by a previously-verified user.")
}
func testTapSendInfoEncryptionForwarderDisplaysAlert() {
@Test
func tapSendInfoEncryptionForwarderDisplaysAlert() {
// Given a room with an event whose key was forwarded
let items = [TextRoomTimelineItem(eventID: "t1", keyForwarder: .test)]
let timelineController = MockTimelineController()
timelineController.timelineItems = items
let viewModel = makeViewModel(timelineController: timelineController)
XCTAssertNil(viewModel.state.bindings.alertInfo)
#expect(viewModel.state.bindings.alertInfo == nil)
viewModel.process(viewAction: .itemSendInfoTapped(itemID: items[0].id))
XCTAssertEqual(viewModel.state.bindings.alertInfo?.title, "alice (@alice:matrix.org) shared this message since you were not in the room when it was sent.")
#expect(viewModel.state.bindings.alertInfo?.title == "alice (@alice:matrix.org) shared this message since you were not in the room when it was sent.")
}
// MARK: - Helpers

View File

@@ -9,10 +9,11 @@
import Combine
@testable import ElementX
import Foundation
import XCTest
import Testing
@Suite
@MainActor
class VoiceMessageRecorderTests: XCTestCase {
struct VoiceMessageRecorderTests {
private var voiceMessageRecorder: VoiceMessageRecorder!
private var audioRecorder: AudioRecorderMock!
@@ -33,7 +34,7 @@ class VoiceMessageRecorderTests: XCTestCase {
private let recordingURL = URL("/some/url")
override func setUp() async throws {
init() async throws {
audioRecorder = AudioRecorderMock()
audioRecorder.underlyingCurrentTime = 0
audioRecorder.averagePowerReturnValue = 0
@@ -61,7 +62,7 @@ class VoiceMessageRecorderTests: XCTestCase {
let deferred = deferFulfillment(voiceMessageRecorder.actions) { action in
switch action {
case .didStopRecording(_, let url) where url == self.recordingURL:
case .didStopRecording(_, let url) where url == recordingURL:
return true
default:
return false
@@ -71,141 +72,153 @@ class VoiceMessageRecorderTests: XCTestCase {
try await deferred.fulfill()
}
func testRecordingURL() {
@Test
func recorderRecordingURL() {
audioRecorder.audioFileURL = recordingURL
XCTAssertEqual(voiceMessageRecorder.recordingURL, recordingURL)
#expect(voiceMessageRecorder.recordingURL == recordingURL)
}
func testRecordingDuration() {
@Test
func recorderRecordingDuration() {
audioRecorder.currentTime = 10.3
XCTAssertEqual(voiceMessageRecorder.recordingDuration, 10.3)
#expect(voiceMessageRecorder.recordingDuration == 10.3)
}
func testStartRecording() async {
@Test
func startRecording() async {
_ = await voiceMessageRecorder.startRecording()
XCTAssert(audioRecorder.recordAudioFileURLCalled)
#expect(audioRecorder.recordAudioFileURLCalled)
}
func testStopRecording() async {
@Test
func stopRecording() async {
_ = await voiceMessageRecorder.stopRecording()
// Internal audio recorder must have been stopped
XCTAssert(audioRecorder.stopRecordingCalled)
#expect(audioRecorder.stopRecordingCalled)
}
func testCancelRecording() async {
@Test
func cancelRecording() async {
await voiceMessageRecorder.cancelRecording()
// Internal audio recorder must have been stopped
XCTAssert(audioRecorder.stopRecordingCalled)
#expect(audioRecorder.stopRecordingCalled)
// The recording audio file must have been deleted
XCTAssert(audioRecorder.deleteRecordingCalled)
#expect(audioRecorder.deleteRecordingCalled)
}
func testDeleteRecording() async {
@Test
func deleteRecording() async {
await voiceMessageRecorder.deleteRecording()
// The recording audio file must have been deleted
XCTAssert(audioRecorder.deleteRecordingCalled)
#expect(audioRecorder.deleteRecordingCalled)
}
func testStartPlaybackNoPreview() async {
@Test
func startPlaybackNoPreview() async {
guard case .failure(.previewNotAvailable) = await voiceMessageRecorder.startPlayback() else {
XCTFail("An error is expected")
Issue.record("An error is expected")
return
}
}
func testStartPlayback() async throws {
@Test
func startPlayback() async throws {
try await setRecordingComplete()
guard case .success = await voiceMessageRecorder.startPlayback() else {
XCTFail("Playback should start")
Issue.record("Playback should start")
return
}
XCTAssertEqual(voiceMessageRecorder.previewAudioPlayerState?.isAttached, true)
XCTAssert(audioPlayer.loadSourceURLPlaybackURLAutoplayCalled)
XCTAssertEqual(audioPlayer.loadSourceURLPlaybackURLAutoplayReceivedArguments?.sourceURL, recordingURL)
XCTAssertEqual(audioPlayer.loadSourceURLPlaybackURLAutoplayReceivedArguments?.playbackURL, recordingURL)
XCTAssertEqual(audioPlayer.loadSourceURLPlaybackURLAutoplayReceivedArguments?.autoplay, true)
XCTAssertFalse(audioPlayer.playCalled)
#expect(voiceMessageRecorder.previewAudioPlayerState?.isAttached == true)
#expect(audioPlayer.loadSourceURLPlaybackURLAutoplayCalled)
#expect(audioPlayer.loadSourceURLPlaybackURLAutoplayReceivedArguments?.sourceURL == recordingURL)
#expect(audioPlayer.loadSourceURLPlaybackURLAutoplayReceivedArguments?.playbackURL == recordingURL)
#expect(audioPlayer.loadSourceURLPlaybackURLAutoplayReceivedArguments?.autoplay == true)
#expect(!audioPlayer.playCalled)
}
func testPausePlayback() async throws {
@Test
func pausePlayback() async throws {
try await setRecordingComplete()
_ = await voiceMessageRecorder.startPlayback()
XCTAssertEqual(voiceMessageRecorder.previewAudioPlayerState?.isAttached, true)
#expect(voiceMessageRecorder.previewAudioPlayerState?.isAttached == true)
voiceMessageRecorder.pausePlayback()
XCTAssert(audioPlayer.pauseCalled)
#expect(audioPlayer.pauseCalled)
}
func testResumePlayback() async throws {
@Test
func resumePlayback() async throws {
try await setRecordingComplete()
audioPlayer.playbackURL = recordingURL
guard case .success = await voiceMessageRecorder.startPlayback() else {
XCTFail("Playback should start")
Issue.record("Playback should start")
return
}
XCTAssertEqual(voiceMessageRecorder.previewAudioPlayerState?.isAttached, true)
#expect(voiceMessageRecorder.previewAudioPlayerState?.isAttached == true)
// The media must not have been reloaded
XCTAssertFalse(audioPlayer.loadSourceURLPlaybackURLAutoplayCalled)
XCTAssertTrue(audioPlayer.playCalled)
#expect(!audioPlayer.loadSourceURLPlaybackURLAutoplayCalled)
#expect(audioPlayer.playCalled)
}
func testStopPlayback() async throws {
@Test
func stopPlayback() async throws {
try await setRecordingComplete()
_ = await voiceMessageRecorder.startPlayback()
XCTAssertEqual(voiceMessageRecorder.previewAudioPlayerState?.isAttached, true)
#expect(voiceMessageRecorder.previewAudioPlayerState?.isAttached == true)
await voiceMessageRecorder.stopPlayback()
XCTAssertEqual(voiceMessageRecorder.previewAudioPlayerState?.isAttached, false)
XCTAssert(audioPlayer.stopCalled)
#expect(voiceMessageRecorder.previewAudioPlayerState?.isAttached == false)
#expect(audioPlayer.stopCalled)
}
func testSeekPlayback() async throws {
@Test
func seekPlayback() async throws {
try await setRecordingComplete()
_ = await voiceMessageRecorder.startPlayback()
XCTAssertEqual(voiceMessageRecorder.previewAudioPlayerState?.isAttached, true)
#expect(voiceMessageRecorder.previewAudioPlayerState?.isAttached == true)
await voiceMessageRecorder.seekPlayback(to: 0.4)
XCTAssertEqual(audioPlayer.seekToReceivedProgress, 0.4)
#expect(audioPlayer.seekToReceivedProgress == 0.4)
}
func testBuildRecordedWaveform() async {
@Test
func buildRecordedWaveform() async throws {
// If there is no recording file, an error is expected
audioRecorder.audioFileURL = nil
guard case .failure(.missingRecordingFile) = await voiceMessageRecorder.buildRecordingWaveform() else {
XCTFail("An error is expected")
Issue.record("An error is expected")
return
}
guard let audioFileURL = Bundle(for: Self.self).url(forResource: "test_audio", withExtension: "mp3") else {
XCTFail("Test audio file is missing")
return
}
let audioFileURL = try #require(Bundle(for: UnitTestsAppCoordinator.self).url(forResource: "test_audio", withExtension: "mp3"), "Test audio file is missing")
audioRecorder.audioFileURL = audioFileURL
guard case .success(let data) = await voiceMessageRecorder.buildRecordingWaveform() else {
XCTFail("A waveform is expected")
Issue.record("A waveform is expected")
return
}
XCTAssert(!data.isEmpty)
#expect(!data.isEmpty)
}
func testSendVoiceMessage_NoRecordingFile() async {
@Test
func sendVoiceMessage_NoRecordingFile() async {
let timelineController = MockTimelineController()
// If there is no recording file, an error is expected
audioRecorder.audioFileURL = nil
guard case .failure(.missingRecordingFile) = await voiceMessageRecorder.sendVoiceMessage(timelineController: timelineController,
audioConverter: audioConverter) else {
XCTFail("An error is expected")
Issue.record("An error is expected")
return
}
}
func testSendVoiceMessage_ConversionError() async {
@Test
func sendVoiceMessage_ConversionError() async {
audioRecorder.audioFileURL = recordingURL
// If the converter returns an error
audioConverter.convertToOpusOggSourceURLDestinationURLThrowableError = AudioConverterError.conversionFailed(nil)
@@ -213,16 +226,14 @@ class VoiceMessageRecorderTests: XCTestCase {
let timelineController = MockTimelineController()
guard case .failure(.failedSendingVoiceMessage) = await voiceMessageRecorder.sendVoiceMessage(timelineController: timelineController,
audioConverter: audioConverter) else {
XCTFail("An error is expected")
Issue.record("An error is expected")
return
}
}
func testSendVoiceMessage_InvalidFile() async {
guard let audioFileURL = Bundle(for: Self.self).url(forResource: "test_voice_message", withExtension: "m4a") else {
XCTFail("Test audio file is missing")
return
}
@Test
func sendVoiceMessage_InvalidFile() async throws {
let audioFileURL = try #require(Bundle(for: UnitTestsAppCoordinator.self).url(forResource: "test_voice_message", withExtension: "m4a"), "Test audio file is missing")
audioRecorder.audioFileURL = audioFileURL
audioConverter.convertToOpusOggSourceURLDestinationURLClosure = { _, destination in
try? FileManager.default.removeItem(at: destination)
@@ -233,16 +244,14 @@ class VoiceMessageRecorderTests: XCTestCase {
timelineProxy.sendVoiceMessageUrlAudioInfoWaveformRequestHandleReturnValue = .failure(.sdkError(SDKError.generic))
guard case .failure(.failedSendingVoiceMessage) = await voiceMessageRecorder.sendVoiceMessage(timelineController: timelineController,
audioConverter: audioConverter) else {
XCTFail("An error is expected")
Issue.record("An error is expected")
return
}
}
func testSendVoiceMessage_WaveformAnlyseFailed() async {
guard let imageFileURL = Bundle(for: Self.self).url(forResource: "test_image", withExtension: "png") else {
XCTFail("Test audio file is missing")
return
}
@Test
func sendVoiceMessage_WaveformAnlyseFailed() async throws {
let imageFileURL = try #require(Bundle(for: UnitTestsAppCoordinator.self).url(forResource: "test_image", withExtension: "png"), "Test image file is missing")
audioRecorder.audioFileURL = imageFileURL
audioConverter.convertToOpusOggSourceURLDestinationURLClosure = { _, destination in
try? FileManager.default.removeItem(at: destination)
@@ -254,16 +263,14 @@ class VoiceMessageRecorderTests: XCTestCase {
timelineProxy.sendVoiceMessageUrlAudioInfoWaveformRequestHandleReturnValue = .failure(.sdkError(SDKError.generic))
guard case .failure(.failedSendingVoiceMessage) = await voiceMessageRecorder.sendVoiceMessage(timelineController: timelineController,
audioConverter: audioConverter) else {
XCTFail("An error is expected")
Issue.record("An error is expected")
return
}
}
func testSendVoiceMessage_SendError() async {
guard let audioFileURL = Bundle(for: Self.self).url(forResource: "test_voice_message", withExtension: "m4a") else {
XCTFail("Test audio file is missing")
return
}
@Test
func sendVoiceMessage_SendError() async throws {
let audioFileURL = try #require(Bundle(for: UnitTestsAppCoordinator.self).url(forResource: "test_voice_message", withExtension: "m4a"), "Test audio file is missing")
audioRecorder.audioFileURL = audioFileURL
audioConverter.convertToOpusOggSourceURLDestinationURLClosure = { source, destination in
try? FileManager.default.removeItem(at: destination)
@@ -277,16 +284,14 @@ class VoiceMessageRecorderTests: XCTestCase {
timelineProxy.sendVoiceMessageUrlAudioInfoWaveformRequestHandleReturnValue = .failure(.sdkError(SDKError.generic))
guard case .failure(.failedSendingVoiceMessage) = await voiceMessageRecorder.sendVoiceMessage(timelineController: timelineController,
audioConverter: audioConverter) else {
XCTFail("An error is expected")
Issue.record("An error is expected")
return
}
}
func testSendVoiceMessage() async {
guard let imageFileURL = Bundle(for: Self.self).url(forResource: "test_voice_message", withExtension: "m4a") else {
XCTFail("Test audio file is missing")
return
}
@Test
func sendVoiceMessage() async throws {
let imageFileURL = try #require(Bundle(for: UnitTestsAppCoordinator.self).url(forResource: "test_voice_message", withExtension: "m4a"), "Test audio file is missing")
let timelineProxy = TimelineProxyMock()
let timelineController = MockTimelineController(timelineProxy: timelineProxy)
@@ -305,38 +310,39 @@ class VoiceMessageRecorderTests: XCTestCase {
try internalConverter.convertToOpusOgg(sourceURL: source, destinationURL: destination)
convertedFileSize = try? UInt64(FileManager.default.sizeForItem(at: destination))
// the source URL must be the recorded file
XCTAssertEqual(source, imageFileURL)
#expect(source == imageFileURL)
// check the converted file extension
XCTAssertEqual(destination.pathExtension, "ogg")
#expect(destination.pathExtension == "ogg")
}
timelineProxy.sendVoiceMessageUrlAudioInfoWaveformRequestHandleClosure = { url, audioInfo, waveform, _ in
XCTAssertEqual(url, convertedFileURL)
XCTAssertEqual(audioInfo.duration, self.audioRecorder.currentTime)
XCTAssertEqual(audioInfo.size, convertedFileSize)
XCTAssertEqual(audioInfo.mimetype, "audio/ogg")
XCTAssertFalse(waveform.isEmpty)
#expect(url == convertedFileURL)
#expect(audioInfo.duration == audioRecorder.currentTime)
#expect(audioInfo.size == convertedFileSize)
#expect(audioInfo.mimetype == "audio/ogg")
#expect(!waveform.isEmpty)
return .success(())
}
guard case .success = await voiceMessageRecorder.sendVoiceMessage(timelineController: timelineController, audioConverter: audioConverter) else {
XCTFail("A success is expected")
Issue.record("A success is expected")
return
}
XCTAssert(audioConverter.convertToOpusOggSourceURLDestinationURLCalled)
XCTAssert(timelineProxy.sendVoiceMessageUrlAudioInfoWaveformRequestHandleCalled)
#expect(audioConverter.convertToOpusOggSourceURLDestinationURLCalled)
#expect(timelineProxy.sendVoiceMessageUrlAudioInfoWaveformRequestHandleCalled)
// the converted file must have been deleted
if let convertedFileURL {
XCTAssertFalse(FileManager.default.fileExists(atPath: convertedFileURL.path()))
#expect(!FileManager.default.fileExists(atPath: convertedFileURL.path()))
} else {
XCTFail("converted file URL is missing")
Issue.record("converted file URL is missing")
}
}
func testAudioRecorderActionHandling_didStartRecording() async throws {
@Test
func audioRecorderActionHandling_didStartRecording() async throws {
let deferred = deferFulfillment(voiceMessageRecorder.actions) { action in
switch action {
case .didStartRecording:
@@ -349,13 +355,14 @@ class VoiceMessageRecorderTests: XCTestCase {
try await deferred.fulfill()
}
func testAudioRecorderActionHandling_didStopRecording() async throws {
@Test
func audioRecorderActionHandling_didStopRecording() async throws {
audioRecorder.audioFileURL = recordingURL
audioRecorder.currentTime = 5
let deferred = deferFulfillment(voiceMessageRecorder.actions) { action in
switch action {
case .didStopRecording(_, let url) where url == self.recordingURL:
case .didStopRecording(_, let url) where url == recordingURL:
return true
default:
return false
@@ -365,7 +372,8 @@ class VoiceMessageRecorderTests: XCTestCase {
try await deferred.fulfill()
}
func testAudioRecorderActionHandling_didFailed() async throws {
@Test
func audioRecorderActionHandling_didFailed() async throws {
audioRecorder.audioFileURL = recordingURL
let deferred = deferFulfillment(voiceMessageRecorder.actions) { action in