diff --git a/Examples/xx-messenger/Sources/AppFeature/AppEnvironment+Live.swift b/Examples/xx-messenger/Sources/AppFeature/AppEnvironment+Live.swift index b93b0359ce10e8050dce8d07f8dfd5f8d0d7b763..d824d6d400b402270d341bb43fbd5f363c84a879 100644 --- a/Examples/xx-messenger/Sources/AppFeature/AppEnvironment+Live.swift +++ b/Examples/xx-messenger/Sources/AppFeature/AppEnvironment+Live.swift @@ -125,7 +125,12 @@ extension AppEnvironment { bgQueue: bgQueue, contact: { contactEnvironment }, myContact: { - MyContactEnvironment() + MyContactEnvironment( + messenger: messenger, + db: dbManager.getDB, + mainQueue: mainQueue, + bgQueue: bgQueue + ) } ) }, diff --git a/Examples/xx-messenger/Sources/MyContactFeature/MyContactFeature.swift b/Examples/xx-messenger/Sources/MyContactFeature/MyContactFeature.swift index 8d335305decc660886a92c5d61f32db4329b4702..c2cfe972977239d535af47d21a18b3c2f5963402 100644 --- a/Examples/xx-messenger/Sources/MyContactFeature/MyContactFeature.swift +++ b/Examples/xx-messenger/Sources/MyContactFeature/MyContactFeature.swift @@ -1,28 +1,115 @@ +import AppCore import ComposableArchitecture +import Foundation import XCTestDynamicOverlay +import XXClient +import XXMessengerClient +import XXModels public struct MyContactState: Equatable { - public init() {} + public enum Field: String, Hashable { + case email + case phone + } + + public init( + contact: XXModels.Contact? = nil, + focusedField: Field? = nil, + email: String = "", + phone: String = "" + ) { + self.contact = contact + self.focusedField = focusedField + self.email = email + self.phone = phone + } + + public var contact: XXModels.Contact? + @BindableState public var focusedField: Field? + @BindableState public var email: String + @BindableState public var phone: String } -public enum MyContactAction: Equatable { +public enum MyContactAction: Equatable, BindableAction { case start + case contactFetched(XXModels.Contact?) + case registerEmailTapped + case unregisterEmailTapped + case registerPhoneTapped + case unregisterPhoneTapped + case loadFactsTapped + case binding(BindingAction<MyContactState>) } public struct MyContactEnvironment { - public init() {} + public init( + messenger: Messenger, + db: DBManagerGetDB, + mainQueue: AnySchedulerOf<DispatchQueue>, + bgQueue: AnySchedulerOf<DispatchQueue> + ) { + self.messenger = messenger + self.db = db + self.mainQueue = mainQueue + self.bgQueue = bgQueue + } + + public var messenger: Messenger + public var db: DBManagerGetDB + public var mainQueue: AnySchedulerOf<DispatchQueue> + public var bgQueue: AnySchedulerOf<DispatchQueue> } #if DEBUG extension MyContactEnvironment { - public static let unimplemented = MyContactEnvironment() + public static let unimplemented = MyContactEnvironment( + messenger: .unimplemented, + db: .unimplemented, + mainQueue: .unimplemented, + bgQueue: .unimplemented + ) } #endif public let myContactReducer = Reducer<MyContactState, MyContactAction, MyContactEnvironment> { state, action, env in + enum DBFetchEffectID {} + switch action { case .start: + return Effect + .catching { try env.messenger.e2e.tryGet().getContact().getId() } + .tryMap { try env.db().fetchContactsPublisher(.init(id: [$0])) } + .flatMap { $0 } + .assertNoFailure() + .map(\.first) + .map(MyContactAction.contactFetched) + .subscribe(on: env.bgQueue) + .receive(on: env.mainQueue) + .eraseToEffect() + .cancellable(id: DBFetchEffectID.self, cancelInFlight: true) + + case .contactFetched(let contact): + state.contact = contact + return .none + + case .registerEmailTapped: + return .none + + case .unregisterEmailTapped: + return .none + + case .registerPhoneTapped: + return .none + + case .unregisterPhoneTapped: + return .none + + case .loadFactsTapped: + return .none + + case .binding(_): return .none } } +.binding() diff --git a/Examples/xx-messenger/Sources/MyContactFeature/MyContactView.swift b/Examples/xx-messenger/Sources/MyContactFeature/MyContactView.swift index d5092e4bfd748c4ff0c45d18797460f9ed4e612b..0887f682e135f943c0d280e2eb1335268d519866 100644 --- a/Examples/xx-messenger/Sources/MyContactFeature/MyContactView.swift +++ b/Examples/xx-messenger/Sources/MyContactFeature/MyContactView.swift @@ -1,5 +1,6 @@ import ComposableArchitecture import SwiftUI +import XXModels public struct MyContactView: View { public init(store: Store<MyContactState, MyContactAction>) { @@ -7,17 +8,113 @@ public struct MyContactView: View { } let store: Store<MyContactState, MyContactAction> + @FocusState var focusedField: MyContactState.Field? struct ViewState: Equatable { - init(state: MyContactState) {} + init(state: MyContactState) { + contact = state.contact + focusedField = state.focusedField + email = state.email + phone = state.phone + } + + var contact: XXModels.Contact? + var focusedField: MyContactState.Field? + var email: String + var phone: String } public var body: some View { WithViewStore(store, observe: ViewState.init) { viewStore in Form { + Section { + Text(viewStore.contact?.username ?? "") + } header: { + Label("Username", systemImage: "person") + } + + Section { + if let contact = viewStore.contact { + if let email = contact.email { + Text(email) + Button(role: .destructive) { + viewStore.send(.unregisterEmailTapped) + } label: { + Text("Unregister") + } + } else { + TextField( + text: viewStore.binding( + get: \.email, + send: { MyContactAction.set(\.$email, $0) } + ), + prompt: Text("Enter email"), + label: { Text("Email") } + ) + .focused($focusedField, equals: .email) + .textInputAutocapitalization(.never) + .disableAutocorrection(true) + Button { + viewStore.send(.registerEmailTapped) + } label: { + Text("Register") + } + } + } else { + Text("") + } + } header: { + Label("Email", systemImage: "envelope") + } + Section { + if let contact = viewStore.contact { + if let phone = contact.phone { + Text(phone) + Button(role: .destructive) { + viewStore.send(.unregisterPhoneTapped) + } label: { + Text("Unregister") + } + } else { + TextField( + text: viewStore.binding( + get: \.phone, + send: { MyContactAction.set(\.$phone, $0) } + ), + prompt: Text("Enter phone"), + label: { Text("Phone") } + ) + .focused($focusedField, equals: .phone) + .textInputAutocapitalization(.never) + .disableAutocorrection(true) + Button { + viewStore.send(.registerPhoneTapped) + } label: { + Text("Register") + } + } + } else { + Text("") + } + } header: { + Label("Phone", systemImage: "phone") + } + + Section { + Button { + viewStore.send(.loadFactsTapped) + } label: { + Text("Load facts from client") + } + } header: { + Text("Actions") + } } .navigationTitle("My Contact") + .task { viewStore.send(.start) } + .onChange(of: viewStore.focusedField) { focusedField = $0 } + .onChange(of: focusedField) { viewStore.send(.set(\.$focusedField, $0)) } } } } @@ -25,11 +122,13 @@ public struct MyContactView: View { #if DEBUG public struct MyContactView_Previews: PreviewProvider { public static var previews: some View { - MyContactView(store: Store( - initialState: MyContactState(), - reducer: .empty, - environment: () - )) + NavigationView { + MyContactView(store: Store( + initialState: MyContactState(), + reducer: .empty, + environment: () + )) + } } } #endif diff --git a/Examples/xx-messenger/Tests/MyContactFeatureTests/MyContactFeatureTests.swift b/Examples/xx-messenger/Tests/MyContactFeatureTests/MyContactFeatureTests.swift index 6807dcd65937aaa5842b4a1d57cf3a4ea995f521..eba876a9e49f21d5a590866fd4cec4efa9775c8a 100644 --- a/Examples/xx-messenger/Tests/MyContactFeatureTests/MyContactFeatureTests.swift +++ b/Examples/xx-messenger/Tests/MyContactFeatureTests/MyContactFeatureTests.swift @@ -1,15 +1,122 @@ +import Combine import ComposableArchitecture +import CustomDump import XCTest +import XXClient +import XXMessengerClient +import XXModels @testable import MyContactFeature final class MyContactFeatureTests: XCTestCase { func testStart() { + let contactId = "contact-id".data(using: .utf8)! + let store = TestStore( initialState: MyContactState(), reducer: myContactReducer, environment: .unimplemented ) + var dbDidFetchContacts: [XXModels.Contact.Query] = [] + let dbContactsPublisher = PassthroughSubject<[XXModels.Contact], Error>() + + store.environment.mainQueue = .immediate + store.environment.bgQueue = .immediate + store.environment.messenger.e2e.get = { + var e2e: E2E = .unimplemented + e2e.getContact.run = { + var contact: XXClient.Contact = .unimplemented(Data()) + contact.getIdFromContact.run = { _ in contactId } + return contact + } + return e2e + } + store.environment.db.run = { + var db: Database = .failing + db.fetchContactsPublisher.run = { query in + dbDidFetchContacts.append(query) + return dbContactsPublisher.eraseToAnyPublisher() + } + return db + } + store.send(.start) + + XCTAssertNoDifference(dbDidFetchContacts, [.init(id: [contactId])]) + + dbContactsPublisher.send([]) + + store.receive(.contactFetched(nil)) + + let contact = XXModels.Contact(id: contactId) + dbContactsPublisher.send([contact]) + + store.receive(.contactFetched(contact)) { + $0.contact = contact + } + + dbContactsPublisher.send(completion: .finished) + } + + func testRegisterEmail() { + let email = "test@email.com" + + let store = TestStore( + initialState: MyContactState(), + reducer: myContactReducer, + environment: .unimplemented + ) + + store.send(.set(\.$email, email)) { + $0.email = email + } + + store.send(.registerEmailTapped) + } + + func testUnregisterEmail() { + let store = TestStore( + initialState: MyContactState(), + reducer: myContactReducer, + environment: .unimplemented + ) + + store.send(.unregisterEmailTapped) + } + + func testRegisterPhone() { + let phone = "123456789" + + let store = TestStore( + initialState: MyContactState(), + reducer: myContactReducer, + environment: .unimplemented + ) + + store.send(.set(\.$phone, phone)) { + $0.phone = phone + } + + store.send(.registerPhoneTapped) + } + + func testUnregisterPhone() { + let store = TestStore( + initialState: MyContactState(), + reducer: myContactReducer, + environment: .unimplemented + ) + + store.send(.unregisterPhoneTapped) + } + + func testLoadFactsFromClient() { + let store = TestStore( + initialState: MyContactState(), + reducer: myContactReducer, + environment: .unimplemented + ) + + store.send(.loadFactsTapped) } }