diff --git a/Examples/xx-messenger/Sources/AppFeature/AppFeature.swift b/Examples/xx-messenger/Sources/AppFeature/AppFeature.swift index 582478d479dcc31b5e12a491e6843919bdd4aa43..43cede697423359094d9b0186350abbc01e79d2e 100644 --- a/Examples/xx-messenger/Sources/AppFeature/AppFeature.swift +++ b/Examples/xx-messenger/Sources/AppFeature/AppFeature.swift @@ -68,7 +68,7 @@ extension AppEnvironment { let appReducer = Reducer<AppState, AppAction, AppEnvironment> { state, action, env in switch action { - case .start, .welcome(.finished), .restore(.finished), .home(.didDeleteAccount): + case .start, .welcome(.finished), .restore(.finished), .home(.deleteAccount(.success)): state.screen = .loading return .run { subscriber in do { diff --git a/Examples/xx-messenger/Sources/HomeFeature/Alerts.swift b/Examples/xx-messenger/Sources/HomeFeature/Alerts.swift index a3347be43974d0e957ea1850369469360bb70311..3b9b9a3d00ee09ec4dd23ddc24fec105f94946fe 100644 --- a/Examples/xx-messenger/Sources/HomeFeature/Alerts.swift +++ b/Examples/xx-messenger/Sources/HomeFeature/Alerts.swift @@ -6,7 +6,7 @@ extension AlertState { title: TextState("Delete Account"), message: TextState("This will permanently delete your account and can't be undone."), buttons: [ - .destructive(TextState("Delete"), action: .send(.deleteAccountConfirmed)), + .destructive(TextState("Delete"), action: .send(.deleteAccount(.confirmed))), .cancel(TextState("Cancel")) ] ) diff --git a/Examples/xx-messenger/Sources/HomeFeature/HomeFeature.swift b/Examples/xx-messenger/Sources/HomeFeature/HomeFeature.swift index 279a5628bd12f2e1a6e18beb6940b1388a92f455..0a019179b2d45f23a41e54ecb084ac7ec835fe2d 100644 --- a/Examples/xx-messenger/Sources/HomeFeature/HomeFeature.swift +++ b/Examples/xx-messenger/Sources/HomeFeature/HomeFeature.swift @@ -10,28 +10,54 @@ import XXMessengerClient public struct HomeState: Equatable { public init( failure: String? = nil, - register: RegisterState? = nil, + isNetworkHealthy: Bool? = nil, + networkNodesReport: NodeRegistrationReport? = nil, + isDeletingAccount: Bool = false, alert: AlertState<HomeAction>? = nil, - isDeletingAccount: Bool = false + register: RegisterState? = nil ) { self.failure = failure - self.register = register - self.alert = alert + self.isNetworkHealthy = isNetworkHealthy self.isDeletingAccount = isDeletingAccount + self.alert = alert + self.register = register } - @BindableState public var failure: String? - @BindableState public var register: RegisterState? - @BindableState public var alert: AlertState<HomeAction>? - @BindableState public var isDeletingAccount: Bool + public var failure: String? + public var isNetworkHealthy: Bool? + public var networkNodesReport: NodeRegistrationReport? + public var isDeletingAccount: Bool + public var alert: AlertState<HomeAction>? + public var register: RegisterState? } -public enum HomeAction: Equatable, BindableAction { - case start - case deleteAccountButtonTapped - case deleteAccountConfirmed - case didDeleteAccount - case binding(BindingAction<HomeState>) +public enum HomeAction: Equatable { + public enum Messenger: Equatable { + case start + case didStartRegistered + case didStartUnregistered + case failure(NSError) + } + + public enum NetworkMonitor: Equatable { + case start + case stop + case health(Bool) + case nodes(NodeRegistrationReport) + } + + public enum DeleteAccount: Equatable { + case buttonTapped + case confirmed + case success + case failure(NSError) + } + + case messenger(Messenger) + case networkMonitor(NetworkMonitor) + case deleteAccount(DeleteAccount) + case didDismissAlert + case didDismissRegister case register(RegisterAction) } @@ -69,41 +95,95 @@ extension HomeEnvironment { public let homeReducer = Reducer<HomeState, HomeAction, HomeEnvironment> { state, action, env in + enum NetworkHealthEffectId {} + enum NetworkNodesEffectId {} + switch action { - case .start: - return .run { subscriber in - do { - try env.messenger.start() + case .messenger(.start): + return .merge( + Effect(value: .networkMonitor(.stop)), + Effect.result { + do { + try env.messenger.start() - if env.messenger.isConnected() == false { - try env.messenger.connect() - } + if env.messenger.isConnected() == false { + try env.messenger.connect() + } - if env.messenger.isLoggedIn() == false { - if try env.messenger.isRegistered() == false { - subscriber.send(.set(\.$register, RegisterState())) - subscriber.send(completion: .finished) - return AnyCancellable {} + if env.messenger.isLoggedIn() == false { + if try env.messenger.isRegistered() == false { + return .success(.messenger(.didStartUnregistered)) + } + try env.messenger.logIn() } - try env.messenger.logIn() + + return .success(.messenger(.didStartRegistered)) + } catch { + return .success(.messenger(.failure(error as NSError))) } - } catch { - subscriber.send(.set(\.$failure, error.localizedDescription)) } - subscriber.send(completion: .finished) - return AnyCancellable {} - } + ) .subscribe(on: env.bgQueue) .receive(on: env.mainQueue) .eraseToEffect() - case .deleteAccountButtonTapped: + case .messenger(.didStartUnregistered): + state.register = RegisterState() + return .none + + case .messenger(.didStartRegistered): + return Effect(value: .networkMonitor(.start)) + + case .messenger(.failure(let error)): + state.failure = error.localizedDescription + return .none + + case .networkMonitor(.start): + return .merge( + Effect.run { subscriber in + let callback = HealthCallback { isHealthy in + subscriber.send(.networkMonitor(.health(isHealthy))) + } + let cancellable = env.messenger.cMix()?.addHealthCallback(callback) + return AnyCancellable { cancellable?.cancel() } + } + .cancellable(id: NetworkHealthEffectId.self, cancelInFlight: true), + Effect.timer( + id: NetworkNodesEffectId.self, + every: .seconds(2), + on: env.bgQueue + ) + .compactMap { _ in try? env.messenger.cMix()?.getNodeRegistrationStatus() } + .map { HomeAction.networkMonitor(.nodes($0)) } + .eraseToEffect() + ) + .subscribe(on: env.bgQueue) + .receive(on: env.mainQueue) + .eraseToEffect() + + case .networkMonitor(.stop): + state.isNetworkHealthy = nil + state.networkNodesReport = nil + return .merge( + .cancel(id: NetworkHealthEffectId.self), + .cancel(id: NetworkNodesEffectId.self) + ) + + case .networkMonitor(.health(let isHealthy)): + state.isNetworkHealthy = isHealthy + return .none + + case .networkMonitor(.nodes(let report)): + state.networkNodesReport = report + return .none + + case .deleteAccount(.buttonTapped): state.alert = .confirmAccountDeletion() return .none - case .deleteAccountConfirmed: + case .deleteAccount(.confirmed): state.isDeletingAccount = true - return .run { subscriber in + return .result { do { let contactId = try env.messenger.e2e.tryGet().getContact().getId() let contact = try env.db().fetchContacts(.init(id: [contactId])).first @@ -113,31 +193,40 @@ public let homeReducer = Reducer<HomeState, HomeAction, HomeEnvironment> } try env.messenger.destroy() try env.db().drop() - subscriber.send(.didDeleteAccount) + return .success(.deleteAccount(.success)) } catch { - subscriber.send(.set(\.$isDeletingAccount, false)) - subscriber.send(.set(\.$alert, .accountDeletionFailed(error))) + return .success(.deleteAccount(.failure(error as NSError))) } - subscriber.send(completion: .finished) - return AnyCancellable {} } .subscribe(on: env.bgQueue) .receive(on: env.mainQueue) .eraseToEffect() - case .didDeleteAccount: + case .deleteAccount(.success): + state.isDeletingAccount = false + return .none + + case .deleteAccount(.failure(let error)): state.isDeletingAccount = false + state.alert = .accountDeletionFailed(error) + return .none + + case .didDismissAlert: + state.alert = nil + return .none + + case .didDismissRegister: + state.register = nil return .none case .register(.finished): state.register = nil - return Effect(value: .start) + return Effect(value: .messenger(.start)) - case .binding(_), .register(_): + case .register(_): return .none } } -.binding() .presenting( registerReducer, state: .keyPath(\.register), diff --git a/Examples/xx-messenger/Sources/HomeFeature/HomeView.swift b/Examples/xx-messenger/Sources/HomeFeature/HomeView.swift index f73f6b8a9ff2696763a995f3358032d3229d2ca3..ce8aac42ab93d9578770f7cb606b7090a6fb6082 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 XXClient public struct HomeView: View { public init(store: Store<HomeState, HomeAction>) { @@ -12,11 +13,15 @@ public struct HomeView: View { struct ViewState: Equatable { var failure: String? + var isNetworkHealthy: Bool? + var networkNodesReport: NodeRegistrationReport? var isDeletingAccount: Bool init(state: HomeState) { failure = state.failure + isNetworkHealthy = state.isNetworkHealthy isDeletingAccount = state.isDeletingAccount + networkNodesReport = state.networkNodesReport } } @@ -24,22 +29,66 @@ public struct HomeView: View { WithViewStore(store.scope(state: ViewState.init)) { viewStore in NavigationView { Form { - if let failure = viewStore.failure { - Section { + Section { + if let failure = viewStore.failure { Text(failure) Button { - viewStore.send(.start) + viewStore.send(.messenger(.start)) } label: { Text("Retry") } - } header: { + } + } header: { + if viewStore.failure != nil { Text("Error") } } + Section { + HStack { + Text("Health") + Spacer() + switch viewStore.isNetworkHealthy { + case .some(true): + Image(systemName: "checkmark.circle.fill") + .foregroundColor(.green) + + case .some(false): + Image(systemName: "xmark.diamond.fill") + .foregroundColor(.red) + + case .none: + Image(systemName: "questionmark.circle") + .foregroundColor(.gray) + } + } + + ProgressView( + value: viewStore.networkNodesReport?.ratio ?? 0, + label: { + Text("Node registration") + }, + currentValueLabel: { + if let report = viewStore.networkNodesReport { + HStack { + Text("\(Int((report.ratio * 100).rounded(.down)))%") + Spacer() + Text("\(report.registered) / \(report.total)") + } + } else { + Text("Unknown") + } + } + ) + .tint((viewStore.networkNodesReport?.ratio ?? 0) >= 0.8 ? .green : .orange) + .animation(.default, value: viewStore.networkNodesReport?.ratio) + } header: { + Text("Network") + } + Section { Button(role: .destructive) { - viewStore.send(.deleteAccountButtonTapped) + viewStore.send(.deleteAccount(.buttonTapped)) } label: { HStack { Text("Delete Account") @@ -57,18 +106,18 @@ public struct HomeView: View { .navigationTitle("Home") .alert( store.scope(state: \.alert), - dismiss: HomeAction.set(\.$alert, nil) + dismiss: HomeAction.didDismissAlert ) } .navigationViewStyle(.stack) - .task { viewStore.send(.start) } + .task { viewStore.send(.messenger(.start)) } .fullScreenCover( store.scope( state: \.register, action: HomeAction.register ), onDismiss: { - viewStore.send(.set(\.$register, nil)) + viewStore.send(.didDismissRegister) }, content: RegisterView.init(store:) ) diff --git a/Examples/xx-messenger/Tests/AppFeatureTests/AppFeatureTests.swift b/Examples/xx-messenger/Tests/AppFeatureTests/AppFeatureTests.swift index 5ea968131af6851815f66f6b22edf2d2d139dc56..5a013b28b16126b422ef3d26b19407d0af2bff5c 100644 --- a/Examples/xx-messenger/Tests/AppFeatureTests/AppFeatureTests.swift +++ b/Examples/xx-messenger/Tests/AppFeatureTests/AppFeatureTests.swift @@ -159,7 +159,7 @@ final class AppFeatureTests: XCTestCase { store.environment.messenger.isLoaded.run = { false } store.environment.messenger.isCreated.run = { false } - store.send(.home(.didDeleteAccount)) { + store.send(.home(.deleteAccount(.success))) { $0.screen = .loading } diff --git a/Examples/xx-messenger/Tests/HomeFeatureTests/HomeFeatureTests.swift b/Examples/xx-messenger/Tests/HomeFeatureTests/HomeFeatureTests.swift index 4fbad5b8c8a3d7775391db66359bd3c97f268ded..c7168d5b4537564a69ac82145b433c6ab22c65ff 100644 --- a/Examples/xx-messenger/Tests/HomeFeatureTests/HomeFeatureTests.swift +++ b/Examples/xx-messenger/Tests/HomeFeatureTests/HomeFeatureTests.swift @@ -7,71 +7,75 @@ import XXModels @testable import HomeFeature final class HomeFeatureTests: XCTestCase { - func testStartUnregistered() { + func testMessengerStartUnregistered() { let store = TestStore( initialState: HomeState(), reducer: homeReducer, environment: .unimplemented ) - let bgQueue = DispatchQueue.test - let mainQueue = DispatchQueue.test var messengerDidStartWithTimeout: [Int] = [] var messengerDidConnect = 0 - store.environment.bgQueue = bgQueue.eraseToAnyScheduler() - store.environment.mainQueue = mainQueue.eraseToAnyScheduler() + store.environment.bgQueue = .immediate + store.environment.mainQueue = .immediate store.environment.messenger.start.run = { messengerDidStartWithTimeout.append($0) } store.environment.messenger.isConnected.run = { false } store.environment.messenger.connect.run = { messengerDidConnect += 1 } store.environment.messenger.isLoggedIn.run = { false } store.environment.messenger.isRegistered.run = { false } - store.send(.start) - - bgQueue.advance() + store.send(.messenger(.start)) XCTAssertNoDifference(messengerDidStartWithTimeout, [30_000]) XCTAssertNoDifference(messengerDidConnect, 1) - mainQueue.advance() - - store.receive(.set(\.$register, RegisterState())) { + store.receive(.networkMonitor(.stop)) + store.receive(.messenger(.didStartUnregistered)) { $0.register = RegisterState() } } - func testStartRegistered() { + func testMessengerStartRegistered() { let store = TestStore( initialState: HomeState(), reducer: homeReducer, environment: .unimplemented ) - let bgQueue = DispatchQueue.test - let mainQueue = DispatchQueue.test var messengerDidStartWithTimeout: [Int] = [] var messengerDidConnect = 0 var messengerDidLogIn = 0 - store.environment.bgQueue = bgQueue.eraseToAnyScheduler() - store.environment.mainQueue = mainQueue.eraseToAnyScheduler() + store.environment.bgQueue = .immediate + store.environment.mainQueue = .immediate store.environment.messenger.start.run = { messengerDidStartWithTimeout.append($0) } store.environment.messenger.isConnected.run = { false } store.environment.messenger.connect.run = { messengerDidConnect += 1 } store.environment.messenger.isLoggedIn.run = { false } store.environment.messenger.isRegistered.run = { true } store.environment.messenger.logIn.run = { messengerDidLogIn += 1 } + store.environment.messenger.cMix.get = { + var cMix: CMix = .unimplemented + cMix.addHealthCallback.run = { _ in Cancellable {} } + cMix.getNodeRegistrationStatus.run = { + struct Unimplemented: Error {} + throw Unimplemented() + } + return cMix + } - store.send(.start) - - bgQueue.advance() + store.send(.messenger(.start)) XCTAssertNoDifference(messengerDidStartWithTimeout, [30_000]) XCTAssertNoDifference(messengerDidConnect, 1) XCTAssertNoDifference(messengerDidLogIn, 1) - mainQueue.advance() + store.receive(.networkMonitor(.stop)) + store.receive(.messenger(.didStartRegistered)) + store.receive(.networkMonitor(.start)) + + store.send(.networkMonitor(.stop)) } func testRegisterFinished() { @@ -83,34 +87,43 @@ final class HomeFeatureTests: XCTestCase { environment: .unimplemented ) - let bgQueue = DispatchQueue.test - let mainQueue = DispatchQueue.test var messengerDidStartWithTimeout: [Int] = [] var messengerDidLogIn = 0 - store.environment.bgQueue = bgQueue.eraseToAnyScheduler() - store.environment.mainQueue = mainQueue.eraseToAnyScheduler() + store.environment.bgQueue = .immediate + store.environment.mainQueue = .immediate store.environment.messenger.start.run = { messengerDidStartWithTimeout.append($0) } store.environment.messenger.isConnected.run = { true } store.environment.messenger.isLoggedIn.run = { false } store.environment.messenger.isRegistered.run = { true } store.environment.messenger.logIn.run = { messengerDidLogIn += 1 } + store.environment.messenger.cMix.get = { + var cMix: CMix = .unimplemented + cMix.addHealthCallback.run = { _ in Cancellable {} } + cMix.getNodeRegistrationStatus.run = { + struct Unimplemented: Error {} + throw Unimplemented() + } + return cMix + } store.send(.register(.finished)) { $0.register = nil } - store.receive(.start) - - bgQueue.advance() + store.receive(.messenger(.start)) XCTAssertNoDifference(messengerDidStartWithTimeout, [30_000]) XCTAssertNoDifference(messengerDidLogIn, 1) - mainQueue.advance() + store.receive(.networkMonitor(.stop)) + store.receive(.messenger(.didStartRegistered)) + store.receive(.networkMonitor(.start)) + + store.send(.networkMonitor(.stop)) } - func testStartMessengerStartFailure() { + func testMessengerStartFailure() { let store = TestStore( initialState: HomeState(), reducer: homeReducer, @@ -124,14 +137,15 @@ final class HomeFeatureTests: XCTestCase { store.environment.mainQueue = .immediate store.environment.messenger.start.run = { _ in throw error } - store.send(.start) + store.send(.messenger(.start)) - store.receive(.set(\.$failure, error.localizedDescription)) { + store.receive(.networkMonitor(.stop)) + store.receive(.messenger(.failure(error as NSError))) { $0.failure = error.localizedDescription } } - func testStartMessengerConnectFailure() { + func testMessengerStartConnectFailure() { let store = TestStore( initialState: HomeState(), reducer: homeReducer, @@ -147,14 +161,15 @@ final class HomeFeatureTests: XCTestCase { store.environment.messenger.isConnected.run = { false } store.environment.messenger.connect.run = { throw error } - store.send(.start) + store.send(.messenger(.start)) - store.receive(.set(\.$failure, error.localizedDescription)) { + store.receive(.networkMonitor(.stop)) + store.receive(.messenger(.failure(error as NSError))) { $0.failure = error.localizedDescription } } - func testStartMessengerIsRegisteredFailure() { + func testMessengerStartIsRegisteredFailure() { let store = TestStore( initialState: HomeState(), reducer: homeReducer, @@ -171,14 +186,15 @@ final class HomeFeatureTests: XCTestCase { store.environment.messenger.isLoggedIn.run = { false } store.environment.messenger.isRegistered.run = { throw error } - store.send(.start) + store.send(.messenger(.start)) - store.receive(.set(\.$failure, error.localizedDescription)) { + store.receive(.networkMonitor(.stop)) + store.receive(.messenger(.failure(error as NSError))) { $0.failure = error.localizedDescription } } - func testStartMessengerLogInFailure() { + func testMessengerStartLogInFailure() { let store = TestStore( initialState: HomeState(), reducer: homeReducer, @@ -196,13 +212,99 @@ final class HomeFeatureTests: XCTestCase { store.environment.messenger.isRegistered.run = { true } store.environment.messenger.logIn.run = { throw error } - store.send(.start) + store.send(.messenger(.start)) - store.receive(.set(\.$failure, error.localizedDescription)) { + store.receive(.networkMonitor(.stop)) + store.receive(.messenger(.failure(error as NSError))) { $0.failure = error.localizedDescription } } + func testNetworkMonitorStart() { + let store = TestStore( + initialState: HomeState(), + reducer: homeReducer, + environment: .unimplemented + ) + + let bgQueue = DispatchQueue.test + let mainQueue = DispatchQueue.test + + var cMixDidAddHealthCallback: [HealthCallback] = [] + var healthCallbackDidCancel = 0 + var nodeRegistrationStatusIndex = 0 + let nodeRegistrationStatus: [NodeRegistrationReport] = [ + .init(registered: 0, total: 10), + .init(registered: 1, total: 11), + .init(registered: 2, total: 12), + ] + + store.environment.bgQueue = bgQueue.eraseToAnyScheduler() + store.environment.mainQueue = mainQueue.eraseToAnyScheduler() + store.environment.messenger.cMix.get = { + var cMix: CMix = .unimplemented + cMix.addHealthCallback.run = { callback in + cMixDidAddHealthCallback.append(callback) + return Cancellable { healthCallbackDidCancel += 1 } + } + cMix.getNodeRegistrationStatus.run = { + defer { nodeRegistrationStatusIndex += 1 } + return nodeRegistrationStatus[nodeRegistrationStatusIndex] + } + return cMix + } + + store.send(.networkMonitor(.start)) + + bgQueue.advance() + + XCTAssertNoDifference(cMixDidAddHealthCallback.count, 1) + + cMixDidAddHealthCallback.first?.handle(true) + mainQueue.advance() + + store.receive(.networkMonitor(.health(true))) { + $0.isNetworkHealthy = true + } + + cMixDidAddHealthCallback.first?.handle(false) + mainQueue.advance() + + store.receive(.networkMonitor(.health(false))) { + $0.isNetworkHealthy = false + } + + bgQueue.advance(by: 2) + mainQueue.advance() + + store.receive(.networkMonitor(.nodes(nodeRegistrationStatus[0]))) { + $0.networkNodesReport = nodeRegistrationStatus[0] + } + + bgQueue.advance(by: 2) + mainQueue.advance() + + store.receive(.networkMonitor(.nodes(nodeRegistrationStatus[1]))) { + $0.networkNodesReport = nodeRegistrationStatus[1] + } + + bgQueue.advance(by: 2) + mainQueue.advance() + + store.receive(.networkMonitor(.nodes(nodeRegistrationStatus[2]))) { + $0.networkNodesReport = nodeRegistrationStatus[2] + } + + store.send(.networkMonitor(.stop)) { + $0.isNetworkHealthy = nil + $0.networkNodesReport = nil + } + + XCTAssertNoDifference(healthCallbackDidCancel, 1) + + mainQueue.advance() + } + func testAccountDeletion() { let store = TestStore( initialState: HomeState(), @@ -254,15 +356,15 @@ final class HomeFeatureTests: XCTestCase { messengerDidDestroy += 1 } - store.send(.deleteAccountButtonTapped) { + store.send(.deleteAccount(.buttonTapped)) { $0.alert = .confirmAccountDeletion() } - store.send(.set(\.$alert, nil)) { + store.send(.didDismissAlert) { $0.alert = nil } - store.send(.deleteAccountConfirmed) { + store.send(.deleteAccount(.confirmed)) { $0.isDeletingAccount = true } @@ -271,7 +373,7 @@ final class HomeFeatureTests: XCTestCase { XCTAssertNoDifference(messengerDidDestroy, 1) XCTAssertNoDifference(dbDidDrop, 1) - store.receive(.didDeleteAccount) { + store.receive(.deleteAccount(.success)) { $0.isDeletingAccount = false } } @@ -298,16 +400,41 @@ final class HomeFeatureTests: XCTestCase { return e2e } - store.send(.deleteAccountConfirmed) { + store.send(.deleteAccount(.confirmed)) { $0.isDeletingAccount = true } - store.receive(.set(\.$isDeletingAccount, false)) { + store.receive(.deleteAccount(.failure(error as NSError))) { $0.isDeletingAccount = false + $0.alert = .accountDeletionFailed(error) } + } - store.receive(.set(\.$alert, .accountDeletionFailed(error))) { - $0.alert = .accountDeletionFailed(error) + func testDidDismissAlert() { + let store = TestStore( + initialState: HomeState( + alert: AlertState(title: TextState("")) + ), + reducer: homeReducer, + environment: .unimplemented + ) + + store.send(.didDismissAlert) { + $0.alert = nil + } + } + + func testDidDismissRegister() { + let store = TestStore( + initialState: HomeState( + register: RegisterState() + ), + reducer: homeReducer, + environment: .unimplemented + ) + + store.send(.didDismissRegister) { + $0.register = nil } } }