diff --git a/Example/example-app/Package.swift b/Example/example-app/Package.swift index 3b02bf979acbbd2d44d20cc04eca3ad9dc4a8ce9..55d546265e9cafd943d11e006d1d3ddcb298cad3 100644 --- a/Example/example-app/Package.swift +++ b/Example/example-app/Package.swift @@ -135,10 +135,19 @@ let package = Package( .target( name: "SessionFeature", dependencies: [ + .target(name: "ErrorFeature"), .product( name: "ComposableArchitecture", package: "swift-composable-architecture" ), + .product( + name: "ComposablePresentation", + package: "swift-composable-presentation" + ), + .product( + name: "ElixxirDAppsSDK", + package: "elixxir-dapps-sdk-swift" + ), ], swiftSettings: swiftSettings ), diff --git a/Example/example-app/Sources/AppFeature/App.swift b/Example/example-app/Sources/AppFeature/App.swift index 1353882a744740f111d93562021f892f3108d5a9..341cb46c6bbc7515e222c9e4157fab6f42c82160 100644 --- a/Example/example-app/Sources/AppFeature/App.swift +++ b/Example/example-app/Sources/AppFeature/App.swift @@ -29,6 +29,7 @@ extension AppEnvironment { ).eraseToAnyScheduler() return AppEnvironment( + makeId: UUID.init, hasClient: clientSubject.map { $0 != nil }.eraseToAnyPublisher(), mainScheduler: mainScheduler, landing: LandingEnvironment( @@ -40,7 +41,11 @@ extension AppEnvironment { mainScheduler: mainScheduler, error: ErrorEnvironment() ), - session: SessionEnvironment() + session: SessionEnvironment( + getClient: { clientSubject.value }, + bgScheduler: bgScheduler, + mainScheduler: mainScheduler + ) ) } } diff --git a/Example/example-app/Sources/AppFeature/AppFeature.swift b/Example/example-app/Sources/AppFeature/AppFeature.swift index b99904b08b9a7f800fb7d244e5e274f3f3715c57..347c13b151a70c09e0188a4e3cd5f4ac690d4c08 100644 --- a/Example/example-app/Sources/AppFeature/AppFeature.swift +++ b/Example/example-app/Sources/AppFeature/AppFeature.swift @@ -10,7 +10,8 @@ struct AppState: Equatable { case session(SessionState) } - var scene: Scene = .landing(LandingState()) + var id: UUID = UUID() + var scene: Scene = .landing(LandingState(id: UUID())) } extension AppState.Scene { @@ -45,6 +46,7 @@ enum AppAction: Equatable { } struct AppEnvironment { + var makeId: () -> UUID var hasClient: AnyPublisher<Bool, Never> var mainScheduler: AnySchedulerOf<DispatchQueue> var landing: LandingEnvironment @@ -55,20 +57,22 @@ let appReducer = Reducer<AppState, AppAction, AppEnvironment> { state, action, env in switch action { case .viewDidLoad: - struct HasClientEffectId: Hashable {} + struct HasClientEffectId: Hashable { + var id: UUID + } return env.hasClient .removeDuplicates() .map(AppAction.clientDidChange(hasClient:)) .receive(on: env.mainScheduler) .eraseToEffect() - .cancellable(id: HasClientEffectId(), cancelInFlight: true) + .cancellable(id: HasClientEffectId(id: state.id), cancelInFlight: true) case .clientDidChange(let hasClient): if hasClient { - let sessionState = state.scene.asSession ?? SessionState() + let sessionState = state.scene.asSession ?? SessionState(id: env.makeId()) state.scene = .session(sessionState) } else { - let landingState = state.scene.asLanding ?? LandingState() + let landingState = state.scene.asLanding ?? LandingState(id: env.makeId()) state.scene = .landing(landingState) } return .none @@ -95,6 +99,7 @@ let appReducer = Reducer<AppState, AppAction, AppEnvironment> #if DEBUG extension AppEnvironment { static let failing = AppEnvironment( + makeId: { fatalError() }, hasClient: Empty().eraseToAnyPublisher(), mainScheduler: .failing, landing: .failing, diff --git a/Example/example-app/Sources/LandingFeature/LandingFeature.swift b/Example/example-app/Sources/LandingFeature/LandingFeature.swift index dfca1d37c532b6cdbf682c3ea5465f4829e513e0..511b1107b0afed85ddcbf2a9a03c3b99841a055b 100644 --- a/Example/example-app/Sources/LandingFeature/LandingFeature.swift +++ b/Example/example-app/Sources/LandingFeature/LandingFeature.swift @@ -5,17 +5,20 @@ import ErrorFeature public struct LandingState: Equatable { public init( + id: UUID, hasStoredClient: Bool = false, isMakingClient: Bool = false, isRemovingClient: Bool = false, error: ErrorState? = nil ) { + self.id = id self.hasStoredClient = hasStoredClient self.isMakingClient = isMakingClient self.isRemovingClient = isRemovingClient self.error = error } + var id: UUID var hasStoredClient: Bool var isMakingClient: Bool var isRemovingClient: Bool diff --git a/Example/example-app/Sources/LandingFeature/LandingView.swift b/Example/example-app/Sources/LandingFeature/LandingView.swift index a4dd2b73face637d99e3615922a512d84d9994e5..4a45669a3538af966d54876187964d4697a59ee5 100644 --- a/Example/example-app/Sources/LandingFeature/LandingView.swift +++ b/Example/example-app/Sources/LandingFeature/LandingView.swift @@ -80,7 +80,7 @@ public struct LandingView_Previews: PreviewProvider { public static var previews: some View { NavigationView { LandingView(store: .init( - initialState: .init(), + initialState: .init(id: UUID()), reducer: .empty, environment: () )) diff --git a/Example/example-app/Sources/SessionFeature/NetworkFollowerStatusView.swift b/Example/example-app/Sources/SessionFeature/NetworkFollowerStatusView.swift new file mode 100644 index 0000000000000000000000000000000000000000..5d1ba1dee39fba281819f0865cfaecd73bae6038 --- /dev/null +++ b/Example/example-app/Sources/SessionFeature/NetworkFollowerStatusView.swift @@ -0,0 +1,44 @@ +import ElixxirDAppsSDK +import SwiftUI + +struct NetworkFollowerStatusView: View { + var status: NetworkFollowerStatus? + + var body: some View { + switch status { + case .stopped: + Label("Stopped", systemImage: "stop.fill") + + case .starting: + Label("Starting...", systemImage: "play") + + case .running: + Label("Running", systemImage: "play.fill") + + case .stopping: + Label("Stopping...", systemImage: "stop") + + case .unknown(let code): + Label("Status \(code)", systemImage: "questionmark") + + case .none: + Label("Unknown", systemImage: "questionmark") + } + } +} + +#if DEBUG +struct NetworkFollowerStatusView_Previews: PreviewProvider { + static var previews: some View { + Group { + NetworkFollowerStatusView(status: .stopped) + NetworkFollowerStatusView(status: .starting) + NetworkFollowerStatusView(status: .running) + NetworkFollowerStatusView(status: .stopping) + NetworkFollowerStatusView(status: .unknown(code: -1)) + NetworkFollowerStatusView(status: nil) + } + .previewLayout(.sizeThatFits) + } +} +#endif diff --git a/Example/example-app/Sources/SessionFeature/NetworkHealthStatusView.swift b/Example/example-app/Sources/SessionFeature/NetworkHealthStatusView.swift new file mode 100644 index 0000000000000000000000000000000000000000..e13cb6fc56378fc9717b4b7e5fe3469c0b7f3162 --- /dev/null +++ b/Example/example-app/Sources/SessionFeature/NetworkHealthStatusView.swift @@ -0,0 +1,33 @@ +import SwiftUI + +struct NetworkHealthStatusView: View { + var status: Bool? + + var body: some View { + switch status { + case .some(true): + Label("Healthy", systemImage: "wifi") + .foregroundColor(.green) + + case .some(false): + Label("Unhealthy", systemImage: "bolt.horizontal.fill") + .foregroundColor(.red) + + case .none: + Label("Unknown", systemImage: "questionmark") + } + } +} + +#if DEBUG +struct NetworkHealthStatusView_Previews: PreviewProvider { + static var previews: some View { + Group { + NetworkHealthStatusView(status: true) + NetworkHealthStatusView(status: false) + NetworkHealthStatusView(status: nil) + } + .previewLayout(.sizeThatFits) + } +} +#endif diff --git a/Example/example-app/Sources/SessionFeature/SessionFeature.swift b/Example/example-app/Sources/SessionFeature/SessionFeature.swift index 3b7dd16fe1aecf92658f3d9e2508b2fefc20927a..0756f738008e1ea1870ffd1a11a600fccab5e0ca 100644 --- a/Example/example-app/Sources/SessionFeature/SessionFeature.swift +++ b/Example/example-app/Sources/SessionFeature/SessionFeature.swift @@ -1,27 +1,145 @@ +import Combine import ComposableArchitecture +import ElixxirDAppsSDK +import ErrorFeature public struct SessionState: Equatable { - public init() {} + public init( + id: UUID, + networkFollowerStatus: NetworkFollowerStatus? = nil, + isNetworkHealthy: Bool? = nil, + error: ErrorState? = nil + ) { + self.id = id + self.networkFollowerStatus = networkFollowerStatus + self.isNetworkHealthy = isNetworkHealthy + self.error = error + } + + public var id: UUID + public var networkFollowerStatus: NetworkFollowerStatus? + public var isNetworkHealthy: Bool? + public var error: ErrorState? } public enum SessionAction: Equatable { case viewDidLoad + case updateNetworkFollowerStatus + case didUpdateNetworkFollowerStatus(NetworkFollowerStatus?) + case runNetworkFollower(Bool) + case networkFollowerDidFail(NSError) + case monitorNetworkHealth(Bool) + case didUpdateNetworkHealth(Bool?) + case didDismissError + case error(ErrorAction) } public struct SessionEnvironment { - public init() {} + public init( + getClient: @escaping () -> Client?, + bgScheduler: AnySchedulerOf<DispatchQueue>, + mainScheduler: AnySchedulerOf<DispatchQueue> + ) { + self.getClient = getClient + self.bgScheduler = bgScheduler + self.mainScheduler = mainScheduler + } + + public var getClient: () -> Client? + public var bgScheduler: AnySchedulerOf<DispatchQueue> + public var mainScheduler: AnySchedulerOf<DispatchQueue> } public let sessionReducer = Reducer<SessionState, SessionAction, SessionEnvironment> { state, action, env in switch action { case .viewDidLoad: + return .merge([ + .init(value: .updateNetworkFollowerStatus), + .init(value: .monitorNetworkHealth(true)), + ]) + + case .updateNetworkFollowerStatus: + return Effect.future { fulfill in + let status = env.getClient()?.networkFollower.status() + fulfill(.success(.didUpdateNetworkFollowerStatus(status))) + } + .subscribe(on: env.bgScheduler) + .receive(on: env.mainScheduler) + .eraseToEffect() + + case .didUpdateNetworkFollowerStatus(let status): + state.networkFollowerStatus = status + return .none + + case .runNetworkFollower(let start): + state.networkFollowerStatus = start ? .starting : .stopping + return Effect.run { subscriber in + do { + if start { + try env.getClient()?.networkFollower.start(timeoutMS: 30_000) + } else { + try env.getClient()?.networkFollower.stop() + } + } catch { + subscriber.send(.networkFollowerDidFail(error as NSError)) + } + let status = env.getClient()?.networkFollower.status() + subscriber.send(.didUpdateNetworkFollowerStatus(status)) + subscriber.send(completion: .finished) + return AnyCancellable {} + } + .subscribe(on: env.bgScheduler) + .receive(on: env.mainScheduler) + .eraseToEffect() + + case .networkFollowerDidFail(let error): + state.error = ErrorState(error: error) + return .none + + case .monitorNetworkHealth(let start): + struct MonitorEffectId: Hashable { + var id: UUID + } + let effectId = MonitorEffectId(id: state.id) + if start { + return Effect.run { subscriber in + var cancellable = env.getClient()?.monitorNetworkHealth { isHealthy in + subscriber.send(.didUpdateNetworkHealth(isHealthy)) + } + return AnyCancellable { + cancellable?.cancel() + } + } + .subscribe(on: env.bgScheduler) + .receive(on: env.mainScheduler) + .eraseToEffect() + .cancellable(id: effectId, cancelInFlight: true) + } else { + return Effect.cancel(id: effectId) + .subscribe(on: env.bgScheduler) + .eraseToEffect() + } + + case .didUpdateNetworkHealth(let isHealthy): + state.isNetworkHealthy = isHealthy + return .none + + case .didDismissError: + state.error = nil + return .none + + case .error(_): return .none } } #if DEBUG extension SessionEnvironment { - public static let failing = SessionEnvironment() + public static let failing = SessionEnvironment( + getClient: { .failing }, + bgScheduler: .failing, + mainScheduler: .failing + ) } #endif diff --git a/Example/example-app/Sources/SessionFeature/SessionView.swift b/Example/example-app/Sources/SessionFeature/SessionView.swift index eb1fcf0071a4e534f867c443b2913cbab1ec0779..395cfcb160067fedc31ec3ddd5200aa6960864e2 100644 --- a/Example/example-app/Sources/SessionFeature/SessionView.swift +++ b/Example/example-app/Sources/SessionFeature/SessionView.swift @@ -1,4 +1,7 @@ import ComposableArchitecture +import ComposablePresentation +import ElixxirDAppsSDK +import ErrorFeature import SwiftUI public struct SessionView: View { @@ -9,16 +12,58 @@ public struct SessionView: View { let store: Store<SessionState, SessionAction> struct ViewState: Equatable { - init(state: SessionState) {} + let networkFollowerStatus: NetworkFollowerStatus? + let isNetworkHealthy: Bool? + + init(state: SessionState) { + networkFollowerStatus = state.networkFollowerStatus + isNetworkHealthy = state.isNetworkHealthy + } } public var body: some View { WithViewStore(store.scope(state: ViewState.init)) { viewStore in - Text("SessionView") - .navigationTitle("Session") - .task { - viewStore.send(.viewDidLoad) + Form { + Section { + NetworkFollowerStatusView(status: viewStore.networkFollowerStatus) + + Button { + viewStore.send(.runNetworkFollower(true)) + } label: { + Text("Start") + } + .disabled(viewStore.networkFollowerStatus != .stopped) + + Button { + viewStore.send(.runNetworkFollower(false)) + } label: { + Text("Stop") + } + .disabled(viewStore.networkFollowerStatus != .running) + } header: { + Text("Network follower") + } + + Section { + NetworkHealthStatusView(status: viewStore.isNetworkHealthy) + } header: { + Text("Network health") } + } + .navigationTitle("Session") + .task { + viewStore.send(.viewDidLoad) + } + .sheet( + store.scope( + state: \.error, + action: SessionAction.error + ), + onDismiss: { + viewStore.send(.didDismissError) + }, + content: ErrorView.init(store:) + ) } } } @@ -27,7 +72,7 @@ public struct SessionView: View { public struct SessionView_Previews: PreviewProvider { public static var previews: some View { SessionView(store: .init( - initialState: .init(), + initialState: .init(id: UUID()), reducer: .empty, environment: () )) diff --git a/Example/example-app/Tests/AppFeatureTests/AppFeatureTests.swift b/Example/example-app/Tests/AppFeatureTests/AppFeatureTests.swift index 96a0b07759825f391e32584051116f09acf55934..ce5891ed1d1e147abdca7390420e2888e82321a5 100644 --- a/Example/example-app/Tests/AppFeatureTests/AppFeatureTests.swift +++ b/Example/example-app/Tests/AppFeatureTests/AppFeatureTests.swift @@ -7,10 +7,12 @@ import XCTest final class AppFeatureTests: XCTestCase { func testViewDidLoad() throws { + let newId = UUID() let hasClient = PassthroughSubject<Bool, Never>() let mainScheduler = DispatchQueue.test var env = AppEnvironment.failing + env.makeId = { newId } env.hasClient = hasClient.eraseToAnyPublisher() env.mainScheduler = mainScheduler.eraseToAnyScheduler() @@ -31,7 +33,7 @@ final class AppFeatureTests: XCTestCase { mainScheduler.advance() store.receive(.clientDidChange(hasClient: true)) { - $0.scene = .session(SessionState()) + $0.scene = .session(SessionState(id: newId)) } hasClient.send(true) @@ -41,7 +43,7 @@ final class AppFeatureTests: XCTestCase { mainScheduler.advance() store.receive(.clientDidChange(hasClient: false)) { - $0.scene = .landing(LandingState()) + $0.scene = .landing(LandingState(id: newId)) } hasClient.send(completion: .finished) diff --git a/Example/example-app/Tests/LandingFeatureTests/LandingFeatureTests.swift b/Example/example-app/Tests/LandingFeatureTests/LandingFeatureTests.swift index df9791e7de8c81a4a43f21a11e16f188afa044f7..c5b56f988cd96cf0f8a8c9e03ab2afad9ef885cc 100644 --- a/Example/example-app/Tests/LandingFeatureTests/LandingFeatureTests.swift +++ b/Example/example-app/Tests/LandingFeatureTests/LandingFeatureTests.swift @@ -9,7 +9,7 @@ final class LandingFeatureTests: XCTestCase { env.clientStorage.hasStoredClient = { true } let store = TestStore( - initialState: LandingState(), + initialState: LandingState(id: UUID()), reducer: landingReducer, environment: env ) @@ -33,7 +33,7 @@ final class LandingFeatureTests: XCTestCase { env.mainScheduler = mainScheduler.eraseToAnyScheduler() let store = TestStore( - initialState: LandingState(), + initialState: LandingState(id: UUID()), reducer: landingReducer, environment: env ) @@ -68,7 +68,7 @@ final class LandingFeatureTests: XCTestCase { env.mainScheduler = mainScheduler.eraseToAnyScheduler() let store = TestStore( - initialState: LandingState(), + initialState: LandingState(id: UUID()), reducer: landingReducer, environment: env ) @@ -101,7 +101,7 @@ final class LandingFeatureTests: XCTestCase { env.mainScheduler = mainScheduler.eraseToAnyScheduler() let store = TestStore( - initialState: LandingState(), + initialState: LandingState(id: UUID()), reducer: landingReducer, environment: env ) @@ -133,7 +133,7 @@ final class LandingFeatureTests: XCTestCase { env.mainScheduler = mainScheduler.eraseToAnyScheduler() let store = TestStore( - initialState: LandingState(), + initialState: LandingState(id: UUID()), reducer: landingReducer, environment: env ) @@ -167,7 +167,7 @@ final class LandingFeatureTests: XCTestCase { env.mainScheduler = mainScheduler.eraseToAnyScheduler() let store = TestStore( - initialState: LandingState(), + initialState: LandingState(id: UUID()), reducer: landingReducer, environment: env ) diff --git a/Example/example-app/Tests/SessionFeatureTests/SessionFeatureTests.swift b/Example/example-app/Tests/SessionFeatureTests/SessionFeatureTests.swift index 5ed11e32b14f6ee73da18a53cee600418122f87c..2ada840d7d9d02bdb1c8732836502ad15d15be33 100644 --- a/Example/example-app/Tests/SessionFeatureTests/SessionFeatureTests.swift +++ b/Example/example-app/Tests/SessionFeatureTests/SessionFeatureTests.swift @@ -1,15 +1,158 @@ import ComposableArchitecture +import ElixxirDAppsSDK +import ErrorFeature import XCTest @testable import SessionFeature final class SessionFeatureTests: XCTestCase { - func testViewDidLoad() throws { + func testViewDidLoad() { + var networkFollowerStatus: NetworkFollowerStatus! + var didStartMonitoringNetworkHealth = 0 + var didStopMonitoringNetworkHealth = 0 + var networkHealthCallback: ((Bool) -> Void)! + let bgScheduler = DispatchQueue.test + let mainScheduler = DispatchQueue.test + + var env = SessionEnvironment.failing + env.getClient = { + var client = Client.failing + client.networkFollower.status.status = { networkFollowerStatus } + client.monitorNetworkHealth.listen = { callback in + networkHealthCallback = callback + didStartMonitoringNetworkHealth += 1 + return Cancellable { + didStopMonitoringNetworkHealth += 1 + } + } + return client + } + env.bgScheduler = bgScheduler.eraseToAnyScheduler() + env.mainScheduler = mainScheduler.eraseToAnyScheduler() + let store = TestStore( - initialState: SessionState(), + initialState: SessionState(id: UUID()), reducer: sessionReducer, - environment: .failing + environment: env ) store.send(.viewDidLoad) + + store.receive(.updateNetworkFollowerStatus) + store.receive(.monitorNetworkHealth(true)) + + networkFollowerStatus = .stopped + bgScheduler.advance() + mainScheduler.advance() + + store.receive(.didUpdateNetworkFollowerStatus(.stopped)) { + $0.networkFollowerStatus = .stopped + } + + XCTAssertEqual(didStartMonitoringNetworkHealth, 1) + XCTAssertEqual(didStopMonitoringNetworkHealth, 0) + + networkHealthCallback(true) + bgScheduler.advance() + mainScheduler.advance() + + store.receive(.didUpdateNetworkHealth(true)) { + $0.isNetworkHealthy = true + } + + store.send(.monitorNetworkHealth(false)) + + bgScheduler.advance() + + XCTAssertEqual(didStartMonitoringNetworkHealth, 1) + XCTAssertEqual(didStopMonitoringNetworkHealth, 1) + } + + func testStartStopNetworkFollower() { + var networkFollowerStatus: NetworkFollowerStatus! + var didStartNetworkFollowerWithTimeout = [Int]() + var didStopNetworkFollower = 0 + var networkFollowerStartError: NSError? + let bgScheduler = DispatchQueue.test + let mainScheduler = DispatchQueue.test + + var env = SessionEnvironment.failing + env.getClient = { + var client = Client.failing + client.networkFollower.status.status = { + networkFollowerStatus + } + client.networkFollower.start.start = { + didStartNetworkFollowerWithTimeout.append($0) + if let error = networkFollowerStartError { + throw error + } + } + client.networkFollower.stop.stop = { + didStopNetworkFollower += 1 + } + return client + } + env.bgScheduler = bgScheduler.eraseToAnyScheduler() + env.mainScheduler = mainScheduler.eraseToAnyScheduler() + + let store = TestStore( + initialState: SessionState(id: UUID()), + reducer: sessionReducer, + environment: env + ) + + store.send(.runNetworkFollower(true)) { + $0.networkFollowerStatus = .starting + } + + networkFollowerStatus = .running + bgScheduler.advance() + mainScheduler.advance() + + XCTAssertEqual(didStartNetworkFollowerWithTimeout, [30_000]) + XCTAssertEqual(didStopNetworkFollower, 0) + + store.receive(.didUpdateNetworkFollowerStatus(.running)) { + $0.networkFollowerStatus = .running + } + + store.send(.runNetworkFollower(false)) { + $0.networkFollowerStatus = .stopping + } + + networkFollowerStatus = .stopped + bgScheduler.advance() + mainScheduler.advance() + + XCTAssertEqual(didStartNetworkFollowerWithTimeout, [30_000]) + XCTAssertEqual(didStopNetworkFollower, 1) + + store.receive(.didUpdateNetworkFollowerStatus(.stopped)) { + $0.networkFollowerStatus = .stopped + } + + store.send(.runNetworkFollower(true)) { + $0.networkFollowerStatus = .starting + } + + networkFollowerStartError = NSError(domain: "test", code: 1234) + networkFollowerStatus = .stopped + bgScheduler.advance() + mainScheduler.advance() + + XCTAssertEqual(didStartNetworkFollowerWithTimeout, [30_000, 30_000]) + XCTAssertEqual(didStopNetworkFollower, 1) + + store.receive(.networkFollowerDidFail(networkFollowerStartError!)) { + $0.error = ErrorState(error: networkFollowerStartError!) + } + + store.receive(.didUpdateNetworkFollowerStatus(.stopped)) { + $0.networkFollowerStatus = .stopped + } + + store.send(.didDismissError) { + $0.error = nil + } } }