From ed82cec9f8dd0bb215c2c0d58c3f67649dc64dfb Mon Sep 17 00:00:00 2001 From: David Langley Date: Wed, 15 Sep 2021 16:09:41 +0100 Subject: [PATCH] Allow defer in xcAwait. simplify sending state actions from a publisher. Fix tests. --- .../StateStorePublisherExtensions.swift | 26 -------------- .../Test/XCTestPublisherExtensions.swift | 35 +++++++++++++++---- .../ViewModel/StateStoreViewModel.swift | 15 ++++++-- .../Mock/MockTemplateUserProfileService.swift | 2 +- .../TemplateUserProfileViewModelTests.swift | 17 +++++---- .../TemplateUserProfileViewModel.swift | 5 +-- 6 files changed, 55 insertions(+), 45 deletions(-) delete mode 100644 RiotSwiftUI/Modules/Common/StateStore/StateStorePublisherExtensions.swift diff --git a/RiotSwiftUI/Modules/Common/StateStore/StateStorePublisherExtensions.swift b/RiotSwiftUI/Modules/Common/StateStore/StateStorePublisherExtensions.swift deleted file mode 100644 index a5332c1fc..000000000 --- a/RiotSwiftUI/Modules/Common/StateStore/StateStorePublisherExtensions.swift +++ /dev/null @@ -1,26 +0,0 @@ -import Foundation -import Combine - -@available(iOS 14.0, *) -extension Publisher { - - func sinkDispatchTo(_ store: StateStoreViewModel) where SA == Output, Failure == Never { - return self - .subscribe(on: DispatchQueue.main) - .sink { [weak store] (output) in - guard let store = store else { return } - store.dispatch(action: output) - } - .store(in: &store.cancellables) - } - - - func dispatchTo(_ store: StateStoreViewModel) -> Publishers.HandleEvents> where SA == Output, Failure == Never { - return self - .subscribe(on: DispatchQueue.main) - .handleEvents(receiveOutput: { [weak store] action in - guard let store = store else { return } - store.dispatch(action: action) - }) - } -} diff --git a/RiotSwiftUI/Modules/Common/Test/XCTestPublisherExtensions.swift b/RiotSwiftUI/Modules/Common/Test/XCTestPublisherExtensions.swift index 4861d08ab..7c0f2ec72 100644 --- a/RiotSwiftUI/Modules/Common/Test/XCTestPublisherExtensions.swift +++ b/RiotSwiftUI/Modules/Common/Test/XCTestPublisherExtensions.swift @@ -34,6 +34,25 @@ extension XCTestCase { _ publisher: T, timeout: TimeInterval = 10 ) throws -> T.Output { + return try xcAwaitDeferred(publisher, timeout: timeout)() + } + + /// XCTest utility that allows for a deferred wait of results from publishers, so that the output can be used for assertions. + /// + /// ``` + /// let collectedEvents = somePublisher.collect(3).first() + /// let awaitDeferred = xcAwaitDeferred(collectedEvents) + /// // Do some other work that publishes to somePublisher + /// XCTAssertEqual(try awaitDeferred(), [expected, values, here]) + /// ``` + /// - Parameters: + /// - publisher: The publisher to wait on. + /// - timeout: A timeout after which we give up. + /// - Returns: A closure that starts the waiting of results when called. The closure will return the unwrapped result. + func xcAwaitDeferred( + _ publisher: T, + timeout: TimeInterval = 10 + ) -> (() throws -> (T.Output)) { var result: Result? let expectation = self.expectation(description: "Awaiting publisher") @@ -52,12 +71,14 @@ extension XCTestCase { result = .success(value) } ) - waitForExpectations(timeout: timeout) - cancellable.cancel() - let unwrappedResult = try XCTUnwrap( - result, - "Awaited publisher did not produce any output" - ) - return try unwrappedResult.get() + return { + self.waitForExpectations(timeout: timeout) + cancellable.cancel() + let unwrappedResult = try XCTUnwrap( + result, + "Awaited publisher did not produce any output" + ) + return try unwrappedResult.get() + } } } diff --git a/RiotSwiftUI/Modules/Common/ViewModel/StateStoreViewModel.swift b/RiotSwiftUI/Modules/Common/ViewModel/StateStoreViewModel.swift index 4bddd9340..2cc2b43d5 100644 --- a/RiotSwiftUI/Modules/Common/ViewModel/StateStoreViewModel.swift +++ b/RiotSwiftUI/Modules/Common/ViewModel/StateStoreViewModel.swift @@ -72,13 +72,13 @@ class ViewModelContext: ObservableObject { } } -@available(iOS 14, *) /// A common ViewModel implementation for handling of `State`, `StateAction`s and `ViewAction`s /// /// Generic type State is constrained to the BindableState protocol in that it may contain (but doesn't have to) /// a specific portion of state that can be safely bound to. /// If we decide to add more features to our state management (like doing state processing off the main thread) /// we can do it in this centralised place. +@available(iOS 14, *) class StateStoreViewModel { typealias Context = ViewModelContext @@ -105,7 +105,8 @@ class StateStoreViewModel { self.context = Context(initialViewState: initialViewState) self.state = CurrentValueSubject(initialViewState) // Connect the state to context viewState, that view uses for observing (but not modifying directly) the state. - self.state.weakAssign(to: \.context.viewState, on: self) + self.state + .weakAssign(to: \.context.viewState, on: self) .store(in: &cancellables) // Receive events from the view and pass on to the `ViewModel` for processing. self.context.viewActions.sink { [weak self] action in @@ -120,6 +121,16 @@ class StateStoreViewModel { func dispatch(action: StateAction) { Self.reducer(state: &state.value, action: action) } + + /// Send state actions from a publisher to modify the state within the reducer. + /// - Parameter actionPublisher: The publisher that produces actions to be sent to the reducer + func dispatch(actionPublisher: AnyPublisher) { + actionPublisher.sink { [weak self] action in + guard let self = self else { return } + Self.reducer(state: &self.state.value, action: action) + } + .store(in: &cancellables) + } /// Override to handle mutations to the `State` /// diff --git a/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Service/Mock/MockTemplateUserProfileService.swift b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Service/Mock/MockTemplateUserProfileService.swift index 0ce280d76..0684ace87 100644 --- a/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Service/Mock/MockTemplateUserProfileService.swift +++ b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Service/Mock/MockTemplateUserProfileService.swift @@ -37,6 +37,6 @@ class MockTemplateUserProfileService: TemplateUserProfileServiceProtocol { } func simulateUpdate(presence: TemplateUserProfilePresence) { - self.presenceSubject.send(presence) + self.presenceSubject.value = presence } } diff --git a/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Test/Unit/TemplateUserProfileViewModelTests.swift b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Test/Unit/TemplateUserProfileViewModelTests.swift index f14b1a2e6..dd9dd9fba 100644 --- a/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Test/Unit/TemplateUserProfileViewModelTests.swift +++ b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Test/Unit/TemplateUserProfileViewModelTests.swift @@ -26,29 +26,32 @@ class TemplateUserProfileViewModelTests: XCTestCase { static let displayName = "Alice" } var service: MockTemplateUserProfileService! - var viewModel: TemplateUserProfileViewModel! + var viewModel: TemplateUserProfileViewModelProtocol! + var context: TemplateUserProfileViewModelType.Context! var cancellables = Set() override func setUpWithError() throws { service = MockTemplateUserProfileService(displayName: Constants.displayName, presence: Constants.presenceInitialValue) - viewModel = TemplateUserProfileViewModel(templateUserProfileService: service) + viewModel = TemplateUserProfileViewModel.makeTemplateUserProfileViewModel(templateUserProfileService: service) + context = viewModel.context } func testInitialState() { - XCTAssertEqual(viewModel.viewState.displayName, Constants.displayName) - XCTAssertEqual(viewModel.viewState.presence, Constants.presenceInitialValue) + XCTAssertEqual(context.viewState.displayName, Constants.displayName) + XCTAssertEqual(context.viewState.presence, Constants.presenceInitialValue) } func testFirstPresenceReceived() throws { - let presencePublisher = viewModel.$viewState.map(\.presence).removeDuplicates().collect(1).first() + let presencePublisher = context.$viewState.map(\.presence).removeDuplicates().collect(1).first() XCTAssertEqual(try xcAwait(presencePublisher), [Constants.presenceInitialValue]) } func testPresenceUpdatesReceived() throws { - let presencePublisher = viewModel.$viewState.map(\.presence).removeDuplicates().collect(3).first() + let presencePublisher = context.$viewState.map(\.presence).removeDuplicates().collect(3).first() + let awaitDeferred = xcAwaitDeferred(presencePublisher) let newPresenceValue1: TemplateUserProfilePresence = .online let newPresenceValue2: TemplateUserProfilePresence = .idle service.simulateUpdate(presence: newPresenceValue1) service.simulateUpdate(presence: newPresenceValue2) - XCTAssertEqual(try xcAwait(presencePublisher), [Constants.presenceInitialValue, newPresenceValue1, newPresenceValue2]) + XCTAssertEqual(try awaitDeferred(), [Constants.presenceInitialValue, newPresenceValue1, newPresenceValue2]) } } diff --git a/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/ViewModel/TemplateUserProfileViewModel.swift b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/ViewModel/TemplateUserProfileViewModel.swift index 82834b977..c877a8cee 100644 --- a/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/ViewModel/TemplateUserProfileViewModel.swift +++ b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/ViewModel/TemplateUserProfileViewModel.swift @@ -57,9 +57,10 @@ class TemplateUserProfileViewModel: TemplateUserProfileViewModelType, TemplateUs } private func setupPresenceObserving() { - templateUserProfileService.presenceSubject + let presenceUpdatePublisher = templateUserProfileService.presenceSubject .map(TemplateUserProfileStateAction.updatePresence) - .sinkDispatchTo(self) + .eraseToAnyPublisher() + dispatch(actionPublisher: presenceUpdatePublisher) } // MARK: - Public