Add tests for linking a new device. (#4934)

* Replace GrantLoginWithQrCodeHandlerSDKMock with LinkNewDeviceServiceMock.

Add tests for all initial states on the QRCodeLoginScreen.

* Add tests for linking both mobile and desktop devices.

* Add UI tests for linking a new device.

* Don't show the Link Desktop Computer button when running on macOS.

This mirrors the decision to hide the Sign In With QR Code button on the start screen.
This commit is contained in:
Doug
2026-01-09 13:10:14 +00:00
committed by GitHub
parent 00dc901ce1
commit 7cb472a4f7
21 changed files with 409 additions and 93 deletions
+4 -4
View File
@@ -659,6 +659,7 @@
7434A7F02D587A920B376A9A /* LoginScreenViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5A43964330459965AF048A8C /* LoginScreenViewModelTests.swift */; }; 7434A7F02D587A920B376A9A /* LoginScreenViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5A43964330459965AF048A8C /* LoginScreenViewModelTests.swift */; };
743790BF6A5B0577EA74AF14 /* ReadMarkerRoomTimelineItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DF3D25B3EDB283B5807EADCF /* ReadMarkerRoomTimelineItem.swift */; }; 743790BF6A5B0577EA74AF14 /* ReadMarkerRoomTimelineItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DF3D25B3EDB283B5807EADCF /* ReadMarkerRoomTimelineItem.swift */; };
748F482FEF4E04D61C39AAD7 /* EmojiPickerScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = F174A5627CDB3CAF280D1880 /* EmojiPickerScreenModels.swift */; }; 748F482FEF4E04D61C39AAD7 /* EmojiPickerScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = F174A5627CDB3CAF280D1880 /* EmojiPickerScreenModels.swift */; };
74DF5BC17DE9F51E077FD457 /* LinkNewDeviceServiceMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA257D747DD7E6FFA5C2BE2D /* LinkNewDeviceServiceMock.swift */; };
7501442D52A65F73DF79FFD4 /* PaginationIndicatorRoomTimelineItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B987FC3FDBAA0E1C5AA235C /* PaginationIndicatorRoomTimelineItem.swift */; }; 7501442D52A65F73DF79FFD4 /* PaginationIndicatorRoomTimelineItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B987FC3FDBAA0E1C5AA235C /* PaginationIndicatorRoomTimelineItem.swift */; };
754602A7B2AAD443C4228ED4 /* SwiftState in Frameworks */ = {isa = PBXBuildFile; productRef = 9573B94B1C86C6DF751AF3FD /* SwiftState */; }; 754602A7B2AAD443C4228ED4 /* SwiftState in Frameworks */ = {isa = PBXBuildFile; productRef = 9573B94B1C86C6DF751AF3FD /* SwiftState */; };
755395927DDD6EBDDA5E217A /* SettingsFlowCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = D28F7A6CEEA4A2815B0F0F55 /* SettingsFlowCoordinator.swift */; }; 755395927DDD6EBDDA5E217A /* SettingsFlowCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = D28F7A6CEEA4A2815B0F0F55 /* SettingsFlowCoordinator.swift */; };
@@ -785,7 +786,6 @@
88F348E2CB14FF71CBBB665D /* AudioRoomTimelineItemContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7475C5AE20BA896930907EA8 /* AudioRoomTimelineItemContent.swift */; }; 88F348E2CB14FF71CBBB665D /* AudioRoomTimelineItemContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7475C5AE20BA896930907EA8 /* AudioRoomTimelineItemContent.swift */; };
890F0D453FE388756479AC97 /* AnalyticsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C687844F60BFF532D49A994C /* AnalyticsTests.swift */; }; 890F0D453FE388756479AC97 /* AnalyticsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C687844F60BFF532D49A994C /* AnalyticsTests.swift */; };
89198AE2649DD77673D5793B /* ExtensionLogger.swift in Sources */ = {isa = PBXBuildFile; fileRef = 41A8571A8A071FB41778C016 /* ExtensionLogger.swift */; }; 89198AE2649DD77673D5793B /* ExtensionLogger.swift in Sources */ = {isa = PBXBuildFile; fileRef = 41A8571A8A071FB41778C016 /* ExtensionLogger.swift */; };
89261D215E4A432E887CD156 /* GrantLoginWithQrCodeHandlerSDKMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = FBD288168C48D3F76177FCBF /* GrantLoginWithQrCodeHandlerSDKMock.swift */; };
8944548A684F1C837CEC47F4 /* RoomMembersListScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D0946F77B696176E062D037 /* RoomMembersListScreenModels.swift */; }; 8944548A684F1C837CEC47F4 /* RoomMembersListScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D0946F77B696176E062D037 /* RoomMembersListScreenModels.swift */; };
89658A44C9FC19B58FD1C226 /* ServerConfirmationScreenViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F08776C48FFB47CACF64ED10 /* ServerConfirmationScreenViewModelTests.swift */; }; 89658A44C9FC19B58FD1C226 /* ServerConfirmationScreenViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F08776C48FFB47CACF64ED10 /* ServerConfirmationScreenViewModelTests.swift */; };
899793EFC63DF93C3E0141E7 /* RoomMemberDetailsScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0FA60F848D1C14F873F9621A /* RoomMemberDetailsScreenCoordinator.swift */; }; 899793EFC63DF93C3E0141E7 /* RoomMemberDetailsScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0FA60F848D1C14F873F9621A /* RoomMemberDetailsScreenCoordinator.swift */; };
@@ -2544,6 +2544,7 @@
B902EA6CD3296B0E10EE432B /* HomeScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeScreen.swift; sourceTree = "<group>"; }; B902EA6CD3296B0E10EE432B /* HomeScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeScreen.swift; sourceTree = "<group>"; };
B99E13633862847D8B7E2815 /* StartChatScreenModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StartChatScreenModels.swift; sourceTree = "<group>"; }; B99E13633862847D8B7E2815 /* StartChatScreenModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StartChatScreenModels.swift; sourceTree = "<group>"; };
BA241DEEF7C8A7181C0AEDC9 /* UserPreferenceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserPreferenceTests.swift; sourceTree = "<group>"; }; BA241DEEF7C8A7181C0AEDC9 /* UserPreferenceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserPreferenceTests.swift; sourceTree = "<group>"; };
BA257D747DD7E6FFA5C2BE2D /* LinkNewDeviceServiceMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkNewDeviceServiceMock.swift; sourceTree = "<group>"; };
BA40B98B098B6F0371B750B3 /* TemplateScreenModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TemplateScreenModels.swift; sourceTree = "<group>"; }; BA40B98B098B6F0371B750B3 /* TemplateScreenModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TemplateScreenModels.swift; sourceTree = "<group>"; };
BA919F521E9F0EE3638AFC85 /* BugReportScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BugReportScreen.swift; sourceTree = "<group>"; }; BA919F521E9F0EE3638AFC85 /* BugReportScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BugReportScreen.swift; sourceTree = "<group>"; };
BB284643AF7AB131E307DCE0 /* AudioSessionProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioSessionProtocol.swift; sourceTree = "<group>"; }; BB284643AF7AB131E307DCE0 /* AudioSessionProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioSessionProtocol.swift; sourceTree = "<group>"; };
@@ -2884,7 +2885,6 @@
FB9EABCA9348DFA27439A809 /* WaveformCursorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WaveformCursorView.swift; sourceTree = "<group>"; }; FB9EABCA9348DFA27439A809 /* WaveformCursorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WaveformCursorView.swift; sourceTree = "<group>"; };
FBB0328F2887BF0A65BC5D49 /* NotificationSettingsEditScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationSettingsEditScreen.swift; sourceTree = "<group>"; }; FBB0328F2887BF0A65BC5D49 /* NotificationSettingsEditScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationSettingsEditScreen.swift; sourceTree = "<group>"; };
FBC776F301D374A3298C69DA /* AppCoordinatorProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppCoordinatorProtocol.swift; sourceTree = "<group>"; }; FBC776F301D374A3298C69DA /* AppCoordinatorProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppCoordinatorProtocol.swift; sourceTree = "<group>"; };
FBD288168C48D3F76177FCBF /* GrantLoginWithQrCodeHandlerSDKMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GrantLoginWithQrCodeHandlerSDKMock.swift; sourceTree = "<group>"; };
FC3797A2325BE44FFB478BE9 /* LeaveSpaceRoomDetailsCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LeaveSpaceRoomDetailsCell.swift; sourceTree = "<group>"; }; FC3797A2325BE44FFB478BE9 /* LeaveSpaceRoomDetailsCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LeaveSpaceRoomDetailsCell.swift; sourceTree = "<group>"; };
FC83F47D2173B7538AA72E0E /* RoomSummaryProviderMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomSummaryProviderMock.swift; sourceTree = "<group>"; }; FC83F47D2173B7538AA72E0E /* RoomSummaryProviderMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomSummaryProviderMock.swift; sourceTree = "<group>"; };
FC9044BE0E4A66F5B963E834 /* AudioFileEventsTimelineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioFileEventsTimelineView.swift; sourceTree = "<group>"; }; FC9044BE0E4A66F5B963E834 /* AudioFileEventsTimelineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioFileEventsTimelineView.swift; sourceTree = "<group>"; };
@@ -3623,6 +3623,7 @@
867DC9530C42F7B5176BE465 /* JoinedRoomProxyMock.swift */, 867DC9530C42F7B5176BE465 /* JoinedRoomProxyMock.swift */,
9E8F4D7D61B80EBD5CB92F8A /* KnockedRoomProxyMock.swift */, 9E8F4D7D61B80EBD5CB92F8A /* KnockedRoomProxyMock.swift */,
7F957320D0EB7D7B4E30C79D /* KnockRequestProxyMock.swift */, 7F957320D0EB7D7B4E30C79D /* KnockRequestProxyMock.swift */,
BA257D747DD7E6FFA5C2BE2D /* LinkNewDeviceServiceMock.swift */,
6F65E4BB9E82EB8373207CF8 /* MediaProviderMock.swift */, 6F65E4BB9E82EB8373207CF8 /* MediaProviderMock.swift */,
8DA1E8F287680C8ED25EDBAC /* NetworkMonitorMock.swift */, 8DA1E8F287680C8ED25EDBAC /* NetworkMonitorMock.swift */,
840182D7A61402D5947DE094 /* NotificationItemProxyMock.swift */, 840182D7A61402D5947DE094 /* NotificationItemProxyMock.swift */,
@@ -6430,7 +6431,6 @@
children = ( children = (
8EAF4A49F3ACD8BB8B0D2371 /* ClientSDKMock.swift */, 8EAF4A49F3ACD8BB8B0D2371 /* ClientSDKMock.swift */,
0A81FD0C60175FA081EB19AD /* EventTimelineItem.swift */, 0A81FD0C60175FA081EB19AD /* EventTimelineItem.swift */,
FBD288168C48D3F76177FCBF /* GrantLoginWithQrCodeHandlerSDKMock.swift */,
5EFB1D29B0870AFB6A56E9B8 /* IdentityResetHandleSDKMock.swift */, 5EFB1D29B0870AFB6A56E9B8 /* IdentityResetHandleSDKMock.swift */,
580BDCD23DD02481AB5FFB47 /* LeaveSpaceHandleSDKMock.swift */, 580BDCD23DD02481AB5FFB47 /* LeaveSpaceHandleSDKMock.swift */,
); );
@@ -8024,7 +8024,6 @@
D34E328E9E65904358248FDD /* GlobalSearchScreenModels.swift in Sources */, D34E328E9E65904358248FDD /* GlobalSearchScreenModels.swift in Sources */,
55D18AA4F4A2257642EBDB94 /* GlobalSearchScreenViewModel.swift in Sources */, 55D18AA4F4A2257642EBDB94 /* GlobalSearchScreenViewModel.swift in Sources */,
E32A18802EB37EEE3EF7B965 /* GlobalSearchScreenViewModelProtocol.swift in Sources */, E32A18802EB37EEE3EF7B965 /* GlobalSearchScreenViewModelProtocol.swift in Sources */,
89261D215E4A432E887CD156 /* GrantLoginWithQrCodeHandlerSDKMock.swift in Sources */,
F3C9CAD26FD4D7D6EBACF501 /* HTMLFixtures.swift in Sources */, F3C9CAD26FD4D7D6EBACF501 /* HTMLFixtures.swift in Sources */,
E8C4D9F93F0DCED211D5F187 /* HTMLParserStyle.swift in Sources */, E8C4D9F93F0DCED211D5F187 /* HTMLParserStyle.swift in Sources */,
0C1E537A49ABB386F7554D4A /* HighlightedTimelineItemModifier.swift in Sources */, 0C1E537A49ABB386F7554D4A /* HighlightedTimelineItemModifier.swift in Sources */,
@@ -8124,6 +8123,7 @@
C16E25C41B858BF27E0C4FC6 /* LinkNewDeviceScreenViewModel.swift in Sources */, C16E25C41B858BF27E0C4FC6 /* LinkNewDeviceScreenViewModel.swift in Sources */,
92FE657CDFAFE3031576EB43 /* LinkNewDeviceScreenViewModelProtocol.swift in Sources */, 92FE657CDFAFE3031576EB43 /* LinkNewDeviceScreenViewModelProtocol.swift in Sources */,
A37BFB32EAB8AEF6DD5BA0DC /* LinkNewDeviceService.swift in Sources */, A37BFB32EAB8AEF6DD5BA0DC /* LinkNewDeviceService.swift in Sources */,
74DF5BC17DE9F51E077FD457 /* LinkNewDeviceServiceMock.swift in Sources */,
866FA35E7A2339EF8B6D91CA /* LinkPreviewView.swift in Sources */, 866FA35E7A2339EF8B6D91CA /* LinkPreviewView.swift in Sources */,
6E47D126DD7585E8F8237CE7 /* LoadableAvatarImage.swift in Sources */, 6E47D126DD7585E8F8237CE7 /* LoadableAvatarImage.swift in Sources */,
D9F80CE61BF8FF627FDB0543 /* LoadableImage.swift in Sources */, D9F80CE61BF8FF627FDB0543 /* LoadableImage.swift in Sources */,
@@ -106,6 +106,7 @@ extension ClientProxyMock {
resetIdentityReturnValue = .success(IdentityResetHandleSDKMock(.init())) resetIdentityReturnValue = .success(IdentityResetHandleSDKMock(.init()))
spaceService = SpaceServiceProxyMock(configuration.spaceServiceConfiguration) spaceService = SpaceServiceProxyMock(configuration.spaceServiceConfiguration)
linkNewDeviceServiceReturnValue = LinkNewDeviceServiceMock(.init())
roomForIdentifierClosure = { [weak self] identifier in roomForIdentifierClosure = { [weak self] identifier in
if let room = self?.roomSummaryProvider.roomListPublisher.value.first(where: { $0.id == identifier }) { if let room = self?.roomSummaryProvider.roomListPublisher.value.first(where: { $0.id == identifier }) {
@@ -4049,13 +4049,13 @@ class ClientProxyMock: ClientProxyProtocol, @unchecked Sendable {
return linkNewDeviceServiceCallsCount > 0 return linkNewDeviceServiceCallsCount > 0
} }
var linkNewDeviceServiceUnderlyingReturnValue: LinkNewDeviceService! var linkNewDeviceServiceUnderlyingReturnValue: LinkNewDeviceServiceProtocol!
var linkNewDeviceServiceReturnValue: LinkNewDeviceService! { var linkNewDeviceServiceReturnValue: LinkNewDeviceServiceProtocol! {
get { get {
if Thread.isMainThread { if Thread.isMainThread {
return linkNewDeviceServiceUnderlyingReturnValue return linkNewDeviceServiceUnderlyingReturnValue
} else { } else {
var returnValue: LinkNewDeviceService? = nil var returnValue: LinkNewDeviceServiceProtocol? = nil
DispatchQueue.main.sync { DispatchQueue.main.sync {
returnValue = linkNewDeviceServiceUnderlyingReturnValue returnValue = linkNewDeviceServiceUnderlyingReturnValue
} }
@@ -4073,9 +4073,9 @@ class ClientProxyMock: ClientProxyProtocol, @unchecked Sendable {
} }
} }
} }
var linkNewDeviceServiceClosure: (() -> LinkNewDeviceService)? var linkNewDeviceServiceClosure: (() -> LinkNewDeviceServiceProtocol)?
func linkNewDeviceService() -> LinkNewDeviceService { func linkNewDeviceService() -> LinkNewDeviceServiceProtocol {
linkNewDeviceServiceCallsCount += 1 linkNewDeviceServiceCallsCount += 1
if let linkNewDeviceServiceClosure = linkNewDeviceServiceClosure { if let linkNewDeviceServiceClosure = linkNewDeviceServiceClosure {
return linkNewDeviceServiceClosure() return linkNewDeviceServiceClosure()
@@ -10917,6 +10917,143 @@ class KnockedRoomProxyMock: KnockedRoomProxyProtocol, @unchecked Sendable {
} }
} }
} }
class LinkNewDeviceServiceMock: LinkNewDeviceServiceProtocol, @unchecked Sendable {
//MARK: - linkMobileDevice
var linkMobileDeviceUnderlyingCallsCount = 0
var linkMobileDeviceCallsCount: Int {
get {
if Thread.isMainThread {
return linkMobileDeviceUnderlyingCallsCount
} else {
var returnValue: Int? = nil
DispatchQueue.main.sync {
returnValue = linkMobileDeviceUnderlyingCallsCount
}
return returnValue!
}
}
set {
if Thread.isMainThread {
linkMobileDeviceUnderlyingCallsCount = newValue
} else {
DispatchQueue.main.sync {
linkMobileDeviceUnderlyingCallsCount = newValue
}
}
}
}
var linkMobileDeviceCalled: Bool {
return linkMobileDeviceCallsCount > 0
}
var linkMobileDeviceUnderlyingReturnValue: LinkNewDeviceService.LinkMobileProgressPublisher!
var linkMobileDeviceReturnValue: LinkNewDeviceService.LinkMobileProgressPublisher! {
get {
if Thread.isMainThread {
return linkMobileDeviceUnderlyingReturnValue
} else {
var returnValue: LinkNewDeviceService.LinkMobileProgressPublisher? = nil
DispatchQueue.main.sync {
returnValue = linkMobileDeviceUnderlyingReturnValue
}
return returnValue!
}
}
set {
if Thread.isMainThread {
linkMobileDeviceUnderlyingReturnValue = newValue
} else {
DispatchQueue.main.sync {
linkMobileDeviceUnderlyingReturnValue = newValue
}
}
}
}
var linkMobileDeviceClosure: (() -> LinkNewDeviceService.LinkMobileProgressPublisher)?
func linkMobileDevice() -> LinkNewDeviceService.LinkMobileProgressPublisher {
linkMobileDeviceCallsCount += 1
if let linkMobileDeviceClosure = linkMobileDeviceClosure {
return linkMobileDeviceClosure()
} else {
return linkMobileDeviceReturnValue
}
}
//MARK: - linkDesktopDevice
var linkDesktopDeviceWithUnderlyingCallsCount = 0
var linkDesktopDeviceWithCallsCount: Int {
get {
if Thread.isMainThread {
return linkDesktopDeviceWithUnderlyingCallsCount
} else {
var returnValue: Int? = nil
DispatchQueue.main.sync {
returnValue = linkDesktopDeviceWithUnderlyingCallsCount
}
return returnValue!
}
}
set {
if Thread.isMainThread {
linkDesktopDeviceWithUnderlyingCallsCount = newValue
} else {
DispatchQueue.main.sync {
linkDesktopDeviceWithUnderlyingCallsCount = newValue
}
}
}
}
var linkDesktopDeviceWithCalled: Bool {
return linkDesktopDeviceWithCallsCount > 0
}
var linkDesktopDeviceWithReceivedScannedQRData: Data?
var linkDesktopDeviceWithReceivedInvocations: [Data] = []
var linkDesktopDeviceWithUnderlyingReturnValue: LinkNewDeviceService.LinkDesktopProgressPublisher!
var linkDesktopDeviceWithReturnValue: LinkNewDeviceService.LinkDesktopProgressPublisher! {
get {
if Thread.isMainThread {
return linkDesktopDeviceWithUnderlyingReturnValue
} else {
var returnValue: LinkNewDeviceService.LinkDesktopProgressPublisher? = nil
DispatchQueue.main.sync {
returnValue = linkDesktopDeviceWithUnderlyingReturnValue
}
return returnValue!
}
}
set {
if Thread.isMainThread {
linkDesktopDeviceWithUnderlyingReturnValue = newValue
} else {
DispatchQueue.main.sync {
linkDesktopDeviceWithUnderlyingReturnValue = newValue
}
}
}
}
var linkDesktopDeviceWithClosure: ((Data) -> LinkNewDeviceService.LinkDesktopProgressPublisher)?
func linkDesktopDevice(with scannedQRData: Data) -> LinkNewDeviceService.LinkDesktopProgressPublisher {
linkDesktopDeviceWithCallsCount += 1
linkDesktopDeviceWithReceivedScannedQRData = scannedQRData
DispatchQueue.main.async {
self.linkDesktopDeviceWithReceivedInvocations.append(scannedQRData)
}
if let linkDesktopDeviceWithClosure = linkDesktopDeviceWithClosure {
return linkDesktopDeviceWithClosure(scannedQRData)
} else {
return linkDesktopDeviceWithReturnValue
}
}
}
class MediaLoaderMock: MediaLoaderProtocol, @unchecked Sendable { class MediaLoaderMock: MediaLoaderProtocol, @unchecked Sendable {
//MARK: - loadMediaContentForSource //MARK: - loadMediaContentForSource
@@ -0,0 +1,33 @@
//
// Copyright 2025 Element Creations Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
// Please see LICENSE files in the repository root for full details.
//
import SwiftUI
extension LinkNewDeviceServiceMock {
static var mockQRCodeImage: UIImage {
mockBase64QRCode.data(using: .utf8).flatMap { UIImage(qrCodeData: $0) } ?? UIImage()
}
static let mockBase64QRCode = """
TUFUUklYAgS0yzZ1QVpQ1jlnoxWX3d5jrWRFfELxjS2gN7pz9y+3PABaaHR0
cHM6Ly9zeW5hcHNlLW9pZGMubGFiLmVsZW1lbnQuZGV2L19zeW5hcHNlL2Ns
aWVudC9yZW5kZXp2b3VzLzAxSFg5SzAwUTFINktQRDQ3RUc0RzFUM1hHACVo
dHRwczovL3N5bmFwc2Utb2lkYy5sYWIuZWxlbWVudC5kZXYv
"""
struct Configuration {
var linkMobileProgressPublisher: LinkNewDeviceService.LinkMobileProgressPublisher = .init(.starting)
var linkDesktopProgressPublisher: LinkNewDeviceService.LinkDesktopProgressPublisher = .init(.starting)
}
convenience init(_ configuration: Configuration) {
self.init()
linkMobileDeviceReturnValue = configuration.linkMobileProgressPublisher
linkDesktopDeviceWithReturnValue = configuration.linkDesktopProgressPublisher
}
}
@@ -1,34 +0,0 @@
//
// Copyright 2025 Element Creations Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
// Please see LICENSE files in the repository root for full details.
//
import Foundation
import MatrixRustSDK
import MatrixRustSDKMocks
extension GrantLoginWithQrCodeHandlerSDKMock {
struct Configuration {
var generateDelay: Duration = .seconds(0)
var generatedBase64QRCode = """
TUFUUklYAgS0yzZ1QVpQ1jlnoxWX3d5jrWRFfELxjS2gN7pz9y+3PABaaHR0
cHM6Ly9zeW5hcHNlLW9pZGMubGFiLmVsZW1lbnQuZGV2L19zeW5hcHNlL2Ns
aWVudC9yZW5kZXp2b3VzLzAxSFg5SzAwUTFINktQRDQ3RUc0RzFUM1hHACVo
dHRwczovL3N5bmFwc2Utb2lkYy5sYWIuZWxlbWVudC5kZXYv
"""
}
convenience init(_ configuration: Configuration) {
self.init()
generateProgressListenerClosure = { listener in
Task {
try await Task.sleep(for: configuration.generateDelay)
let bytes = Data(base64Encoded: configuration.generatedBase64QRCode) ?? Data()
try listener.onUpdate(state: .qrReady(qrCode: .fromBytes(bytes: bytes)))
}
}
}
}
@@ -16,6 +16,8 @@ enum LinkNewDeviceScreenViewModelAction {
struct LinkNewDeviceScreenViewState: BindableState { struct LinkNewDeviceScreenViewState: BindableState {
enum Mode: Equatable { case loading, readyToLink(isGeneratingCode: Bool), notSupported } enum Mode: Equatable { case loading, readyToLink(isGeneratingCode: Bool), notSupported }
var mode: Mode = .loading var mode: Mode = .loading
let showLinkDesktopComputerButton: Bool
} }
enum LinkNewDeviceScreenViewAction { enum LinkNewDeviceScreenViewAction {
@@ -22,7 +22,9 @@ class LinkNewDeviceScreenViewModel: LinkNewDeviceScreenViewModelType, LinkNewDev
init(clientProxy: ClientProxyProtocol) { init(clientProxy: ClientProxyProtocol) {
self.clientProxy = clientProxy self.clientProxy = clientProxy
super.init(initialViewState: LinkNewDeviceScreenViewState()) let isQRCodeScanningSupported = !ProcessInfo.processInfo.isiOSAppOnMac
super.init(initialViewState: LinkNewDeviceScreenViewState(showLinkDesktopComputerButton: isQRCodeScanningSupported))
Task { await checkQRCodeLoginSupport() } Task { await checkQRCodeLoginSupport() }
} }
@@ -70,11 +70,13 @@ struct LinkNewDeviceScreen: View {
.buttonStyle(.compound(.primary)) .buttonStyle(.compound(.primary))
.accessibilityIdentifier(A11yIdentifiers.linkNewDeviceScreen.mobileDevice) .accessibilityIdentifier(A11yIdentifiers.linkNewDeviceScreen.mobileDevice)
Button { context.send(viewAction: .linkDesktopComputer) } label: { if context.viewState.showLinkDesktopComputerButton {
Label(L10n.screenLinkNewDeviceRootDesktopComputer, icon: \.computer) Button { context.send(viewAction: .linkDesktopComputer) } label: {
Label(L10n.screenLinkNewDeviceRootDesktopComputer, icon: \.computer)
}
.buttonStyle(.compound(.primary))
.accessibilityIdentifier(A11yIdentifiers.linkNewDeviceScreen.desktopComputer)
} }
.buttonStyle(.compound(.primary))
.accessibilityIdentifier(A11yIdentifiers.linkNewDeviceScreen.desktopComputer)
} }
.disabled(isGeneratingCode) .disabled(isGeneratingCode)
case .notSupported: case .notSupported:
@@ -97,8 +99,6 @@ struct LinkNewDeviceScreen: View {
// MARK: - Previews // MARK: - Previews
import MatrixRustSDKMocks
struct LinkNewDeviceScreen_Previews: PreviewProvider, TestablePreview { struct LinkNewDeviceScreen_Previews: PreviewProvider, TestablePreview {
static let viewModel = makeViewModel(mode: .readyToLink(isGeneratingCode: false)) static let viewModel = makeViewModel(mode: .readyToLink(isGeneratingCode: false))
static let generatingViewModel = makeViewModel(mode: .readyToLink(isGeneratingCode: true)) static let generatingViewModel = makeViewModel(mode: .readyToLink(isGeneratingCode: true))
@@ -143,7 +143,6 @@ struct LinkNewDeviceScreen_Previews: PreviewProvider, TestablePreview {
return false return false
} }
} }
clientProxy.linkNewDeviceServiceReturnValue = .init(handler: GrantLoginWithQrCodeHandlerSDKMock(.init(generateDelay: .seconds(20))))
let viewModel = LinkNewDeviceScreenViewModel(clientProxy: clientProxy) let viewModel = LinkNewDeviceScreenViewModel(clientProxy: clientProxy)
@@ -19,7 +19,7 @@ enum QRCodeLoginScreenMode {
/// Configures the screen to login this device by scanning a QR code. /// Configures the screen to login this device by scanning a QR code.
case login(QRCodeLoginServiceProtocol) case login(QRCodeLoginServiceProtocol)
/// Configures the screen to link another device by scanning a QR code. /// Configures the screen to link another device by scanning a QR code.
case linkDesktop(LinkNewDeviceService) case linkDesktop(LinkNewDeviceServiceProtocol)
/// Configures the screen to link another device by showing it a QR code. /// Configures the screen to link another device by showing it a QR code.
case linkMobile(LinkNewDeviceService.LinkMobileProgressPublisher) case linkMobile(LinkNewDeviceService.LinkMobileProgressPublisher)
} }
@@ -180,6 +180,13 @@ enum QRCodeLoginState: Equatable {
} }
} }
var isDisplayQR: Bool {
switch self {
case .displayQR: true
default: false
}
}
var isError: Bool { var isError: Bool {
switch self { switch self {
case .error, .scan(.scanFailed): true case .error, .scan(.scanFailed): true
@@ -153,7 +153,7 @@ class QRCodeLoginScreenViewModel: QRCodeLoginScreenViewModelType, QRCodeLoginScr
// TODO: when user cancels in UI then the underlying login needs to be cancelled too. It's unclear if we have that exposed in the bindings yet. // TODO: when user cancels in UI then the underlying login needs to be cancelled too. It's unclear if we have that exposed in the bindings yet.
private func handleScan(qrData: Data, linkService: LinkNewDeviceService) { private func handleScan(qrData: Data, linkService: LinkNewDeviceServiceProtocol) {
guard currentTask == nil else { return } guard currentTask == nil else { return }
state.state = .scan(.connecting) state.state = .scan(.connecting)
@@ -337,11 +337,7 @@ struct QRCodeLoginScreen_Previews: PreviewProvider, TestablePreview {
static let deviceNotSignedInStateViewModel = QRCodeLoginScreenViewModel.mock(state: .scan(.scanFailed(.deviceNotSignedIn))) static let deviceNotSignedInStateViewModel = QRCodeLoginScreenViewModel.mock(state: .scan(.scanFailed(.deviceNotSignedIn)))
// Showing // Showing
static let showingStateViewModel = { static let showingStateViewModel = QRCodeLoginScreenViewModel.mock(state: .displayQR(LinkNewDeviceServiceMock.mockQRCodeImage))
let base64QRCode = GrantLoginWithQrCodeHandlerSDKMock.Configuration().generatedBase64QRCode
let image = base64QRCode.data(using: .utf8).flatMap { UIImage(qrCodeData: $0) } ?? UIImage()
return QRCodeLoginScreenViewModel.mock(state: .displayQR(image))
}()
// Displaying codes // Displaying codes
static let deviceCodeStateViewModel = QRCodeLoginScreenViewModel.mock(state: .displayCode(.deviceCode("12"))) static let deviceCodeStateViewModel = QRCodeLoginScreenViewModel.mock(state: .displayCode(.deviceCode("12")))
@@ -10,7 +10,15 @@ import CoreImage.CIFilterBuiltins
import MatrixRustSDK import MatrixRustSDK
import SwiftUI import SwiftUI
class LinkNewDeviceService { // sourcery: AutoMockable
protocol LinkNewDeviceServiceProtocol {
/// Links a new device by showing it a QR code.
func linkMobileDevice() -> LinkNewDeviceService.LinkMobileProgressPublisher
/// Links a new device using a QR code generated by said device.
func linkDesktopDevice(with scannedQRData: Data) -> LinkNewDeviceService.LinkDesktopProgressPublisher
}
class LinkNewDeviceService: LinkNewDeviceServiceProtocol {
/// Publishes the progress of linking a new device by showing it a QR code. /// Publishes the progress of linking a new device by showing it a QR code.
typealias LinkMobileProgressPublisher = CurrentValuePublisher<LinkMobileProgress, QRCodeLoginError> typealias LinkMobileProgressPublisher = CurrentValuePublisher<LinkMobileProgress, QRCodeLoginError>
/// Publishes the progress of linking a new device by scanning a QR code generated by said device. /// Publishes the progress of linking a new device by scanning a QR code generated by said device.
@@ -716,7 +716,7 @@ class ClientProxy: ClientProxyProtocol {
} }
} }
func linkNewDeviceService() -> LinkNewDeviceService { func linkNewDeviceService() -> LinkNewDeviceServiceProtocol {
LinkNewDeviceService(handler: client.newGrantLoginWithQrCodeHandler()) LinkNewDeviceService(handler: client.newGrantLoginWithQrCodeHandler())
} }
@@ -195,7 +195,7 @@ protocol ClientProxyProtocol: AnyObject {
func removeUserAvatar() async -> Result<Void, ClientProxyError> func removeUserAvatar() async -> Result<Void, ClientProxyError>
func linkNewDeviceService() -> LinkNewDeviceService func linkNewDeviceService() -> LinkNewDeviceServiceProtocol
func deactivateAccount(password: String?, eraseData: Bool) async -> Result<Void, ClientProxyError> func deactivateAccount(password: String?, eraseData: Bool) async -> Result<Void, ClientProxyError>
@@ -736,9 +736,15 @@ class MockScreen: Identifiable {
return navigationStackCoordinator return navigationStackCoordinator
case .linkNewDevice: case .linkNewDevice:
let linkMobileProgressSubject: CurrentValueSubject<LinkNewDeviceService.LinkMobileProgress, QRCodeLoginError> = .init(.qrReady(LinkNewDeviceServiceMock.mockQRCodeImage))
let linkNewDeviceService = LinkNewDeviceServiceMock(.init(linkMobileProgressPublisher: linkMobileProgressSubject.asCurrentValuePublisher()))
let clientProxy = ClientProxyMock(.init())
clientProxy.linkNewDeviceServiceReturnValue = linkNewDeviceService
let navigationStackCoordinator = NavigationStackCoordinator() let navigationStackCoordinator = NavigationStackCoordinator()
let flowCoordinator = LinkNewDeviceFlowCoordinator(navigationStackCoordinator: navigationStackCoordinator, let flowCoordinator = LinkNewDeviceFlowCoordinator(navigationStackCoordinator: navigationStackCoordinator,
flowParameters: CommonFlowParameters(userSession: UserSessionMock(.init()), flowParameters: CommonFlowParameters(userSession: UserSessionMock(.init(clientProxy: clientProxy)),
bugReportService: BugReportServiceMock(.init()), bugReportService: BugReportServiceMock(.init()),
elementCallService: ElementCallServiceMock(.init()), elementCallService: ElementCallServiceMock(.init()),
timelineControllerFactory: TimelineControllerFactoryMock(.init()), timelineControllerFactory: TimelineControllerFactoryMock(.init()),
+23 -1
View File
@@ -11,16 +11,38 @@ import XCTest
class LinkNewDeviceTests: XCTestCase { class LinkNewDeviceTests: XCTestCase {
enum Step { enum Step {
static let selectDevice = 1 static let selectDevice = 1
static let linkMobileDevice = 2
static let linkDesktopComputer = 3
static let dismissed = 99 static let dismissed = 99
} }
func testFlow() async throws { func testFlow() async throws {
// Root screen
let app = Application.launch(.linkNewDevice) let app = Application.launch(.linkNewDevice)
try await app.assertScreenshot(step: Step.selectDevice) try await app.assertScreenshot(step: Step.selectDevice)
// Link showing a QR code
let mobileDeviceButton = app.buttons[A11yIdentifiers.linkNewDeviceScreen.mobileDevice]
mobileDeviceButton.tap()
try await app.assertScreenshot(step: Step.linkMobileDevice)
// Pop back to the root screen
let backButton = app.buttons["Link new device"]
backButton.tap()
try await app.assertScreenshot(step: Step.selectDevice)
// Link scanning a QR code
let desktopComputerButton = app.buttons[A11yIdentifiers.linkNewDeviceScreen.desktopComputer]
desktopComputerButton.tap()
try await app.assertScreenshot(step: Step.linkDesktopComputer)
// Pop back to the root screen
backButton.tap()
try await app.assertScreenshot(step: Step.selectDevice)
// Dismiss the flow
let cancelButton = app.buttons[A11yIdentifiers.linkNewDeviceScreen.cancel] let cancelButton = app.buttons[A11yIdentifiers.linkNewDeviceScreen.cancel]
cancelButton.tap() cancelButton.tap()
try await app.assertScreenshot(step: Step.dismissed) try await app.assertScreenshot(step: Step.dismissed)
} }
} }
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:d01d3cb0985b5720c032a0e157bbe7e453f4b7cba00d050f7731e8a821814be0
size 190548
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:4f35198629723417903f1709bffd656ec968ce9ec135be1f52df5e7fa41352d5
size 173283
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:d64aface8fd46495e451a982c47f477f9381a1e669778f027582bc968537d4ce
size 164767
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:c1fc3df2dca28137e09abdb4a411168fb1c68cfab92bcaf40951351ec9812722
size 136852
@@ -9,41 +9,63 @@
import Combine import Combine
import XCTest import XCTest
import MatrixRustSDK
@testable import ElementX @testable import ElementX
import MatrixRustSDKMocks
@MainActor @MainActor
final class QRCodeLoginScreenViewModelTests: XCTestCase { final class QRCodeLoginScreenViewModelTests: XCTestCase {
private var qrProgressSubject: CurrentValueSubject<QRLoginProgress, AuthenticationServiceError>! private var qrLoginProgressSubject: CurrentValueSubject<QRLoginProgress, AuthenticationServiceError>!
private var qrServiceMock: QRCodeLoginServiceMock! private var qrCodeLoginService: QRCodeLoginServiceMock!
private var appMediatorMock: AppMediatorMock!
private var linkMobileProgressSubject: CurrentValueSubject<LinkNewDeviceService.LinkMobileProgress, QRCodeLoginError>!
private var linkDesktopProgressSubject: CurrentValueSubject<LinkNewDeviceService.LinkDesktopProgress, QRCodeLoginError>!
private var linkNewDeviceService: LinkNewDeviceServiceMock!
private var appMediator: AppMediatorMock!
private var viewModel: QRCodeLoginScreenViewModelProtocol! private var viewModel: QRCodeLoginScreenViewModelProtocol!
private var context: QRCodeLoginScreenViewModelType.Context { viewModel.context }
private var context: QRCodeLoginScreenViewModelType.Context { func testLoginInitialState() {
viewModel.context setupViewModel(mode: .login)
}
override func setUp() {
qrProgressSubject = .init(.starting)
qrServiceMock = QRCodeLoginServiceMock()
qrServiceMock.loginWithQRCodeDataReturnValue = qrProgressSubject.asCurrentValuePublisher()
appMediatorMock = AppMediatorMock.default
viewModel = QRCodeLoginScreenViewModel(mode: .login(qrServiceMock),
canSignInManually: true,
appMediator: appMediatorMock)
}
func testInitialState() {
XCTAssertEqual(context.viewState.state, .loginInstructions) XCTAssertEqual(context.viewState.state, .loginInstructions)
XCTAssertNil(context.qrResult) XCTAssertNil(context.qrResult)
XCTAssertFalse(qrServiceMock.loginWithQRCodeDataCalled) XCTAssertFalse(qrCodeLoginService.loginWithQRCodeDataCalled)
XCTAssertFalse(appMediatorMock.requestAuthorizationIfNeededCalled) XCTAssertFalse(appMediator.requestAuthorizationIfNeededCalled)
XCTAssertFalse(appMediatorMock.openAppSettingsCalled) XCTAssertFalse(appMediator.openAppSettingsCalled)
XCTAssertFalse(linkNewDeviceService.linkMobileDeviceCalled)
XCTAssertFalse(linkNewDeviceService.linkDesktopDeviceWithCalled)
}
func testLinkDesktopInitialState() {
setupViewModel(mode: .linkDesktop)
XCTAssertEqual(context.viewState.state, .linkDesktopInstructions)
XCTAssertNil(context.qrResult)
XCTAssertFalse(linkNewDeviceService.linkDesktopDeviceWithCalled)
XCTAssertFalse(appMediator.requestAuthorizationIfNeededCalled)
XCTAssertFalse(appMediator.openAppSettingsCalled)
XCTAssertFalse(linkNewDeviceService.linkMobileDeviceCalled)
XCTAssertFalse(qrCodeLoginService.loginWithQRCodeDataCalled)
}
func testLinkMobileInitialState() {
setupViewModel(mode: .linkMobile)
XCTAssertTrue(context.viewState.state.isDisplayQR)
XCTAssertTrue(linkNewDeviceService.linkMobileDeviceCalled)
XCTAssertFalse(linkNewDeviceService.linkDesktopDeviceWithCalled)
XCTAssertFalse(qrCodeLoginService.loginWithQRCodeDataCalled)
XCTAssertNil(context.qrResult)
} }
func testRequestCameraPermission() async throws { func testRequestCameraPermission() async throws {
appMediatorMock.requestAuthorizationIfNeededReturnValue = false setupViewModel(mode: .login)
appMediator.requestAuthorizationIfNeededReturnValue = false
XCTAssert(context.viewState.state == .loginInstructions) XCTAssert(context.viewState.state == .loginInstructions)
let deferred = deferFulfillment(viewModel.context.$viewState) { state in let deferred = deferFulfillment(viewModel.context.$viewState) { state in
@@ -51,15 +73,16 @@ final class QRCodeLoginScreenViewModelTests: XCTestCase {
} }
context.send(viewAction: .startScan) context.send(viewAction: .startScan)
try await deferred.fulfill() try await deferred.fulfill()
XCTAssertTrue(appMediatorMock.requestAuthorizationIfNeededCalled) XCTAssertTrue(appMediator.requestAuthorizationIfNeededCalled)
context.send(viewAction: .errorAction(.openSettings)) context.send(viewAction: .errorAction(.openSettings))
await Task.yield() await Task.yield()
XCTAssertTrue(appMediatorMock.openAppSettingsCalled) XCTAssertTrue(appMediator.openAppSettingsCalled)
XCTAssertNil(context.qrResult) XCTAssertNil(context.qrResult)
} }
func testLogin() async throws { func testLogin() async throws {
setupViewModel(mode: .login)
XCTAssert(context.viewState.state == .loginInstructions) XCTAssert(context.viewState.state == .loginInstructions)
var deferred = deferFulfillment(context.$viewState) { state in var deferred = deferFulfillment(context.$viewState) { state in
@@ -67,7 +90,7 @@ final class QRCodeLoginScreenViewModelTests: XCTestCase {
} }
context.send(viewAction: .startScan) context.send(viewAction: .startScan)
try await deferred.fulfill() try await deferred.fulfill()
XCTAssertTrue(appMediatorMock.requestAuthorizationIfNeededCalled) XCTAssertTrue(appMediator.requestAuthorizationIfNeededCalled)
deferred = deferFulfillment(context.$viewState) { state in deferred = deferFulfillment(context.$viewState) { state in
state.state == .scan(.connecting) state.state == .scan(.connecting)
@@ -78,13 +101,13 @@ final class QRCodeLoginScreenViewModelTests: XCTestCase {
deferred = deferFulfillment(context.$viewState) { state in deferred = deferFulfillment(context.$viewState) { state in
state.state == .displayCode(.deviceCode("01")) state.state == .displayCode(.deviceCode("01"))
} }
qrProgressSubject.send(.establishingSecureChannel(checkCode: 1, checkCodeString: "01")) qrLoginProgressSubject.send(.establishingSecureChannel(checkCode: 1, checkCodeString: "01"))
try await deferred.fulfill() try await deferred.fulfill()
deferred = deferFulfillment(context.$viewState) { state in deferred = deferFulfillment(context.$viewState) { state in
state.state == .displayCode(.verificationCode("ABCDEF")) state.state == .displayCode(.verificationCode("ABCDEF"))
} }
qrProgressSubject.send(.waitingForToken(userCode: "ABCDEF")) qrLoginProgressSubject.send(.waitingForToken(userCode: "ABCDEF"))
try await deferred.fulfill() try await deferred.fulfill()
let deferredAction = deferFulfillment(viewModel.actionsPublisher) { action in let deferredAction = deferFulfillment(viewModel.actionsPublisher) { action in
@@ -93,7 +116,109 @@ final class QRCodeLoginScreenViewModelTests: XCTestCase {
default: false default: false
} }
} }
qrProgressSubject.send(.signedIn(UserSessionMock(.init(clientProxy: ClientProxyMock())))) qrLoginProgressSubject.send(.signedIn(UserSessionMock(.init(clientProxy: ClientProxyMock()))))
try await deferredAction.fulfill() try await deferredAction.fulfill()
} }
func testLinkDesktopComputer() async throws {
setupViewModel(mode: .linkDesktop)
XCTAssert(context.viewState.state == .linkDesktopInstructions)
var deferred = deferFulfillment(context.$viewState) { $0.state == .scan(.scanning) }
context.send(viewAction: .startScan)
try await deferred.fulfill()
XCTAssertTrue(appMediator.requestAuthorizationIfNeededCalled)
deferred = deferFulfillment(context.$viewState) { $0.state == .scan(.connecting) }
context.qrResult = .init()
try await deferred.fulfill()
deferred = deferFulfillment(context.$viewState) { $0.state == .displayCode(.deviceCode("01")) }
linkDesktopProgressSubject.send(.establishingSecureChannel(checkCodeString: "01"))
try await deferred.fulfill()
var deferredAction = deferFulfillment(viewModel.actionsPublisher) { action in
guard case .requestOIDCAuthorisation = action else { return false }
return true
}
linkDesktopProgressSubject.send(.waitingForAuthorisation(verificationURL: .homeDirectory))
try await deferredAction.fulfill()
let currentState = context.viewState.state
let deferredFailure = deferFailure(context.$viewState, timeout: 1) { $0.state != currentState }
linkDesktopProgressSubject.send(.syncingSecrets)
try await deferredFailure.fulfill()
deferredAction = deferFulfillment(viewModel.actionsPublisher) { action in
guard case .dismiss = action else { return false }
return true
}
linkDesktopProgressSubject.send(.done)
try await deferredAction.fulfill()
}
func testLinkMobileDevice() async throws {
setupViewModel(mode: .linkMobile)
XCTAssert(context.viewState.state.isDisplayQR)
let checkCodeSender = CheckCodeSenderSDKMock()
let checkCodeSenderProxy = CheckCodeSenderProxy(underlyingSender: checkCodeSender)
var deferredState = deferFulfillment(context.$viewState) { $0.state == .confirmCode(.inputCode(checkCodeSenderProxy)) }
linkMobileProgressSubject.send(.qrScanned(checkCodeSenderProxy))
try await deferredState.fulfill()
deferredState = deferFulfillment(context.$viewState) { $0.state == .confirmCode(.sendingCode) }
context.checkCodeInput = "01"
context.send(viewAction: .sendCheckCode)
try await deferredState.fulfill()
var deferredAction = deferFulfillment(viewModel.actionsPublisher) { action in
guard case .requestOIDCAuthorisation = action else { return false }
return true
}
linkMobileProgressSubject.send(.waitingForAuthorisation(verificationURL: .homeDirectory))
try await deferredAction.fulfill()
let currentState = context.viewState.state
let deferredFailure = deferFailure(context.$viewState, timeout: 1) { $0.state != currentState }
linkMobileProgressSubject.send(.syncingSecrets)
try await deferredFailure.fulfill()
deferredAction = deferFulfillment(viewModel.actionsPublisher) { action in
guard case .dismiss = action else { return false }
return true
}
linkMobileProgressSubject.send(.done)
try await deferredAction.fulfill()
}
// MARK: - Helpers
enum Mode { case login, linkDesktop, linkMobile }
private func setupViewModel(mode: Mode) {
qrLoginProgressSubject = .init(.starting)
qrCodeLoginService = QRCodeLoginServiceMock()
qrCodeLoginService.loginWithQRCodeDataReturnValue = qrLoginProgressSubject.asCurrentValuePublisher()
linkMobileProgressSubject = .init(.qrReady(LinkNewDeviceServiceMock.mockQRCodeImage))
linkDesktopProgressSubject = .init(.starting)
linkNewDeviceService = LinkNewDeviceServiceMock(.init(linkMobileProgressPublisher: linkMobileProgressSubject.asCurrentValuePublisher(),
linkDesktopProgressPublisher: linkDesktopProgressSubject.asCurrentValuePublisher()))
let screenMode: QRCodeLoginScreenMode
switch mode {
case .login:
screenMode = .login(qrCodeLoginService)
case .linkDesktop:
screenMode = .linkDesktop(linkNewDeviceService)
case .linkMobile:
screenMode = .linkMobile(linkNewDeviceService.linkMobileDevice())
}
appMediator = AppMediatorMock.default
viewModel = QRCodeLoginScreenViewModel(mode: screenMode,
canSignInManually: true,
appMediator: appMediator)
}
} }