diff --git a/.circleci/config.yml b/.circleci/config.yml new file mode 100644 index 0000000..7e749b0 --- /dev/null +++ b/.circleci/config.yml @@ -0,0 +1,41 @@ +# Use the latest 2.1 version of CircleCI pipeline process engine. +# See: https://circleci.com/docs/2.0/configuration-reference +# For a detailed guide to building and testing on iOS, read the docs: +# https://circleci.com/docs/2.0/testing-ios/ +version: 2.1 + +## Orbs +orbs: + macos: circleci/macos@2 + +## Jobs +jobs: + unit_test: + # Specify the execution environment. You can specify an image from Dockerhub or use one of our Convenience Images from CircleCI's Developer Hub. + # See: https://circleci.com/docs/2.0/configuration-reference/#docker-machine-macos-windows-executor + macos: + xcode: 13.3.1 # Specify the Xcode version to use + environment: + HOMEBREW_NO_AUTO_UPDATE: 1 + steps: + - macos/preboot-simulator: + version: "15.4" + platform: "iOS" + device: "iPhone 13 Pro" + - checkout + - run: + name: Install xcpretty + command: gem install xcpretty + - run: + name: Run unit tests + command: Scripts/test -d "OS=15.4,name=iPhone 13 Pro" | xcpretty --color --report junit --output ~/test-results/results.xml + - store_test_results: + path: ~/test-results + - store_artifacts: + path: ~/Library/Logs/DiagnosticReports + +## Workflows +workflows: + on_push: + jobs: + - unit_test diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml deleted file mode 100644 index 1bd2eb6..0000000 --- a/.github/workflows/ci.yml +++ /dev/null @@ -1,18 +0,0 @@ -name: CI - -on: - push: - branches: [ main ] - pull_request: - branches: [ main ] - -jobs: - ios-latest: - name: Unit Test - iOS 15.4, Xcode 13.3.1 - runs-on: macOS-12 - env: - DEVELOPER_DIR: /Applications/Xcode_13.3.1.app/Contents/Developer - steps: - - uses: actions/checkout@v2 - - name: Run Tests - run: Scripts/test -d "OS=15.4,name=iPhone 13 Pro" diff --git a/Example/AppStore.swift b/Example/AppStore.swift index fb24c8e..2c41f2a 100644 --- a/Example/AppStore.swift +++ b/Example/AppStore.swift @@ -118,7 +118,7 @@ struct HighlyComplicatedIncrementMiddleware: Middlewareable { func execute(event: AppEvent, state: () -> AppState) async { guard case .incrementWithDelayViaMiddleware = event else { return } - try? await Task.sleep(seconds: 2) + try? await Task.sleep(seconds: 0.5) self._outputStream.yield(.increment) } } diff --git a/ExampleTests/RootScreenTests.swift b/ExampleTests/RootScreenTests.swift index 81974d5..9cdc734 100644 --- a/ExampleTests/RootScreenTests.swift +++ b/ExampleTests/RootScreenTests.swift @@ -1,13 +1,11 @@ import XCTest -import RedUxTestUtilities @testable import Example - class RootScreenTests: XCTestCase { func testStateChange() async { let store = RootScreen.LocalStore.make() - XCTAssertStateChange( + await XCTAssertStateChange( store: store, events: [ .increment, diff --git a/ExampleTests/Supporting Files/StateAssertions.swift b/ExampleTests/Supporting Files/StateAssertions.swift deleted file mode 100644 index 2d0c5d2..0000000 --- a/ExampleTests/Supporting Files/StateAssertions.swift +++ /dev/null @@ -1,86 +0,0 @@ -import Asynchrone -import RedUx -import XCTest - - -/// Assert that a store's state changes match expectation after sending -/// a collection of events. -/// - Parameters: -/// - store: The store to test state changes against. -/// - events: The events to send to the store. -/// - statesToMatch: An array of state changes expected. These will be asserted -/// equal against the store's state changes. -/// - timeout: Time to wait for store state changes. Defaults to `5` -/// - file: The file where this assertion is being called. Defaults to `#filePath`. -/// - line: The line in the file where this assertion is being called. Defaults to `#line`. -func XCTAssertStateChange( - store: Store, - events: [Event], - matches statesToMatch: [State], - timeout: TimeInterval = 5.0, - file: StaticString = #filePath, - line: UInt = #line -) { - // Add initial state - var states: [State] = [store.state] - store.stateSequence - .removeDuplicates() - .sink { states.append($0) } - - for event in events { - store.send(event) - } - - XCTAssertEventuallyEqualStates( - states, - statesToMatch, - timeout: timeout, - file: file, - line: line - ) -} - - - -// MARK: XCTAssertEventuallyEqualStates - -private func XCTAssertEventuallyEqualStates( - _ expressionA: @autoclosure @escaping () -> [State], - _ expressionB: @autoclosure @escaping () -> [State], - timeout: TimeInterval = 5.0, - file: StaticString = #filePath, - line: UInt = #line -) { - Task.detached(priority: .low) { - let timeoutDate = Date(timeIntervalSinceNow: timeout) - - while true { - let resultA = expressionA() - let resultB = expressionB() - - switch resultA == resultB { - // All good! - case true: - return - // False and timed out. - case false where Date.now.compare(timeoutDate) == .orderedDescending: - let error = XCTAssertStatesEventuallyEqualError( - stateChanges: resultA, - stateChangesExpected: resultB - ) - - XCTFail( - error.message, - file: file, - line: line - ) - return - // False but still within timeout limit. - case false:() - } - - try? await Task.sleep(nanoseconds: 50_000_000) - await Task.yield() - } - } -} diff --git a/ExampleTests/Supporting Files/XCTAssertStatesEventuallyEqualError.swift b/ExampleTests/Supporting Files/XCTAssertStatesEventuallyEqualError.swift deleted file mode 100644 index e784740..0000000 --- a/ExampleTests/Supporting Files/XCTAssertStatesEventuallyEqualError.swift +++ /dev/null @@ -1,41 +0,0 @@ -struct XCTAssertStatesEventuallyEqualError: Error { - let message: String - - var localizedDescription: String { - self.message - } - - // MARK: Initialization - - init(_ message: String) { - self.message = message - } - - init(stateChanges: [State], stateChangesExpected: [State]) { - self.init( -""" - ---------------------------- -Failed To Assert Equality ---------------------------- - -# State Changes -\( - stateChanges.enumerated().map { - "\($0)) \(String(describing: $1))" - }.joined(separator: "\n") -) - - -# States Changes Expected -\( - stateChangesExpected.enumerated().map { - "\($0)) \(String(describing: $1))" - }.joined(separator: "\n") -) - ---------------------------- -""" - ) - } -} diff --git a/Package.swift b/Package.swift index 7f4d948..6b1a114 100644 --- a/Package.swift +++ b/Package.swift @@ -16,7 +16,7 @@ let package = Package( ) ], dependencies: [ - .package(url: "https://github.com/reddavis/Asynchrone", from: "0.12.0") + .package(url: "https://github.com/reddavis/Asynchrone", from: "0.17.0") ], targets: [ .target( diff --git a/RedUx.xcodeproj/project.pbxproj b/RedUx.xcodeproj/project.pbxproj index 1f5dc1b..8cc77e2 100644 --- a/RedUx.xcodeproj/project.pbxproj +++ b/RedUx.xcodeproj/project.pbxproj @@ -12,6 +12,7 @@ A40EE2A1279F344100663E6C /* Details.swift in Sources */ = {isa = PBXBuildFile; fileRef = A40EE2A0279F344100663E6C /* Details.swift */; }; A40EE2A327A02D7600663E6C /* ViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A40EE2A227A02D7600663E6C /* ViewModel.swift */; }; A40EE2A527A044FE00663E6C /* ViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A40EE2A427A044FE00663E6C /* ViewModelTests.swift */; }; + A410A72E282D48700035A40D /* Assertions.swift in Sources */ = {isa = PBXBuildFile; fileRef = A48E085C2743D1FE008090E5 /* Assertions.swift */; }; A425FB70275F9769002AFD72 /* ReducerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A425FB6F275F9769002AFD72 /* ReducerTests.swift */; }; A4773C442827B10800828A14 /* ActionStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = A4773C432827B10800828A14 /* ActionStatus.swift */; }; A4773C462827B12800828A14 /* ValueStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = A4773C452827B12800828A14 /* ValueStatus.swift */; }; @@ -114,8 +115,6 @@ A4A12B5927B6A77D0094B270 /* Middlewareable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Middlewareable.swift; sourceTree = ""; }; A4A12B5B27B6CCC70094B270 /* AnyMiddlewareable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnyMiddlewareable.swift; sourceTree = ""; }; A4B9E5A627A269590000ED07 /* RedUxable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RedUxable.swift; sourceTree = ""; }; - A4C06ADF27AA8EBA00C3B5D7 /* XCTAssertStatesEventuallyEqualError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XCTAssertStatesEventuallyEqualError.swift; sourceTree = ""; }; - A4C06AE827AAACC600C3B5D7 /* StateAssertions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StateAssertions.swift; sourceTree = ""; }; A4C36199276A086A00511525 /* AppStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStore.swift; sourceTree = ""; }; A4D2CF9F26C0FC34008D25DE /* View+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+Extension.swift"; sourceTree = ""; }; A4D2CFA526C16A69008D25DE /* Package.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Package.swift; sourceTree = ""; }; @@ -184,15 +183,6 @@ path = Status; sourceTree = ""; }; - A47BE5FC27BC46DD0011ECE6 /* Supporting Files */ = { - isa = PBXGroup; - children = ( - A4C06AE827AAACC600C3B5D7 /* StateAssertions.swift */, - A4C06ADF27AA8EBA00C3B5D7 /* XCTAssertStatesEventuallyEqualError.swift */, - ); - path = "Supporting Files"; - sourceTree = ""; - }; A47BE66A27CBED280011ECE6 /* Extensions */ = { isa = PBXGroup; children = ( @@ -310,7 +300,6 @@ isa = PBXGroup; children = ( A48E08642743D463008090E5 /* RootScreenTests.swift */, - A47BE5FC27BC46DD0011ECE6 /* Supporting Files */, ); path = ExampleTests; sourceTree = ""; @@ -595,6 +584,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + A410A72E282D48700035A40D /* Assertions.swift in Sources */, A48E08652743D463008090E5 /* RootScreenTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -1028,7 +1018,7 @@ repositoryURL = "https://github.com/reddavis/Asynchrone"; requirement = { kind = upToNextMajorVersion; - minimumVersion = 0.14.0; + minimumVersion = 0.16.0; }; }; /* End XCRemoteSwiftPackageReference section */ diff --git a/RedUx/Source/Store.swift b/RedUx/Source/Store.swift index fef03c0..554a829 100644 --- a/RedUx/Source/Store.swift +++ b/RedUx/Source/Store.swift @@ -79,14 +79,14 @@ public final class Store { defer { self.isProcessingEvent = false - self.state = state } - while !self.eventBacklog.isEmpty { + repeat { let event = self.eventBacklog.removeFirst() let eventStream = self.reducer(&state, event, self.environment) + self.state = state - Task { [state] in + Task(priority: .high) { [state] in for middleware in self.middlewares { await middleware.execute(event: event, state: { state }) } @@ -96,7 +96,7 @@ public final class Store { self.send(event) } } - } + } while !self.eventBacklog.isEmpty } // MARK: Middleware @@ -143,13 +143,12 @@ extension Store { ) // Propagate changes to state to scoped store. - scopedStore.parentStatePropagationTask = Task(priority: .high) { [stateSequence, weak scopedStore] in - scopedStore?.state = toScopedState(self.state) - - for await state in stateSequence { - scopedStore?.state = toScopedState(state) + scopedStore.parentStatePropagationTask = Just(self.state) + .eraseToAnyAsyncSequenceable() + .chain(with: self.stateSequence) + .sink { [weak scopedStore] in + scopedStore?.state = toScopedState($0) } - } return scopedStore } diff --git a/RedUx/Source/ViewModel.swift b/RedUx/Source/ViewModel.swift index 8a78cde..50e02e9 100644 --- a/RedUx/Source/ViewModel.swift +++ b/RedUx/Source/ViewModel.swift @@ -1,3 +1,4 @@ +import Asynchrone import SwiftUI /// A view model wraps a store and observes state changes that can be used @@ -43,8 +44,8 @@ public final class ViewModel: ObservableObject { @Published public var state: State // Private - private var stateTask: Task? private let _send: (Event) -> Void + private var stateTask: Task? // MARK: Initialization @@ -53,23 +54,15 @@ public final class ViewModel: ObservableObject { public init(_ store: Store) { self.state = store.state self._send = { store.send($0) } - self.stateTask = Task { [weak self] in - guard let self = self else { return } - - do { + + self.stateTask = store + .stateSequence + .removeDuplicates() + .sink(priority: .userInitiated) { state in await MainActor.run { - self.state = store.state + self.state = state } - - for try await state in store.stateSequence.removeDuplicates() { - guard !Task.isCancelled else { break } - - await MainActor.run { - self.state = state - } - } - } catch { } - } + } } deinit { diff --git a/RedUxTests/Supporting Files/Assertions.swift b/RedUxTests/Supporting Files/Assertions.swift index b51455c..195e4d0 100644 --- a/RedUxTests/Supporting Files/Assertions.swift +++ b/RedUxTests/Supporting Files/Assertions.swift @@ -1,53 +1,7 @@ +@testable import RedUx +import Asynchrone import XCTest -/// Assert two async expressions are eventually equal. -/// - Parameters: -/// - expressionA: Expression A -/// - expressionB: Expression B -/// - timeout: Time to wait for store state changes. Defaults to `5` -/// - file: The file where this assertion is being called. Defaults to `#filePath`. -/// - line: The line in the file where this assertion is being called. Defaults to `#line`. -func XCTAssertEventuallyEqual( - _ expressionA: @escaping @autoclosure () -> T?, - _ expressionB: @escaping @autoclosure () -> T?, - timeout: TimeInterval = 5.0, - file: StaticString = #filePath, - line: UInt = #line -) { - Task.detached(priority: .low) { - let timeoutDate = Date(timeIntervalSinceNow: timeout) - - while true { - let resultA = expressionA() - let resultB = expressionB() - - switch resultA == resultB { - // All good! - case true: - return - // False and timed out. - case false where Date.now.compare(timeoutDate) == .orderedDescending: - let error = XCTAssertEventuallyEqualError( - resultA: resultA, - resultB: resultB - ) - - XCTFail( - error.message, - file: file, - line: line - ) - return - // False but still within timeout limit. - case false:() - } - - try? await Task.sleep(nanoseconds: 50_000_000) - await Task.yield() - } - } -} - /// Assert an async closure thorws an error. /// - Parameters: /// - closure: The closure. @@ -132,7 +86,7 @@ func XCTAsyncAssertEqual( ) } -// MARK: XCTAssertEventuallyEqualError +// MARK: await XCTAssertEventuallyEqualError struct XCTAssertEventuallyEqualError: Error { let message: String @@ -172,3 +126,157 @@ Failed To Assert Equality } } +// MARK: XCTAssertStatesEventuallyEqualError + +struct XCTAssertStatesEventuallyEqualError: Error { + let message: String + + var localizedDescription: String { + message + } + + // MARK: Initialization + + init(_ message: String) { + self.message = message + } + + init(stateChanges: [State], stateChangesExpected: [State]) { + self.init( + """ + +--------------------------- +Failed To Assert Equality +--------------------------- + +# State Changes +\( + stateChanges.enumerated().map { + "\($0)) \(String(describing: $1))" + }.joined(separator: "\n") +) + + +# States Changes Expected +\( + stateChangesExpected.enumerated().map { + "\($0)) \(String(describing: $1))" + }.joined(separator: "\n") +) + +--------------------------- +""" + ) + } +} + +// MARK: XCTestCase + +/// Assert that a store's state changes match expectation after sending +/// a collection of events. +/// - Parameters: +/// - store: The store to test state changes against. +/// - events: The events to send to the store. +/// - statesToMatch: An array of state changes expected. These will be asserted +/// equal against the store's state changes. +/// - timeout: Time to wait for store state changes. Defaults to `5` +/// - file: The file where this assertion is being called. Defaults to `#filePath`. +/// - line: The line in the file where this assertion is being called. Defaults to `#line`. +func XCTAssertStateChange( + store: Store, + events: [Event], + matches statesToMatch: [State], + timeout: TimeInterval = 5.0, + file: StaticString = #filePath, + line: UInt = #line +) async { + let timeoutDate = Date(timeIntervalSinceNow: timeout) + var states: [State] = [] + + // We use the semaphore in order to guarantee the sink task has started. + // This is to ensure we collect all events. + let semaphore = DispatchSemaphore(value: 0) + Just(store.state) + .eraseToAnyAsyncSequenceable() + .chain(with: store.stateSequence) + .removeDuplicates() + .sink { + states.append($0) + semaphore.signal() + } + + Task.detached(priority: .low) { + semaphore.wait() + for event in events { + store.send(event) + } + } + + while true { + switch states == statesToMatch { + // All good! + case true: + return + // False and timed out. + case false where Date.now.compare(timeoutDate) == .orderedDescending: + let error = XCTAssertStatesEventuallyEqualError( + stateChanges: states, + stateChangesExpected: statesToMatch + ) + + XCTFail( + error.message, + file: file, + line: line + ) + return + // False but still within timeout limit. + case false: + try? await Task.sleep(nanoseconds: 50000000) + } + } +} + +/// Assert two async expressions are eventually equal. +/// - Parameters: +/// - expressionA: Expression A +/// - expressionB: Expression B +/// - timeout: Time to wait for store state changes. Defaults to `5` +/// - file: The file where this assertion is being called. Defaults to `#filePath`. +/// - line: The line in the file where this assertion is being called. Defaults to `#line`. +func XCTAssertEventuallyEqual( + _ expressionA: @escaping @autoclosure () -> T?, + _ expressionB: @escaping @autoclosure () -> T?, + timeout: TimeInterval = 5.0, + file: StaticString = #filePath, + line: UInt = #line +) async { + let timeoutDate = Date(timeIntervalSinceNow: timeout) + + while true { + let resultA = expressionA() + let resultB = expressionB() + + switch resultA == resultB { + // All good! + case true: + return + // False and timed out. + case false where Date.now.compare(timeoutDate) == .orderedDescending: + let error = XCTAssertEventuallyEqualError( + resultA: resultA, + resultB: resultB + ) + + XCTFail( + error.message, + file: file, + line: line + ) + return + // False but still within timeout limit. + case false: + try? await Task.sleep(nanoseconds: 50000000) + } + } +} diff --git a/RedUxTests/Tests/StoreTests.swift b/RedUxTests/Tests/StoreTests.swift index d0df3c2..a8a0a53 100644 --- a/RedUxTests/Tests/StoreTests.swift +++ b/RedUxTests/Tests/StoreTests.swift @@ -36,7 +36,7 @@ final class StoreTests: XCTestCase { // MARK: Scoped store - func testScopedStore() { + func testScopedStore() async { let scopedStore = self.store.scope( state: \.subState, event: AppEvent.subEvent, @@ -44,20 +44,20 @@ final class StoreTests: XCTestCase { ) let value = "a" - XCTAssertNil(scopedStore.state.value) - scopedStore.send(.setValue(value)) - - // Check scoped store's value changes - XCTAssertEventuallyEqual(scopedStore.state.value, value) - - // Check scoped store's received event - XCTAssertEventuallyEqual(scopedStore.state.eventsReceived, [.setValue(value)]) + await XCTAssertStateChange( + store: scopedStore, + events: [.setValue(value)], + matches: [ + self.store.state.subState, + .init(value: value, eventsReceived: [.setValue(value)]) + ] + ) // Check parent store's value changes - XCTAssertEventuallyEqual(self.store.state.eventsReceived, [.subEvent(.setValue(value))]) + XCTAssertEqual(self.store.state.eventsReceived, [.subEvent(.setValue(value))]) } - func testSendingEffectTriggeringEventToScopedStore() { + func testSendingEffectTriggeringEventToScopedStore() async { let scopedStore = self.store.scope( state: \.subState, event: AppEvent.subEvent, @@ -66,68 +66,99 @@ final class StoreTests: XCTestCase { let value = "a" XCTAssertNil(scopedStore.state.value) - scopedStore.send(.setValueViaEffect(value)) - - // Check scoped store's value changes - XCTAssertEventuallyEqual(scopedStore.state.value, value) - - // Check scoped store's received event - XCTAssertEventuallyEqual( - scopedStore.state.eventsReceived, - [.setValueViaEffect(value), .setValue(value)] + await XCTAssertStateChange( + store: scopedStore, + events: [.setValueViaEffect(value)], + matches: [ + .init(), + .init(value: nil, eventsReceived: [.setValueViaEffect(value)]), + .init(value: value, eventsReceived: [.setValueViaEffect(value), .setValue(value)]) + ] ) - + // Check parent store's value changes - XCTAssertEventuallyEqual( + XCTAssertEqual( self.store.state.eventsReceived, - [.subEvent(.setValueViaEffect(value)), .setValue(value)] + [.subEvent(.setValueViaEffect(value)), .subEvent(.setValue(value))] ) } // MARK: Effects func testSendingEventThatTriggersAnEffect() async { - XCTAssertNil(self.store.state.value) - self.store.send(.setValueViaEffect("a")) - - XCTAssertEventuallyEqual( - self.store.state, - .init( - value: "a", - eventsReceived: [ - .subEvent(.setValueViaEffect("a")), - .subEvent(.setValue("a")) - ] - ) + await XCTAssertStateChange( + store: self.store, + events: [.setValueViaEffect("a")], + matches: [ + .init(), + .init( + eventsReceived: [ + .setValueViaEffect("a") + ] + ), + .init( + value: "a", + eventsReceived: [ + .setValueViaEffect("a"), + .setValue("a") + ] + ) + ] ) } // MARK: Middleware func testMiddleware() async { - XCTAssertNil(self.store.state.value) - self.store.send(.setValueViaMiddleware("a")) - - XCTAssertEventuallyEqual(self.store.state.value, "a") - - XCTAssertEventuallyEqual( - self.store.state.eventsReceived, - [.setValueViaMiddleware("a"), .setValue("a")] + await XCTAssertStateChange( + store: self.store, + events: [.setValueViaMiddleware("a")], + matches: [ + .init(), + .init( + eventsReceived: [ + .setValueViaMiddleware("a") + ] + ), + .init( + value: "a", + eventsReceived: [ + .setValueViaMiddleware("a"), + .setValue("a") + ] + ) + ] ) } func testScopedMiddleware() async { - XCTAssertNil(self.store.state.value) - self.store.send(.subEvent(.setValueViaMiddleware("a"))) - - XCTAssertEventuallyEqual( - self.store.state.subState.value, - "a" - ) - - XCTAssertEventuallyEqual( - self.store.state.eventsReceived, - [.subEvent(.setValueViaMiddleware("a")), .subEvent(.setValue("a"))] + await XCTAssertStateChange( + store: self.store, + events: [.subEvent(.setValueViaMiddleware("a"))], + matches: [ + .init(), + .init( + eventsReceived: [ + .subEvent(.setValueViaMiddleware("a")) + ], + subState: .init( + eventsReceived: [.setValueViaMiddleware("a")] + ) + ), + .init( + eventsReceived: [ + .subEvent(.setValueViaMiddleware("a")), + .subEvent(.setValue("a")) + ], + subState: .init( + value: "a", + eventsReceived: [ + .setValueViaMiddleware("a"), + .setValue("a") + ] + ) + ) + ] ) } } diff --git a/RedUxTests/Tests/ViewModelTests.swift b/RedUxTests/Tests/ViewModelTests.swift index 28a92d4..7bf9be4 100644 --- a/RedUxTests/Tests/ViewModelTests.swift +++ b/RedUxTests/Tests/ViewModelTests.swift @@ -23,189 +23,112 @@ final class ViewModelTests: XCTestCase { // MARK: Tests - func testStateChangesFromEventPropagateToViewModel() { + func testEventsAreForwardedToStore() async { XCTAssertNil(self.viewModel.state.value) self.viewModel.send(.setValue(self.value)) - XCTAssertEventuallyEqual( - self.store.state.value, - self.value - ) - - XCTAssertEventuallyEqual( - self.viewModel.state.value, - self.value - ) - - XCTAssertEventuallyEqual( - self.store.state.eventsReceived, - [.setValue(self.value)] + await XCTAssertEventuallyEqual( + self.store.state, + .init( + value: self.value, + eventsReceived: [.setValue(self.value)] + ) ) + } + + func testStateChangesFromEventPropagateToViewModel() async { + XCTAssertNil(self.viewModel.state.value) + self.viewModel.send(.setValue(self.value)) - XCTAssertEventuallyEqual( - self.viewModel.state.eventsReceived, - [.setValue(self.value)] + await XCTAssertEventuallyEqual( + self.viewModel.state, + .init( + value: self.value, + eventsReceived: [.setValue(self.value)] + ) ) } - func testStateChangesFromEventViaMiddlewarePropagateToViewModel() { + func testStateChangesFromEventViaMiddlewarePropagateToViewModel() async { XCTAssertNil(self.store.state.value) - self.store.send(.setValueViaEffect(self.value)) - - XCTAssertEventuallyEqual( - self.store.state.value, - self.value - ) - - XCTAssertEventuallyEqual( - self.viewModel.state.value, - self.value - ) - - XCTAssertEventuallyEqual( - self.store.state.eventsReceived, - [.setValueViaMiddleware(self.value), .setValue(self.value)] - ) + self.viewModel.send(.setValueViaMiddleware(self.value)) - XCTAssertEventuallyEqual( - self.viewModel.state.eventsReceived, - [.setValueViaMiddleware(self.value), .setValue(self.value)] + await XCTAssertEventuallyEqual( + self.viewModel.state, + .init( + value: self.value, + eventsReceived: [.setValueViaMiddleware(self.value), .setValue(self.value)] + ) ) } - func testStateChangesFromEventViaEffectPropagateToViewModel() { + func testStateChangesFromEventViaEffectPropagateToViewModel() async { XCTAssertNil(self.store.state.value) self.store.send(.setValueViaEffect(self.value)) - XCTAssertEventuallyEqual( - self.store.state.value, - self.value - ) - - XCTAssertEventuallyEqual( - self.viewModel.state.value, - self.value - ) - - XCTAssertEventuallyEqual( - self.store.state.eventsReceived, - [.setValueViaEffect(self.value), .setValue(self.value)] - ) - - XCTAssertEventuallyEqual( - self.viewModel.state.eventsReceived, - [.setValueViaEffect(self.value), .setValue(self.value)] + await XCTAssertEventuallyEqual( + self.viewModel.state, + .init( + value: self.value, + eventsReceived: [.setValueViaEffect(self.value), .setValue(self.value)] + ) ) } // MARK: Bindings - func testBindingWithValue() { + func testBindingWithValue() async { let binding = self.viewModel.binding( value: \.value, - event: { .setValue($0 ?? "") } + event: AppEvent.setValue ) XCTAssertNil(self.store.state.value) binding.wrappedValue = self.value - - XCTAssertEventuallyEqual( - self.store.state.value, - self.value - ) - XCTAssertEventuallyEqual( - self.viewModel.state.value, - self.value - ) - - XCTAssertEventuallyEqual( - self.store.state.eventsReceived, - [.setValue(self.value)] - ) - - XCTAssertEventuallyEqual( - self.viewModel.state.eventsReceived, - [.setValue(self.value)] + await XCTAssertEventuallyEqual( + self.viewModel.state, + .init( + value: self.value, + eventsReceived: [.setValue(self.value)] + ) ) } - func testBindingEmitsEventOnWrappedValueChange() { - let binding = self.viewModel.binding( - value: \.value, - event: .setValueToA - ) - XCTAssertNil(self.store.state.value) - - // trigger the .setValueToA event. - binding.wrappedValue = "whatever" - - XCTAssertEventuallyEqual( - self.store.state.value, - self.value - ) - - XCTAssertEventuallyEqual( - self.viewModel.state.value, - self.value - ) - - XCTAssertEventuallyEqual( - self.store.state.eventsReceived, - [.setValueToA] - ) - - XCTAssertEventuallyEqual( - self.viewModel.state.eventsReceived, - [.setValueToA] - ) - } - - func testBindingRemovesDuplicateSetterCalls() { + func testBindingRemovesDuplicateSetterCalls() async { let binding = self.viewModel.binding( value: \.value, event: AppEvent.setValue ) XCTAssertNil(self.store.state.value) - // trigger the .setValue event. + // trigger the .setValue event twice. + binding.wrappedValue = self.value binding.wrappedValue = self.value - XCTAssertEventuallyEqual( - self.store.state.value, - self.value - ) - - XCTAssertEventuallyEqual( - self.viewModel.state.value, - self.value - ) - - XCTAssertEventuallyEqual( - self.store.state.eventsReceived, - [.setValue(self.value)] - ) - - XCTAssertEventuallyEqual( - self.viewModel.state.eventsReceived, - [.setValue(self.value)] + await XCTAssertEventuallyEqual( + self.viewModel.state, + .init( + value: self.value, + eventsReceived: [.setValue(self.value)] + ) ) } - func testReadonlyBindingReceivesValueChange() { + func testReadonlyBindingReceivesValueChange() async { let binding = self.viewModel.binding( value: \.value ) XCTAssertNil(binding.wrappedValue) self.viewModel.send(.setValue(self.value)) - XCTAssertEventuallyEqual( + await XCTAssertEventuallyEqual( binding.wrappedValue, self.value ) } - func testReadonlyBindingDoesNotEmitsEventOnWrappedValueChange() { + func testReadonlyBindingDoesNotEmitsEventOnWrappedValueChange() async { let binding = self.viewModel.binding( value: \.value ) diff --git a/scripts/test b/scripts/test index ca2a277..8640a11 100755 --- a/scripts/test +++ b/scripts/test @@ -1,22 +1,32 @@ #!/bin/sh +## HT to Nuke! - https://github.com/kean/Nuke set -eo pipefail scheme="RedUx" -while getopts "s:d:" opt; do +while getopts "s:d:p:" opt; do case $opt in - s) scheme=${OPTARG};; + s) scheme=$OPTARG;; d) destination=$OPTARG;; + p) test_plan=$OPTARG;; esac done shift $((OPTIND -1)) echo "scheme = ${scheme}" echo "destinations = ${destination}" +echo "test plan = ${test_plan}" + +test_plan_argument="" +if [ ! -z "$test_plan" ]; then + test_plan_argument="-testPlan ${test_plan}" +fi xcodebuild -version -xcodebuild build-for-testing -scheme "$scheme" -destination "$destination" echo "\nRunning tests for destination: $destination" -xcodebuild test-without-building -scheme "$scheme" -destination "$destination" +echo "\n- Destination: $destination" +echo "\n- Scheme: $scheme" +echo "\n- Test Plan: $test_plan_argument" +xcodebuild test -scheme "$scheme" -destination "$destination" -enableCodeCoverage YES $test_plan_argument