From 5e4eec5a05858f03c93288e44eff2ace27ee9a90 Mon Sep 17 00:00:00 2001 From: Dariusz Rybicki <dariusz@elixxir.io> Date: Mon, 5 Sep 2022 15:04:49 +0200 Subject: [PATCH 1/3] Refactor --- .../Sources/AppFeature/AppFeature.swift | 2 +- .../Sources/HomeFeature/Alerts.swift | 2 +- .../Sources/HomeFeature/HomeFeature.swift | 91 +++++++++++++------ .../Sources/HomeFeature/HomeView.swift | 10 +- .../AppFeatureTests/AppFeatureTests.swift | 2 +- .../HomeFeatureTests/HomeFeatureTests.swift | 81 +++++++++++------ 6 files changed, 124 insertions(+), 64 deletions(-) diff --git a/Examples/xx-messenger/Sources/AppFeature/AppFeature.swift b/Examples/xx-messenger/Sources/AppFeature/AppFeature.swift index 582478d4..43cede69 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 a3347be4..3b9b9a3d 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 279a5628..8116fde0 100644 --- a/Examples/xx-messenger/Sources/HomeFeature/HomeFeature.swift +++ b/Examples/xx-messenger/Sources/HomeFeature/HomeFeature.swift @@ -20,18 +20,31 @@ public struct HomeState: Equatable { self.isDeletingAccount = isDeletingAccount } - @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 register: RegisterState? + public var alert: AlertState<HomeAction>? + public var isDeletingAccount: Bool } -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 DeleteAccount: Equatable { + case buttonTapped + case confirmed + case success + case failure(NSError) + } + + case messenger(Messenger) + case deleteAccount(DeleteAccount) + case didDismissAlert + case didDismissRegister case register(RegisterAction) } @@ -70,8 +83,8 @@ extension HomeEnvironment { public let homeReducer = Reducer<HomeState, HomeAction, HomeEnvironment> { state, action, env in switch action { - case .start: - return .run { subscriber in + case .messenger(.start): + return .result { do { try env.messenger.start() @@ -81,29 +94,38 @@ public let homeReducer = Reducer<HomeState, HomeAction, HomeEnvironment> if env.messenger.isLoggedIn() == false { if try env.messenger.isRegistered() == false { - subscriber.send(.set(\.$register, RegisterState())) - subscriber.send(completion: .finished) - return AnyCancellable {} + return .success(.messenger(.didStartUnregistered)) } try env.messenger.logIn() } + + return .success(.messenger(.didStartRegistered)) } catch { - subscriber.send(.set(\.$failure, error.localizedDescription)) + return .success(.messenger(.failure(error as NSError))) } - 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 .none + + case .messenger(.failure(let error)): + state.failure = error.localizedDescription + 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 +135,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 f73f6b8a..b2c9e660 100644 --- a/Examples/xx-messenger/Sources/HomeFeature/HomeView.swift +++ b/Examples/xx-messenger/Sources/HomeFeature/HomeView.swift @@ -28,7 +28,7 @@ public struct HomeView: View { Section { Text(failure) Button { - viewStore.send(.start) + viewStore.send(.messenger(.start)) } label: { Text("Retry") } @@ -39,7 +39,7 @@ public struct HomeView: View { Section { Button(role: .destructive) { - viewStore.send(.deleteAccountButtonTapped) + viewStore.send(.deleteAccount(.buttonTapped)) } label: { HStack { Text("Delete Account") @@ -57,18 +57,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 5ea96813..5a013b28 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 4fbad5b8..60c7e305 100644 --- a/Examples/xx-messenger/Tests/HomeFeatureTests/HomeFeatureTests.swift +++ b/Examples/xx-messenger/Tests/HomeFeatureTests/HomeFeatureTests.swift @@ -7,7 +7,7 @@ import XXModels @testable import HomeFeature final class HomeFeatureTests: XCTestCase { - func testStartUnregistered() { + func testMessengerStartUnregistered() { let store = TestStore( initialState: HomeState(), reducer: homeReducer, @@ -27,7 +27,7 @@ final class HomeFeatureTests: XCTestCase { store.environment.messenger.isLoggedIn.run = { false } store.environment.messenger.isRegistered.run = { false } - store.send(.start) + store.send(.messenger(.start)) bgQueue.advance() @@ -36,12 +36,12 @@ final class HomeFeatureTests: XCTestCase { mainQueue.advance() - store.receive(.set(\.$register, RegisterState())) { + store.receive(.messenger(.didStartUnregistered)) { $0.register = RegisterState() } } - func testStartRegistered() { + func testMessengerStartRegistered() { let store = TestStore( initialState: HomeState(), reducer: homeReducer, @@ -63,7 +63,7 @@ final class HomeFeatureTests: XCTestCase { store.environment.messenger.isRegistered.run = { true } store.environment.messenger.logIn.run = { messengerDidLogIn += 1 } - store.send(.start) + store.send(.messenger(.start)) bgQueue.advance() @@ -72,6 +72,8 @@ final class HomeFeatureTests: XCTestCase { XCTAssertNoDifference(messengerDidLogIn, 1) mainQueue.advance() + + store.receive(.messenger(.didStartRegistered)) } func testRegisterFinished() { @@ -100,7 +102,7 @@ final class HomeFeatureTests: XCTestCase { $0.register = nil } - store.receive(.start) + store.receive(.messenger(.start)) bgQueue.advance() @@ -108,9 +110,11 @@ final class HomeFeatureTests: XCTestCase { XCTAssertNoDifference(messengerDidLogIn, 1) mainQueue.advance() + + store.receive(.messenger(.didStartRegistered)) } - func testStartMessengerStartFailure() { + func testMessengerStartFailure() { let store = TestStore( initialState: HomeState(), reducer: homeReducer, @@ -124,14 +128,14 @@ 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(.messenger(.failure(error as NSError))) { $0.failure = error.localizedDescription } } - func testStartMessengerConnectFailure() { + func testMessengerStartConnectFailure() { let store = TestStore( initialState: HomeState(), reducer: homeReducer, @@ -147,14 +151,14 @@ 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(.messenger(.failure(error as NSError))) { $0.failure = error.localizedDescription } } - func testStartMessengerIsRegisteredFailure() { + func testMessengerStartIsRegisteredFailure() { let store = TestStore( initialState: HomeState(), reducer: homeReducer, @@ -171,14 +175,14 @@ 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(.messenger(.failure(error as NSError))) { $0.failure = error.localizedDescription } } - func testStartMessengerLogInFailure() { + func testMessengerStartLogInFailure() { let store = TestStore( initialState: HomeState(), reducer: homeReducer, @@ -196,9 +200,9 @@ 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(.messenger(.failure(error as NSError))) { $0.failure = error.localizedDescription } } @@ -254,15 +258,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 +275,7 @@ final class HomeFeatureTests: XCTestCase { XCTAssertNoDifference(messengerDidDestroy, 1) XCTAssertNoDifference(dbDidDrop, 1) - store.receive(.didDeleteAccount) { + store.receive(.deleteAccount(.success)) { $0.isDeletingAccount = false } } @@ -298,16 +302,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 } } } -- GitLab From cd58c0a6f853e0e0c4be3b8b0133ed9863524275 Mon Sep 17 00:00:00 2001 From: Dariusz Rybicki <dariusz@elixxir.io> Date: Mon, 5 Sep 2022 16:46:13 +0200 Subject: [PATCH 2/3] Monitor network health in HomeFeature --- .../Sources/HomeFeature/HomeFeature.swift | 82 +++++++++++----- .../Sources/HomeFeature/HomeView.swift | 24 +++++ .../HomeFeatureTests/HomeFeatureTests.swift | 97 ++++++++++++++----- 3 files changed, 156 insertions(+), 47 deletions(-) diff --git a/Examples/xx-messenger/Sources/HomeFeature/HomeFeature.swift b/Examples/xx-messenger/Sources/HomeFeature/HomeFeature.swift index 8116fde0..e054e6ac 100644 --- a/Examples/xx-messenger/Sources/HomeFeature/HomeFeature.swift +++ b/Examples/xx-messenger/Sources/HomeFeature/HomeFeature.swift @@ -10,20 +10,23 @@ import XXMessengerClient public struct HomeState: Equatable { public init( failure: String? = nil, - register: RegisterState? = nil, + isNetworkHealthy: Bool? = 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 } public var failure: String? - public var register: RegisterState? - public var alert: AlertState<HomeAction>? + public var isNetworkHealthy: Bool? public var isDeletingAccount: Bool + public var alert: AlertState<HomeAction>? + public var register: RegisterState? } public enum HomeAction: Equatable { @@ -34,6 +37,12 @@ public enum HomeAction: Equatable { case failure(NSError) } + public enum NetworkMonitor: Equatable { + case start + case stop + case health(Bool) + } + public enum DeleteAccount: Equatable { case buttonTapped case confirmed @@ -42,6 +51,7 @@ public enum HomeAction: Equatable { } case messenger(Messenger) + case networkMonitor(NetworkMonitor) case deleteAccount(DeleteAccount) case didDismissAlert case didDismissRegister @@ -82,28 +92,33 @@ extension HomeEnvironment { public let homeReducer = Reducer<HomeState, HomeAction, HomeEnvironment> { state, action, env in + enum NetworkHealthEffectId {} + switch action { case .messenger(.start): - return .result { - do { - try env.messenger.start() - - if env.messenger.isConnected() == false { - try env.messenger.connect() - } + 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.isLoggedIn() == false { - if try env.messenger.isRegistered() == false { - return .success(.messenger(.didStartUnregistered)) + 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))) + return .success(.messenger(.didStartRegistered)) + } catch { + return .success(.messenger(.failure(error as NSError))) + } } - } + ) .subscribe(on: env.bgQueue) .receive(on: env.mainQueue) .eraseToEffect() @@ -113,12 +128,33 @@ public let homeReducer = Reducer<HomeState, HomeAction, HomeEnvironment> return .none case .messenger(.didStartRegistered): - return .none + return Effect(value: .networkMonitor(.start)) case .messenger(.failure(let error)): state.failure = error.localizedDescription return .none + case .networkMonitor(.start): + return .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) + .subscribe(on: env.bgQueue) + .receive(on: env.mainQueue) + .eraseToEffect() + + case .networkMonitor(.stop): + state.isNetworkHealthy = nil + return .cancel(id: NetworkHealthEffectId.self) + + case .networkMonitor(.health(let isHealthy)): + state.isNetworkHealthy = isHealthy + return .none + case .deleteAccount(.buttonTapped): state.alert = .confirmAccountDeletion() return .none diff --git a/Examples/xx-messenger/Sources/HomeFeature/HomeView.swift b/Examples/xx-messenger/Sources/HomeFeature/HomeView.swift index b2c9e660..d105e0bd 100644 --- a/Examples/xx-messenger/Sources/HomeFeature/HomeView.swift +++ b/Examples/xx-messenger/Sources/HomeFeature/HomeView.swift @@ -12,10 +12,12 @@ public struct HomeView: View { struct ViewState: Equatable { var failure: String? + var isNetworkHealthy: Bool? var isDeletingAccount: Bool init(state: HomeState) { failure = state.failure + isNetworkHealthy = state.isNetworkHealthy isDeletingAccount = state.isDeletingAccount } } @@ -37,6 +39,28 @@ public struct HomeView: View { } } + 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) + } + } + } header: { + Text("Network") + } + Section { Button(role: .destructive) { viewStore.send(.deleteAccount(.buttonTapped)) diff --git a/Examples/xx-messenger/Tests/HomeFeatureTests/HomeFeatureTests.swift b/Examples/xx-messenger/Tests/HomeFeatureTests/HomeFeatureTests.swift index 60c7e305..97ac8900 100644 --- a/Examples/xx-messenger/Tests/HomeFeatureTests/HomeFeatureTests.swift +++ b/Examples/xx-messenger/Tests/HomeFeatureTests/HomeFeatureTests.swift @@ -14,13 +14,11 @@ final class HomeFeatureTests: XCTestCase { 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 } @@ -29,13 +27,10 @@ final class HomeFeatureTests: XCTestCase { store.send(.messenger(.start)) - bgQueue.advance() - XCTAssertNoDifference(messengerDidStartWithTimeout, [30_000]) XCTAssertNoDifference(messengerDidConnect, 1) - mainQueue.advance() - + store.receive(.networkMonitor(.stop)) store.receive(.messenger(.didStartUnregistered)) { $0.register = RegisterState() } @@ -48,32 +43,35 @@ final class HomeFeatureTests: XCTestCase { 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 {} } + return cMix + } store.send(.messenger(.start)) - bgQueue.advance() - 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() { @@ -85,18 +83,21 @@ 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 {} } + return cMix + } store.send(.register(.finished)) { $0.register = nil @@ -104,14 +105,14 @@ final class HomeFeatureTests: XCTestCase { store.receive(.messenger(.start)) - bgQueue.advance() - 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 testMessengerStartFailure() { @@ -130,6 +131,7 @@ final class HomeFeatureTests: XCTestCase { store.send(.messenger(.start)) + store.receive(.networkMonitor(.stop)) store.receive(.messenger(.failure(error as NSError))) { $0.failure = error.localizedDescription } @@ -153,6 +155,7 @@ final class HomeFeatureTests: XCTestCase { store.send(.messenger(.start)) + store.receive(.networkMonitor(.stop)) store.receive(.messenger(.failure(error as NSError))) { $0.failure = error.localizedDescription } @@ -177,6 +180,7 @@ final class HomeFeatureTests: XCTestCase { store.send(.messenger(.start)) + store.receive(.networkMonitor(.stop)) store.receive(.messenger(.failure(error as NSError))) { $0.failure = error.localizedDescription } @@ -202,11 +206,56 @@ final class HomeFeatureTests: XCTestCase { store.send(.messenger(.start)) + 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 + ) + + var cMixDidAddHealthCallback: [HealthCallback] = [] + var healthCallbackDidCancel = 0 + + store.environment.bgQueue = .immediate + store.environment.mainQueue = .immediate + store.environment.messenger.cMix.get = { + var cMix: CMix = .unimplemented + cMix.addHealthCallback.run = { callback in + cMixDidAddHealthCallback.append(callback) + return Cancellable { healthCallbackDidCancel += 1 } + } + return cMix + } + + store.send(.networkMonitor(.start)) + + XCTAssertNoDifference(cMixDidAddHealthCallback.count, 1) + + cMixDidAddHealthCallback.first?.handle(true) + + store.receive(.networkMonitor(.health(true))) { + $0.isNetworkHealthy = true + } + + cMixDidAddHealthCallback.first?.handle(false) + + store.receive(.networkMonitor(.health(false))) { + $0.isNetworkHealthy = false + } + + store.send(.networkMonitor(.stop)) { + $0.isNetworkHealthy = nil + } + + XCTAssertNoDifference(healthCallbackDidCancel, 1) + } + func testAccountDeletion() { let store = TestStore( initialState: HomeState(), -- GitLab From b927f507530ff541afbcf7e81034b115f5f79642 Mon Sep 17 00:00:00 2001 From: Dariusz Rybicki <dariusz@elixxir.io> Date: Mon, 5 Sep 2022 17:25:50 +0200 Subject: [PATCH 3/3] Monitor node registration in HomeFeature --- .../Sources/HomeFeature/HomeFeature.swift | 38 ++++++++++--- .../Sources/HomeFeature/HomeView.swift | 31 ++++++++++- .../HomeFeatureTests/HomeFeatureTests.swift | 55 ++++++++++++++++++- 3 files changed, 110 insertions(+), 14 deletions(-) diff --git a/Examples/xx-messenger/Sources/HomeFeature/HomeFeature.swift b/Examples/xx-messenger/Sources/HomeFeature/HomeFeature.swift index e054e6ac..0a019179 100644 --- a/Examples/xx-messenger/Sources/HomeFeature/HomeFeature.swift +++ b/Examples/xx-messenger/Sources/HomeFeature/HomeFeature.swift @@ -11,6 +11,7 @@ public struct HomeState: Equatable { public init( failure: String? = nil, isNetworkHealthy: Bool? = nil, + networkNodesReport: NodeRegistrationReport? = nil, isDeletingAccount: Bool = false, alert: AlertState<HomeAction>? = nil, register: RegisterState? = nil @@ -24,6 +25,7 @@ public struct HomeState: Equatable { 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? @@ -41,6 +43,7 @@ public enum HomeAction: Equatable { case start case stop case health(Bool) + case nodes(NodeRegistrationReport) } public enum DeleteAccount: Equatable { @@ -93,6 +96,7 @@ extension HomeEnvironment { public let homeReducer = Reducer<HomeState, HomeAction, HomeEnvironment> { state, action, env in enum NetworkHealthEffectId {} + enum NetworkNodesEffectId {} switch action { case .messenger(.start): @@ -135,26 +139,44 @@ public let homeReducer = Reducer<HomeState, HomeAction, HomeEnvironment> return .none case .networkMonitor(.start): - return .run { subscriber in - let callback = HealthCallback { isHealthy in - subscriber.send(.networkMonitor(.health(isHealthy))) + 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() } } - let cancellable = env.messenger.cMix()?.addHealthCallback(callback) - return AnyCancellable { cancellable?.cancel() } - } - .cancellable(id: NetworkHealthEffectId.self, cancelInFlight: true) + .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 - return .cancel(id: NetworkHealthEffectId.self) + 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 diff --git a/Examples/xx-messenger/Sources/HomeFeature/HomeView.swift b/Examples/xx-messenger/Sources/HomeFeature/HomeView.swift index d105e0bd..ce8aac42 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>) { @@ -13,12 +14,14 @@ 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 } } @@ -26,15 +29,17 @@ 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(.messenger(.start)) } label: { Text("Retry") } - } header: { + } + } header: { + if viewStore.failure != nil { Text("Error") } } @@ -57,6 +62,26 @@ public struct HomeView: View { .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") } diff --git a/Examples/xx-messenger/Tests/HomeFeatureTests/HomeFeatureTests.swift b/Examples/xx-messenger/Tests/HomeFeatureTests/HomeFeatureTests.swift index 97ac8900..c7168d5b 100644 --- a/Examples/xx-messenger/Tests/HomeFeatureTests/HomeFeatureTests.swift +++ b/Examples/xx-messenger/Tests/HomeFeatureTests/HomeFeatureTests.swift @@ -58,6 +58,10 @@ final class HomeFeatureTests: XCTestCase { store.environment.messenger.cMix.get = { var cMix: CMix = .unimplemented cMix.addHealthCallback.run = { _ in Cancellable {} } + cMix.getNodeRegistrationStatus.run = { + struct Unimplemented: Error {} + throw Unimplemented() + } return cMix } @@ -96,6 +100,10 @@ final class HomeFeatureTests: XCTestCase { store.environment.messenger.cMix.get = { var cMix: CMix = .unimplemented cMix.addHealthCallback.run = { _ in Cancellable {} } + cMix.getNodeRegistrationStatus.run = { + struct Unimplemented: Error {} + throw Unimplemented() + } return cMix } @@ -219,41 +227,82 @@ final class HomeFeatureTests: XCTestCase { environment: .unimplemented ) + let bgQueue = DispatchQueue.test + let mainQueue = DispatchQueue.test + var cMixDidAddHealthCallback: [HealthCallback] = [] var healthCallbackDidCancel = 0 - - store.environment.bgQueue = .immediate - store.environment.mainQueue = .immediate + 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() { -- GitLab