diff --git a/Examples/xx-messenger/.swiftpm/xcode/xcshareddata/xcschemes/UserSearchFeature.xcscheme b/Examples/xx-messenger/.swiftpm/xcode/xcshareddata/xcschemes/UserSearchFeature.xcscheme new file mode 100644 index 0000000000000000000000000000000000000000..2f80716697639781318b3bf917803385b18422ae --- /dev/null +++ b/Examples/xx-messenger/.swiftpm/xcode/xcshareddata/xcschemes/UserSearchFeature.xcscheme @@ -0,0 +1,78 @@ +<?xml version="1.0" encoding="UTF-8"?> +<Scheme + LastUpgradeVersion = "1340" + version = "1.3"> + <BuildAction + parallelizeBuildables = "YES" + buildImplicitDependencies = "YES"> + <BuildActionEntries> + <BuildActionEntry + buildForTesting = "YES" + buildForRunning = "YES" + buildForProfiling = "YES" + buildForArchiving = "YES" + buildForAnalyzing = "YES"> + <BuildableReference + BuildableIdentifier = "primary" + BlueprintIdentifier = "UserSearchFeature" + BuildableName = "UserSearchFeature" + BlueprintName = "UserSearchFeature" + ReferencedContainer = "container:"> + </BuildableReference> + </BuildActionEntry> + </BuildActionEntries> + </BuildAction> + <TestAction + buildConfiguration = "Debug" + selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" + selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" + shouldUseLaunchSchemeArgsEnv = "YES" + codeCoverageEnabled = "YES"> + <Testables> + <TestableReference + skipped = "NO"> + <BuildableReference + BuildableIdentifier = "primary" + BlueprintIdentifier = "UserSearchFeatureTests" + BuildableName = "UserSearchFeatureTests" + BlueprintName = "UserSearchFeatureTests" + ReferencedContainer = "container:"> + </BuildableReference> + </TestableReference> + </Testables> + </TestAction> + <LaunchAction + buildConfiguration = "Debug" + selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" + selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" + launchStyle = "0" + useCustomWorkingDirectory = "NO" + ignoresPersistentStateOnLaunch = "NO" + debugDocumentVersioning = "YES" + debugServiceExtension = "internal" + allowLocationSimulation = "YES"> + </LaunchAction> + <ProfileAction + buildConfiguration = "Release" + shouldUseLaunchSchemeArgsEnv = "YES" + savedToolIdentifier = "" + useCustomWorkingDirectory = "NO" + debugDocumentVersioning = "YES"> + <MacroExpansion> + <BuildableReference + BuildableIdentifier = "primary" + BlueprintIdentifier = "UserSearchFeature" + BuildableName = "UserSearchFeature" + BlueprintName = "UserSearchFeature" + ReferencedContainer = "container:"> + </BuildableReference> + </MacroExpansion> + </ProfileAction> + <AnalyzeAction + buildConfiguration = "Debug"> + </AnalyzeAction> + <ArchiveAction + buildConfiguration = "Release" + revealArchiveInOrganizer = "YES"> + </ArchiveAction> +</Scheme> diff --git a/Examples/xx-messenger/Package.swift b/Examples/xx-messenger/Package.swift index 8f1e26f79d8f80884a0e60b4a81a249fb00f76d8..f9ceb75fdf29afbda5362c20139003d77ebb131f 100644 --- a/Examples/xx-messenger/Package.swift +++ b/Examples/xx-messenger/Package.swift @@ -23,6 +23,7 @@ let package = Package( .library(name: "HomeFeature", targets: ["HomeFeature"]), .library(name: "RegisterFeature", targets: ["RegisterFeature"]), .library(name: "RestoreFeature", targets: ["RestoreFeature"]), + .library(name: "UserSearchFeature", targets: ["UserSearchFeature"]), .library(name: "WelcomeFeature", targets: ["WelcomeFeature"]), ], dependencies: [ @@ -70,6 +71,7 @@ let package = Package( .target(name: "HomeFeature"), .target(name: "RegisterFeature"), .target(name: "RestoreFeature"), + .target(name: "UserSearchFeature"), .target(name: "WelcomeFeature"), .product(name: "ComposableArchitecture", package: "swift-composable-architecture"), .product(name: "ComposablePresentation", package: "swift-composable-presentation"), @@ -90,6 +92,7 @@ let package = Package( dependencies: [ .target(name: "AppCore"), .target(name: "RegisterFeature"), + .target(name: "UserSearchFeature"), .product(name: "ComposableArchitecture", package: "swift-composable-architecture"), .product(name: "ComposablePresentation", package: "swift-composable-presentation"), .product(name: "XXMessengerClient", package: "elixxir-dapps-sdk-swift"), @@ -134,6 +137,22 @@ let package = Package( ], swiftSettings: swiftSettings ), + .target( + name: "UserSearchFeature", + dependencies: [ + .product(name: "ComposableArchitecture", package: "swift-composable-architecture"), + .product(name: "XXClient", package: "elixxir-dapps-sdk-swift"), + .product(name: "XXMessengerClient", package: "elixxir-dapps-sdk-swift"), + ], + swiftSettings: swiftSettings + ), + .testTarget( + name: "UserSearchFeatureTests", + dependencies: [ + .target(name: "UserSearchFeature"), + ], + swiftSettings: swiftSettings + ), .target( name: "WelcomeFeature", dependencies: [ diff --git a/Examples/xx-messenger/Project/XXMessenger.xcodeproj/xcshareddata/xcschemes/XXMessenger.xcscheme b/Examples/xx-messenger/Project/XXMessenger.xcodeproj/xcshareddata/xcschemes/XXMessenger.xcscheme index 669debfc2ca857cae6de176b19f3caeb579e56e6..041cf3f70c88c188af478b1c3ec2bb88d5a7e4f1 100644 --- a/Examples/xx-messenger/Project/XXMessenger.xcodeproj/xcshareddata/xcschemes/XXMessenger.xcscheme +++ b/Examples/xx-messenger/Project/XXMessenger.xcodeproj/xcshareddata/xcschemes/XXMessenger.xcscheme @@ -79,6 +79,16 @@ ReferencedContainer = "container:.."> </BuildableReference> </TestableReference> + <TestableReference + skipped = "NO"> + <BuildableReference + BuildableIdentifier = "primary" + BlueprintIdentifier = "UserSearchFeatureTests" + BuildableName = "UserSearchFeatureTests" + BlueprintName = "UserSearchFeatureTests" + ReferencedContainer = "container:.."> + </BuildableReference> + </TestableReference> <TestableReference skipped = "NO"> <BuildableReference diff --git a/Examples/xx-messenger/Sources/AppFeature/AppEnvironment+Live.swift b/Examples/xx-messenger/Sources/AppFeature/AppEnvironment+Live.swift index 19f1b7f5a750f278ca41ff031acf08409cccaa02..5d7777802ed09dcfb2e36b14ba2a5b55f1fe906e 100644 --- a/Examples/xx-messenger/Sources/AppFeature/AppEnvironment+Live.swift +++ b/Examples/xx-messenger/Sources/AppFeature/AppEnvironment+Live.swift @@ -3,6 +3,7 @@ import Foundation import HomeFeature import RegisterFeature import RestoreFeature +import UserSearchFeature import WelcomeFeature import XXMessengerClient import XXModels @@ -44,6 +45,13 @@ extension AppEnvironment { mainQueue: mainQueue, bgQueue: bgQueue ) + }, + userSearch: { + UserSearchEnvironment( + messenger: messenger, + mainQueue: mainQueue, + bgQueue: bgQueue + ) } ) } diff --git a/Examples/xx-messenger/Sources/HomeFeature/HomeFeature.swift b/Examples/xx-messenger/Sources/HomeFeature/HomeFeature.swift index 0a019179b2d45f23a41e54ecb084ac7ec835fe2d..51083ea0ccea39c7bf13abdd0ff0566dac623529 100644 --- a/Examples/xx-messenger/Sources/HomeFeature/HomeFeature.swift +++ b/Examples/xx-messenger/Sources/HomeFeature/HomeFeature.swift @@ -4,6 +4,7 @@ import ComposableArchitecture import ComposablePresentation import Foundation import RegisterFeature +import UserSearchFeature import XXClient import XXMessengerClient @@ -14,13 +15,15 @@ public struct HomeState: Equatable { networkNodesReport: NodeRegistrationReport? = nil, isDeletingAccount: Bool = false, alert: AlertState<HomeAction>? = nil, - register: RegisterState? = nil + register: RegisterState? = nil, + userSearch: UserSearchState? = nil ) { self.failure = failure self.isNetworkHealthy = isNetworkHealthy self.isDeletingAccount = isDeletingAccount self.alert = alert self.register = register + self.userSearch = userSearch } public var failure: String? @@ -29,6 +32,7 @@ public struct HomeState: Equatable { public var isDeletingAccount: Bool public var alert: AlertState<HomeAction>? public var register: RegisterState? + public var userSearch: UserSearchState? } public enum HomeAction: Equatable { @@ -58,7 +62,10 @@ public enum HomeAction: Equatable { case deleteAccount(DeleteAccount) case didDismissAlert case didDismissRegister + case userSearchButtonTapped + case didDismissUserSearch case register(RegisterAction) + case userSearch(UserSearchAction) } public struct HomeEnvironment { @@ -67,13 +74,15 @@ public struct HomeEnvironment { db: DBManagerGetDB, mainQueue: AnySchedulerOf<DispatchQueue>, bgQueue: AnySchedulerOf<DispatchQueue>, - register: @escaping () -> RegisterEnvironment + register: @escaping () -> RegisterEnvironment, + userSearch: @escaping () -> UserSearchEnvironment ) { self.messenger = messenger self.db = db self.mainQueue = mainQueue self.bgQueue = bgQueue self.register = register + self.userSearch = userSearch } public var messenger: Messenger @@ -81,6 +90,7 @@ public struct HomeEnvironment { public var mainQueue: AnySchedulerOf<DispatchQueue> public var bgQueue: AnySchedulerOf<DispatchQueue> public var register: () -> RegisterEnvironment + public var userSearch: () -> UserSearchEnvironment } extension HomeEnvironment { @@ -89,7 +99,8 @@ extension HomeEnvironment { db: .unimplemented, mainQueue: .unimplemented, bgQueue: .unimplemented, - register: { .unimplemented } + register: { .unimplemented }, + userSearch: { .unimplemented } ) } @@ -219,11 +230,19 @@ public let homeReducer = Reducer<HomeState, HomeAction, HomeEnvironment> state.register = nil return .none + case .userSearchButtonTapped: + state.userSearch = UserSearchState() + return .none + + case .didDismissUserSearch: + state.userSearch = nil + return .none + case .register(.finished): state.register = nil return Effect(value: .messenger(.start)) - case .register(_): + case .register(_), .userSearch(_): return .none } } @@ -234,3 +253,10 @@ public let homeReducer = Reducer<HomeState, HomeAction, HomeEnvironment> action: /HomeAction.register, environment: { $0.register() } ) +.presenting( + userSearchReducer, + state: .keyPath(\.userSearch), + id: .notNil(), + action: /HomeAction.userSearch, + environment: { $0.userSearch() } +) diff --git a/Examples/xx-messenger/Sources/HomeFeature/HomeView.swift b/Examples/xx-messenger/Sources/HomeFeature/HomeView.swift index ce8aac42ab93d9578770f7cb606b7090a6fb6082..e52e8d1b7fb94004fc29b4cb82041400467dbe8c 100644 --- a/Examples/xx-messenger/Sources/HomeFeature/HomeView.swift +++ b/Examples/xx-messenger/Sources/HomeFeature/HomeView.swift @@ -2,6 +2,7 @@ import ComposableArchitecture import ComposablePresentation import RegisterFeature import SwiftUI +import UserSearchFeature import XXClient public struct HomeView: View { @@ -86,6 +87,20 @@ public struct HomeView: View { Text("Network") } + Section { + Button { + viewStore.send(.userSearchButtonTapped) + } label: { + HStack { + Text("Search users") + Spacer() + Image(systemName: "chevron.forward") + } + } + } header: { + Text("Contacts") + } + Section { Button(role: .destructive) { viewStore.send(.deleteAccount(.buttonTapped)) @@ -108,6 +123,16 @@ public struct HomeView: View { store.scope(state: \.alert), dismiss: HomeAction.didDismissAlert ) + .background(NavigationLinkWithStore( + store.scope( + state: \.userSearch, + action: HomeAction.userSearch + ), + onDeactivate: { + viewStore.send(.didDismissUserSearch) + }, + destination: UserSearchView.init(store:) + )) } .navigationViewStyle(.stack) .task { viewStore.send(.messenger(.start)) } diff --git a/Examples/xx-messenger/Sources/RegisterFeature/RegisterFeature.swift b/Examples/xx-messenger/Sources/RegisterFeature/RegisterFeature.swift index a8d3280c5e321cb78ade668570dcc40159f0b7b7..8e1f35411fdd0ec6d952f556420f9c72073c1e1f 100644 --- a/Examples/xx-messenger/Sources/RegisterFeature/RegisterFeature.swift +++ b/Examples/xx-messenger/Sources/RegisterFeature/RegisterFeature.swift @@ -1,6 +1,6 @@ import AppCore import ComposableArchitecture -import SwiftUI +import Foundation import XCTestDynamicOverlay import XXMessengerClient import XXModels @@ -13,11 +13,13 @@ public struct RegisterState: Equatable { public init( focusedField: Field? = nil, username: String = "", - isRegistering: Bool = false + isRegistering: Bool = false, + failure: String? = nil ) { self.focusedField = focusedField self.username = username self.isRegistering = isRegistering + self.failure = failure } @BindableState public var focusedField: Field? diff --git a/Examples/xx-messenger/Sources/RegisterFeature/RegisterView.swift b/Examples/xx-messenger/Sources/RegisterFeature/RegisterView.swift index 195b655b4806b3156a4fe4353fb4cd2ec708c51b..c3601630ea07f3acdd2e6a5ba497705b06d12417 100644 --- a/Examples/xx-messenger/Sources/RegisterFeature/RegisterView.swift +++ b/Examples/xx-messenger/Sources/RegisterFeature/RegisterView.swift @@ -37,6 +37,8 @@ public struct RegisterView: View { label: { Text("Username") } ) .focused($focusedField, equals: .username) + .textInputAutocapitalization(.never) + .disableAutocorrection(true) } header: { Text("Username") } diff --git a/Examples/xx-messenger/Sources/UserSearchFeature/UserSearchFeature.swift b/Examples/xx-messenger/Sources/UserSearchFeature/UserSearchFeature.swift new file mode 100644 index 0000000000000000000000000000000000000000..86a13c0ec97966619bea881f5ac0b8c9de962fed --- /dev/null +++ b/Examples/xx-messenger/Sources/UserSearchFeature/UserSearchFeature.swift @@ -0,0 +1,135 @@ +import ComposableArchitecture +import Foundation +import XCTestDynamicOverlay +import XXClient +import XXMessengerClient + +public struct UserSearchState: Equatable { + public enum Field: String, Hashable { + case username + case email + case phone + } + + public struct Result: Equatable, Identifiable { + public init( + id: Data, + contact: Contact, + username: String? = nil, + email: String? = nil, + phone: String? = nil + ) { + self.id = id + self.contact = contact + self.username = username + self.email = email + self.phone = phone + } + + public var id: Data + public var contact: XXClient.Contact + public var username: String? + public var email: String? + public var phone: String? + } + + public init( + focusedField: Field? = nil, + query: MessengerSearchUsers.Query = .init(), + isSearching: Bool = false, + failure: String? = nil, + results: IdentifiedArrayOf<Result> = [] + ) { + self.focusedField = focusedField + self.query = query + self.isSearching = isSearching + self.failure = failure + self.results = results + } + + @BindableState public var focusedField: Field? + @BindableState public var query: MessengerSearchUsers.Query + public var isSearching: Bool + public var failure: String? + public var results: IdentifiedArrayOf<Result> +} + +public enum UserSearchAction: Equatable, BindableAction { + case searchTapped + case didFail(String) + case didSucceed([Contact]) + case binding(BindingAction<UserSearchState>) +} + +public struct UserSearchEnvironment { + public init( + messenger: Messenger, + mainQueue: AnySchedulerOf<DispatchQueue>, + bgQueue: AnySchedulerOf<DispatchQueue> + ) { + self.messenger = messenger + self.mainQueue = mainQueue + self.bgQueue = bgQueue + } + + public var messenger: Messenger + public var mainQueue: AnySchedulerOf<DispatchQueue> + public var bgQueue: AnySchedulerOf<DispatchQueue> +} + +#if DEBUG +extension UserSearchEnvironment { + public static let unimplemented = UserSearchEnvironment( + messenger: .unimplemented, + mainQueue: .unimplemented, + bgQueue: .unimplemented + ) +} +#endif + +public let userSearchReducer = Reducer<UserSearchState, UserSearchAction, UserSearchEnvironment> +{ state, action, env in + switch action { + case .searchTapped: + state.focusedField = nil + state.isSearching = true + state.results = [] + state.failure = nil + return .result { [query = state.query] in + do { + return .success(.didSucceed(try env.messenger.searchUsers(query: query))) + } catch { + return .success(.didFail(error.localizedDescription)) + } + } + .subscribe(on: env.bgQueue) + .receive(on: env.mainQueue) + .eraseToEffect() + + case .didSucceed(let contacts): + state.isSearching = false + state.failure = nil + state.results = IdentifiedArray(uniqueElements: contacts.compactMap { contact in + guard let id = try? contact.getId() else { return nil } + let facts = (try? contact.getFacts()) ?? [] + return UserSearchState.Result( + id: id, + contact: contact, + username: facts.first(where: { $0.type == 0 })?.fact, + email: facts.first(where: { $0.type == 1 })?.fact, + phone: facts.first(where: { $0.type == 2 })?.fact + ) + }) + return .none + + case .didFail(let failure): + state.isSearching = false + state.failure = failure + state.results = [] + return .none + + case .binding(_): + return .none + } +} +.binding() diff --git a/Examples/xx-messenger/Sources/UserSearchFeature/UserSearchView.swift b/Examples/xx-messenger/Sources/UserSearchFeature/UserSearchView.swift new file mode 100644 index 0000000000000000000000000000000000000000..266938a29b096d05f695c2f39f55f8c0fb993442 --- /dev/null +++ b/Examples/xx-messenger/Sources/UserSearchFeature/UserSearchView.swift @@ -0,0 +1,125 @@ +import ComposableArchitecture +import SwiftUI +import XXMessengerClient + +public struct UserSearchView: View { + public init(store: Store<UserSearchState, UserSearchAction>) { + self.store = store + } + + let store: Store<UserSearchState, UserSearchAction> + @FocusState var focusedField: UserSearchState.Field? + + struct ViewState: Equatable { + var focusedField: UserSearchState.Field? + var query: MessengerSearchUsers.Query + var isSearching: Bool + var failure: String? + var results: IdentifiedArrayOf<UserSearchState.Result> + + init(state: UserSearchState) { + focusedField = state.focusedField + query = state.query + isSearching = state.isSearching + failure = state.failure + results = state.results + } + } + + public var body: some View { + WithViewStore(store.scope(state: ViewState.init)) { viewStore in + Form { + Section { + TextField( + text: viewStore.binding( + get: { $0.query.username ?? "" }, + send: { UserSearchAction.set(\.$query.username, $0.isEmpty ? nil : $0) } + ), + prompt: Text("Enter username"), + label: { Text("Username") } + ) + .focused($focusedField, equals: .username) + + TextField( + text: viewStore.binding( + get: { $0.query.email ?? "" }, + send: { UserSearchAction.set(\.$query.email, $0.isEmpty ? nil : $0) } + ), + prompt: Text("Enter email"), + label: { Text("Email") } + ) + .focused($focusedField, equals: .email) + + TextField( + text: viewStore.binding( + get: { $0.query.phone ?? "" }, + send: { UserSearchAction.set(\.$query.phone, $0.isEmpty ? nil : $0) } + ), + prompt: Text("Enter phone"), + label: { Text("Phone") } + ) + .focused($focusedField, equals: .phone) + + Button { + viewStore.send(.searchTapped) + } label: { + HStack { + Text("Search") + Spacer() + if viewStore.isSearching { + ProgressView() + } else { + Image(systemName: "magnifyingglass") + } + } + } + .disabled(viewStore.query.isEmpty) + } + .disabled(viewStore.isSearching) + .textInputAutocapitalization(.never) + .disableAutocorrection(true) + + if let failure = viewStore.failure { + Section { + Text(failure) + } header: { + Text("Error") + } + } + + ForEach(viewStore.results) { result in + Section { + if let username = result.username { + Text(username) + } + if let email = result.email { + Text(email) + } + if let phone = result.phone { + Text(phone) + } + if result.username == nil, result.email == nil, result.phone == nil { + Image(systemName: "questionmark") + .frame(maxWidth: .infinity) + } + } + } + } + .onChange(of: viewStore.focusedField) { focusedField = $0 } + .onChange(of: focusedField) { viewStore.send(.set(\.$focusedField, $0)) } + .navigationTitle("User Search") + } + } +} + +#if DEBUG +public struct UserSearchView_Previews: PreviewProvider { + public static var previews: some View { + UserSearchView(store: Store( + initialState: UserSearchState(), + reducer: .empty, + environment: () + )) + } +} +#endif diff --git a/Examples/xx-messenger/Tests/HomeFeatureTests/HomeFeatureTests.swift b/Examples/xx-messenger/Tests/HomeFeatureTests/HomeFeatureTests.swift index c7168d5b4537564a69ac82145b433c6ab22c65ff..aa309016c08c4a75e62c6ecd5cf47e32191832e1 100644 --- a/Examples/xx-messenger/Tests/HomeFeatureTests/HomeFeatureTests.swift +++ b/Examples/xx-messenger/Tests/HomeFeatureTests/HomeFeatureTests.swift @@ -1,5 +1,6 @@ import ComposableArchitecture import RegisterFeature +import UserSearchFeature import XCTest import XXClient import XXMessengerClient @@ -437,4 +438,30 @@ final class HomeFeatureTests: XCTestCase { $0.register = nil } } + + func testUserSearchButtonTapped() { + let store = TestStore( + initialState: HomeState(), + reducer: homeReducer, + environment: .unimplemented + ) + + store.send(.userSearchButtonTapped) { + $0.userSearch = UserSearchState() + } + } + + func testDidDismissUserSearch() { + let store = TestStore( + initialState: HomeState( + userSearch: UserSearchState() + ), + reducer: homeReducer, + environment: .unimplemented + ) + + store.send(.didDismissUserSearch) { + $0.userSearch = nil + } + } } diff --git a/Examples/xx-messenger/Tests/UserSearchFeatureTests/UserSearchFeatureTests.swift b/Examples/xx-messenger/Tests/UserSearchFeatureTests/UserSearchFeatureTests.swift new file mode 100644 index 0000000000000000000000000000000000000000..8d2f414a0a6c4fa130ea285863ae92ef80f70c21 --- /dev/null +++ b/Examples/xx-messenger/Tests/UserSearchFeatureTests/UserSearchFeatureTests.swift @@ -0,0 +1,119 @@ +import ComposableArchitecture +import XCTest +import XXClient +import XXMessengerClient +@testable import UserSearchFeature + +final class UserSearchFeatureTests: XCTestCase { + func testSearch() { + let store = TestStore( + initialState: UserSearchState(), + reducer: userSearchReducer, + environment: .unimplemented + ) + + var didSearchWithQuery: [MessengerSearchUsers.Query] = [] + + struct GetIdFromContactError: Error {} + struct GetFactsFromContactError: Error {} + + var contact1 = Contact.unimplemented("contact-1".data(using: .utf8)!) + contact1.getIdFromContact.run = { _ in "contact-1-id".data(using: .utf8)! } + contact1.getFactsFromContact.run = { _ in + [Fact(fact: "contact-1-username", type: 0), + Fact(fact: "contact-1-email", type: 1), + Fact(fact: "contact-1-phone", type: 2)] + } + var contact2 = Contact.unimplemented("contact-1".data(using: .utf8)!) + contact2.getIdFromContact.run = { _ in "contact-2-id".data(using: .utf8)! } + contact2.getFactsFromContact.run = { _ in + [Fact(fact: "contact-2-username", type: 0), + Fact(fact: "contact-2-email", type: 1), + Fact(fact: "contact-2-phone", type: 2)] + } + var contact3 = Contact.unimplemented("contact-3".data(using: .utf8)!) + contact3.getIdFromContact.run = { _ in throw GetIdFromContactError() } + var contact4 = Contact.unimplemented("contact-4".data(using: .utf8)!) + contact4.getIdFromContact.run = { _ in "contact-4-id".data(using: .utf8)! } + contact4.getFactsFromContact.run = { _ in throw GetFactsFromContactError() } + let contacts = [contact1, contact2, contact3, contact4] + + store.environment.bgQueue = .immediate + store.environment.mainQueue = .immediate + store.environment.messenger.searchUsers.run = { query in + didSearchWithQuery.append(query) + return contacts + } + + store.send(.set(\.$focusedField, .username)) { + $0.focusedField = .username + } + + store.send(.set(\.$query.username, "Username")) { + $0.query.username = "Username" + } + + store.send(.searchTapped) { + $0.focusedField = nil + $0.isSearching = true + $0.results = [] + $0.failure = nil + } + + store.receive(.didSucceed(contacts)) { + $0.isSearching = false + $0.failure = nil + $0.results = [ + .init( + id: "contact-1-id".data(using: .utf8)!, + contact: contact1, + username: "contact-1-username", + email: "contact-1-email", + phone: "contact-1-phone" + ), + .init( + id: "contact-2-id".data(using: .utf8)!, + contact: contact2, + username: "contact-2-username", + email: "contact-2-email", + phone: "contact-2-phone" + ), + .init( + id: "contact-4-id".data(using: .utf8)!, + contact: contact4, + username: nil, + email: nil, + phone: nil + ) + ] + } + } + + func testSearchFailure() { + let store = TestStore( + initialState: UserSearchState(), + reducer: userSearchReducer, + environment: .unimplemented + ) + + struct Failure: Error {} + let failure = Failure() + + store.environment.bgQueue = .immediate + store.environment.mainQueue = .immediate + store.environment.messenger.searchUsers.run = { _ in throw failure } + + store.send(.searchTapped) { + $0.focusedField = nil + $0.isSearching = true + $0.results = [] + $0.failure = nil + } + + store.receive(.didFail(failure.localizedDescription)) { + $0.isSearching = false + $0.failure = failure.localizedDescription + $0.results = [] + } + } +}