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